工具癖生活不易 我用python程序员

从零开始用Vue+Flask开发知乎小视频下载工具

2018-06-23  本文已影响39人  采菊东篱下

作为一个几乎从来没做过前端开发的程序员,我近期花了一个周从零开始学习Vue的知识,做了一个知乎小视频的下载Demo,并且成功部署到线上。

整个Demo长的下面这个样子。


知乎视频下载

目前前端是基于Vue,后端是基于Flask。

Vue入门

首先我得解决Vue入门的问题,我之前了解过一部分html和js的语法,于是我用一个晚上的时间把 Vue官方教程 过了一遍,大致了解了一下Vue到底是个怎么回事,对着里面的一些小Demo敲了一边代码。

我知道目前的前端开发流行SPA,而不是几年前由后端基于html模版来渲染各种表单和html元素。
于是我去搜索Vue SPA相关的文章和教程,我发现了这篇文章 Full-stack single page application with Vue.js and Flask 。它写的真是太棒了,真正的从零开始搭建一个单页的应用,于是我把其余那些打开的Chrome标签页全部关掉,只需要这一篇文章就够了。

Vue+Flask SPA

我按照里面的步骤一步一步在我的Mac电脑上操作,很快就运行起来了一个HelloWorld的程序。那一刻真的感觉太棒了,工程项目脚手架搭建起来后我就开始考虑具体做一个有用的小工具出来练练手,熟悉一下Vue SPA相关的开发套路。

由于之前从来没有了解过webpack,我又花了两个小时去看了一下webpack相关的文档,弄明白里面一些关键文件和配置的用法,包括整个前端项目的演进过程。

知乎视频下载

某一天我在逛知乎时发现一个非常性感的视频,于是我就想着把这个小视频保存到我的电脑上,但是当我点击右键时我并没有发现另存为的按钮,于是我就打开chrome想着把视频的URL给找出来然后直接下载,但是我发现url不是mp4或者其他我熟悉的格式,通过观察加载过程中浏览器的网络请求发现是m3u8格式。

m3u8对我来说是一个完全陌生的东西,然后我就去搜索m3u8相关的资料,发现可以通过ffmpeg来进行下载和解码,然后又发现了一些别人写的知乎视频下载的python脚本。对于python相关的代码我比较在行,复制了一段从网页中解析真正视频url的代码过来做了部分简单的修改,调试了十几分钟就调通了,直接在命令行运行python脚本就可以下载下来一个大概长度在2分多种左右性感的小视频。

主要的代码大概长这个样子,这两个函数就可以从一个回答的页面解析出真正的m3u8文件的url了,然后传给ffmpeg的参数就可以了。注意一下,这里其实不需要已经登陆用户的cookie,因为就算不登陆也可以直接浏览器观看视频的。

HEADERS = {
    'User-Agent': '浏览器的UA',
}

def get_video_ids_from_url(url):
    """
    回答或者文章的 url
    """
    r = requests.get(url, headers=HEADERS)
    r.encoding='utf-8'
    html = r.text
    # print(html)
    video_ids = re.findall(r'data-lens-id="(\d+)"', html)
    print("video_ids: ", video_ids)
    if video_ids:
        return set([int(video_id) for video_id in video_ids])
    return []


def yield_video_m3u8_url_from_video_ids(video_ids):
    for video_id in video_ids:
        headers = {}
        headers['Referer'] = 'https://v.vzuu.com/video/{}'.format(video_id)
        headers['Origin'] = 'https://v.vzuu.com'
        headers['Host'] = 'lens.zhihu.com'
        headers['Content-Type'] = 'application/json'
        headers['Authorization'] = 'oauth c3cef7c66a1843f8b3a9e6a1e3160e20'

        api_video_url = 'https://lens.zhihu.com/api/videos/{}'.format(int(video_id))

        r = requests.get(api_video_url, headers={**HEADERS, **headers})
        playlist = r.json()['playlist']
        m3u8_url = playlist[QUALITY]['play_url']
        yield video_id, m3u8_url

前后端打通

前端不需要别的,只要一个输入框,一个按钮,一个下载的进度条和播放器就可以了。
我不具备写自定义CSS的能力,所以我选择了Bootstrap-Vue来让页面看起来美观一些。

我按照Bootstrap-Vue官方教程 将组件添加进了之前由webpack生成的脚手架中。

添加完后的frontend/src/router/index.js文件如下:

import Vue from 'vue'
import Router from 'vue-router'
import BootstrapVue from 'bootstrap-vue'
import 'bootstrap/dist/css/bootstrap.css'
import 'bootstrap-vue/dist/bootstrap-vue.css'

const routerOptions = [
  { path: '/', component: 'Home' },
  { path: '/about', component: 'About' },
  { path: '*', component: 'NotFound' }

]

const routes = routerOptions.map(route => {
  return {
    ...route,
    component: () => import(`@/components/${route.component}.vue`)
  }
})

Vue.use(Router)
Vue.use(BootstrapVue)

export default new Router({
  routes,
  mode: 'history'
})

添加完成后就可以在Vue文件中使用了。

在Home.vue文件中添加html 模版代码。

<template>
  <div class="container">
    <b-form @submit="onSubmit" v-if="show">
      <b-form-group id="fieldset1"
                    description="示例:https://www.zhihu.com/question/xxx/answer/xxx"
                    label=""
                    label-for="zhihu"
                    :invalid-feedback="invalidFeedback"
                    :valid-feedback="validFeedback"
                    :state="state">
        <b-input-group prepend="知乎">
          <b-form-input id="zhihu"
                        :state="state"
                        v-model.trim="seed"
                        required>
          </b-form-input>
          <b-input-group-append>
            <b-button type="submit" variant="primary">下载</b-button>
          </b-input-group-append>
        </b-input-group>
      </b-form-group>
    </b-form>
    <div v-for="item in items" :key="item.video" class="col-md-6">
      <div class="card">
        <b-progress :value="item.progress" variant="success" :striped="item.striped" :animated="item.animate" class="mb-2"></b-progress>
        <b-embed type="video" aspect="16by9" controls>
          <source  :src="item.video" type='video/mp4' v-if="item.ok"/>
        </b-embed>
      </div>
    </div>
  </div>
</template>

因为一个回答可能包含多个小视频,所以这里需要for循环进行处理和展示。

进度条的功能其实花了我特别长的时间,我在前端启动了一个定时器每隔5s去查询后端的下载进度,然后根据下载进度实时更新页面上的dom元素。在这个期间我学习了Vue关于数组对象变动检测的相关知识。

这其中最大的障碍其实是在后端,在python中是通过调用ffmpeg的命令来实现的视频下载,而ffmpeg的输出并没有非常好的格式和直接的下载进度,所以我需要从ffmpeg杂乱无章的输出中解析当前的下载进度。我在google搜索了很多相关的资料,不断的尝试各种解决方案,最终终于搞定了。

首先在下载之前先要获取要下载的视频的时长,可以通过ffprobe命令添加一些参数来搞定。

def exec_output(command):
    """
    执行ffmpeg命令并返回所有输出,如果执行失败,抛出FfmpegException
    :param command: ffmpeg命令
    :return: ffmpeg标准输出
    """
    try:
        process = subprocess.check_output(command, shell=True, stderr=subprocess.STDOUT)
        return process
    except subprocess.CalledProcessError as err:
        raise FfmpegException(err.returncode, err.cmd, err.output)

def duration_seconds(url): 
    result = exec_output("ffprobe -v quiet -print_format json -show_format {}".format(url))
    video_info = json.loads(str(result.decode('utf8')))
    duration_seconds = video_info['format'].get("duration", None)
    if duration_seconds is not None:
        return round(float(duration_seconds))
    else:
        return 0

然后获取ffmpeg的实时流输出,为了前端可以异步获取,我选择把解析出来的时间存储到redis中。

def progress(m3u8_url, directory, filename):
    # '/path/to/dist/static/video/zhihu/xxx-yyy.mp4'
    prefix = directory + '/dist/'
    key = hashlib.md5(filename.encode('utf-8')).hexdigest()
    cmd = "ffmpeg -v quiet -progress /dev/stdout -i '{input}' {output}".format(input=m3u8_url, output=prefix+filename)
    child1 = subprocess.Popen(cmd, cwd=basedir, shell=True, stdout=subprocess.PIPE)
    # https://stackoverflow.com/questions/7161821/how-to-grep-a-continuous-stream
    cmd2 = "grep --line-buffered -e out_time_ms -e progress"
    child2 = subprocess.Popen(cmd2, shell=True, stdin=child1.stdout, stdout=subprocess.PIPE)
    for line in iter(child2.stdout.readline, b''):
        tmp = line.decode('utf-8').strip().split('=')
        
        if tmp[0] == 'out_time_ms':
            out_time_ms = tmp[1]
            # print(out_time_ms)
            r.set(key, out_time_ms)
        else:
            if tmp[1] == 'end':
                r.delete(key)
                print("download complete")

这一个函数调试了我半天时间,ffmpeg的progress参数和grep --line-buffered参数,以及subprocess.Popen函数的组合使用终于搞定了进度问题。

剩下的问题就简单多了,无非就是设置一下flask的路由,然后前端vue通过axios发送请求从redis中获取实时的下载进度然后设置dom元素在页面上的实时刷新。

部署

本地开发调试通过之后我开始把代码部署到线上环境。

首先就是运行 npm run build 对前端代码进行打包,打包完成后我将整个目录包括源代码全都放到了服务器上。然后在服务器上安装需要的运行时环境,我的服务器是centos7操作系统。

我需要在服务器上通过源代码编译安装python3,然后再安装virtualenv,安装完项目需要的依赖后flask的运行环境就搭建好了。

由于网站依赖于redis,我选择使用docker来安装redis,我很庆幸这个选择,因为我开始并没有设置redis的访问密码而且监听了公网的ip地址,一个消失之后我发现redis中有一些奇怪的key,那是被黑客利用远程命令执行漏洞获取了root权限,赶紧把容器删除掉。

因为我目前已经基于docker-compose运行了一些服务,比如wordpress和mysql等,所以我继续在docker-compose.yml中添加了redis的配置,这次只监听127.0.0.1地址。

version: "3"
services:

   redis:
     image: redis
     ports:
       - "127.0.0.1:6379:6379"
     restart: always

编辑完成后直接运行 docker-compose up -d 就会启动一个redis的容器。

进程管理工具我选择了supervisor,这个工具虽然不是很稳定,但是对于我来说是最熟悉的,我刚开始打算基于docker部署,但是那会稍微话费我一些时间,而且这个小工具还在不断的添加新的小功能,等到稳定后再上docker就好。

[program:downloader]
environment=FLASK_ENV="PRO"
command=/root/downloader/backend/venv/bin/python -u run.py
stdout_logfile=/root/downloader/downloader.log
autostart=true
autorestart=true
startsecs=5
priority=1
stopasgroup=true
killasgroup=true
user=root
redirect_stderr=true
directory=/root/downloader

这个是 /etc/supervisor/conf.d/downloader.ini 文件的内容。
配置完之后运行下面命令网站就运行起来了。

/usr/bin/supervisord -c /etc/supervisor/supervisord.conf

现在这个网站还是只能本地访问,因为flask也是监听的localhost地址。本身80端口已经被我的个人博客占领了, 所以我需要一个nginx来代理。

nginx是我熟悉的工具,不费吹灰之力搭建好了。

    upstream wordpress {
        ip_hash;
        server 127.0.0.1:8080;
    }
    upstream downloader {
        ip_hash;
        server 127.0.0.1:8081;
    }

    server {
        listen       80;
        server_name downloader.dig404.com;
        location / {
            proxy_pass http://downloader;
            proxy_http_version 1.1;
            proxy_set_header Connection "";
            proxy_set_header   Host             $host;
            proxy_set_header   X-Real-IP        $remote_addr;
            proxy_set_header   X-Forwarded-For  $proxy_add_x_forwarded_for;

        }
    }

    server {
        listen       80 default_server;
        server_name  _;
        root         /usr/share/nginx/html;

        # Load configuration files for the default server block.
        include /etc/nginx/default.d/*.conf;

        location / {
            proxy_pass http://wordpress;
            proxy_http_version 1.1;
            proxy_set_header Connection "";
            proxy_set_header   Host             $host;
            proxy_set_header   X-Real-IP        $remote_addr;
            proxy_set_header   X-Forwarded-For  $proxy_add_x_forwarded_for;
        }
        error_page 404 /404.html;
            location = /40x.html {
        }

        error_page 500 502 503 504 /50x.html;
            location = /50x.html {
        }
    }

上面nginx对外都是监听的80端口,根据访问的host不同nginx会代理到不同的上游地址。

最后在域名管理页面配置好域名就可以从外部访问了。

后续

这主要是一个学习Vue SPA玩具小项目,还有很多的地方可以改善。比如前端页面元素可以更加丰富一些,操作更加友好,后端的一些错误检查,日志统计等等都可以加上。

最后整个项目的代码放在了我的 github 上,后续有时间会不断的完善,顺便也是继续学习的过程。

具体里面的一些技巧和实践经验以及学习过程的总结会写一些单独的小文章放在我的个人技术博客上。

上一篇下一篇

猜你喜欢

热点阅读