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

作为一个几乎从来没做过前端开发的程序员,我近期花了一个周从零开始学习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 上,后续有时间会不断的完善,顺便也是继续学习的过程。

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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,194评论 6 490
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,058评论 2 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 156,780评论 0 346
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,388评论 1 283
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,430评论 5 384
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,764评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,907评论 3 406
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,679评论 0 266
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,122评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,459评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,605评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,270评论 4 329
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,867评论 3 312
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,734评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,961评论 1 265
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,297评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,472评论 2 348

推荐阅读更多精彩内容