背景

上周去做了一个团体核算检测,三天后拿到了结果。直接从他们的机构的官方网站查询就行了,报告格式是PDF,因为整个过程除了输入身份证号和姓名之外,并没有其他的身份校验,所以好奇性地把最终报告的页面地址直接在隐身模式下打开,结果….有下文了,又是一个专门为爬虫设计的简单系统,前端是php,后台是Java。没有登录,没有验证码,没有请求次数限制,就好像是在说:服务全开,全开!

下载

报告分移动端和PC端两个版本,仔细看了前端的JS脚本发现都是走的同一个接口,只不过移动端是把最终的pdf内容转成了图片格式。直接在Network inspector里面找到了下载报告的XHR请求,导出对应的curl命名,贴在命令行运行就可以直接运行了。参数大概是这样的(已脱敏):

curl 'http://the/post/url' \
  -H 'Connection: keep-alive' \
  -H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.106 Safari/537.36' \
  -H 'Content-Type: multipart/form-data; boundary=----WebKitFormBoundarybfGJ0I90Seu9Ywjc' \
  -H 'Accept: */*' \
  -H 'Origin: http://www.example.com' \
  -H 'Referer: http://www.example.com/reports/' \
  -H 'Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7,zh-TW;q=0.6' \
  --data-binary $'------WebKitFormBoundarybfGJ0I90Seu9Ywjc\r\nContent-Disposition: form-data; name="sampleNo"\r\n\r\nXFFFFFF000000\r\n------WebKitFormBoundarybfGJ0I90Seu9Ywjc--\r\n' \
  --compressed \
  --insecure

我尝试把这个request转成python requests的代码,但是失败了,具体原因后面再查,不影响我先把报告批量爬下来。直接用python把这个curl命令封装一下就好了,因为是response type始终是PDF,所以需要考虑过滤一下错误的报告。如果请求了无效的报告编号,那最终的pdf是无效了,里面的内容其实一个JSON字符串,可以通过macOS下的file命令直接判定。很简单的逻辑,实现起来是这样子:

def download_via_curl(order_id):
    'Download covid-19 report via curl.'
    filepath = os.path.join(DIR, order_id)

    if os.path.exists(filepath):
        return True, 'report exists'

    command = """curl 'http://the/post/url' \
-H 'Connection: keep-alive' \
-H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.106 Safari/537.36' \
-H 'Content-Type: multipart/form-data; boundary=----WebKitFormBoundarygxQA506xLQ8L4PcZ' \
-H 'Accept: */*' \
-H 'Origin: http://www.example.com' \
-H 'Referer: http://www.example.com/reports/covid19/{id}/' \
-H 'Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7,zh-TW;q=0.6' \
-H 'dnt: 1' \
--data-binary $'------WebKitFormBoundarygxQA506xLQ8L4PcZ\\r\\nContent-Disposition: form-data; name="sampleNo"\\r\\n\\r\\n{id}\\r\\n------WebKitFormBoundarygxQA506xLQ8L4PcZ--\\r\\n' \
--compressed \
--insecure \
--output "{path}" \
--silent
  """.format(id=order_id, path=filepath)

    # logger.debug(command)
    p = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE)
    p.wait()

    if p.returncode != 0:
        return False, 'request error'

    def get_file_type(filepath):
        command = 'file -b %s' % filepath
        p = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE)
        p.wait()
        return p.stdout.read().rstrip()

    file_type = get_file_type(filepath)

    if 'PDF' in file_type:
        return True, None
    else:
        os.remove(filepath)
        return False, 'Invalid file type %s' % file_type

接下来只要找到根据我的报告编号的规则来生成一系列新的序号就可以了,同样为了不暴露敏感信息的原则,编号的规则基本是这样的:XXXFFF000000,前6位含字母和数字的应该是一个系列,猜测每一个团体大概率用的应该是同一个编号,后6位纯数字就是对应的针对被检测人的序号了。从有效数据开始遍历,我用我自己的编号先往前面遍历,再往后面遍历。就能拿到尽可能多的报告编号。

def batch_download():
    prefix = 'XXXFFF'
    start = 100000
    idx, failure = start, 0

    while True:
        order_id = prefix + '%06d' % idx
        result, msg = download_via_curl(order_id)

        if result:
            logger.debug('Download report %s successfully! %s' % (order_id, msg or ''))
            failure = 0
        else:
            logger.debug('Download report %s failed with error %s!' % (order_id, msg or ''))
            failure += 1

        if failure >= 1000:
            logger.error('Found %d continuous download errors!' % failure)
            break

        # idx += 1
        idx -= 1

因为需要考虑到一种错误,因为当时在排队检查的时候,每一个样本最后都会被贴上一个编号,这个编号就是最终生成报告的编号,因为检测人员的操作可能存在的失误,会让有一些编号失效,这些号码会被弃用。猜测还有可能有些编号的纸张因为其他原因丢失或者弃用的原因,所以判定不会出现1000个以上的无效号码,如果出现了,那么往前/后的方向更远的编号应该也不会是有效的。

分析报告

按照上面的规则,目前我拿到的16000+份有效的PDF报告,每个文件几乎都是392k,内容基本都是一样的,除了姓名和一个简单的结果会有所不同。同样的结果如果返回的JSON的话,一定会给我省很多的磁盘空间,都怪我没有在他们做系统设计的时候提需求,否则也不会出这种冗余数据的笑话!

老老实实解析PDF内容吧,这类方法很多,有不少开源的PDF解析库,这次我直接用了在SO上发现的一个基于Python的苹果Foundation framework的解法。

def parse_result_from_pdf(filename):
    'Inspired by https://apple.stackexchange.com/a/352539/148516'
    username, result = None, None

    pdfURL = NSURL.fileURLWithPath_(filename)
    pdfDoc = PDFDocument.alloc().initWithURL_(pdfURL)

    if not pdfDoc:
        return username, result

    pdfString = NSString.stringWithString_(pdfDoc.string())
    # logger.debug(pdfString)

    for line in pdfString.split('\n'):
        if u'姓 名' in line:
            username = line.split(':')[1].split(' ')[0]
        elif u'阴性' == line:
            result = False
        elif u'阳性' == line:
            result = True

    return username, result

性能比我想象得快,大概30~40ms,考虑到是基于Python调用的我还能接受。和解析pdf简历不同,这种结构高度统一的数据直接暴力按行遍历取结果就行了。接下来就是把之前下载到本地的PDF报告遍历一下做个统计就行了。终于到了我想看的数据部分了。

def analyze():
    'Analyze the downloaded reports\' result.'
    health, total = 0, 0
    bad_list, unknown_list = [], []

    for root, dirs, files in os.walk(DIR, topdown=False):
        for name in files:
            if name.endswith('.pdf'):
                total += 1
                fullpath = os.path.join(root, name)
                username, result = parse_result_from_pdf(fullpath)
                logger.info('%s\t%s\t%s\t%s' % (str(total).ljust(6), (username or 'Unknown').ljust(8), u'😱😱😱' if result else u'😀', name))

                if result is True:
                    bad_list.append(name)
                elif result is False:
                    health += 1
                else:
                    unknown_list.append(name)

    logger.info('In total: \nGood: %d, Bad: %d, Unknown: %d' % (health, len(bad_list), len(unknown_list)))

    if len(bad_list) > 0:
        logger.info('Bad list: %s' % (', '.join(bad_list)))

    if len(unknown_list) > 0:
        logger.info('Unknown list: %s' % (', '.join(unknown_list)))

输出很无聊,就不贴出来了,最后的统计结果是这样的:

21:17:34 - __main__ - INFO - In total:
Good: 16098, Bad: 0, Unknown: 1
21:17:34 - __main__ - INFO - Unknown list: XXXFFF100000.pdf

没有一个感染的,全部都合格,那个Unknown的结果后面单独查了一下是一个无效PDF的问题。老实说从数据上看第一眼我有点儿震惊,想了想也挺正常,挺庆幸的。至少一定程度上证明目前公司层面上没有瞒报的问题,所有人都健康。截止到这里,这是我爬这段数据的原本初衷。

不过话说回来,那些家伙居然没有把身份证号和手机号录到PDF里面,除了一个姓名和结果之外就什么有用的信息都没有,这个层面上我真的有点儿失望,什么福利都没有,白忙活了。

总结

这个机构在首页号称是“助力新冠疫情后期湖北省产前筛查计划”的,还在这次北京疫情中拿到了新冠病毒核酸检测资质。要说他们泄露了公众隐私,从目前结果上看也没有那么夸张,要说他们没有泄露隐私,他们泄露了自己实验结果本身和对应的少量用户信息。

点名了,机构名称叫北京安诺优达医学检验实验室有限公司

背景

下午从某个搞笑视频里听到火影忍者里面的OP,突然很想重温一下其中的一部分剧情,想起几年前最后一次更新的本地库里的集数并不全,有些集数还因为数据来源混乱的原因分辨率特别差。索性想办法这次更新一个完整集合,结合我最近知道的一个爬数据利器。

非(shi)常(zai)感(bao)谢(qian)优酷为我们提供的数据支撑,目前我看到的是所有数据全部无限制可播,所以我应该不用考虑VIP权限和cookie的问题了。下载方法优先想到的是you-get,虽然下载过程中发现有些问题,不过还是想办法绕了过去。其中最大的问题就是--playlist / -l没有生效,貌似是输入url类型不对,不过我始终找不到优酷关于这个播放列表的页面逻辑,貌似并不存在想youtube那样的playlist的单独页面,它只有单集的播放页面,播放列表是嵌套在所有单集页面的右侧的,you-get目前的实现可能还没有覆盖到那一部分数据解析,所以始终提示我找不到下一集。

不想在这个时候去给他们修bug,只能想办法手动找到所有单集的页面地址,然后一一传给you-get去处理了。找了前几集的页面url看了一下规则,也是有规则可寻的,但是优酷在集数之外又有一层分组的逻辑,比如第1-30是第一部分,第31-60是第二部分,依次类推…每个部分的单集url又不一样,这就开始无聊了….我尝试直接在shell里用for生成批量的url然后下载,有两个问题,每个集数部分的URL会不一样,我需要重新不断更换格式化字符串,其次是页面中目前的url中夹杂着特殊符号包括!~,这些符号好像会在shell里面直接被展开,加双引号都没起作用。直接放弃了。

Web Scraper

祭出大杀器Web Scraper,之前看了官方的几个引导视频,但是目前还是不会用太高级的功能,就直接从最基础的操作入口。Create new sitemap -> Input a start-url -> Add new selector -> Input a name -> Select type as Link -> Select elements interactively -> Save selector -> Scrape -> Export data as CSV -> Download, 详细的操作指南这里就不记录了,一顿操作之后我拿到了第一部分的集数数据,大概是这样子的:

web-scraper-order,web-scraper-start-url,item-link,item-link-href
"1590847460-7","https://v.youku.com/v_show/id_XNTI4NjExNDA4.html?spm=a2hbt.13141534.0.13141534&s=cc001f06962411de83b1","7","https://v.youku.com/v_show/id_XNTI4NjEyMTQw.html?s=cc001f06962411de83b1"
"1590847460-22","https://v.youku.com/v_show/id_XNTI4NjExNDA4.html?spm=a2hbt.13141534.0.13141534&s=cc001f06962411de83b1","22","https://v.youku.com/v_show/id_XNTI4NjIxNDEy.html?s=cc001f06962411de83b1"
"1590847460-29","https://v.youku.com/v_show/id_XNTI4NjExNDA4.html?spm=a2hbt.13141534.0.13141534&s=cc001f06962411de83b1","29","https://v.youku.com/v_show/id_XNTI4NjIzNDcy.html?s=cc001f06962411de83b1"
"1590847460-14","https://v.youku.com/v_show/id_XNTI4NjExNDA4.html?spm=a2hbt.13141534.0.13141534&s=cc001f06962411de83b1","14","https://v.youku.com/v_show/id_XNTI4NjE0MTgw.html?s=cc001f06962411de83b1"
...

前面两列的数据分别是集数的序号和爬取页面的地址,对我没有意义,主要是需要第三列的集数号和第四列的页面地址。

下载

先用you-get试试效果怎样:

awk -F ',' 'NR>1 {print $4}' youku-naruto.csv | xargs -I{} you-get -anf --no-caption {}

首先要从csv文件中取到第四列的数据并且传给you-get,所以第一个awk主要负责数据过滤就行了,NR>1是为了过滤csv文件中的第一行头部数据。后面的you-get主要负责下载单集视频即可,其中视频下载的过程是通过分片下载的,但是you-get目前的merge操作出问题了,大致原因是从优酷拿到的response header里面的type并不在下载器目前配置的type list里面,所以无法merge成功,所以指定了-n标识不需要merge,只能想办法后面通过ffmpeg甚至cat解决了。-f标识强制覆盖本地已有的文件,-a是自动重命名。--no-caption标识不需要下载任何字幕和弹幕等数据。

批量下载成功,但是下载下来的视频文件都是每一集的中文名,没有集码,这个我实在不能接受,毕竟之后本地播放器一定是根据字符串的排序来决定在播放列表中的顺序的。那就先暂定用集数作输出视频的文件名,给you-get指定-O就好。所以优化了一下:

awk -F ',' 'NR>1 { system("you-get -anf --no-caption -O " $3 " " $4)}' youku-naruto.csv

因为需要同时从csv中提取两个参数给you-get,所以不能再用xargs了,只能通过awk的内置system函数启动you-get了,拼接好集码编号和url就行。

命名的问题解决了,但是下载的顺序还是无序的。这归根于Web-Scraper在爬取的时候的数据好像就是乱序的,具体什么原因还不清楚,不过我只能先手动解决排序了。

awk -F ',' 'NR>1 { print $3, $4}' youku-naruto.csv | sort | awk '{ system("you-get -anf --no-caption -O " $1 " " $2)}'

先从csv中把集码和url拿到,把集码放在输出先前,然后通过管道传给sort直接排序,最后再取到对应的数据传给awk嵌套的you-get。因为第一个awk已经把有效的数据过滤出来了,所以在第二个awk里面直接取第一二部分就可以,另外第一个awk里面在print的时候用了空格做分隔,所以第二个awk里面可以直接使用默认分隔符就好了。

合并碎片

终于开始批量下载了,前30集的结果大概是这个样子。

➜  1-30 find . -type f | sort | xargs -n 7
./10[00].mp4 ./10[01].mp4 ./10[02].mp4 ./10[03].mp4 ./10[04].mp4 ./10[05].mp4 ./10[06].mp4
./11[00].mp4 ./11[01].mp4 ./11[02].mp4 ./11[03].mp4 ./11[04].mp4 ./11[05].mp4 ./11[06].mp4
./12[00].mp4 ./12[01].mp4 ./12[02].mp4 ./12[03].mp4 ./12[04].mp4 ./12[05].mp4 ./12[06].mp4
./13[00].mp4 ./13[01].mp4 ./13[02].mp4 ./13[03].mp4 ./13[04].mp4 ./13[05].mp4 ./13[06].mp4
./14[00].mp4 ./14[01].mp4 ./14[02].mp4 ./14[03].mp4 ./14[04].mp4 ./14[05].mp4 ./14[06].mp4
./15[00].mp4 ./15[01].mp4 ./15[02].mp4 ./15[03].mp4 ./15[04].mp4 ./15[05].mp4 ./15[06].mp4
./16[00].mp4 ./16[01].mp4 ./16[02].mp4 ./16[03].mp4 ./16[04].mp4 ./16[05].mp4 ./16[06].mp4
./17[00].mp4 ./17[01].mp4 ./17[02].mp4 ./17[03].mp4 ./17[04].mp4 ./17[05].mp4 ./17[06].mp4
./18[00].mp4 ./18[01].mp4 ./18[02].mp4 ./18[03].mp4 ./18[04].mp4 ./18[05].mp4 ./18[06].mp4
./19[00].mp4 ./19[01].mp4 ./19[02].mp4 ./19[03].mp4 ./19[04].mp4 ./19[05].mp4 ./19[06].mp4
./1[00].mp4 ./1[01].mp4 ./1[02].mp4 ./1[03].mp4 ./1[04].mp4 ./1[05].mp4 ./1[06].mp4
./20[00].mp4 ./20[01].mp4 ./20[02].mp4 ./20[03].mp4 ./20[04].mp4 ./20[05].mp4 ./20[06].mp4
./21[00].mp4 ./21[01].mp4 ./21[02].mp4 ./21[03].mp4 ./21[04].mp4 ./21[05].mp4 ./21[06].mp4
./22[00].mp4 ./22[01].mp4 ./22[02].mp4 ./22[03].mp4 ./22[04].mp4 ./22[05].mp4 ./22[06].mp4
./23[00].mp4 ./23[01].mp4 ./23[02].mp4 ./23[03].mp4 ./23[04].mp4 ./23[05].mp4 ./23[06].mp4
./24[00].mp4 ./24[01].mp4 ./24[02].mp4 ./24[03].mp4 ./24[04].mp4 ./24[05].mp4 ./24[06].mp4
./25[00].mp4 ./25[01].mp4 ./25[02].mp4 ./25[03].mp4 ./25[04].mp4 ./25[05].mp4 ./25[06].mp4
./26[00].mp4 ./26[01].mp4 ./26[02].mp4 ./26[03].mp4 ./26[04].mp4 ./26[05].mp4 ./26[06].mp4
./26[07].mp4 ./27[00].mp4 ./27[01].mp4 ./27[02].mp4 ./27[03].mp4 ./27[04].mp4 ./27[05].mp4
./27[06].mp4 ./28[00].mp4 ./28[01].mp4 ./28[02].mp4 ./28[03].mp4 ./28[04].mp4 ./28[05].mp4
./28[06].mp4 ./29[00].mp4 ./29[01].mp4 ./29[02].mp4 ./29[03].mp4 ./29[04].mp4 ./29[05].mp4
./29[06].mp4 ./30[00].mp4 ./30[01].mp4 ./30[02].mp4 ./30[03].mp4 ./30[04].mp4 ./30[05].mp4
./30[06].mp4 ./3[00].mp4 ./3[01].mp4 ./3[02].mp4 ./3[03].mp4 ./3[04].mp4 ./3[05].mp4
./3[06].mp4 ./4[00].mp4 ./4[01].mp4 ./4[02].mp4 ./4[03].mp4 ./4[04].mp4 ./4[05].mp4
./4[06].mp4 ./5[00].mp4 ./5[01].mp4 ./5[02].mp4 ./5[03].mp4 ./5[04].mp4 ./5[05].mp4
./5[06].mp4 ./6[00].mp4 ./6[01].mp4 ./6[02].mp4 ./6[03].mp4 ./6[04].mp4 ./6[05].mp4
./6[06].mp4 ./7[00].mp4 ./7[01].mp4 ./7[02].mp4 ./7[03].mp4 ./7[04].mp4 ./7[05].mp4
./7[06].mp4 ./8[00].mp4 ./8[01].mp4 ./8[02].mp4 ./8[03].mp4 ./8[04].mp4 ./8[05].mp4
./8[06].mp4 ./9[00].mp4 ./9[01].mp4 ./9[02].mp4 ./9[03].mp4 ./9[04].mp4 ./9[05].mp4
./9[06].mp4 ./main.py ./我是木叶丸[00].mp4 ./我是木叶丸[01].mp4 ./我是木叶丸[02].mp4 ./我是木叶丸[03].mp4 ./我是木叶丸[04].mp4
./我是木叶丸[05].mp4 ./我是木叶丸[06].mp4

本来是期望xargs -n 7就可以直接针对每一集的7个碎片文件进行分组然后直接cat,但是仔细看了结果才发现第26集居然有7个碎片,不确定后面有没有类似这种情况的出现,想了半天也没有什么好的办法能直接在bash里面做这种group by的操作,有点尴尬,最后还是不得不借助Python来处理。用Python内置的set类型想来是更方便的。

import sys

files = [x.rstrip() for x in sys.stdin.readlines()]
prefixes = set(map(lambda x: x[:x.index('[')], files))
groups = [[y for y in files if y.startswith(x + '[')] for x in prefixes]
print(reduce(lambda sum, x: sum + ' '.join(sorted(x)) + '\n', groups, ''))

find的结果继续pipe到python脚本,分组终于告一段落了。

➜  1-30 find . -type f -name "*.mp4" | python ../main.py
Alias tip: ff "*.mp4" | python ../main.py
./我是木叶丸[00].mp4 ./我是木叶丸[01].mp4 ./我是木叶丸[02].mp4 ./我是木叶丸[03].mp4 ./我是木叶丸[04].mp4 ./我是木叶丸[05].mp4 ./我是木叶丸[06].mp4
./8[00].mp4 ./8[01].mp4 ./8[02].mp4 ./8[03].mp4 ./8[04].mp4 ./8[05].mp4 ./8[06].mp4
./9[00].mp4 ./9[01].mp4 ./9[02].mp4 ./9[03].mp4 ./9[04].mp4 ./9[05].mp4 ./9[06].mp4
./4[00].mp4 ./4[01].mp4 ./4[02].mp4 ./4[03].mp4 ./4[04].mp4 ./4[05].mp4 ./4[06].mp4
./5[00].mp4 ./5[01].mp4 ./5[02].mp4 ./5[03].mp4 ./5[04].mp4 ./5[05].mp4 ./5[06].mp4
./6[00].mp4 ./6[01].mp4 ./6[02].mp4 ./6[03].mp4 ./6[04].mp4 ./6[05].mp4 ./6[06].mp4
./7[00].mp4 ./7[01].mp4 ./7[02].mp4 ./7[03].mp4 ./7[04].mp4 ./7[05].mp4 ./7[06].mp4
./1[00].mp4 ./1[01].mp4 ./1[02].mp4 ./1[03].mp4 ./1[04].mp4 ./1[05].mp4 ./1[06].mp4
./3[00].mp4 ./3[01].mp4 ./3[02].mp4 ./3[03].mp4 ./3[04].mp4 ./3[05].mp4 ./3[06].mp4
./23[00].mp4 ./23[01].mp4 ./23[02].mp4 ./23[03].mp4 ./23[04].mp4 ./23[05].mp4 ./23[06].mp4
./22[00].mp4 ./22[01].mp4 ./22[02].mp4 ./22[03].mp4 ./22[04].mp4 ./22[05].mp4 ./22[06].mp4
./21[00].mp4 ./21[01].mp4 ./21[02].mp4 ./21[03].mp4 ./21[04].mp4 ./21[05].mp4 ./21[06].mp4
./20[00].mp4 ./20[01].mp4 ./20[02].mp4 ./20[03].mp4 ./20[04].mp4 ./20[05].mp4 ./20[06].mp4
./27[00].mp4 ./27[01].mp4 ./27[02].mp4 ./27[03].mp4 ./27[04].mp4 ./27[05].mp4 ./27[06].mp4
./26[00].mp4 ./26[01].mp4 ./26[02].mp4 ./26[03].mp4 ./26[04].mp4 ./26[05].mp4 ./26[06].mp4 ./26[07].mp4
./25[00].mp4 ./25[01].mp4 ./25[02].mp4 ./25[03].mp4 ./25[04].mp4 ./25[05].mp4 ./25[06].mp4
./24[00].mp4 ./24[01].mp4 ./24[02].mp4 ./24[03].mp4 ./24[04].mp4 ./24[05].mp4 ./24[06].mp4
./29[00].mp4 ./29[01].mp4 ./29[02].mp4 ./29[03].mp4 ./29[04].mp4 ./29[05].mp4 ./29[06].mp4
./28[00].mp4 ./28[01].mp4 ./28[02].mp4 ./28[03].mp4 ./28[04].mp4 ./28[05].mp4 ./28[06].mp4
./30[00].mp4 ./30[01].mp4 ./30[02].mp4 ./30[03].mp4 ./30[04].mp4 ./30[05].mp4 ./30[06].mp4
./18[00].mp4 ./18[01].mp4 ./18[02].mp4 ./18[03].mp4 ./18[04].mp4 ./18[05].mp4 ./18[06].mp4
./19[00].mp4 ./19[01].mp4 ./19[02].mp4 ./19[03].mp4 ./19[04].mp4 ./19[05].mp4 ./19[06].mp4
./16[00].mp4 ./16[01].mp4 ./16[02].mp4 ./16[03].mp4 ./16[04].mp4 ./16[05].mp4 ./16[06].mp4
./17[00].mp4 ./17[01].mp4 ./17[02].mp4 ./17[03].mp4 ./17[04].mp4 ./17[05].mp4 ./17[06].mp4
./14[00].mp4 ./14[01].mp4 ./14[02].mp4 ./14[03].mp4 ./14[04].mp4 ./14[05].mp4 ./14[06].mp4
./15[00].mp4 ./15[01].mp4 ./15[02].mp4 ./15[03].mp4 ./15[04].mp4 ./15[05].mp4 ./15[06].mp4
./12[00].mp4 ./12[01].mp4 ./12[02].mp4 ./12[03].mp4 ./12[04].mp4 ./12[05].mp4 ./12[06].mp4
./13[00].mp4 ./13[01].mp4 ./13[02].mp4 ./13[03].mp4 ./13[04].mp4 ./13[05].mp4 ./13[06].mp4
./10[00].mp4 ./10[01].mp4 ./10[02].mp4 ./10[03].mp4 ./10[04].mp4 ./10[05].mp4 ./10[06].mp4
./11[00].mp4 ./11[01].mp4 ./11[02].mp4 ./11[03].mp4 ./11[04].mp4 ./11[05].mp4 ./11[06].mp4

接下来只要把分组后每一行的结果拼成一个文件就可以了,这个时候又有一个问题,生成的文件名我应该如何定义和取到,想了想最后还是直接在python里面一起输出了,直接放在行首。既然文件名都输出了,为什么不直接把cat/ffmpeg的命名部分一次性合成呢?

from __future__ import print_function
import sys

files = sorted([x.rstrip() for x in sys.stdin.readlines()])
prefixes = sorted(set(map(lambda x: x[:x.index('[')], files)))

for prefix in prefixes: 
    print('echo "', end='')

    for file in files:
        if file.startswith(prefix + '['):
            print('file \'%s\'\\n' % file, end=' ')

    print('"| ffmpeg -protocol_whitelist file,pipe -safe 0 -f concat -i pipe: -c copy ' + prefix + '.mp4')

至此,每一集的合成命令大概是这样的:

echo "file './1[00].mp4' \\nfile './1[01].mp4' \\nfile './1[02].mp4' \\nfile './1[03].mp4' \\nfile './1[04].mp4' \\nfile './1[05].mp4' \\nfile './1[06].mp4' \\nfile './1[07].mp4' \\n"| ffmpeg -protocol_whitelist file,pipe -safe 0 -f concat -i pipe: -c copy ./1.mp4

为什么没有用cat?我试了一下,合成的文件是可以无缝跳转播放的,文件大小也符合预期,但是在播放器里面无法seek(到第二个视频碎片之后的内容),猜测原因是cat只是在数据上的合并,但是最后输出的时候没有给输出文件修改视频本身相关的meta data,毕竟cat是专门针对所有的通用文件的,视频文件的合并处理最终还是需要ffmpeg

Concatenation of files with same codecs

There are two methods within ffmpeg that can be used to concatenate files of the same type:

  1. the concat ‘‘demuxer’’
  2. the concat ‘‘protocol’’

The demuxer is more flexible – it requires the same codecs, but different container formats can be used; and it can be used with any container formats, while the protocol only works with a select few containers.

While the demuxer works at the stream level, the concat protocol works at the file level.

关于pipe protocol的使用定义,参见 ffmpeg protocols

接下来把合成的语句pipe到下一个命令,这里用xargs处理。

find . -type f -name "*.mp4" | python ../main.py | xargs -I{} sh -c "{}"

合并完成。

中文问题

To be continued.

爬虫的开源项目已经满天飞了,各式各样的姿势都有,但是一旦中断维护的项目几乎就算是废掉了,因为网站改版了或者服务升级了。

起初好像是我发现A站上有各种搞笑视频拼接起来的合集,把各种小视频片段拼接起来,然后选择性加一些文案到视频里面,热度都还挺高,所以我想了一下自己实践的可行性。

  1. 首先需要视频素材,小视频片段很容易爬,比如从Twitter上下手。
  2. 需要有一个收集方法,比如从某些po主的首页爬,也可以是自己的点赞过包含视频的Twitter。
  3. 所有爬取的视频存储在本地,生成每日或者每周的视频合集。
  4. 通过ffmpeg合并视频,选择性加一些文案。
  5. 上传视频的自动化。

但是慢慢开始写的时候很多想法又变了,比如:

  1. 核心的操作其实还是下载和上传的自动化,加上任务的自动化管理,所以就放弃了从Twitter上爬视频。虽然我很久之前就写过爬Twitter的各种媒体资源,但是不想一开始就把重心放在视频片段管理和合并的逻辑上。

  2. 最后转向从youtube上爬取,因为相比之下不需要管理片段和合并的操作了。

    但是这么做还是会有一些侵权风险。

  3. 上传视频到A站的自动化应该是我给自己挖的最大的坑了吧,因为我用了Selenium,但是没有一次性考虑好运行环境的问题。

    1. 起初并没有想那么多,打算写完了部署在Google Cloud (GCP) 或者Raspberry Pi 3B+或者家里的mac-mini上的,考虑用Selenium还是没什么大问题的。优先选择GCP的原因是从youtube上下载速度必然非常快,但是上传到A站服务器一定会慢,但是应该还能接受。

    2. 用Selenium的时候最开始我用的是Chrome/chrome-driver,平时不觉得,但是在gcp上跑的第二天后就提醒我该系统升级了,内存已经超载了,GCP应该是有一个tolerance time,前面几天运行没有问题,但是很快就炸了。由于某些不可描述的原因,我还在薅GCP那300刀的羊毛,创建的实例只有0.6G的最低内存配置,升级系统会加速消费速度,猜测还会影响其他已经在运行的其他服务的带宽,所以最终迁移到了Raspberry Pi上。

    3. 家里的RPi最近一次用是用来部署一个Kodi结合Plex的家庭影音平台,后来因为搬家的原因所以基本闲置了。我忘了几年前买它时候的配置,那还是一个armv7l的32bit架构。Chrome又坑了我一次,不知道具体从什么时候开始,Google放弃了支持32bit的Chrome for linux。这下完犊子了,我在Debian 9上各种尝试找到最后一个支持32bit的chrome和chromedriver安装包,尝试dpkg离线安装但是死活不成功,好像是依赖库的版本和其他软件不兼容的问题。

    4. 实在没办法了,Selenium还是支持不少其他webdriver的,优先考虑的自然是Firefox,庆幸的是FF还是一直32bit的(给Mozilla点个赞!),安装过程很顺利。但是运行过程中又碰到了一个奇怪的问题,辗转几次定位到了是virtual display的问题。奇怪的是在GCP上使用Chrome并没有问题,不知道是GCP还是Chrome做了额外的处理。

      UPDATE 1:

      最终还是没有在RPi上通过Firefox跑起来,Firefox for Raspbian还是更新速度还是赶不上,碰到了marionette不兼容Selenium的问题,找到的解决办法都是建议升级Firefox,最终放弃了。

      现在重新部署到阿里云T5 Debian 10上了,x86_64架构,Chromium 79,终于稳定运行了。不过这个服务器不久就要到期了,只能暂时先这么用着再说了。

    5. 运行方式的选择途中我还想过另一个种奇葩的逻辑:用GCP的优势毕竟是因为youtube下载快,用阿里云/RPi的优势是长传快,为什么不结合一下?没有必要非要让所有代码运行在一个机器下。所以尝试了一下可行性,单独让youtube-downloader-cli相关的代码在GCP上运行,然后通过rsync或者Paramiko把所有视频和数据库(sqlite文件)传回到阿里云/RPi,最后上传到A站等。这种做法又会直接让sqlite数据的状态管理发生冲突。所以至少得先换掉数据库,比如把数据库换成托管在阿里云的MySQL自建库。之所以考虑rsync传输是因为它支持“断点续传”,校验和重试。用Paramiko是因为Python原生的调用方式,但是它内置的SFTP功能和rsync相比我没测试过。

    6. 为什么不用mac-mini?因为费电….🤣

  4. 交互方式上也发生了一些改变,最开始是基于click interactive模式,但是后来我觉得操作上可以再懒一点儿,运行不想要那么多的人为干预,所以去掉了所有的interactive交互模式,把不会经常变化的参数全部移到配置文件里控制。

video-worker-structure

这个项目看起来只是在解决一个问题,但是其实是由三个小项目组成的,这一点倒是从头到尾都没变过,主要是为了逻辑的拓展,比如后期接入B站等上传的自动化逻辑。断断续续已经写了三个月多了,纯粹是为了自娱自乐。碰到了各种奇葩问题,也发现了一些有意思的东西。比如很多命令和参数设计完之后我自己都记不住,加入了justfile支持。

所有代码都托管在了Github,其中两个项目后来改成了Public,但是video-worker还是没有开源,还是因为那个原因。

  • acfun-uploader-cli
  • youtube-downloader-cli
  • video-worker
    ➜  video-worker git:(master) ./main.py --help
    Usage: main.py [OPTIONS] COMMAND [ARGS]...
    
    Options:
      --help  Show this message and exit.
    
    Commands:
      account    Create and/or list accounts.
      add        Parse and download the specified youtube videos.
      channel    Load video platform channels.
      cleanup    Clean up all the videos which has been uploaded to all the...
      configure  Show the current configurations.
      platform   List all the platforms' flags.
      remove     Remove the specified type entities by id.
      run        Start the downloader and uploader in alone threads.
    ➜  video-worker git:(master) 
    

写这篇文章的过程中我有好几次想中断,不太想公布一些问题,毕竟涉及到crawler都会有些擦边球的意思,考虑到看到的人流量几乎可以忽略不计就还是发了吧。写出来的原本目的是这个项目写到现在这个时间节点上,我也需要捋一捋我自己的想法结构。