步骤/目录： 1.厨具价格及购买 2.食品制作过程 3.通用食品制作过程本文首发于个人博客https://lisper517.top/index.php/archives/61/，转载请注明出处。本文的目的是探讨如何做一人份的饭。1.厨具价格及购买购买各种厨具。价格 名称 579 米家智能调压电压力锅5L（带米量杯，饭勺，汤勺） 189 米家智能小饭煲2（带米量杯，舀饭勺）在米家app中可以管理这些智能家居，在手机上进行预约、选择菜谱等，比如智能小饭煲也可用于煮鸡蛋。另外还有很多有意思的菜谱，可以说app构成了智能厨具60%的功能。唯一可惜的是不能自己创造菜谱，只能等官方发布。不仅小米推出了智能家居，其他品牌也有智能生态，但小米的比较全，虽然很多是贴牌的。2.食品制作过程（1）2022.10.28一块鸡胸肉切成丁、分作三份（一份约100g），鸡胸肉丁放入陶瓷碗中，两份冷冻、一份即食。鸡胸肉丁加入生抽、白醋、盐、鸡精，腌制半小时。用 米家智能小饭煲2 自带的米量杯量1平杯米，倒入饭煲内胆，自来水淘两次，加水到1.5杯线。米量杯量取1/4杯绿豆、1/4杯玉米渣，放入钢碗中浸泡半小时。取半颗土豆（约104g）切成粗丝、半根胡萝卜（约113g）切成片，迷你炒锅倒菜籽油，用木制勺子翻炒，加生抽、盐，炒至胡萝卜可用勺子稍用力扎透，将锅中的菜倒入饭煲。锅中倒油，用木制炒勺翻炒鸡胸肉，炒至失去肉色，倒入饭煲内。泡好的绿豆、玉米渣倒入饭煲内。在米家app中选择 精煮饭 ，耗时1h，等待。舀出饭后，加入三勺醪糟米。评价：一般。少量绿豆或玉米渣较硬，可能没有煮熟，下次可以搅拌一下，或者再加点水。3.通用食品制作过程单人做饭想节约时间，无非是做焖饭或者乱炖。（1）焖饭焖饭就是在电饭煲里，下面放生米，上面放菜，一同出锅。通用做法是加米，加调味料，菜肉切丁放入，加水至淹没菜肉。焖饭的教程有很多，这里就不献丑了。焖饭的缺点是菜还需要自己切成丁，不然容易煮不熟，但是切丁比较耗时；一些比较难熟的食材还需要提前炒熟。另外电饭煲要买大一点的，否则放不下。（2）乱炖这里着重讲一下乱炖，因为除了味道一般，基本没有缺点。笔者的通用做法是使用电压力锅一次做午饭、晚饭。将250g肉切一刀分成2块，加入两种非肉类食材（也基本不怎么切），加入通用调味料（3块冰糖或1勺白砂糖，半勺辣椒粉，2勺生抽，盐、料酒、醋、老抽、郫县豆瓣各1勺，如果有其他调味料可适量添加，如鸡精味精），加水到高出食材。然后用电饭煲做2顿米饭。这样做的优点是处理时间快，基本上15-20min就能做好2顿的菜（不切菜不仅因为懒，还因为电压力锅切菜后容易炖烂）；由于大部分盐都溶解在水里，盐也不会特别高；不需要植物油，非常健康（如果想补充油脂，可以买肉时带点肥的）；全程200℃以下水热，不会因焦糊产生致癌物。相比较而言，正常炒菜处理时间长，高油高盐，唯一的优点是味道可以，但如果不会炒菜则味道也好不到哪里去。（3）早饭使用智能家电还可以轻松做早饭。一般来说笔者推荐的搭配是蛋白质+主食+一种水果。最简单的蛋白质来源是鸡蛋，电饭煲可以在app中找到煮鸡蛋的菜谱。主食方面，如果想吃的丰富一点，可以在煮鸡蛋的同时在电饭煲里放支架，放上预制的冷冻包子、馒头等；笔者一般选择速食面包。水果，选择应季的便宜水果即可，比如香蕉、苹果、梨、草莓、蓝莓等，其中香蕉是最方便的。
步骤/目录： 1.需求介绍 2.ffmpeg 3.tinypng 4.squoosh （1）Node.js （2）克隆仓库并安装运行 a.本地运行网页服务器 b.运行桌面版 c.squoosh-cli （3）squoosh的使用 5.修改vimg_processor_multi本文首发于个人博客https://lisper517.top/index.php/archives/60/，转载请注明出处。本文的目的是介绍一些图片压缩的工具和使用方法。本文写作日期为2022年10月19日。运行的平台为win10，编辑器为VS Code。1.需求介绍在处理前端时，常常需要放一些图片在网页中，但是一般素材图片占用空间都很大，直接放到网页上不太合适，如果有方法把图片压缩一下就very的nice。本文就介绍一些图片压缩的工具和方法。2.ffmpeg在之前的文章 Python，爬虫与深度学习（17）——电影的信息爬取、下载与处理（二） 中，笔者用python写了一个vimg_processor类，主要是用ffmpeg来获取图片/视频信息、压缩图片/视频。其中使用ffmpeg压缩图片的命令参考了 这篇文章 ，如下：'ffmpeg -i "{}" -vf palettegen=max_colors=256:stats_mode=single -y "{}"'.format(self.origin_abs_path, self.palette) 'ffmpeg -i "{}" -i "{}" -lavfi "[0][1:v] paletteuse" -pix_fmt "{}" -y "{}"'.format(self.origin_abs_path, self.palette, self.pix_fmt, self.temp_img) 'ffmpeg -i "{}" -vf scale={} -y "{}"'.format(self.temp_img, self.definition, self.compressed_abs_path)总的来说是使用调色板将原图使用的256色减少，然后根据调色板输出图片，最后再压缩图片尺寸。笔者其实不太了解ffmpeg，使用上面的命令，虽然图片占用空间能压缩到很小，但色彩减少导致肉眼能看出很多差距，比较明显的是在人像中，如果涂了口红，压缩后的图片会呈现一种灰红色的质感，这是无法接受的。经过一番寻找，笔者发现了其他压缩图片的方法，它们压缩图片的算法比较复杂，效果也远非简单的3句ffmpeg命令可比。3.tinypngTinyPNG 据说是一个久负盛名的图片压缩网站，可惜它的压缩算法没有开源。如果你只是想压缩几张图片，可以到它的网址 https://tinypng.com/ 手动上传图片，据说每天只能上传20张不超过5MB的图片；如果需要批量压缩图片，你可以到 https://tinypng.com/developers 填写姓名、邮箱申请一个API key（限制是每月只能压缩500张），然后使用python或者其他语言写一个客户端实现压缩脚本，可以参考 这个项目 。使用TinyPNG确实压缩效果不错，可惜源码不开源，而且能压缩的图片张数有限制（据说有方法开源绕开限制，参考 这篇文章 ，原理是tinypng服务器限制上传次数时用到了ip进行判断，所以在构造请求头时修改了ip）。综合考虑下来，笔者放弃了TinyPNG，毕竟万一哪天这类闭源工具开始收费就不太合适了。像TinyPNG这样的在线图片压缩网站还有很多，比如：https://tinyjpg.com/ （TinyPNG的系列网站）https://imagestool.com/zh_CN/compress-images.htmlhttps://squoosh.app/本文将重点介绍squoosh。4.squooshsquoosh 是google chrome labs推出的一款开源图片压缩工具，源码放到了github上： https://github.com/GoogleChromeLabs/squoosh ，这些源码主要是TypeScript、JavaScript写的，但是笔者希望用python调用这个图像压缩的工具，这需要一些技巧，下面以Win10为例讲解。（1）Node.js因为要用JavaScript，需要安装Node.js、npm。按照 runoob教程 ，进行如下操作：到 https://nodejs.org/zh-cn/download/ 下载对应的安装包，笔者选择的是14.15.1的64位Windows安装包（下载地址为 https://nodejs.org/dist/v14.15.1/node-v14.15.1-x64.msi ，因为在 这个问题 中提到开发时用的就是14.15.1版本），一路确认，唯一需要注意的是中间有一步是勾选 Automatically install the neccessary tools ，把这个勾选去掉，因为它会安装其他版本的python3、污染环境。安装完后，在cmd中实验一下，输入 node --version 、 npm --version ，可输出版本号。其他安装方法包括安装二进制文件、docker安装、linux上编译安装，这里就不介绍了。（2）克隆仓库并安装运行a.本地运行网页服务器第一种方法是在本地运行网页服务器，这种方法并不是很推荐。到 项目的github地址 下载zip包（或者你也可用pull到本地），解压到合适的位置，在cmd中进入该目录，进行如下操作：npm install npm run build npm run dev最后一行命令会开启一个本地的服务器，好比你把 https://squoosh.app/ 搬到了本地的服务器运行。笔者在实验时出现了一些错误，具体信息如下：>npm install npm WARN read-shrinkwrap This version of npm is compatible with lockfileVersion@1, but package-lock.json was generated for lockfileVersion@2. I'll try to do my best with it! > squoosh@2.0.0 prepare D:\squoosh-dev > husky install npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@2.1.3 (node_modules\fsevents): npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@2.1.3: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"}) added 816 packages from 299 contributors and audited 819 packages in 34.939s 43 packages are looking for funding run `npm fund` for details found 106 vulnerabilities (89 moderate, 12 high, 5 critical) run `npm audit fix` to fix them, or `npm audit` for details >npm run build > squoosh@2.0.0 build D:\squoosh-dev > rollup -c && node lib/move-output.js src/static-build/index.tsx → .tmp/build... Browserslist: caniuse-lite is outdated. Please run: npx browserslist@latest --update-db Unable to determine site origin, defaulting to https://squoosh.app created .tmp/build in 26.7s>npm run dev > squoosh@2.0.0 dev D:\squoosh-dev > DEV_PORT="${DEV_PORT:=5000}" run-p watch serve 'DEV_PORT' 不是内部或外部命令，也不是可运行的程序 或批处理文件。 npm ERR! code ELIFECYCLE npm ERR! errno 1 npm ERR! squoosh@2.0.0 dev: `DEV_PORT="${DEV_PORT:=5000}" run-p watch serve` npm ERR! Exit status 1 npm ERR! npm ERR! Failed at the squoosh@2.0.0 dev script. npm ERR! This is probably not a problem with npm. There is likely additional logging output above. npm ERR! A complete log of this run can be found in: npm ERR! C:\Users\xxx\AppData\Roaming\npm-cache\_logs\xxxx-debug.log不知道这是为什么。即使运行 npm audit fix 、 npx browserslist@latest --update-db 也无济于事；安装16版本的Node.js，又报错 Could not load /squoosh-dev/src/shared/prerendered-app/colors.css ，最后只好放弃。根据报错信息，如果成功安装，大概访问本机的5000端口就能看到squoosh网页。实际上，就算通过这种方式成功安装了squoosh，也和浏览器访问 https://squoosh.app/ 几乎一样，唯一的区别是不需要联网。如果有需要，笔者更推荐下一种方法。b.运行桌面版根据 https://github.com/matiasbenedetto/squoosh-desktop-app 这个项目的介绍，到 https://squoosh-desktop.vercel.app/ 网页去下载 Windows版的Portable文件 即可，是一个exe文件（只有45MB左右大小），打开来是一个隐藏了一些UI的浏览器，这种方式下都不需要安装Node.js了。c.squoosh-cli上面两种方法提供的主要是网页UI，只适合少量图片压缩。如果要大量压缩图片，最好还是能使用命令行、脚本。squoosh确实提供了这样的API。同样安装Node.js-v14.15.1，下载squoosh项目的zip包，解压到合适的位置，在cmd中进入该目录，但是这次执行以下命令：npm i -g @squoosh/cli这样的话会安装squoosh-cli，以后在cmd中用 squoosh-cli <options...> 就可以调用squoosh进行图片压缩。或者你也可以不运行 npm i -g @squoosh/cli 安装，而是直接用 npx @squoosh/cli <options...> 的格式来调用squoosh（推荐还是用 npm i -g @squoosh/cli 安装一下）。最后记得把squoosh-dev文件夹加到PATH中，就可以随时调用squoosh-cli。（3）squoosh的使用前两种方法安装squoosh或者网页访问 https://squoosh.app/ 时使用都差不多，这里主要介绍一下命令行使用 squoosh-cli 的参数。cmd输入 squoosh-cli -h 可显示以下信息：Usage: squoosh-cli [options] <files...> Options: -d, --output-dir Output directory (default: ".") -s, --suffix <suffix> Append suffix to output files (default: "") --max-optimizer-rounds <rounds> Maximum number of compressions to use for auto optimizations (default: "6") --optimizer-butteraugli-target <butteraugli distance> Target Butteraugli distance for auto optimizer (default: "1.4") --resize [config] Resize the image before compressing --quant [config] Reduce the number of colors used (aka. paletting) --rotate [config] Rotate image --mozjpeg [config] Use MozJPEG to generate a .jpg file with the given configuration --webp [config] Use WebP to generate a .webp file with the given configuration --avif [config] Use AVIF to generate a .avif file with the given configuration --jxl [config] Use JPEG-XL to generate a .jxl file with the given configuration --wp2 [config] Use WebP2 to generate a .wp2 file with the given configuration --oxipng [config] Use OxiPNG to generate a .png file with the given configuration -h, --help display help for command参考 这篇文章 ，使用以下参数：squoosh-cli --mozjpeg {quality:75,baseline:false,arithmetic:false,progressive:true,optimize_coding:true,smoothing:0,color_space:3,quant_table:3,trellis_multipass:false,trellis_opt_zero:false,trellis_opt_table:false,trellis_loops:1,auto_subsample:true,chroma_subsample:2,separate_chroma_quality:false,chroma_quality:75} "输入路径" -d "输出目录" -s "#test#"就能达到非常不错的压缩效果。你可以自己在squoosh网页里实验不同的参数，笔者只实验调整了一下quality。5.修改vimg_processor_multi修改了之前的vimg_processor_multi.py，调整了vimg_processor的compress_img方法，稍微修改了compress_imgs函数，最终成品如下（所有标注 #需要更改 的上一行是可能需要更改的地方）：#!/usr/bin/python3 # -*- coding: UTF-8 -*- import os, time, json, subprocess, re, traceback, redis, platform, sys from MyPythonLib import log as mylog from MyPythonLib import redis as myredis #CPU_THREADS = (12, 8) CPU_THREADS = (15, 10) # ffmpeg软编解码时线程数，根据CPU配置调整。分别为解码用的线程、编码用的线程，在压缩时，前者大于后者 #需要更改 # 可以用一个文件实验最佳的CPU_THREADS。软编解码的快慢看ffmpeg最右边的 speed=数字x NAS_mounted_path = 'D:\\' # NAS挂载路径。由于函数get_redis_rel_path_from_abs_path，最后一个分隔符必须带上 #需要更改 CWD = os.getcwd() r_pool = myredis.get_pool(host='192.168.1.1', port=56379) #需要更改 r_cur = redis.Redis(connection_pool=r_pool) log_path = os.path.join(CWD, 'logs') #需要更改 if not os.path.isdir(s=log_path): os.mkdir(path=log_path) log = mylog.get_logger(abs_path=log_path, log_file_name='vimg_processor_multi.log') TEMP_DIR = os.path.join(CWD, 'temp') # temp这个文件夹主要是在压缩图片时存放临时的调色板 #需要更改 if not os.path.isdir(s=TEMP_DIR): os.mkdir(path=TEMP_DIR) redis_key_set_todo = 'vimg_processor:set:todo' # 这个redis集合存放需要压缩的文件的相对路径（仅包含压缩前的文件名），路径分隔符为linux的/（下同） redis_key_set_wrong = 'vimg_processor:set:wrong' #集合，存放压缩时出现错误的文件的相对路径（仅包含压缩前的文件名） redis_key_str_ongoing = 'vimg_processor:str:ongoing:' # 存放正在压缩的文件的相对路径，键名包含压缩前的文件相对路径，值为压缩后的文件相对路径 redis_key_str_done = 'vimg_processor:str:done:' # 存放已压缩完的文件的相对路径，键名包含压缩前的文件相对路径，值为压缩后的文件相对路径 start_time = time.time() fail_msg_list = [] # 每次有错误信息时都放到这个列表中 sys_kind = platform.system() # 字符串，表示系统类型 def get_suffix(filename: str) -> str: '''返回文件后缀名。若文件无后缀名（以 . 结尾，或者不含 . ）则返回空字符串。 :param filename: 文件名（可带路径前缀） :return str，文件后缀名（不含 . ）''' suffix = os.path.splitext(filename)[-1] return (suffix[1:], '')[suffix == ''] def is_video(filename: str) -> bool: '''根据后缀名判断文件是否视频。 :param filename: 文件名（可带路径前缀） :return bool，True表示文件是视频，False表示非视频''' video_suffix_set = { "WMV", "ASF", "ASX", "RM", "RMVB", "MP4", "TS", "MPG", "3GP", "MOV", "M4V", "AVI", "MKV", "FIV", "VOB", "FLV", "MPEG", "ISO" } #需要更改 #iso文件不一定能用ffmpeg打开，可以自行取舍 return (False, True)[get_suffix(filename).upper() in video_suffix_set] def is_img(filename: str) -> bool: '''根据后缀名判断文件是否图片。 :param filename: 文件名（可带路径前缀） :return bool，True表示文件是图片，False表示非图片''' img_suffix_set = {"JPG", "JPEG", "BMP", "PNG", "WEBP"} #需要更改 return (False, True)[get_suffix(filename).upper() in img_suffix_set] def replace_path_seperator(path: str, sys_kind: str) -> str: '''根据操作系统，替换路径中的分隔符。 :param path: 需要替换的路径 :param sys_kind: 目标操作系统 :return str，替换好的路径''' if sys_kind == 'Windows': return path.replace('/', '\\') elif sys_kind == 'Linux': return path.replace('\\', '/') def is_origin(filename: str) -> bool: '''接受文件名，判断这个文件是源文件还是压缩后的文件。 :param filename: 文件名（可带路径前缀） :return bool，True表示该文件为源文件''' prefix = os.path.splitext(filename)[0] return (True, False)[re.search(r'#test#$', prefix) != None] def get_redis_rel_path_from_abs_path(abs_path: str) -> str: '''根据本机上文件的绝对路径，返回存储到redis中的相对路径。 :由于redis中存储的是使用linux分隔符的相对路径，所以需要这个函数转换： :将abs_path首先去掉NAS_mounted_path，然后分隔符换成linux的。由于这个函数，NAS_mounted_path最后需要带上一个分隔符 :param abs_path: 文件在本机的绝对路径 :return str，redis中存储的相对路径，即 XX/XX/XX 这样的形式。注意最前面没有分隔符''' global NAS_mounted_path redis_rel_path = abs_path[len(NAS_mounted_path):] return replace_path_seperator(redis_rel_path, sys_kind='Linux') def get_abs_path_from_redis_rel_path(redis_rel_path: str) -> str: '''根据redis中存储的相对路径，返回本机上该文件的绝对路径。 :将redis_rel_path连上NAS_mounted_path，然后替换分隔符为本机的 :param redis_rel_path: redis中存储的相对路径 :return str，文件在本机中的绝对路径''' global NAS_mounted_path, sys_kind abs_path = os.path.join(NAS_mounted_path, redis_rel_path) return replace_path_seperator(abs_path, sys_kind=sys_kind) def get_compressed_abs_path(abs_path: str, fmt: str = '') -> str: '''根据源文件的绝对路径，返回压缩后文件的绝对路径。这里的绝对路径都是本机上的。 :压缩后的图像或者视频将放在同路径下，所以需要给转换后的文件名中加上字符串#test#（在后缀名前）：1.mp4 -> 1#test#.mp4 :有些特殊情况下压缩后文件会重名，比如一个文件夹中同时存在1.mp4、1.rmvb时，又用fmt指定压缩后格式。此时会对压缩后的文件加上序号重命名（1#test#.mp4，1#test#(1).mp4，1#test#(2).mp4） :param abs_path: 需要压缩的文件的绝对路径 :param fmt: 指定转换后文件的格式，为空字符串时不改变格式 :return str，压缩后文件的绝对路径，根据操作系统调整''' prefix, suffix = os.path.splitext(abs_path) suffix = (fmt, suffix.lstrip('.'))[fmt == ''] compressed_abs_path = prefix + '#test#.' + suffix i = 1 while (os.path.exists(compressed_abs_path)): compressed_abs_path = prefix + '#test#(' + str(i) + ').' + suffix i += 1 return compressed_abs_path def get_origin_abs_path(abs_path: str) -> str: '''根据压缩后文件的绝对路径到redis中查询，返回源文件的绝对路径。这里的绝对路径都是本机上的。 :这个函数只应该用于已经压缩完的文件，函数将到redis_key_str_done的所有字符串中进行查询 :param abs_path: 压缩后文件的绝对路径 :return str，源文件的绝对路径，根据操作系统调整；若无法查询到，返回空字符串''' global r_cur, sys_kind, NAS_mounted_path, redis_key_str_done compressed_redis_rel_path = get_redis_rel_path_from_abs_path(abs_path) redis_rel_paths_keys = r_cur.keys(redis_key_str_done + '*') for redis_rel_path_key in redis_rel_paths_keys: if r_cur.get(redis_rel_path_key) == compressed_redis_rel_path: origin_abs_path = redis_rel_path_key[len(redis_key_str_done):] origin_abs_path = replace_path_seperator(origin_abs_path, sys_kind=sys_kind) origin_abs_path = os.path.join(NAS_mounted_path, origin_abs_path) return origin_abs_path return '' class vimg_processor(object): '''用于压缩的图像视频类。初始化时会读取图像视频的一些参数。''' def __init__(self, origin_abs_path: str, compressed_abs_path: str = "", video_size: str = '', img_max_pix: int = 0, fmt: str = ''): '''用文件的绝对路径等参数初始化对象。 :param origin_abs_path: 源文件的绝对路径 :param compressed_abs_path: 输出文件的绝对路径。默认为同目录下文件名末加#test#保存（1.mp4 -> 1#test#.mp4），有重名时会自动按 1#test#.mp4 、1#test#(1).mp4 、1#test#(2).mp4 的顺序重命名 :param video_size: 输出视频文件时，适配的屏幕大小。有 phone 、 pad 、 pc 、 空字符串 四种选择，空字符串表示保留原宽高 :param img_max_pix: 输出图片文件时，若源图片的宽高同时超过该值，宽高中较大的一个数值会被缩小到该值，另一个数值也等比例缩小；为0时，表示图片的宽高不缩小（宽高的单位为像素点） :param fmt: 输出文件的格式，即后缀名。为空字符串时，表示按原格式输出。其他可取值如 mp4 、 jpg 等''' self.origin_abs_path = origin_abs_path self.compressed_abs_path = (compressed_abs_path, get_compressed_abs_path( origin_abs_path, fmt=fmt))[compressed_abs_path == ""] self.width, self.height = 0, 1 if is_video(origin_abs_path): self.get_video_info() w, h = self.width, self.height ratio = w / h self.ratio = ratio definition = '' if video_size == 'phone': if ratio <= 0.6: definition = (f'{w}:{h}', '360:640')[w >= 360] elif ratio <= 1.0: definition = (f'{w}:{h}', '360:480')[w >= 360] elif ratio <= 1.66: definition = (f'{w}:{h}', '480:360')[w >= 480] elif ratio > 1.66: definition = (f'{w}:{h}', '640:360')[w >= 640] elif video_size == 'pad': if ratio <= 0.6: definition = (f'{w}:{h}', '720:1280')[w >= 720] elif ratio <= 1.0: definition = (f'{w}:{h}', '720:960')[w >= 720] elif ratio <= 1.66: definition = (f'{w}:{h}', '960:720')[w >= 960] elif ratio > 1.66: definition = (f'{w}:{h}', '1280:720')[w >= 1280] elif video_size == 'pc': if ratio <= 0.6: definition = (f'{w}:{h}', '1080:1920')[w >= 1080] elif ratio <= 1.0: definition = (f'{w}:{h}', '1080:1440')[w >= 1080] elif ratio <= 1.66: definition = (f'{w}:{h}', '1440:1080')[w >= 1440] elif ratio > 1.66: definition = (f'{w}:{h}', '1920:1080')[w >= 1920] elif video_size == '': definition = f'{w}:{h}' self.definition = definition elif is_img(origin_abs_path): self.pix_fmt = '' # pix_fmt即像素格式 fmt = (fmt, get_suffix(origin_abs_path))[fmt == ''] self.palette = os.path.join(TEMP_DIR, 'palette.' + fmt) self.temp_img = os.path.join(TEMP_DIR, 'temp_img.' + fmt) self.get_img_info() w, h = self.width, self.height img_max_pix = abs(img_max_pix) if img_max_pix == 0: pass elif w > img_max_pix and h > img_max_pix: ratio = max(w, h) / img_max_pix w, h = int(w / ratio), int(h / ratio) self.definition = f'{w}:{h}' def get_video_info(self): '''使用ffprobe获取视频的宽高（width、height）。''' global sys_kind command_probe = f'ffprobe -select_streams v -show_entries format=bit_rate -show_streams -v quiet -of csv="p=0" -of json -i "{self.origin_abs_path}"' if sys_kind == 'Windows': result = subprocess.Popen(command_probe, shell=False, stdout=subprocess.PIPE).stdout elif sys_kind == 'Linux': result = subprocess.Popen(command_probe, shell=True, stdout=subprocess.PIPE).stdout list_std = result.readlines() str_tmp = '' for item in list_std: str_tmp += bytes.decode(item.strip()) json_data = json.loads(str_tmp) self.width = int(json_data['streams'][0]['width']) self.height = int(json_data['streams'][0]['height']) # self.bitrate = int(json_data['format']['bit_rate']) / 1024 # 以后可能用到视频的比特率 # 视频的比特率有时不能用ffprobe读取到，建议自己根据视频大小和时长计算 def get_img_info(self): '''获取图片的width、height、pix_fmt。''' global sys_kind command_probe = f'ffprobe -hide_banner -v quiet -print_format json -show_format -show_streams "{self.origin_abs_path}"' if sys_kind == 'Windows': result = subprocess.Popen(command_probe, shell=False, stdout=subprocess.PIPE).stdout elif sys_kind == 'Linux': result = subprocess.Popen(command_probe, shell=True, stdout=subprocess.PIPE).stdout list_std = result.readlines() str_tmp = '' for item in list_std: str_tmp += bytes.decode(item.strip()) json_data = json.loads(str_tmp) self.pix_fmt = str(json_data['streams'][0]['pix_fmt']) self.width = int(json_data['streams'][0]['width']) self.height = int(json_data['streams'][0]['height']) def compress_video(self): '''通过ffmpeg压缩视频文件（输出的像素格式指定为yuv420p）。''' global CPU_THREADS # AMD显卡加速，硬编码 '''command_compress = 'ffmpeg -hwaccel dxva2 -i "{}" -y -r 24 -pix_fmt yuv420p -vf scale={} -c:v h264_amf -b:a 64k -ar 44100 "{}"'.format( self.origin_abs_path, self.definition, self.compressed_abs_path)''' # NVIDIA显卡加速，硬编解码 '''command_compress = 'ffmpeg -hwaccel cuvid -c:v h264_cuvid -i "{}" -threads 8 -y -r 24 -pix_fmt yuv420p -vf scale={} -c:v h264_nvenc -b:a 64k -ar 44100 "{}"'.format( self.origin_abs_path, self.definition, self.compressed_abs_path)''' # CPU软编解码。-threads 参数可限制线程数，控制CPU占用。 command_compress = 'ffmpeg -threads {} -i "{}" -threads {} -y -r 24 -pix_fmt yuv420p -vf scale={} -c:v h264 -b:a 64k -ar 44100 "{}"'.format( CPU_THREADS[0], self.origin_abs_path, CPU_THREADS[1], self.definition, self.compressed_abs_path) result = os.system(command_compress) if result != 0: raise Exception( f'vimg_processor.compress_video failed，视频压缩失败：{command_compress}' ) def compress_img_ffmpeg(self): '''通过ffmpeg压缩图像文件。''' command_palette = 'ffmpeg -i "{}" -vf palettegen=max_colors=256:stats_mode=single -y "{}"'.format( self.origin_abs_path, self.palette) command_compress = 'ffmpeg -i "{}" -i "{}" -lavfi "[0][1:v] paletteuse" -pix_fmt "{}" -y "{}"'.format( self.origin_abs_path, self.palette, self.pix_fmt, self.temp_img) result = os.system(command_palette) if result != 0: raise Exception( f'vimg_processor.compress_img_ffmpeg failed，无法获得调色板：{command_palette}' ) result = os.system(command_compress) if result != 0: raise Exception( f'vimg_processor.compress_img_ffmpeg failed，生成中间图片失败：{command_compress}' ) command_compress = 'ffmpeg -i "{}" -vf scale={} -y "{}"'.format( self.temp_img, self.definition, self.compressed_abs_path) result = os.system(command_compress) if result != 0: raise Exception( f'vimg_processor.compress_img_ffmpeg failed，生成最终图片失败：{command_compress}' ) def compress_img_squoosh(self, quality: int = 75): '''通过squoosh-cli压缩图像文件。\n安装squoosh-cli见https://lisper517.top/index.php/archives/60/ :param quality: 压缩图片的品质，取值范围为0-100，详见squoosh的说明''' basename_origin = os.path.basename(self.origin_abs_path) abs_dir = self.origin_abs_path[:-len(basename_origin)] basename_compressed = os.path.basename(self.compressed_abs_path) prefix_origin = os.path.splitext(basename_origin)[0] prefix_compressed = os.path.splitext(basename_compressed)[0] suffix = prefix_compressed[len(prefix_origin):] fmt = get_suffix(basename_compressed).upper() if fmt == 'JPG': fmt = 'mozjpeg' elif fmt == 'PNG': fmt = 'oxipng' elif fmt == 'WEBP': fmt = 'webp' command_compress = '''squoosh-cli --{} {{quality:{},baseline:false,arithmetic:false,progressive:true,optimize_coding:true,smoothing:0,color_space:3,quant_table:3,trellis_multipass:false,trellis_opt_zero:false,trellis_opt_table:false,trellis_loops:1,auto_subsample:true,chroma_subsample:2,separate_chroma_quality:false,chroma_quality:75}} "{}" -d "{}" -s "{}"'''.format( fmt, quality, self.origin_abs_path, abs_dir, suffix) result = os.system(command_compress) if result != 0: raise Exception( f'vimg_processor.compress_img_squoosh failed，生成最终图片失败：{command_compress}' ) '''------------------------------------------------------------主要功能区------------------------------------------------------------''' '''------------------------------------------------------------主要功能区------------------------------------------------------------''' '''------------------------------------------------------------主要功能区------------------------------------------------------------''' def get_definitions_video(target_dir: str): '''获取一个文件夹下所有视频文件的分辨率，按宽高比进行分类。 :param target_dir: 目标文件夹的绝对路径 :结果会输出到当前目录下的 definitions.txt 文件中''' global CWD ratio_dict = dict() # 字典，键为宽高的比值，值为列表，包括该比值下的具体宽高数值 # 例：1.7777778:['640x360', '1280x720', '1920x1080'] for dirpath, dirnames, filenames in os.walk(target_dir): for filename in filenames: abs_path = os.path.join(dirpath, filename) if is_video(abs_path): print('\r视频文件：{:200}'.format(abs_path), end='') vimg = vimg_processor(abs_path) ratio = vimg.ratio definition = "{}x{}".format(vimg.width, vimg.height) if ratio_dict.get(ratio) == None: ratio_dict[ratio] = [definition] else: if definition not in ratio_dict[ratio]: ratio_dict[ratio].append(definition) # 按宽高比值升序输出 ratio_list = [] for key, value in ratio_dict.items(): ratio_list.append(key) ratio_list.sort() with open(os.path.join(CWD, 'definitions.txt'), 'w', encoding='utf8') as f: for ratio in ratio_list: ratio_dict[ratio].sort() f.write('宽高比：{:.6}，成员：'.format(ratio)) for definition in ratio_dict[ratio][:-1]: f.write(definition + ' , ') f.write(ratio_dict[ratio][-1] + '\n') print('') def compress_videos(video_size: str = '', fmt: str = ''): '''从redis中得到需要压缩的视频文件，进行压缩，并完成压缩；直到redis中没有剩余任务。 :对应地，该项目会在redis的key中移动，从todo到ongoing，最后到done；若出错，则放到wrong。 :输出文件在源文件名末尾加 #test# 放在同目录下（1.mp4 -> 1#test#.mp4）；输出文件有重名时会自动按 1#test#.mp4 、1#test#(1).mp4 、1#test#(2).mp4 的顺序重命名。 :param video_size: 视频适配的屏幕大小。 phone、pad、pc、空字符串 分别表示适配手机、平板、电脑、不改变原大小 :param fmt: 指定输出的文件格式，空字符串表示原格式。建议用mp4 ''' global NAS_mounted_path, r_cur, log, start_time, fail_msg_list, sys_kind global redis_key_set_todo, redis_key_set_wrong, redis_key_str_ongoing, redis_key_str_done msg = '开始时间：{}'.format(time.strftime('%Y-%m-%d %H:%M:%S')) mylog.log_print(log, 'info', msg) while (True): if r_cur.scard(redis_key_set_todo) == 0: break try: redis_rel_path = r_cur.spop(redis_key_set_todo, count=1)[0] rel_path = replace_path_seperator(redis_rel_path, sys_kind=sys_kind) abs_path = os.path.join(NAS_mounted_path, rel_path) if is_video(abs_path) and os.path.isfile(abs_path): vimg = vimg_processor(origin_abs_path=abs_path, video_size=video_size, fmt=fmt) compressed_abs_path = vimg.compressed_abs_path if os.path.exists(compressed_abs_path): # 输出文件已存在时跳过 continue start_time = time.time() log.info('{}压缩中...'.format(abs_path)) compressed_redis_rel_path = get_redis_rel_path_from_abs_path( compressed_abs_path) r_cur.set(redis_key_str_ongoing + redis_rel_path, compressed_redis_rel_path) vimg.compress_video() r_cur.delete(redis_key_str_ongoing + redis_rel_path) r_cur.set(redis_key_str_done + redis_rel_path, compressed_redis_rel_path) log.info('视频压缩成功，耗时{:.2f}min'.format( (time.time() - start_time) / 60)) except: except_info = traceback.format_exc() fail_msg_list.append(except_info) mylog.log_print(log, 'error', except_info) if os.path.exists(compressed_abs_path): os.remove(compressed_abs_path) r_cur.srem(redis_key_set_todo, redis_rel_path) r_cur.delete(redis_key_str_ongoing + redis_rel_path) r_cur.delete(redis_key_str_done + redis_rel_path) exc_type, exc_value, exc_traceback = sys.exc_info() # 获得异常名 exc_type = exc_type.__name__ if exc_type == 'KeyboardInterrupt': # 在linux上时，无法得到KeyboardInterrupt r_cur.sadd(redis_key_set_todo, redis_rel_path) else: mylog.log_print(log, 'error', f'错误类型为{exc_type}') r_cur.sadd(redis_key_set_wrong, redis_rel_path) break def compress_imgs(compressor: str = 'squoosh', fmt: str = '', img_max_pix: int = 0, quality: int = 75): '''从redis中得到需要压缩的图片文件，进行压缩，并完成压缩；直到redis中没有剩余任务。 :对应地，该项目会在redis的key中移动，从todo到ongoing，最后到done；若出错，则放到wrong。 :输出文件在源文件名末尾加 #test# 放在同目录下（1.jpg -> 1#test#.jpg）；输出文件有重名时会自动按 1#test#.jpg 、1#test#(1).jpg 、1#test#(2).jpg 的顺序重命名。 :param compressor: 指定压缩的方式。可选择 ffmpeg 或 squoosh ，推荐后者 :param fmt: 指定输出的文件格式，空字符串表示原格式。建议用jpg，目前squoosh只支持jpg、png、webp :param img_max_pix: （仅在ffmpeg时可用）输出图片文件时，若源图片的宽高同时超过该值，宽高中较大的一个数值会被缩小到该值，另一个数值也等比例缩小；为0时，表示图片的宽高不缩小（宽高的单位为像素点） :使用squoosh时，图片会保留原有尺寸 :param quality: （仅在squoosh时可用）压缩图片的品质，取值范围为0-100，详见squoosh的说明 ''' global NAS_mounted_path, TEMP_DIR, r_cur, log, start_time, fail_msg_list, sys_kind global redis_key_set_todo, redis_key_set_wrong, redis_key_str_ongoing, redis_key_str_done msg = '开始时间：{}'.format(time.strftime('%Y-%m-%d %H:%M:%S')) mylog.log_print(log, 'info', msg) while (True): if r_cur.scard(redis_key_set_todo) == 0: break try: redis_rel_path = r_cur.spop(redis_key_set_todo, count=1)[0] rel_path = replace_path_seperator(redis_rel_path, sys_kind=sys_kind) abs_path = os.path.join(NAS_mounted_path, rel_path) if is_img(abs_path) and os.path.isfile(abs_path): vimg = vimg_processor(origin_abs_path=abs_path, img_max_pix=img_max_pix, fmt=fmt) compressed_abs_path = vimg.compressed_abs_path if os.path.exists(compressed_abs_path): # 输出文件已存在时跳过 continue log.info('{}压缩中...'.format(abs_path)) compressed_redis_rel_path = get_redis_rel_path_from_abs_path( compressed_abs_path) r_cur.set(redis_key_str_ongoing + redis_rel_path, compressed_redis_rel_path) if compressor == 'ffmpeg': vimg.compress_img_ffmpeg() elif compressor == 'squoosh': vimg.compress_img_squoosh(quality=quality) r_cur.delete(redis_key_str_ongoing + redis_rel_path) r_cur.set(redis_key_str_done + redis_rel_path, compressed_redis_rel_path) except: except_info = traceback.format_exc() fail_msg_list.append(except_info) mylog.log_print(log, 'error', except_info) if os.path.exists(compressed_abs_path): os.remove(compressed_abs_path) r_cur.srem(redis_key_set_todo, redis_rel_path) r_cur.delete(redis_key_str_ongoing + redis_rel_path) r_cur.delete(redis_key_str_done + redis_rel_path) r_cur.sadd(redis_key_set_wrong, redis_rel_path) exc_type, exc_value, exc_traceback = sys.exc_info() # 获得异常名 exc_type = exc_type.__name__ if exc_type == 'KeyboardInterrupt': # 在linux上时，无法得到KeyboardInterrupt r_cur.sadd(redis_key_set_todo, redis_rel_path) else: mylog.log_print(log, 'error', f'错误类型为{exc_type}') r_cur.sadd(redis_key_set_wrong, redis_rel_path) break # 最后清理一下TEMP_DIR for dirpath, dirnames, filenames in os.walk(TEMP_DIR): for filename in filenames: os.remove(os.path.join(dirpath, filename)) def delete_test(): '''视频或图片压缩完毕后，比较原文件与压缩后的文件，留下较小且大小非0的文件。 :由于ffmpeg处理效果不确定，建议检查一下处理后的文件，确认无误后再执行，因为源文件会被彻底删除。具体见 下载-压缩-入库流程 :将到redis的redis_key_str_done中读取压缩前后的文件相对路径。删除#test#文件后，删除redis_key_str_done对应的键 ''' global r_cur, log, fail_msg_list global redis_key_str_done redis_rel_path_done_keys = r_cur.keys(redis_key_str_done + '*') for redis_rel_path_done_key in redis_rel_path_done_keys: origin_redis_rel_path = redis_rel_path_done_key[len(redis_key_str_done ):] compressed_redis_rel_path = r_cur.get(redis_rel_path_done_key) origin_abs_path = get_abs_path_from_redis_rel_path( origin_redis_rel_path) compressed_abs_path = get_abs_path_from_redis_rel_path( compressed_redis_rel_path) # redis中存储的相对路径都是 压缩中 ，但是现在这些文件已经手动放到了 检查中 里 origin_abs_path = origin_abs_path.replace('压缩中', '检查中', 1) #需要更改 compressed_abs_path = compressed_abs_path.replace('压缩中', '检查中', 1) #需要更改 if os.path.isfile(origin_abs_path) and os.path.isfile( compressed_abs_path): before_size = os.path.getsize(origin_abs_path) after_size = os.path.getsize(compressed_abs_path) if after_size < before_size and after_size != 0: try: os.remove(origin_abs_path) # 注意这里的压缩后文件名改回原名时是把最右边的第一个#test#去掉，而不是直接改成源文件名 # 因为如果压缩时用fmt指定输出格式，则可能当前目录下有重名文件 # 1.avi、1.wmv -> 1#test#.mp4、1#test#(1).mp4 -> 1.mp4、1(1).mp4 prefix, suffix = os.path.splitext(compressed_abs_path) reversed_prefix = prefix[::-1] reversed_prefix = reversed_prefix.replace('#tset#', '', 1) prefix = reversed_prefix[::-1] temp = prefix + suffix os.rename(compressed_abs_path, temp) r_cur.delete(redis_rel_path_done_key) log.info(f'将{origin_abs_path}替换为小文件') except PermissionError: r_cur.set(redis_rel_path_done_key, compressed_redis_rel_path) msg = f'delete_test failed，删除失败：{origin_abs_path}' mylog.log_print(log, 'warning', msg) fail_msg_list.append(msg) continue else: os.remove(compressed_abs_path) msg = f'{origin_abs_path}压缩后的文件比之前大（或大小为0），删掉了处理后的文件' log.info(msg) # 最后检查一下有没有剩余的处理后文件 dir_checking = replace_path_seperator('xxx/xxx/检查中', sys_kind=sys_kind) #需要更改 dir_checking = os.path.join(NAS_mounted_path, dir_checking) for dirpath, dirnames, filenames in os.walk(dir_checking): for filename in filenames: if '#test#' in filename: abs_path = os.path.join(dirpath, filename) msg = f'疑似还有#test#文件：{abs_path}' mylog.log_print(log, 'warning', msg) fail_msg_list.append(msg) def main(): '''执行入口。''' target_dir = r'D:\xxx\xxx\压缩中' #需要更改 global sys_kind try: #get_definitions_video(target_dir) #compress_videos(video_size='pad', fmt='mp4') compress_imgs(compressor='squoosh', fmt='jpg', quality=75) #delete_test() print('已完成') if len(fail_msg_list) != 0: print('本次压缩时出现了以下异常：') for i in fail_msg_list: print(i) if sys_kind == 'Windows': input() except: except_info = traceback.format_exc() mylog.log_print(log, 'error', except_info) if sys_kind == 'Windows': input() if __name__ == '__main__': main() '''下载-压缩-入库流程： 1.将需要压缩的文件先暂时放到 待压缩 中，进行人工筛选 2.筛选完后，将 待压缩 中的文件手动剪切到 压缩中 文件夹里 3.直连NAS的机器运行python脚本，更新redis库、移动文件 4.其他连接到NAS的机器从redis中获取任务、进行压缩 5.从 压缩完 中将文件剪切到 检查中 里，进行人工检查 6.检查完后，手动运行vimg_processor_multi里的delete_test，保留源文件或压缩后文件中的一个 7.手动将 检查中 里的文件放到其他目录，比如 待迁移 或 入库中 里 8.运行file_to_sql，将本地文件入库 '''如前所述，这是一个NAS、多电脑协作压缩的版本，如果只是在本地压缩图片的话可以自行修改。
步骤/目录： 0.Samba的陷阱 1.需求介绍 2.Samba与frp 3.ftp 4.webdav本文首发于个人博客https://lisper517.top/index.php/archives/59/，转载请注明出处。本文的目的是使用树莓派搭建可公网访问的NAS，并且比较Samba, ftp与webdav这三种方式的异同。本文实验日期为2022年10月11日。使用的是树莓派4B（内存8G版），系统为Pi OS 64位桌面版（2022年4月4日更新，镜像名 2022-04-04-raspios-bullseye-arm64.img ）。0.Samba的陷阱当你在windows中打开 我的电脑 ，此时打开的东西叫文件管理器（Windows Explorer）。从文件管理器向其它地方拷贝东西，WE会要求目标硬盘先创建一个同等大小的空镜像（内容全部为0的镜像），再填充数据；Samba会调用 ftruncate() 函数来创建，而该函数在FAT文件系统上运行奇慢，需要花很长时间才能创建好空白镜像，WE以为Samba卡死了，最终会返回0x8007003B错误（ 参考回答 ）。FAT文件系统创建空镜像这么慢，归根结底是不支持sparse file（稀疏文件），所以它创建空镜像时真的会在硬盘上挨个写入0；而支持sparse file的，直接指定空镜像文件的头尾区块、存在metadata中即可，不用管这些区块上原本是1还是0。在Samba的设置中有一项隐藏的默认值 strict allocate = no ，如果改成yes，当单个文件大小超过500MB，Samba自己就会要求先生成空镜像再填充数据（参考 wiki-Samba 的 SMB/SMB2 write activities 一节）。综上所述，不需要改Samba的配置，只要保证Samba挂载的不是FAT文件系统（exFAT、FAT32）而是ext4、NTFS就行了（ 参考回答 ）。先创建空镜像再写入，本意是好的，只是FAT执行坏了。1.需求介绍在之前 树莓派+Samba+frp搭建NAS（网络云存储） 一文中，曾介绍过使用Samba在局域网内搭建NAS，并且使用千兆路由器后达到了不错的内网分享速度。在那篇文章中还将samba使用的445端口通过frp映射到公网ip，试图在公网也能访问NAS，但实际上并没有成功。本文将再次尝试，并且除了Samba之外，还尝试使用ftp与webdav方式搭建。需要注意的是，文中提到的所有方法都是树莓派直连硬盘，也就是说用linux把硬盘分享为NAS；如果你用windows电脑直连硬盘分享NAS，本文可能帮不了你。最后，本文中使用的都是云服务器+frp，这样NAS的带宽会被严重限制为云服务器的带宽，有条件的建议还是用DDNS，见之前的文章 路由器与DDNS （如果你家用的是移动的宽带，也可以不用看那篇文章，就用云服务器+frp）。2.Samba与frp在之前的文章中，成功在树莓派上搭建了Samba，内网已经能流畅访问，千兆路由器下，文件读写最快能达到113MB/s，而机械硬盘的最高读写速度也才150MB/s左右，已经很不错了。后来试着用frp开放到公网，怀疑因为云服务器带宽不够（640KB/s），导致windows上连接超时。之后换用 3.75MB/s 的云服务器，还是无法连接，这时才怀疑是否操作有些问题。最终，通过一番寻找，笔者成功解决了Samba开放到外网、用windows连接的问题。这里就跳过Samba的搭建，直接讲如何配置frp，windows如何连接。云服务器、frps和frpc的其他配置见前文，frpc配置好后添加一个samba的映射即可：[samba] type = tcp local_ip = localhost local_port = 445 remote_port = 44500这里 remote_port 选择44500而不是445，主要是因为安全性。445端口本来就是windows用来共享文件夹的，据有些文章说以前 永恒之蓝 病毒针对445、139等端口搞了一些事情，导致现在445、139等端口被全网封禁（139、138、137也和文件共享有关，据说如果445走不通就会尝试这些端口）。所以现在映射445到服务器其他端口，你可以自己选一个大一点的，这里以44500为例。设置完frpc.ini后，在云服务器的防火墙中打开44500端口。到这里，其实已经可以连接到Samba了，mac、linux都可以指定端口号，在文件浏览器中输入 smb://云服务器ip或域名:44500/共享名 即可，然后输入用户名密码即可；linux用命令行挂载时，需要如下操作：apt install -y cifs-utils #CentOS把apt换成yum即可 mkdir /mnt/test mount -t cifs -o username="用户名",password="密码",port=44500 //云服务器ip或域名/共享名 /mnt/test但是windows不支持把Samba换到其他端口，只能使用445端口交流。好在有其他的方法，比如将云服务器的44500端口在windows电脑上连接到445端口。用管理员模式运行cmd，进行如下操作（这里参考了 这篇文章 ）：netstat -aon | findstr "445"如果有输出任何字符的话，说明系统占用了445端口，一般这个进程的pid为4，相关的服务名为Server。cmd中输入 services.msc ，按Enter打开，出现一个管理服务的界面，找到一个名称为 Server 的服务，右键单击，选择属性，在 常规 分页中把它的 启动类型 从 自动 改为 禁用 。这个Server服务估计是管理一些共享文件夹的，不过笔者实验时docker desktop也需要用它（但是windows想要连接Samba必须关掉Server）。之后重启电脑，再次用管理员模式打开cmd，再次输入 netstat -aon | findstr "445" ，确认这次没有输出任何字符、只输出了空行。然后运行以下命令：netsh interface portproxy add v4tov4 listenaddress=127.0.0.1 listenport=445 connectaddress=云服务器ip或域名 connectport=44550 netsh interface portproxy show all #查看一下 #之后使用netsh interface portproxy delete v4tov4 listenaddress=127.0.0.1 listenport=445即可删除这条端口转接设置那么现在云服务器的44500端口就接到windows电脑的445端口上，你往本机的445端口发送什么东西，都会被转发到云服务器的44500端口（像iptables配置的端口转发一样）。此时你在文件浏览器中输入 \\localhost\共享名 ，或者用之前文章提到的 此电脑 -> 计算机 -> 映射网络驱动器 也可以，同样填 \\localhost\共享名 ，之后输入用户名密码即可。如果Samba开放到公网后想用android手机连接，ES文件浏览器不能用（可能需要开会员才能连公网SMB，不是很想开），笔者找到的其他android的SMB客户端又要用Google Play下载。如果需要用android连NAS，建议看后面的内容，或者你把手机刷成其他海外版固件。3.ftpftp是一项上古技术了，很早期就被用于计算机间分享文件，现在也有很多镜像站的网站名称中有ftp。早期的ftp不需要验证，后来发展出了vsftp，即 very secure ftp ，所以拿来做公网开放的NAS也还行。在树莓派上进行如下操作：apt-get install vsftpd nano /etc/vsftpd.conf把vsftpd的配置文件进行如下修改：在最开头加上两行： pasv_enable=YES 和 port_enable=YES 再加上两行： pasv_min_port=65000 和 pasv_max_port=65001 把 #write_enable=YES 前面的#去掉 把 #local_umask=022 前面的#去掉然后重启一下vsftpd服务：service vsftpd restart和samba一样，ftp也需要用户名、密码登录，而且ftp用户、密码对应同名的系统用户、密码（但是samba虽然用户同名，密码却独立于系统密码）；默认情况下，使用某个系统用户的用户名、密码登录，登录后就会看到该用户的家目录，所以这里可以创建一个系统用户ftp_user：useradd -m -s /bin/bash ftp_user passwd ftp_user然后你可以挂上需要共享的硬盘，把硬盘mount到/home/ftp_user下。如果有防火墙的话，打开20、21、65000、65001端口（比如ufw、iptables），就可以用客户端连接了。在windows机器上，打开资源管理器，直接在地址栏输入 ftp://ftp机器ip地址:21/ 即可连接，会提示输入用户名密码（ftp_user和 passwd ftp_user 设置的密码）。在linux机器上，带图形界面的话也可以在资源管理器输入 ftp://ftp机器ip地址:21/ ；在命令行界面，可以使用 curlftpfs 扩展：yum install -y curlftpfs #ubuntu是apt mkdir /mnt/ftp_test curlftpfs -o codepage=utf8 ftp://用户名:密码@ftp机器ip地址:端口号 /mnt/ftp_test即可把ftp文件系统挂载到/mnt/ftp_test下。在Mac上，笔者太过贫穷没有Mac，但是估计也是在地址栏输入 ftp://ftp机器ip地址:21/ 就行了。ftp最重要的缺陷可能是无法实时播放。笔者在内网实验时，很多视频播放器都支持播放ftp视频（即从URL打开），比如开源的SMPlayer；但是输入URL时，如果你的FTP有密码（不允许匿名连接），则可能还要在URL中加上用户名密码，比较麻烦；据说用WebDrive（收费）等软件可将FTP服务器挂载到一个驱动器号下、实时打开视频，但是笔者没有继续实验。ftp也有优点，比如ES文件浏览器可以轻松支持公网FTP；开放到公网时，从windows客户端连接时可以随意换端口（从21换成2121或者其他），不像samba那样只能在windows客户端上把本机的445和公网ip的samba端口连接。单从传输速度看，ftp和samba差不多。但是综合考虑，笔者还是推荐samba。关于ftp公网开放，使用之前文章提到的frp可以实现，笔者没有继续深究。最后稍微讲解一下ftp的配置。ftp的连接方式分为2种，PORT主动方式和PASV被动方式，默认使用PORT方式。客户端使用21端口（或者其他自行设置的端口）连接到服务器后，需要传输文件时，PORT方式是客户端打开一个随机端口（这个端口在一定范围内）、服务器端用20端口向客户端建立连接（受客户端防火墙影响），PASV方式则是服务器打开一个随机端口（用pasv_max_port和pasv_min_port确定上下限）、客户端连接到服务器的这个随机端口（受服务器端防火墙影响）。从方便的角度考虑，PASV不需要客户端在每次连接时放行端口，应该更好一点。更多ftp的配置可参考 这篇文章 。4.webdav关于在树莓派上设置webdav的内容，可参考 这篇文章 。据说webdav在开放到公网后，从windows客户端连接时比samba简单一点，笔者没有验证，主要是samba确实已经够用了，而且兼容性上samba也是最优选，参考笔者的命令可很方便地随时随地打开samba的NAS，实时查看、编辑文件，很多电视盒子、机顶盒等设备都支持SMB。
1.需求介绍 2.解锁SSH （1）降回开发版固件 （2）连接SSH 3.固化SSH （1）备份 Bdata 和 crash 分区 （2）修改 Bdata 和 crash （3）刷入 Bdata 和 crash （4）切换系统版本 （5）稳定版固件固化SSH 4.其他设置 附录：小米路由器修复工具的使用本文首发于个人博客https://lisper517.top/index.php/archives/58/，转载请注明出处。本文的目的是为小米AX6S路由器固化SSH，这样即使升级官方固件，SSH也会开放。本文实验日期2022年10月11日，电脑为windows10系统。1.需求介绍在之前 寝室多设备连接校园网的解决方案 一文中，曾经为小米路由器4C刷上原版openwrt固件（固件可以理解为操作系统），在路由器上运行脚本实现校园网自动拨号、保持连接。实际上，如果使用性能更好的路由器，刷上其他固件以后还能做很多其他事情，但这一切都要通过基础的SSH来进行；可是路由器刷上其他固件后，由于不如原厂固件贴合，常常有路由器重启、wifi信号弱等问题。综上，使用原厂固件，优点是系统稳定，缺点是没有打开SSH、自由度不高；使用其他固件可以打开SSH、得到更多功能，但缺点是不如原厂固件稳定、wifi信号差。所以，在使用原厂固件的情况下开启SSH功能，成为一个比较折衷的选择。本文的目的就是对AX6S路由器，在使用原厂固件时打开SSH功能，并且将SSH功能固化，即使原厂固件升级或者路由器重启时也保持SSH打开。操作步骤和工具完全参考了 这个项目 。该项目包含的文件可到原网页下载，或者使用 笔者的备份 进行下载。本文中不含图片，项目中的md文档包含图片，更加直观。另外需要说明的是，不同厂家或者不同型号的路由器，开启SSH的方法不尽相同，其他型号的路由器打开SSH不能完全使用本文的方法。2.解锁SSH对于小米AX6S路由器，一般是把原厂固件降回开发版固件（默认开启Telnet和SSH功能），然后用SN号计算出root密码，连接到SSH。（1）降回开发版固件浏览器进入AX6S主页：192.168.31.1，找到 常用设置 -> 系统状态 -> 升级检测 ，点击 手动升级 -> 选择文件 ，选择 miwifi_rb03_firmware_stable_1.2.7.bin 这个文件上传、开始升级。这个文件在 小米路由器官网 无法找到，因为现在AX6S和许多其他型号都不提供开发版固件下载（目前好像只有AX9000，就是售价1299￥的那款路由器提供开发版固件）。但是它的小米路由器修复工具挺不错的，救砖能力强大，建议下载一个，网址为 https://bigota.miwifi.com/xiaoqiang/tools/MIWIFIRepairTool.x86.zip 。升级过程中注意路由器不要断电了。升级的同时，到 root密码计算网站 ，输入AX6S下方标签贴的SN号（或者在路由器管理页面也可以看到），经过一定计算得出root密码（小米官方固件的开发版都可以这样得到root密码）。路由器升级完成后指示灯会重新变蓝，刷新一下 192.168.31.1 可以确认，管理密码是一样的，但是能看到背景里有水印：only for test。（2）连接SSH使用putty.exe，ip为 192.168.31.1 ，端口就是22，进行连接。但有时开发版也可能只开启了Telnet、未打开SSH，那么就需要用Telnet开启SSH。对于win10机器，在 windows设置 里搜索 启用或关闭windows功能 ，在下面的框中勾选 Telnet 客户端 后确定，这样就开启了Telnet功能。然后在cmd中输入 telnet 192.168.31.1 ，输入root和密码即可用telnet连接到AX6S。接下来进行如下操作以解锁SSH：nvram set telnet_en=1 nvram set ssh_en=1 nvram set uart_en=1 nvram set boot_wait=on nvram commit /etc/init.d/dropbear enable /etc/init.d/dropbear start再次使用putty连接AX6S的SSH，成功。至此，SSH解锁成功。为了让原厂固件升级后或路由器重启后SSH功能也保持开启，还需要进行一些额外操作。3.固化SSH以下的命令使用SSH或Telnet输入都可以。（1）备份 Bdata 和 crash 分区使用 cat /proc/mtd 查看 Bdata 和 crash 对应的dev名，笔者实验时分别为 mtd5 和 mtd7 。接下来使用如下命令备份它们：nanddump -f /tmp/Bdata_mtd5.img /dev/mtd5 nanddump -f /tmp/crash_mtd7.img /dev/mtd7如果 mtd5 和 mtd7 不是你的 Bdata 和 crash 对应的dev名，记得更改。然后用WinSCP连接到AX6S（文件协议选SCP），到/tmp下将 Bdata_mtd5.img 和 crash_mtd7.img 这两个备份的文件保存到windows机器上。在进一步改动之前，再额外把这两个文件备一份副本。（2）修改 Bdata 和 crash使用HxD.exe打开 crash_mtd7.img ，将开头的四位改成 A5 5A 00 00 后保存。对于 Bdata_mtd5.img 要稍微复杂一点。同样用HxD.exe打开，进行下列操作：将 telnet_en、ssh_en、uart_en 的值都从 0 修改为 1 。（在右侧）复制字符串 boot_wait=on ，粘贴到末尾。具体是复制这串字符，把鼠标移到 b74_5G.. 的两个小数点中间，单击右键，选择 覆盖方式粘贴 。点击工具栏的 编辑 -> 选择块 ，起始偏移输入 4 ，结束偏移输入 FFFF ，点击确定。点击工具栏的 分析 -> 校验码 ，选择 CRC-32 ，点击确定。下方会弹出一个框，有一列校验和，假设这个校验和为 12345678 ，就把它逆序后输入到前4位，也就是说前四位应该修改为 78 56 34 12 。最后，点击保存。（3）刷入 Bdata 和 crash使用WinSCP将修改好的 crash_mtd7.img 放回到/tmp下，覆盖原来的文件，然后执行以下命令：mtd -r write /tmp/crash_mtd7.img crashAX6S会自己重启。之后重新用putty、WinSCP连接，同样把修改好的 Bdata_mtd5.img 放回到/tmp下，然后执行以下命令：mtd -r write /tmp/Bdata_mtd5.img BdataAX6S也会重启。再次用putty连接，输入以下命令：mtd erase crash #可以修复指示灯不亮等问题。笔者实验时，开启SSH后Internet灯就没亮过，使用这条命令后能亮 rebootAX6S再次重启。（4）切换系统版本实际上这里的开发版也可以切换到稳定版，只是换一下启动分区。putty使用如下命令：nvram get flag_last_success如果返回值为1，说明现在开发版系统的分区号为1，则稳定版的分区号为0。使用如下命令切换到稳定版：nvram set flag_last_success=0 nvram set flag_boot_rootfs=0 nvram commit reboot路由器会重启。如果 nvram get flag_last_success 返回0，那么把后面4条命令中的前两条最后一个数字从0换成1即可。（5）稳定版固件固化SSH经过刚才的步骤，我们使用稳定版也可以开启SSH了。但是默认情况下稳定版的SSH是关闭的，你可以使用Telnet连接AX6S，并用下面的命令临时开启SSH：sed -i 's/channel=.*/channel=\"debug\"/g' /etc/init.d/dropbear /etc/init.d/dropbear restart这样虽然开启了SSH，但重启路由器后SSH又会关闭。解决办法是写一个开启SSH的脚本，路由器开机后自动运行该脚本、开启SSH。使用上面的命令临时开启SSH，然后进行如下操作：mkdir /data/auto_ssh vi /data/auto_ssh/auto_ssh.sh复制如下内容（复制后，选中putty框，按一下i，shift+鼠标右键粘贴）：#!/bin/sh host_key=/etc/dropbear/dropbear_rsa_host_key host_key_bk=/data/auto_ssh/dropbear_rsa_host_key # 如果存在备份的SSH密钥，将备份的密钥链接到dropbear使用的密钥 if [ -f $host_key_bk ]; then ln -sf $host_key_bk $host_key fi # 当前固件为稳定版时，需要执行下面命令开启SSH channel=`/sbin/uci get /usr/share/xiaoqiang/xiaoqiang_version.version.CHANNEL` if [ "$channel" = "release" ]; then sed -i 's/channel=.*/channel="debug"/g' /etc/init.d/dropbear /etc/init.d/dropbear restart fi # 备份SSH密钥 if [ ! -s $host_key_bk ]; then i=0 while [ $i -le 30 ] do if [ -s $host_key ]; then cp -f $host_key $host_key_bk 2>/dev/null break fi let i++ sleep 1s done fi按esc，按shift+:，输入wq后按enter即可保存。再进行下列操作：chmod +x /data/auto_ssh/auto_ssh.sh uci set firewall.auto_ssh=include uci set firewall.auto_ssh.type='script' uci set firewall.auto_ssh.path='/data/auto_ssh/auto_ssh.sh' uci set firewall.auto_ssh.enabled='1' uci commit firewall这样，就写了一个开启SSH的脚本，并把它添加到开机自启的服务中了，现在可以使用 reboot 重启，看看是否在重启后能连上SSH。如果恢复出厂设置，或者再次刷机后，脚本会消失，自己重新添加一下、设置开机自启就行了。如果要关闭开机自启SSH，使用如下命令：uci delete firewall.auto_ssh uci commit firewall reboot最后，原文还介绍了安装shellclash和刷入openwrt的方法，感兴趣的可参考md文件或 github原址 。4.其他设置为了安全性，需要进行一些设置。用 passwd root 为root用户更改密码，建议改的复杂一点。另外，建议SSH使用密钥登录、禁止密码登录。下面的内容看不懂也没关系，一般来说用复杂的root密码已经够了（除非你用DDNS或其他方式把路由器开放到公网）。在为AX6S设置SSH前需要了解，小米路由器的原厂固件是从openwrt改来的（官方介绍是这样说的），对于openwrt，管理SSH的工具是dropbear；或者你也可以直接更改 /etc/config/dropbear ，可以把这个文件当成 /etc/ssh/sshd_config ，但是格式上不同。使用puttygen生成一个公钥和私钥，然后putty连接AX6S后进行如下操作：vi /etc/dropbear/authorized_keys把公钥复制到这里，然后试试能否用putty密钥连接AX6S，注意一定要用密钥连接上再进行接下来的操作。有时会提示 server refused our key ，但是你还是能输入root的密码登录SSH，这种情况不算是密钥登录。然后看一下默认的SSH设置：vi /etc/config/dropbear笔者实验时，原来的设置为：config dropbear option PasswordAuth 'on' option RootPasswordAuth 'on' option Port '22' option BannerFile '/etc/banner'参考 openwrt官网docs 可以了解更多。下面关闭密码登录：uci set dropbear.@dropbear[0].PasswordAuth="0" uci set dropbear.@dropbear[0].RootPasswordAuth="0" uci commit dropbear /etc/init.d/dropbear restart最后，笔者实验时发现，每次路由器重启后， /etc/dropbear/authorized_keys 这个文件会被删除掉，导致无法用密钥连接。这个问题可以修改 /data/auto_ssh/auto_ssh.sh 开机脚本来解决：vi /data/auto_ssh/auto_ssh.sh在开头添加几行，最后全文件如下：#!/bin/sh echo "你的公钥" > /etc/dropbear/authorized_keys #添加其他公钥，使用如下命令 #tee -a /etc/dropbear/authorized_keys < "其他公钥" host_key=/etc/dropbear/dropbear_rsa_host_key host_key_bk=/data/auto_ssh/dropbear_rsa_host_key # 如果存在备份的SSH密钥，将备份的密钥链接到dropbear使用的密钥 if [ -f $host_key_bk ]; then ln -sf $host_key_bk $host_key fi # 当前固件为稳定版时，需要执行下面命令开启SSH channel=`/sbin/uci get /usr/share/xiaoqiang/xiaoqiang_version.version.CHANNEL` if [ "$channel" = "release" ]; then sed -i 's/channel=.*/channel="debug"/g' /etc/init.d/dropbear /etc/init.d/dropbear restart fi # 备份SSH密钥 if [ ! -s $host_key_bk ]; then i=0 while [ $i -le 30 ] do if [ -s $host_key ]; then cp -f $host_key $host_key_bk 2>/dev/null break fi let i++ sleep 1s done fi最后，虽然SSH用密钥连接，但是telnet还开着、可用密码连接，是一个隐患。如果想关闭telnet，笔者的方法是：crontab -e在里面添加一行 * * * * * /etc/init.d/telnet stop ，这样每分钟关闭一次telnet服务，虽然有点笨拙，但至少telnet不会常开了；或者你也可以用iptables关闭telnet使用的23端口。附录：小米路由器修复工具的使用可能由于本文中修改了 Bdata 和 crash 分区，所以无法使用修复工具，这点要注意。对小米路由器来说其实不太担心变砖，可以自由刷机，因为小米路由器修复工具很好用，下面讲讲修复工具的使用。到官网下载小米路由器修复工具，解压后会报毒，选择 允许在设备上 。到官网下载小米路由器的固件，将电脑连接到路由器（连到路由器的LAN口），断开其他所有网线，运行exe文件，开始刷机。刷机过程中也注意不要断电，有UPS的可以用上。上传刷机包 即选择固件，选择刚才下载的固件； 网卡设置 中，注意选择正确的网卡（比如你的电脑上装了虚拟机，可能显示其他网卡，注意选择有线连接到路由器LAN口的那张网卡）； 到 刷机步骤 界面，断开路由器电源，按住reset键后接通路由器电源，直到橙灯闪烁松开reset键（对于路由器Mesh是紫灯长亮时），再等待5-8min，成功修复后路由器灯会变蓝闪烁，下面的文本框中会出现新的提示，这时路由器再断电重启。这样就成功拯救小米路由器了。由于这个工具的存在，其他路由器可能已经变砖，小米路由器却还能救回来。
步骤/目录： 1.烧录镜像与安装界面 2.安装CentOS 3.初始化操作 （0）静态局域网IP （1）合盖设置 （2）基础设置 （3）ufw （4）lm_sensors与watchdog （5）安全性设置 （6）python3 （7）修改DNS （8）docker （9）其他本文首发于个人博客https://lisper517.top/index.php/archives/57/，转载请注明出处。本文的目的是在废旧笔记本上安装CentOS系统。相比于树莓派，好处是断电后的续航能力、自动关机，树莓派在这方面还是不够完美。本文实验日期为2022年10月6日，笔记本为2011年的上网本，cpu 1.66GHz，内存2G，硬盘250GB。之前装过 CentOS 8 桌面版，有点卡，这次选择 CentOS 7 无桌面版。提醒一下，如果想省心，最好安装Ubuntu。1.烧录镜像与安装界面到 阿里云镜像网站 下载CentOS镜像，笔者选择的是 http://mirrors.aliyun.com/centos/7.9.2009/isos/x86_64/ 中 CentOS-7-x86_64-Everything-2009.iso 这个版本（用torrent下载最快，不要直接下载），用Everything版本实际安装的时候可以选择装多少工具。下载好镜像后用 Win32 Disk Imager 或者 Raspberry Pi Imager （这两个烧录工具的下载见 树莓派4B初始化 ）把镜像烧录到一张空白的sd卡上。笔记本插上sd卡开机，进入BIOS（笔者的笔记本是按F2），选择从sd卡引导开机（或者把sd卡引导开机的优先级调到最高），保存BIOS并退出、重启，直接选择 Install CentOS 7，等待一段时间后进入CentOS的安装界面。2.安装CentOS跟着引导一步步走即可安装成功：语言选择美式英语（也可以选简中），时区改到上海，更改键盘布局，安装介质就是本地的media，软件选择请自行选择（比如选择最简安装），分区时把以前装的系统覆盖掉、选择自动分区（你也可以根据自己的需要手动分区），打开有线、无线网络，开始安装。安装过程中可以设置一下root密码、创建一个系统用户。最后重启，再次进入BIOS，把sd卡引导调到后面。至此CentOS安装完成，重启后进入命令行界面。3.初始化操作安装完成后，用root身份登录，进行一些初始化操作。以下的项目为笔者的习惯，可自行修改。（0）静态局域网IP自带的vi和curl有时候不太好用，所以需要安装vim、wget：yum install -y vim wget然后登录路由器管理页面，把笔记本的有线、无线内网ip都设置成静态的。之后都用PuTTY以root身份远程登录笔记本，进行操作。（1）合盖设置这里参考了 这篇文章 。正常情况下，笔记本关上盖子后会休眠，但是也可以设置为仅仅锁定。vim /etc/systemd/logind.conf其中有一行： #HandleLidSwitch=suspend ，把它改为 HandleLidSwitch=lock ，然后保存设置：systemctl restart systemd-logind之后把笔记本合上屏幕也没关系。（2）基础设置passwd root #修改用户密码 passwd wuhuqifei yum install -y epel-releasewuhuqifei指刚才安装CentOS时创建的系统用户。现在apt和yum的源感觉都不用换了，yum可以自动寻找最快的源。epel-release提供了一些软件库，比如ufw在默认的库里就找不到，装上epel-release后就能找到。如果显示无法解析 mirrorlist.centos.org ，可以更换源（以 mirrors.aliyun.com 阿里源为例）：cd /etc/yum.repos.d/ mv CentOS-Base.repo CentOS-Base.repo_back curl -o /etc/yum.repos.d/CentOS-Base.repo http://mirrors.aliyun.com/repo/Centos-7.repo chmod 777 CentOS-Base.repo yum clean all yum makecache yum update（3）ufwufw是一个简化操作的防火墙（相较于iptables等防火墙的复杂语法）。yum install -y ufw ufw default deny ufw allow 22 ufw enable systemctl enable ufw systemctl start ufw systemctl restart ufw systemctl status ufw.service ufw status如果 yum install -y ufw 失败的话就等会儿再装，直到成功。（4）lm_sensors与watchdoglm_sensors可得到cpu温度（需要硬件支持，一般都支持）；watchdog可以根据温度和其他条件决定是否重启。据说sensors需要64位系统。笔者的笔记本，使用 uname -p 会输出 x86_64 。yum install -y lm_sensors sh -c "yes|sensors-detect" sensors #输出CPU核心温度接下来写一个脚本，使得每隔1s在一个文件里更新CPU温度（仅写入核心0的温度）：mkdir -p ~/mybash mkdir /usr/local/watchdog vim ~/mybash/lm_sensors_into_file.sh内容如下：#!/bin/bash cpu_core0=`/usr/bin/sensors | grep "Core 0" | awk '{print $3}' | cut -c 2-3` cpu_core1=`/usr/bin/sensors | grep "Core 1" | awk '{print $3}' | cut -c 2-3` #可根据需要换成其他cpu核心的温度 echo $cpu_core0 > /usr/local/watchdog/cpu_temperature然后继续操作：chmod +x ~/mybash/lm_sensors_into_file.sh ~/mybash/lm_sensors_into_file.sh cat /usr/local/watchdog/cpu_temperature #显示温度 crontab -e添加一行：* * * * * /root/mybash/lm_sensors_into_file.sh #每分钟运行一次写入温度的脚本然后安装watchdog：yum install -y watchdog systemctl enable watchdog vim /etc/watchdog.conf对/etc/watchdog.conf改动3处：去掉 #max-load-1 = 24 前的# 将 #temperature-device = 改为 temperature-device = /usr/local/watchdog/cpu_temperature 将 #max-temperature = 120 改为 max-temperature = 90最后进行如下操作：systemctl daemon-reload systemctl start watchdog systemctl status watchdog检查一下watchdog能否读取温度文件。（5）安全性设置修改/etc/pam.d/su：#auth required pam_wheel.so use_uid前面去掉注释的#。之前在安装CentOS时创建过一个系统用户，这里以wuhuqifei指代。进行如下操作：usermod -G wheel wuhuqifei经过以上设置，只有在wheel组中的用户才能使用su命令切换用户，并且把wuhuqifei加入了wheel组。下面关闭密码登录ssh，仅开启密钥登录。用puttygen生成密钥，并进行如下操作：su wuhuqifei mkdir /home/wuhuqifei/.ssh touch /home/wuhuqifei/.ssh/authorized_keys chmod 700 -R /home/wuhuqifei/.ssh vim /home/wuhuqifei/.ssh/authorized_keys将公钥粘贴到该文件中。另外，还需要一个openssh、不带密码的密钥用于备份，也把公钥粘贴到该文件中。最后，关闭密码登录ssh：su root vim /etc/ssh/sshd_config对/etc/ssh/sshd_config改动2处：将 #PermitRootLogin yes 改为 PermitRootLogin no 将 PasswordAuthentication yes 改为 PasswordAuthentication no最后应用：systemctl reload sshd到这里时暂时先不关闭PuTTY窗口，而是另外新建一个PuTTY看是否只能通过密钥连接，是否还能以root登录，如果有误的话及时修改；并且试一下能否从wuhuqifei切换到root。（6）python3CentOS最小安装自带python2.7，可以自行安装python3。可以自行编译，或者像笔者一样偷懒：yum install -y python3默认安装的是 python-3.6.8 。笔者从源文件编译 python 3.10 时总是出错（无法使用pip，因为SSL错误），只好选择最简单的方法。（7）修改DNSvim /etc/resolv.conf发现该文件是由NetworkManager生成的（ #Generated by NetworkManager ）。根据 这个回答 ，进行如下操作：nmcli con #查找到想要修改的网络连接的 Name ，后文的 $connectionName 都改成这个 Name nmcli con mod $connectionName ipv4.dns "8.8.8.8 8.8.4.4" #设置ipv4的DNS nmcli con mod $connectionName ipv4.ignore-auto-dns yes #忽略路由器提供的DNS nmcli con mod $connectionName ipv6.dns "2001:4860:4860::8888 2001:4860:4860::8844" #设置ipv6的DNS nmcli con mod $connectionName ipv6.ignore-auto-dns yes service NetworkManager restart vim /etc/resolv.conf/etc/resolv.conf 内的ipv4、ipv6的dns服务器成功修改为谷歌的dns服务器。（8）docker参考 这篇文章 ，进行了如下操作：如果之前安装过docker，先卸载旧版本的：yum remove docker \ docker-client \ docker-client-latest \ docker-common \ docker-latest \ docker-latest-logrotate \ docker-logrotate \ docker-selinux \ docker-engine-selinux \ docker-engine然后再安装：cd /tmp curl -fsSL get.docker.com -o get-docker.sh chmod +x get-docker.sh sh get-docker.sh --mirror Aliyun rm get-docker.sh systemctl enable docker systemctl start docker docker -v mkdir /etc/docker vim /etc/docker/daemon.json写入：{ "registry-mirrors": [ "https://hub-mirror.c.163.com", "https://mirror.baidubce.com", "https://xxxx.mirror.aliyuncs.com" ] }最后一个网址是阿里云给个人的docker官方仓库源（只要注册阿里云账号就送），如果没有的话就不加。然后进行如下操作：systemctl restart docker docker pull portainer/portainer-ce docker volume create portainer_data docker run -d -p 9000:9000 --name portainer --restart always -v /var/run/docker.sock:/var/run/docker.sock -v portainer_data:/data portainer/portainer-ce到 笔记本ip:9000 进行个性化配置。portainer-ce提供了一个网页UI界面，方便管理docker。最后安装docker-compose：curl -L https://github.com/docker/compose/releases/download/v2.10.0/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose chmod +x /usr/local/bin/docker-compose docker-compose -v（9）其他自行安装其他需要的软件，最后更新一下：yum update -y reboot
Python，爬虫与深度学习（17）——电影的信息爬取、下载与处理（二）
步骤/目录： 1.需求介绍 2.电影压缩 3.本地视频入库 4.ffmpeg加速 （1）显卡加速 （2）多电脑加速 5.ffmpeg常用命令本文首发于个人博客https://lisper517.top/index.php/archives/56/，转载请注明出处。本文的目的是在电影下载完成后，进行一些处理。本文写作日期为2022年10月5日。运行的平台为win10，编辑器为VS Code。1.需求介绍首先，现在电影占用的空间越来越大，对电影进行一定压缩有助于存储。其次，在之前mysql的movie表中，还有两列： downloaded 和 file_path ，这两列是用来判断一部电影是否被下载过的。所以目前的需求是对电影进行压缩，并进行入库。2.电影压缩这里使用ffmpeg，它是一个被广泛使用的处理音视频、图片的开源软件，比如格式工厂就是在内部调用ffmpeg转换格式的，暴风影音等也可以使用ffmpeg进行播放。这里是 ffmpeg的官网下载地址 ，但是官网只提供源码下载，你可以自行make编译，或者到 github网页 下载由热心网友为windows系统编译好的ffmpeg压缩包，下载并解压到合适的位置，然后把ffmpeg添加到路径（windows添加路径的方法请自行搜索）。最后在cmd中输入 ffmpeg -h ，成功显示帮助信息即可。在ffmpeg的bin文件夹中，除了最常用的ffmpeg，还有ffplay，用于播放；ffprobe，用于获取视频信息。写一个python脚本，目的是对一个文件夹下的视频文件进行压缩处理，脚本名为 vimg_processor.py ，内容如下：import os, time, json, subprocess, re, traceback from MyPythonLib import log as mylog CWD = os.getcwd() TEMP_DIR = os.path.join(CWD, 'temp') if not os.path.isdir(TEMP_DIR): os.mkdir(TEMP_DIR) #temp这个文件夹主要是在压缩图片时存放临时的调色板 log = mylog.get_logger(os.path.join(CWD, 'logs')) start_time = time.time() fail_msg_list = [] def get_suffix(file_name: str) -> str: '''返回文件后缀名。若文件无后缀名，返回空字符串。 :param file_name:文件名（可带完整路径） :return 大写的后缀名（不含 . ） ''' suffix = os.path.splitext(file_name) suffix = (suffix[-1].lstrip('.')).upper() return suffix def special_format(file_name: str) -> bool: '''判断文件是否特殊格式。 :rmvb格式必须指定输出为mp4，ts、mpg格式跳跃观看时卡顿严重，所以都应该输出为mp4格式。 :param file_name:文件名（可带完整路径） :return bool ''' videoSuffixSet = {"RMVB", "TS", "MPG"} judge = (False, True)[get_suffix(file_name) in videoSuffixSet] return judge def is_video(file_name: str) -> bool: '''根据后缀名判断文件是否视频。 :param file_name:文件名（可带完整路径） :return bool ''' videoSuffixSet = { "WMV", "ASF", "ASX", "RM", "RMVB", "MP4", "TS", "MPG", "3GP", "MOV", "M4V", "AVI", "MKV", "FIV", "VOB", "FLV", "MPEG", "ISO" } judge = (False, True)[get_suffix(file_name) in videoSuffixSet] return judge def is_img(file_name: str) -> bool: '''根据后缀名判断文件是否图片。 :param file_name:文件名（可带完整路径） :return bool ''' imgSuffixSet = {"JPG", "JPEG", "BMP", "PNG", "WEBP"} judge = (False, True)[get_suffix(file_name) in imgSuffixSet] return judge def cannot_process(file_name: str) -> bool: '''判断文件是否暂时处理不了。 :param file_name:文件名（可带完整路径） :return bool ''' videoSuffixSet = set() judge = (False, True)[get_suffix(file_name) in videoSuffixSet] return judge def is_origin(file_name: str) -> bool: '''判断文件是否源文件（源文件中末尾没有#test#）。 :param file_name:文件名（可带完整路径） :return bool ''' prefix = (os.path.splitext(file_name))[0] judge = (True, False)[re.match('.+#test#$', prefix) != None] return judge class vimg_processor(object): '''图像视频类，初始化时会读取图像视频的一些参数。''' def __init__(self, inputName: str, outName: str = "", video_size: str = 'pc', mp4_jpg: bool = False, img_max_pix: int = 700): '''用完整的文件名初始化对象 :param inputName: 源文件的绝对路径 :param outName: 输出文件的绝对路径。默认为同目录下文件名末加#test#保存 :param video_size: 视频文件的输出大小，有 phone 、 pad 、 pc 、 空字符串 三种选择，空串表示保留原宽高 :param mp4_jpg: 输出格式是否固定为mp4和jpg :param img_max_pix: 输出图像的宽高同时超过该值时，宽高中较长的一个数值会被压缩到该值 ''' self.inputName = inputName if outName == "": prefix, suffix = os.path.splitext(inputName) outName = prefix + "#test#" + suffix if mp4_jpg == True: prefix, suffix = os.path.splitext(outName) if is_video(inputName): outName = prefix + '.mp4' elif is_img(inputName): outName = prefix + '.jpg' self.outName = outName if is_img(self.inputName): if mp4_jpg == False: suffix = get_suffix(self.inputName).lower() self.out_name_palette = os.path.join(TEMP_DIR, 'palette' + suffix) self.temp_img = os.path.join(TEMP_DIR, 'temp_img' + suffix) else: self.out_name_palette = os.path.join(TEMP_DIR, 'palette.jpg') self.temp_img = os.path.join(TEMP_DIR, 'temp_img.jpg') # 获取视频文件的宽高，根据宽高比决定输出文件宽高。 # 这里只是在宽度超过上限时减小宽高 self.width, self.height = 0, 1 if is_video(inputName): self.get_video_info() w, h = self.width, self.height self.ratio = w / h if video_size == 'phone': if self.ratio <= 0.6: self.definition = (f'{w}:{h}', '360:640')[w >= 360] elif self.ratio <= 1.0: self.definition = (f'{w}:{h}', '360:480')[w >= 360] elif self.ratio <= 1.66: self.definition = (f'{w}:{h}', '480:360')[w >= 480] elif self.ratio > 1.66: self.definition = (f'{w}:{h}', '640:360')[w >= 640] elif video_size == 'pad': if self.ratio <= 0.6: self.definition = (f'{w}:{h}', '720:1280')[w >= 720] elif self.ratio <= 1.0: self.definition = (f'{w}:{h}', '720:960')[w >= 720] elif self.ratio <= 1.66: self.definition = (f'{w}:{h}', '960:720')[w >= 960] elif self.ratio > 1.66: self.definition = (f'{w}:{h}', '1280:720')[w >= 1280] elif video_size == 'pc': if self.ratio <= 0.6: self.definition = (f'{w}:{h}', '1080:1920')[w >= 1080] elif self.ratio <= 1.0: self.definition = (f'{w}:{h}', '1080:1440')[w >= 1080] elif self.ratio <= 1.66: self.definition = (f'{w}:{h}', '1440:1080')[w >= 1440] elif self.ratio > 1.66: self.definition = (f'{w}:{h}', '1920:1080')[w >= 1920] elif video_size == '': self.definition = f'{w}:{h}' # 获取图片的编码格式 elif is_img(inputName): self.pix_fmt = '' self.get_img_info() if self.width > img_max_pix and self.height > img_max_pix: if self.width <= self.height: ratio = self.width / img_max_pix elif self.width > self.height: ratio = self.height / img_max_pix self.width, self.height = int(self.width / ratio), int( self.height / ratio) self.definition = '{}:{}'.format(self.width, self.height) def get_video_info(self): '''获取视频的宽高。''' probe = 'ffprobe -select_streams v -show_entries format=bit_rate -show_streams -v quiet -of csv="p=0" -of json -i "{}"'.format( self.inputName) result = subprocess.Popen(probe, shell=False, stdout=subprocess.PIPE).stdout list_std = result.readlines() str_tmp = '' for item in list_std: str_tmp += bytes.decode(item.strip()) json_data = json.loads(str_tmp) # self.bitrate = int(json_data['format']['bit_rate']) / 1024 # 以后可能用到视频的比特率 # 视频的比特率有时不能用ffprobe读取到，建议自己根据视频大小和时长计算 try: self.width = int(json_data['streams'][0]['width']) self.height = int(json_data['streams'][0]['height']) except: self.width, self.height = 0, 1 def get_img_info(self): '''获取图片的一些信息。''' probe = 'ffprobe -hide_banner -v quiet -print_format json -show_format -show_streams "{}"'.format( self.inputName) result = subprocess.Popen(probe, shell=False, stdout=subprocess.PIPE).stdout list_std = result.readlines() str_tmp = '' for item in list_std: str_tmp += bytes.decode(item.strip()) json_data = json.loads(str_tmp) try: self.pix_fmt = str(json_data['streams'][0]['pix_fmt']) self.width = int(json_data['streams'][0]['width']) self.height = int(json_data['streams'][0]['height']) except: self.pix_fmt, self.width, self.height = '', 0, 0 def Zip_Video(self): '''通过ffmpeg压缩视频文件（输出的分辨率格式指定为yuv420p）。''' compress = 'ffmpeg -threads 8 -i "{}" -threads 8 -y -r 24 -pix_fmt yuv420p -vf scale={} -c:v h264 -b:a 64k -ar 44100 "{}"'.format( self.inputName, self.definition, self.outName) # -threads 8 限制线程数，解码和编码都设置，保护CPU。压缩视频费cpu compress = os.system(compress) if compress != 0: return (compress, "视频压缩失败") return True def Zip_Img(self): '''通过ffmpeg压缩图像文件。''' get_palette = 'ffmpeg -i "{}" -vf palettegen=max_colors=256:stats_mode=single -y "{}"'.format( self.inputName, self.out_name_palette) compress = 'ffmpeg -i "{}" -i "{}" -lavfi "[0][1:v] paletteuse" -pix_fmt "{}" -y "{}"'.format( self.inputName, self.out_name_palette, self.pix_fmt, self.temp_img) get_palette = os.system(get_palette) if get_palette != 0: return (get_palette, "无法获得调色板") compress = os.system(compress) if compress != 0: return (compress, "生成中间图片失败") compress = 'ffmpeg -i "{}" -vf scale={} -y "{}"'.format( self.temp_img, self.definition, self.outName) compress = os.system(compress) if compress != 0: return (compress, "生成最终图片失败") return True '''------------------------------------------------------------主要功能区------------------------------------------------------------''' '''------------------------------------------------------------主要功能区------------------------------------------------------------''' '''------------------------------------------------------------主要功能区------------------------------------------------------------''' def get_definitions_video(dir): '''获取一个文件夹下所有视频文件的分辨率，按宽高比进行分类。''' ratio_dict = dict() for path_prefix, subdirs, file_names in os.walk(dir): for file_name in file_names: complete_path = os.path.join(path_prefix, file_name) if is_video(complete_path): print('\r视频文件：{:200}'.format(complete_path), end='') vimg = vimg_processor(complete_path) ratio = vimg.ratio definition = "{}x{}".format(vimg.width, vimg.height) if ratio_dict.get(ratio) == None: ratio_dict[ratio] = [definition] else: if definition not in ratio_dict[ratio]: ratio_dict[ratio].append(definition) ratio_list = [] for key, value in ratio_dict.items(): ratio_list.append(key) ratio_list.sort() with open(os.path.join(CWD, 'definitions.txt'), 'w', encoding='utf8') as f: for ratio in ratio_list: ratio_dict[ratio].sort() f.write('宽高比：{:.6}，成员：'.format(ratio)) for definition in ratio_dict[ratio][:-1]: f.write(definition + ' , ') f.write(ratio_dict[ratio][-1] + '\n') print('') def zip_dir_video(dir, video_size='pc', mp4=False): '''压缩一个文件夹下的视频文件。 :输出文件自动命名为同目录下 源文件名+#test# 文件，后缀名不变。 :对已经存在的带有#test#的文件，会跳过。 :param dir: 文件夹的绝对路径 :param video_size: 视频宽高， phone、pad、pc、空字符串 分别表示适配手机、平板、电脑、不改变 :param mp4: 是否仅输出mp4文件 ''' global log, start_time, fail_msg_list msg = '开始时间：{}'.format(time.strftime('%Y-%m-%d %H:%M:%S')) print(msg) log.info(msg) for path_prefix, subdirs, file_names in os.walk(dir): for file_name in file_names: if '#test#.' in file_name: continue complete_path = os.path.join(path_prefix, file_name) if is_video(complete_path) and not cannot_process(complete_path): vimg = vimg_processor(complete_path, mp4_jpg=mp4, video_size=video_size) if os.path.exists(vimg.outName): # 输出文件已存在时跳过 continue if vimg.width == 0 or vimg.height == 1: msg = f'无法获得视频信息{complete_path}' print(msg) log.warning(msg) fail_msg_list.append(msg) continue start_time = time.time() log.info('{}压缩中...'.format(complete_path)) if vimg.Zip_Video() == True: log.info('视频压缩成功，耗时{:.2f}min'.format( (time.time() - start_time) / 60)) else: msg = f'视频压缩失败{complete_path}' log.warning(msg) fail_msg_list.append(msg) def zip_dir_img(dir, jpg=False, img_max_pix=700): '''压缩一个文件夹下的图像文件。 :输出文件自动命名为同目录下 源文件名+#test# 文件，后缀名不变。 :对已经存在的带有#test#的文件，会跳过。 :param dir: 文件夹的绝对路径 :param img_max_pix: 宽高同时超过这个值时，宽高中较大的数值会缩小到这个数值 :param jpg: 是否仅输出jpg文件 ''' global log, start_time, fail_msg_list msg = '开始时间：{}'.format(time.strftime('%Y-%m-%d %H:%M:%S')) print(msg) log.info(msg) for path_prefix, subdirs, file_names in os.walk(dir): for file_name in file_names: if '#test#.' in file_name: continue complete_path = os.path.join(path_prefix, file_name) if is_img(complete_path): print('\r{:200}'.format(complete_path), end='') vimg = vimg_processor(complete_path, mp4_jpg=jpg, img_max_pix=img_max_pix) if os.path.isfile(vimg.outName): # 输出文件已存在时跳过 continue if vimg.width == 0 or vimg.height == 1 or vimg.pix_fmt == '': msg = f'无法获得图片信息{complete_path}' print(msg) log.warning(msg) fail_msg_list.append(msg) continue if vimg.Zip_Img() == True: log.info('图片压缩完毕{}'.format(complete_path)) else: msg = f'图片压缩失败{complete_path}' log.warning(msg) fail_msg_list.append(msg) def delete_test(dir, mp4_jpg=False): '''在处理一个文件夹中的文件后，比较原文件与处理后文件，留下较小且大小非0的文件（对于一些特殊格式，不论大小仅留下处理后的文件）。 :由于ffmpeg处理效果不确定，建议检查一下处理后的文件，确认无误后再执行，因为源文件会被彻底删除。 :默认输出文件就是 源文件名+#test#。对于没有#test#的文件不做处理。 :param dir: 文件夹的绝对路径 :param mp4_jpg: 进行压缩时是否仅输出mp4/jpg文件 ''' global fail_msg_list for path_prefix, subdirs, file_names in os.walk(dir): for file_name in file_names: complete_path = os.path.join(path_prefix, file_name) if (is_video(complete_path) or is_img(complete_path)) and is_origin(complete_path): prefix, suffix = os.path.splitext(complete_path) if mp4_jpg == False: file_name_after_zip = prefix + "#test#" + suffix elif mp4_jpg == True: if is_video(complete_path): file_name_after_zip = prefix + "#test#" + '.mp4' elif is_img(complete_path): file_name_after_zip = prefix + "#test#" + '.jpg' if os.path.isfile(file_name_after_zip): after_size = os.path.getsize(file_name_after_zip) before_size = os.path.getsize(complete_path) if (after_size < before_size or special_format(complete_path)) and after_size != 0: try: os.remove(complete_path) os.rename( file_name_after_zip, file_name_after_zip.replace('#test#.', '.')) log.info('将{}替换为小文件'.format(complete_path)) except PermissionError: msg = '删除失败：{complete_path}' log.warning(msg) fail_msg_list.append(msg) continue else: os.remove(file_name_after_zip) log.info('{}压缩后的文件比之前大（或大小为0），删掉了处理后的文件'.format( file_name_after_zip)) # 最后检查一下有没有剩余的处理后文件 for path_prefix, subdirs, file_names in os.walk(dir): for file_name in file_names: if '#test#.' in file_name: msg = '疑似还有#test#文件：{}'.format( os.path.join(path_prefix, file_name)) log.warning(msg) fail_msg_list.append(msg) if __name__ == '__main__': target_dir = r'D:\压缩中' try: #get_definitions_video(target_dir) zip_dir_video(target_dir, mp4=True, video_size='pad') #zip_dir_img(target_dir, jpg=True, img_max_pix=700) #delete_test(target_dir, mp4_jpg=True) for path_prefix, subdirs, file_names in os.walk(TEMP_DIR): for file_name in file_names: os.remove(os.path.join(path_prefix, file_name)) print('已完成') if len(fail_msg_list) != 0: print('本次压缩时出现了以下异常：') for i in fail_msg_list: print(i) input() except: traceback.print_exc() input()这个脚本中， target_dir 是存放视频文件的目录， zip_dir_video 是对该目录下的视频文件进行压缩，压缩后的视频文件名字末尾（后缀之前）会加上 #test# 以示区分，和原视频放在同一目录下。另外这个脚本也可以用于对图片进行压缩，笔者使用的图片压缩方法很省空间，但鲜艳度不够。在脚本的使用上，把所有要压缩的视频文件都放到target_dir里（可以放到多层目录中），然后首先以原状态运行脚本，压缩完后随机抽取检查一下压缩后的视频文件，注意本次压缩有无提示异常，确认无误后注释掉 zip_dir_video(target_dir, mp4=True, video_size='pad') 这一行，删去 delete_test(target_dir, mp4_jpg=True) 这一行前的注释 # 号，再运行一遍，即可在原视频文件和压缩后的 #test# 文件中保留更小的那个。如果运行时需要暂停或停止，用鼠标点击cmd框中间可暂停；按ctrl+C可停止，停止时要把最后一个正在生成的 #test# 文件删除，然后下次有时间重新运行脚本，这个脚本会自动跳过带有 #test# 的文件，也不会对已经有 #test# 压缩后文件的视频重复压缩。最后，注意 zip_dir_video(target_dir, mp4=True, video_size='pad') 中的 video_size 参数，可以取phone、pad、pc或空字符串，分别表示视频的高x宽适配手机（360x640）、平板（720x1280）、电脑（1080x1920）或原大小。输出视频的宽高越小，压缩速度越快（对2h的视频，用phone大小，笔者的电脑大概需要压缩8-25min，根据源文件大小浮动），一般选phone或pad即可，选pc时的压缩速度太慢了；大小方面，2h的视频，大概是500MB、1.5GB、3.5GB的区别。另外，视频压缩主要消耗cpu资源，在：compress = 'ffmpeg -threads 8 -i "{}" -threads 8 -y -r 24 -pix_fmt yuv420p -vf scale={} -c:v h264 -b:a 64k -ar 44100 "{}"'这一行，两处 -threads 8 是限制调用的cpu核心数，可以根据自己的电脑情况修改。一般cpu使用率75-80%就比较合适，而此时显卡却不会怎么使用，所以可以边压缩视频边玩一些需要显卡的游戏。3.本地视频入库主要是将视频文件（夹）放到合适的目录下，并修改对应的movie表中downloaded和file_path这两列；如果没有对应的movie表，则可以选择在movie表中新建一行。由于自动识别视频比较复杂，这里选择手工识别（但是能简化的地方都简化了，只是输入稍微有点繁琐）。写好的file_into_sql.py文件如下（放到scrapy项目里）：from MyPythonLib import log as mylog from MyPythonLib.log import log_print from MyPythonLib import torrent_parser as t_p from libs import javbus_extensions as j_e import os, re TORRENT_DIRS = [r'D:\torrents'] #存放torrent文件的所有目录 VIDEO_DIR = r'D:\into_sql' #存放本次需要入库的文件（夹） VIDEO_DIR_UNKNOWN = r'D:\unknown' #有些视频不太好处理，想要跳过。所有跳过的文件（夹）会被放到这个目录里 LOG_DIR = os.path.join(os.getcwd(), 'logs') if not os.path.isdir(LOG_DIR): os.mkdir(LOG_DIR) log = mylog.get_logger(LOG_DIR, 'file_to_sql.txt') class video_file_processor: """用于处理视频文件（夹）。主要工作流程： :进入TORRENT_DIRS，记录torrent的hash值与文件名 :对VIDEO_DIR内的视频文件（夹），先在torrent字典中的文件查找，若文件名出现过（或者文件夹内的文件全部匹配），则认定视频文件（夹）对应该种子，那么查找数据库对应的torrent记录，找到对应的电影记录（movie_no）。 :若不能通过torrent记录找到movie_no，则尝试通过code查找movie_no（需要手动输入） :若无法找到code，则尝试通过full_name查找movie_no（需要手动输入） :在通过code或full_name查找movie_no的过程中，也可能新建movie_no :只要找到或新建movie_no，最后该文件都会被放到合适的地方；否则该文件会被转移到VIDEO_DIR_UNKNOWN中""" def __init__(self, logger=None) -> None: """:param log: 可选的logger对象。若""" global log self.log = (logger, log)[logger == None] self.torrents_files = self.get_torrents_files() self.database_api_javbus = j_e.database_api_javbus(self.log) self.conn = self.database_api_javbus.conn self.m_cur = self.database_api_javbus.m_cur self.r_cur = self.database_api_javbus.r_cur self.movie_no = 0 self.code = "unknown" self.full_name = "unknown" def get_torrents_files(self) -> dict: """得到torrents_files。它是一个字典，键为hash_value（仅40位hash），值为该torrent包含的文件名列表（无类型后缀和路径前缀）""" log = self.log message = "正在建立hash与文件关系..." log_print(log, 'info', message) torrents_files = {} p_torrent = re.compile('.torrent$', flags=re.I) for torrent_dir in TORRENT_DIRS: torrent_progress = 1 for dirpath, dirnames, filenames in os.walk(torrent_dir): for filename in filenames: if re.search(p_torrent, filename): abs_torrent_path = os.path.join(dirpath, filename) torrent = t_p.torrent(abs_torrent_path) for i in range(len(torrent.files)): torrent.files[i] = os.path.splitext( torrent.files[i])[0] torrents_files[torrent.hash_value] = torrent.files torrent_progress += 1 print( f'\rtorrent目录{torrent_dir}：{torrent_progress}/{len(filenames)}', end='') message = "已建立hash与文件关系" log_print(log, 'info', message, print_prefix='\n') return torrents_files def process_videos(self): """进入VIDEO_DIR，处理包含的视频文件。""" self.movie_no, self.code, self.full_name = 0, "unknown", "unknown" dirpath, dirnames, filenames = next( os.walk(VIDEO_DIR)) #只进入第一层，也就是说默认一个torrent对应第一层的一个文件或文件夹 for filename in filenames: self.__process_videos_main(dirpath, filename, "file") print('\n', end='') for dirname in dirnames: self.__process_videos_main(dirpath, dirname, "dir") print('\n', end='') def execute_mysql_query_command(self, command: str): """执行mysql查询语句。由于用到多次，所以写成一个方法 :param command: 查询语句""" try: self.m_cur.execute(command) except: message = f'mysql语句出错：{command}。已暂停' log_print(self.log, 'error', message) input() #因为对文件和文件夹的处理差不多，所以写一个函数以便复用代码 def __process_videos_main(self, dirpath, file_dir_name, file_or_dir: str = "file"): """这个函数为处理的主体。 :param dirpath: dirpath，用os.walk()得到，直接传入 :param file_dir_name: 文件或文件夹名 :param file_or_dir: file_dir_name是文件还是文件夹。可取值 "file" 或 "dir" （因为有可能文件和文件夹重名，还是需要这个参数） :详细的选择分支图见注释""" m_cur, log = self.m_cur, self.log movie_found = False abs_file_dir_path = os.path.join(dirpath, file_dir_name) if file_or_dir == "file": filenames_without_suffix = [os.path.splitext(file_dir_name)[0]] elif file_or_dir == "dir": filenames_without_suffix = [] for dirpath_temp, dirnames_temp, filenames_temp in os.walk( abs_file_dir_path): for filename_temp in filenames_temp: filename_without_suffix = os.path.splitext( filename_temp)[0] filenames_without_suffix.append(filename_without_suffix) else: raise Exception( 'file_to_sql.video_file_processor.__process_videos_main错误：file_or_dir参数只应当取 "file" 或 "dir"' ) filenames_without_suffix = set(filenames_without_suffix) #分支图： #---遍历torrents_files #|__发现包含该文件（或该文件夹下所有文件）的torrent # |__查找torrent表中有无记录该hash # |__无记录，则查找code或full_name # |__有1条记录： # |__对应的movie_no为0（默认值），则查找code或full_name # |__对应的movie_no非0，则在movie表中查找该no # |__有1条记录，则请求用户输入 # |__按下Enter，表示使用该movie_no入库 # |__输入任意字符，则查找code或full_name # |__有0条记录，则查找code或full_name # |__其余情况，说明movie表有错误（不可能发生） # |__有多条记录，说明torrent表有错误（几乎不可能发生） #|__未发现对应的torrent，则查找code或full_name for k, v in self.torrents_files.items(): if filenames_without_suffix.issubset(set(v)): message = f'找到包含{file_dir_name}的hash' log_print(log, 'info', message) command_torrent = f'SELECT movie_no FROM torrent WHERE hash_value="{k}";' self.execute_mysql_query_command(command_torrent) results = m_cur.fetchall() if len(results) == 0: message = f'{file_dir_name}对应的hash在torrent表中无记录，查找code或full_name' log_print(log, 'info', message) break elif len(results) == 1: movie_no = int(results[0][0]) if movie_no == 0: message = f'{file_dir_name}对应的hash在torrent表中有记录，但没有对应的movie_no。查找code或full_name' log_print(log, 'info', message) break command_movie = f'SELECT code,full_name FROM movie WHERE no={movie_no};' self.execute_mysql_query_command(command_movie) results = m_cur.fetchall() if len(results) == 1: code, full_name = results[0] message = f'{file_dir_name}对应的hash在torrent表中存在，对应的movie_no={movie_no}，code={code}，full_name={full_name}' log.info(message) affirm = input(message + '\n确认请按下Enter键，或者输入任意字符来查找movie_no：') if affirm == "": self.movie_no = movie_no movie_found = True break else: break elif len(results) == 0: message = f'{file_dir_name}对应的hash在torrent表中存在、有movie_no，但没有对应的movie记录。查找code或full_name' log_print(log, 'info', message) break else: message = f'出现错误：{file_dir_name}对应的hash在torrent表中存在，但对应的movie记录有{len(results)}条（movie_no出现多次）。已暂停' log_print(log, 'warning', message) input() else: message = f'出现错误：{file_dir_name}对应的hash在torrent表中存在，但有{len(results)}条记录（hash_value出现多次）。已暂停' log_print(log, 'warning', message) input() break if not movie_found: self.find_movie_no(abs_file_dir_path, file_or_dir) else: self.into_sql(abs_file_dir_path) def find_movie_no(self, abs_file_dir_path: str, file_or_dir: str = "file"): """通过code或full_name为一个单独的文件或文件夹找到movie_no编号，或新建一个movie_no。需要手动确认code或full_name :param abs_file_dir_path: 文件或文件夹的绝对路径 :param file_or_dir: abs_file_dir_path是文件还是文件夹。可以取 "file" 或 "dir" :会将类的code、full_name、movie_no进行修改；最后一定会调用into_sql方法""" def integrate_multi_percent_into_one(query_str: str) -> str: """将字符串中紧挨着的多个百分号整合为一个。用到2次，所以把这段代码写成函数 :param query_str: 用于mysql的LIKE查询的字符串 :return 处理后的string""" while (re.search(r'%{2,}', query_str)): multi_percent = re.findall(r'%{2,}', query_str) multi_percent = list(set(multi_percent)) multi_percent.sort(key=lambda x: len(x), reverse=True) for i in multi_percent: query_str = query_str.replace(i, '%') return query_str m_cur, log = self.m_cur, self.log file_dir_name = os.path.basename(abs_file_dir_path) #先试着通过code确认movie_no code = video_file_processor.get_code(file_dir_name, file_or_dir) if isinstance(code, str): #当能提取到code时，请求确认 affirm = input( f"为{file_dir_name}自动匹配的code为{code}，确认请直接按下Enter，否则请手动输入code：") code = (affirm.upper(), code)[affirm == ""] elif code == None: #当不能提取到code时，需要手动输入 affirm = input( f"无法为{file_dir_name}自动匹配code，将尝试用full_name确认编号。按下Enter确认，否则请手动输入code：" ) code = (affirm.upper(), 'unknown')[affirm == ""] #流程图 #------------------------------------------------------循环 #|__code=="unknown"，表示用code无法找到movie_no，跳出循环 #|__code为其他字符串，表示疑似code，到movie表中查找 # |__无法根据code精确找到movie_no，则使用模糊查找（对code做一定处理），并输出找到的条目，请求用户输入 # |__直接按下Enter，说明用code无法找到movie_no，跳出循环 # |__输入y，确认现在的code，根据该code新建一条movie记录 # |__输入其他字符串，表示使用该字符串作为code，将会continue # |__可以根据code精确找到1条movie_no，则输出该条目的信息，请求用户输入 # |__输入Enter，表示确认，将会进行入库 # |__输入n，说明用code无法找到movie_no，跳出循环 # |__输入其他字符串，表示使用该字符串作为code，将会continue # |__可以根据code精确找到多条movie_no，则输出所有，并请求用户输入 # |__输入Enter，表示使用movie_no最小的一条进行入库 # |__输入一个数字，进入循环 # |__该数字是一个movie_no，表示选择该movie_no进行入库 # |__该数字不是一个movie_no，则继续要求输入 # |__输入一个数字，返回上述循环验证 # |__输入非纯数字的字符串，则以该字符串作为code，continue最外层循环 # |__输入n，说明用code无法找到movie_no，跳出循环 # |__输入其他字符串，表示使用该字符串作为code，将会continue while (True): if code == "unknown": self.code = "unknown" break elif isinstance(code, str) and code != "unknown": #code可能存在时，在mysql中查找 #若找不到，用LIKE模糊查找给出近似结果；若能找到，则返回 command_movie = f'SELECT no,code,full_name FROM movie WHERE code="{code}";' self.execute_mysql_query_command(command_movie) results = m_cur.fetchall() if len(results) == 0: #将多个 0 替换为 % ，在头尾也加 % ，用LIKE模糊查询 code_like = code.replace('0', '%') code_like = '%' + code_like + '%' code_like = integrate_multi_percent_into_one(code_like) command_movie = f'SELECT no,code FROM movie WHERE code LIKE "{code_like}";' self.execute_mysql_query_command(command_movie) results = m_cur.fetchall() codes_list = [i[1] for i in results] affirm = input( f'在movie中没有完全相同的code（{code}），模糊查找的code有：\n{codes_list}\n按下Enter将尝试用full_name确认编号，或者输入y确认现在的code，或者输入其他code：' ) if affirm == "": self.code = "unknown" break elif affirm == "y": self.movie_no = 0 self.code = code self.into_sql(abs_file_dir_path) return else: code = affirm.upper() continue elif len(results) == 1: affirm = input( f'查找到一条记录：code={results[0][1]}，full_name={results[0][2]}，\n按下Enter以确认，或者输入n来尝试用full_name确认编号，或者输入一个其他的code：' ) if affirm == "": self.movie_no = int(results[0][0]) self.into_sql(abs_file_dir_path) return elif affirm == "n": self.code = "unknown" break else: code = affirm.upper() continue else: message = f'通过code={code}在movie表中查找到{len(results)}条记录，这可能是一个错误。' log_print(log, 'warning', message, print_suffix='查找到的记录如下：（按 no，code，full_name 输出）') for i in results: print(f'{i[0]} , {i[1]} , {i[2]}') affirm = input( '按下Enter表示选择最小的一个movie_no作为编号，或者输入一个存在的no表示选择该编号，或者输入n来尝试用full_name确认编号，或者输入其他code：' ) if affirm == "": min_movie_no = int(results[0][0]) for i in results: movie_no = int(i[0]) if min_movie_no > movie_no: min_movie_no = movie_no self.movie_no = min_movie_no self.into_sql(abs_file_dir_path) return elif re.search(r'^\d+$', affirm): while (re.search(r'^\d+$', affirm)): movie_no = int(affirm) if movie_no not in [int(i[0]) for i in results]: affirm = input( '输入的movie_no不在上面列出项中，请重新输入一个no，或者输入其他code（非纯数字）：' ) else: self.movie_no = int(affirm) self.into_sql(abs_file_dir_path) return code = affirm.upper() continue elif affirm == 'n': self.code = "unknown" break else: code = affirm.upper() continue #然后试着通过full_name确认movie_no full_name = os.path.splitext(file_dir_name)[0] matched_results = [] #分词查找，每次查询结果转为set存储在matched_results中，最后统计出现次数，根据匹配次数从高到低输出 ranked_matched_movie = [] #每个元素为：[(no, full_name), 出现次数] affirm = input( f'将以文件（夹）名作为full_name（{full_name}），按下Enter确认，输入n以跳过这个文件（夹）并将它放入VIDEO_DIR_UNKNOWN中，或者输入一个full_name：' ) if affirm == "": pass elif affirm == "n": self.movie_no = 0 self.full_name = "unknown" self.into_sql(abs_file_dir_path) return else: full_name = affirm #流程图 #------初始的full_name就是文件名（无后缀）/文件夹名，请求用户输入 # |__按下Enter，表示认可该full_name # |__输入n，表示放弃用full_name寻找movie_no，将把文件（夹）放到VIDEO_DIR_UNKNOWN中 # |__输入其他字符串，表示以该字符串作为full_name继续 #------------------------------------------------------循环 #根据full_name得到一个keywords列表，对每个keyword分别进行查询。每次查询的结果非空时，放到matched_results列表中。 #|__若matched_results列表为空，说明通过所有的keyword都不能查询到，则请求用户输入 # |__按下Enter，确认当前的full_name，用它新建一条movie记录 # |__输入n，表示放弃用full_name寻找movie_no，将把文件（夹）放到VIDEO_DIR_UNKNOWN中 # |__输入其他字符串，表示将full_name替换为该字符串，并continue #|__若matched_results列表非空，则统计每个movie条目出现的次数，从高到低进行排序得到ranked_matched_movie。进入循环： # 输出前10条，请求用户输入 # |__按下Enter，以该full_name创建新的movie条目 # |__输入一个数字，进入循环 # |__该数字是一个存在的movie_no，则使用该no进行入库 # |__该数字不是一个存在的movie_no，则继续要求输入 # |__输入一个数字，返回上述循环验证 # |__输入非纯数字的字符串，则以该字符串作为full_name、回到最外层循环 # |__输入n，表示放弃用full_name寻找movie_no，将把文件（夹）放到VIDEO_DIR_UNKNOWN中 # |__输入all，显示ranked_matched_movie的全部条目并continue # |__输入其他字符串，则以该字符串作为full_name、回到最外层循环 while (True): words = self.get_keywords(full_name) for i in range(len(words)): #在头尾也加 % ，用LIKE模糊查询 word_like = '%' + words[i] + '%' word_like = integrate_multi_percent_into_one(word_like) command_movie = f'SELECT no,full_name FROM movie WHERE full_name LIKE "{word_like}";' self.execute_mysql_query_command(command_movie) results = m_cur.fetchall() if len(results) != 0: matched_results.append(set(results)) if len(matched_results) == 0: affirm = input( f'full_name={full_name}没有匹配结果。按下Enter以该full_name新建一条movie记录，输入n以跳过这个文件（夹）并将它放入VIDEO_DIR_UNKNOWN中，或者输入其他值来重新输入full_name：' ) if affirm == "": self.movie = 0 self.full_name = full_name self.into_sql(abs_file_dir_path) return elif affirm == "n": self.movie = 0 self.full_name = "unknown" self.into_sql(abs_file_dir_path) return else: full_name = affirm continue else: for matched_result in matched_results: for match in matched_result: try: exists_index = [ i[0] for i in ranked_matched_movie ].index(match) except ValueError: exists_index = -1 if exists_index == -1: ranked_matched_movie.append([(match), 1]) else: ranked_matched_movie[exists_index][1] += 1 ranked_matched_movie.sort(key=lambda x: x[1], reverse=True) show_all = False while (True): print( f'根据full_name={full_name}查找到以下可能匹配的记录：（按 no , 出现次数 , full_name 输出）' ) for i in range(len(ranked_matched_movie)): if not show_all: if i >= 10: break print( f'{ranked_matched_movie[i][0][0]} , {ranked_matched_movie[i][1]} , {ranked_matched_movie[i][0][1]}' ) if not show_all: affirm = input( '按下Enter以full_name创建一条记录，或者从以上项目中挑选一个no输入，或者输入n以跳过这个文件（夹）并将它放入VIDEO_DIR_UNKNOWN中，或者输入all以显示全部匹配，或者输入其他字符串来作为full_name：' ) else: affirm = input( '按下Enter以full_name创建一条记录，或者从以上项目中挑选一个no输入，或者输入n以跳过这个文件（夹）并将它放入VIDEO_DIR_UNKNOWN中，或者输入其他字符串（非all）来作为full_name：' ) if affirm == "": self.movie = 0 self.full_name = full_name self.into_sql(abs_file_dir_path) return elif re.search(r'^\d+$', affirm): while (re.search(r'^\d+$', affirm)): movie_no = int(affirm) if movie_no not in [ int(i[0][0]) for i in ranked_matched_movie ]: affirm = input( '输入的movie_no不在上面列出项中，请重新输入一个no，或者输入非纯数字字符串来作为full_name：' ) else: self.movie_no = int(affirm) self.into_sql(abs_file_dir_path) return matched_results = [] ranked_matched_movie = [] full_name = affirm break elif affirm == "n": self.movie = 0 self.full_name = "unknown" self.into_sql(abs_file_dir_path) return elif affirm == "all": show_all = True continue else: matched_results = [] ranked_matched_movie = [] full_name = affirm break @staticmethod def get_code(file_dir_name: str, file_or_dir: str = "file"): """根据文件或文件夹名得到对应的code。 :param file_dir_name: 文件名或文件夹名 :param file_or_dir: 表示file_dir_name为文件（为文件时会尝试去掉后缀名）还是文件夹。可取值 "file" 或 "dir" :return string，表示可能的code；或者返回None，表示无法找到code""" if file_or_dir == "file": file_dir_name = os.path.splitext(file_dir_name)[0] p_fc2 = re.compile(r'fc2[-_]{0,1}(ppv){0,1}[-_]{0,1}\d{7}', re.I) code = re.search(p_fc2, file_dir_name) if code: code = code.group() code = code.replace('_', '-') p_ppv = re.compile(r'ppv', re.I) if re.search(p_ppv, code): start, end = re.search(p_ppv, code).span() code = code[:start] + code[end:] code = code.replace('--', '-') if '-' not in code: code = code[:3] + '-' + code[3:] return code p_123456_789 = re.compile(r'\d{6}[-_]{1}\d{3}') code = re.search(p_123456_789, file_dir_name) if code: code = code.group() code = code.replace('_', '-') return code p_code = re.compile(r'\d*[a-zA-Z]{2,6}[-_]{0,1}\d+') code = re.search(p_code, file_dir_name) if isinstance(code, re.Match): code = code.group() code = code.replace('_', '-') if '-' not in code: start, end = re.search('[a-zA-Z]{2,6}[^-]', code).span() code = code[:end - 1] + '-' + code[end - 1:] return code return None @staticmethod def get_keywords(full_name: str): """根据full_name得到关键字列表（list[str]），该列表中的每项字符串都会用于mysql查询。 :param full_name: 文件名（无后缀）或文件夹名 :return list[str]""" keywords_1 = re.findall(r'[a-zA-Z]+', full_name) keywords_2 = re.findall(r'\d+', full_name) keywords = list(set(keywords_1).union(keywords_2)) if len(keywords) == 0: print('full_name中不含英文单词或数字，将作为一个整体在mysql中模糊查找') keywords = [full_name] print(f'对{full_name}提取到的关键词：\n{keywords}') return keywords def into_sql(self, abs_file_dir_path: str): """根据movie_no、code、full_name分情况处理。 :首先移动文件（夹）到合适的位置，然后将其入库（修改movie表中的downloaded和file_path） :对于跳过的文件（夹），则只是转移到VIDEO_DIR_UNKNOWN :param abs_file_dir_path: 绝对路径""" m_cur = self.m_cur print(self.movie_no, self.code, self.full_name) if self.movie_no == 0 and self.code == "unknown" and self.full_name == "unknown": print('跳过该文件（夹）') elif self.movie_no == 0: print(f'以code={self.code}、full_name={self.full_name}新建一条记录') elif self.movie_no != 0: command_movie = f'SELECT code,full_name FROM movie WHERE no={self.movie_no};' self.execute_mysql_query_command(command_movie) code, full_name = m_cur.fetchall()[0] print( f'将对已存在的{self.movie_no}号记录进行修改（code={code},full_name={full_name}）' ) self.movie_no, self.code, self.full_name = 0, "unknown", "unknown" def main(self): """执行入口""" self.process_videos() if __name__ == "__main__": v_f_p = video_file_processor().main() input('入库完成') 这里的 video_file_processor.into_sql 方法还没有完成，可以根据自己的需要调整。另外， get_code 和 get_keywords 两个方法也可以根据需要调整。另外，在import时，除了前文提到的自建库，还可以看到这样两行：from MyPythonLib.log import log_print from MyPythonLib import torrent_parser as t_p这个 log_print 是自己写的一个小函数，用来进行一些既要log又要print的信息，内容如下：def log_print(log: logging.Logger, log_type: str, message: str, print_prefix: str = "", print_suffix: str = ""): """同时log和print。 :param log: Logger对象 :param log_type: Logger的级别 :param message: 输出的信息 :param print_prefix: print时需要在message开头加的字符串 :param print_suffix: print时需要在message末尾加的字符串 :return None""" try: if log_type == 'debug': log.debug(message) elif log_type == 'info': log.info(message) elif log_type == 'warning': log.warning(message) elif log_type == 'error': log.error(message) elif log_type == 'critical': log.critical(message) message = print_prefix + message + print_suffix print(message) except: raise Exception('error: MyPythonLib.log.log_print failed ')torrent_parser则是解析torrent文件的一个自建库，内容如下：#!/usr/bin/python # -*- coding: UTF-8 -*- import collections, hashlib def bencode(elem): if type(elem) == str: elem = str.encode(elem) if type(elem) == bytes: result = str.encode(str(len(elem))) + b":" + elem elif type(elem) == int: result = str.encode("i" + str(elem) + "e") elif type(elem) == list: result = b"l" for item in elem: result += bencode(item) result += b"e" elif type(elem) in [dict, collections.OrderedDict]: result = b"d" for key in elem: result += bencode(key) + bencode(elem[key]) result += b"e" return result def bdecode(bytestr, recursiveCall=False): startingChars = dict({b"i": int, b":": str, b"l": list, b"d": dict}) digits = [b"0", b"1", b"2", b"3", b"4", b"5", b"6", b"7", b"8", b"9"] started = ended = False curtype = None numstring = b"" # for str, int result = None # for list, dict key = None # for dict while len(bytestr) > 0: # reading and popping from the beginning char = bytestr[:1] if not started: bytestr = bytestr[1:] if char in digits: numstring += char elif char in startingChars: started = True curtype = startingChars[char] if curtype == str: size = int(bytes.decode(numstring)) # try to decode strings try: result = bytes.decode(bytestr[:size]) except UnicodeDecodeError: result = bytestr[:size] bytestr = bytestr[size:] ended = True break elif curtype == list: result = [] elif curtype == dict: result = collections.OrderedDict() else: raise ValueError("Expected starting char, got ‘" + bytes.decode(char) + "’") else: # if started if not char == b"e": if curtype == int: bytestr = bytestr[1:] numstring += char elif curtype == list: item, bytestr = bdecode(bytestr, recursiveCall=True) result.append(item) elif curtype == dict: if key == None: key, bytestr = bdecode(bytestr, recursiveCall=True) else: result[key], bytestr = bdecode(bytestr, recursiveCall=True) key = None else: # ending: char == b"e" bytestr = bytestr[1:] if curtype == int: result = int(bytes.decode(numstring)) ended = True break if ended: if recursiveCall: return result, bytestr else: return result else: raise ValueError("String ended unexpectedly") class torrent(): '''解析种子文件。''' def __init__(self, abs_torrent_file_path: str) -> None: ''':param abs_torrent_file_path: 种子文件的绝对路径''' self.abs_torrent_file_path = abs_torrent_file_path bytes_stream = '' with open(abs_torrent_file_path, 'rb') as f: bytes_stream = f.read() self.metadata = bdecode(bytes_stream) encodedInfo = bencode(self.metadata["info"]) self.hash_value = hashlib.sha1(encodedInfo).hexdigest().upper() self.files = [] if 'name' in self.metadata['info'].keys( ) and 'files' not in self.metadata['info'].keys(): self.files.append(self.metadata['info']['name']) elif 'files' in self.metadata['info'].keys(): for i in self.metadata['info']['files']: self.files.append(i['path'][0]) if __name__ == '__main__': pass为了能读取torrent文件、从torrent文件得到 正确的 40位的hash，笔者翻了很多教程，中文互联网上到处乱抄、有很多人讲课也是乱讲的，最后还是在 这个github项目 里得到了正确答案。4.ffmpeg加速实际运行 vimg_processor.py 脚本时，可能会觉得视频处理速度慢。这个问题有两种解决方法：使用显卡加速，或者多台电脑同时压缩视频。但在继续阅读之前，你需要先查看一下运行 vimg_processor.py 脚本时存放压缩视频的文件夹所在的那块硬盘的读写速度，比如在windows上可以打开 任务管理器 看看那块硬盘的读写速度是否已经达到极限（一般的机械硬盘大概150MB/s，固态硬盘则可达到1~1.5GB/s）。（1）显卡加速如果对ffmpeg有些了解，分析一下上文中的那条ffmpeg命令：ffmpeg -threads 8 -i "{}" -threads 8 -y -r 24 -pix_fmt yuv420p -vf scale={} -c:v h264 -b:a 64k -ar 44100 "{}"会发现，这样的命令只是调用了CPU、不调用GPU或其他计算的硬件，好处是可以在运行ffmpeg的同时玩一些需要显卡的游戏（但是ffmpeg即使调用显卡也未必占用很多），坏处是可能比较慢。实际上，ffmpeg调用CPU处理视频的过程称为软编码（输出视频）、软解码（读取视频），调用GPU或其他计算硬件处理则是硬编码、硬解码。CPU和GPU在运算上的区别主要是CPU擅长处理多种性质不同的运算（加减乘除，逻辑运算等），而GPU擅长的是计算乘法和，比如 a1*b1 + a2*b2 + a3*b3 ... 这种（因为显卡输出画面时需要处理大量类似的运算），这样的区别也决定了GPU用来做深度学习一般更快。你可以这样理解：CPU软编解码，类似于人手，你可以用手拿起扇子扇风，这样的效率比较低，但是手能处理多种复杂的任务；GPU硬编解码，类似于用电风扇吹风，电风扇只能用来吹风（它在硬件层面就是为了吹风而设计的），但是它吹风的效率远非用手扇风可比。回到ffmpeg上，用GPU加速ffmpeg首先要确定ffmpeg支持的显卡驱动和你的显卡型号。用 ffmpeg -hwaccels 可以查看ffmpeg支持的加速方式。如果你用的是windows电脑，并且像前文一样从ffmpeg官网直接下载windows平台的压缩包来安装ffmpeg，那么多半会看到以下输出：...... Hardware acceleration methods: cuda dxva2 qsv d3d11va opencl vulkan熟悉显卡的朋友应该知道，cuda是英伟达NVIDIA推出的针对自家产品的一种架构，cuda实现了一些CPU的指令集，这样你就可以把GPU当成CPU来用（虽然有些问题用GPU很慢）；dxva2则是微软推出的。总之，如果你用英伟达显卡，就去英伟达官网下显驱；如果用AMD显卡，就去AMD官网下显卡驱动。装好驱动后，把压缩命令加上三个参数：硬件加速方式，解码器，编码器。比如用英伟达显卡，就是：ffmpeg -hwaccel cuvid -c:v h264_cuvid -i "{}" -y -r 24 -pix_fmt yuv420p -vf scale={} -c:v h264_nvenc -b:a 64k -ar 44100 "{}"对应的是 -hwaccel cuvid （加速方式）， -c:v h264_cuvid （硬解码器）和 -c:v h264_nvenc （硬编码器）。如果用AMD显卡，可以这样写：ffmpeg -hwaccel dxva2 -i "{}" -y -r 24 -pix_fmt yuv420p -vf scale={} -c:v h264_amf -b:a 64k -ar 44100 "{}"ffmpeg对AMD显卡没有实现硬解码器，所以只能软解码。对应的是 -hwaccel dxva2 （加速方式）， -c:v h264_amf （硬编码器）。最后，你还可以查看ffmpeg支持的编解码器，如果只限定h264的话，在cmd中输入：ffmpeg -encoders | findstr "h264" 、 ffmpeg -decoders | findstr "h264" 或者 ffmpeg -codecs | findstr "h264" 即可。笔者的CPU主频3.6GHz，AMD显卡，只用CPU软解编码时占用CPU 70-80%，GPU 0%；用GPU加速后CPU占用30%，GPU占用10%。对于一段40min、比特率12000kb/s的视频片段，以pad标准压缩，前者花费10.98min，后者花费4.58min；不足之处是后者生成的文件稍大，大概是前者的7/6，也就是多了1/6，实际上硬编码生成的文件都比软编码的大。如果对文件大小敏感的话，可以参考下一种方法进行压缩加速。（2）多电脑加速如果一台电脑不够用，你还可以选择用多台电脑同时对一个文件夹内的视频进行处理。首先需要共享这个文件夹（NAS），比如用笔者其他文章中提到的Samba共享出来，然后修改一下 vimg_processor.py ，目标是能在多台电脑上对同一个文件夹内的视频进行编解码（需要解决冲突问题，笔者的解决方法是在直连NAS的电脑上运行一个redis数据库，来存储不同状态的视频），并且能够根据电脑的配置调整ffmpeg命令。但是，使用NAS、多电脑之前，先看一下网速和硬盘读写是否已达上限。如果所有电脑都在同一个内网路由器下，且是千兆路由器，那么NAS读写速度大概最高110~120MB/s之间，看一下直连分享NAS的机器在每台电脑压缩时进出网速是多少，按笔者的经验一般一台台式机，压缩时接收的流量更多（因为是压缩），需要30~60 Mbps，也即3.75~7.5 MB/s（笔记本根据配置不同可能在1~5 MB/s），所以可以多加几台电脑。另外，实际的机械硬盘读写速度在150MB/s左右，减去110~120MB/s还有30~40MB/s的读写速度只能被直连NAS的机器使用，有条件的可以用更高速的路由器。然后开始编写脚本。首先在直连NAS的电脑上编写一个python脚本，比如叫 vimg_update_redis.py ，负责更新redis、移动文件（所有 #需要更改 的前一行是可能需要自定义的地方）：#!/usr/bin/python3 # -*- coding: UTF-8 -*- import os, re, traceback, redis, platform from MyPythonLib import log as mylog from MyPythonLib import redis as myredis NAS_mounted_path = '/xxx/xxx/xxx/xxx/' # NAS挂载路径。由于函数get_redis_rel_path_from_abs_path，最后一个分隔符必须带上 #需要更改 r_pool = myredis.get_pool(host='192.168.1.1', port=56379) #需要更改 r_cur = redis.Redis(connection_pool=r_pool) log_path = os.path.join('/xxx/xxx/logs') #需要更改 if not os.path.isdir(s=log_path): os.mkdir(path=log_path) log = mylog.get_logger(abs_path=log_path, log_file_name='vimg_update_redis.log') redis_key_set_todo = 'vimg_processor:set:todo' # 这个redis集合存放需要压缩的文件的相对路径（仅包含压缩前的文件名），路径分隔符为linux的/（下同） redis_key_set_wrong = 'vimg_processor:set:wrong' #集合，存放压缩时出现错误的文件的相对路径（仅包含压缩前的文件名） redis_key_str_ongoing = 'vimg_processor:str:ongoing:' # 存放正在压缩的文件的相对路径，键名包含压缩前的文件相对路径，值为压缩后的文件相对路径 redis_key_str_done = 'vimg_processor:str:done:' # 存放已压缩完的文件的相对路径，键名包含压缩前的文件相对路径，值为压缩后的文件相对路径 sys_kind = platform.system() def get_suffix(filename: str) -> str: '''返回文件后缀名。若文件无后缀名（以 . 结尾，或者不含 . ）则返回空字符串。 :param filename: 文件名（可带路径前缀） :return str，文件后缀名（不含 . ）''' suffix = os.path.splitext(filename)[-1] return (suffix[1:], '')[suffix == ''] def is_video(filename: str) -> bool: '''根据后缀名判断文件是否视频。 :param filename: 文件名（可带路径前缀） :return bool，True表示文件是视频，False表示非视频''' video_suffix_set = { "WMV", "ASF", "ASX", "RM", "RMVB", "MP4", "TS", "MPG", "3GP", "MOV", "M4V", "AVI", "MKV", "FIV", "VOB", "FLV", "MPEG", "ISO" } #需要更改 #iso文件不一定能用ffmpeg打开，可以自行取舍 return (False, True)[get_suffix(filename).upper() in video_suffix_set] def is_img(filename: str) -> bool: '''根据后缀名判断文件是否图片。 :param filename: 文件名（可带路径前缀） :return bool，True表示文件是图片，False表示非图片''' img_suffix_set = {"JPG", "JPEG", "BMP", "PNG", "WEBP"} #需要更改 return (False, True)[get_suffix(filename).upper() in img_suffix_set] def replace_path_seperator(path: str, sys_kind: str) -> str: '''根据操作系统，替换路径中的分隔符。 :param path: 需要替换的路径 :param sys_kind: 目标操作系统 :return str，替换好的路径''' if sys_kind == 'Windows': return path.replace('/', '\\') elif sys_kind == 'Linux': return path.replace('\\', '/') def is_origin(filename: str) -> bool: '''接受文件名，判断这个文件是源文件还是压缩后的文件。 :param filename: 文件名（可带路径前缀） :return bool，True表示该文件为源文件''' prefix = os.path.splitext(filename)[0] return (True, False)[re.search(r'#test#$', prefix) != None] def get_redis_rel_path_from_abs_path(abs_path: str) -> str: '''根据本机上文件的绝对路径，返回存储到redis中的相对路径。 :由于redis中存储的是使用linux分隔符的相对路径，所以需要这个函数转换： :将abs_path首先去掉NAS_mounted_path，然后分隔符换成linux的。由于这个函数，NAS_mounted_path最后需要带上一个分隔符 :param abs_path: 文件在本机的绝对路径 :return str，redis中存储的相对路径，即 XX/XX/XX 这样的形式。注意最前面没有分隔符''' global NAS_mounted_path redis_rel_path = abs_path[len(NAS_mounted_path):] return replace_path_seperator(redis_rel_path, sys_kind='Linux') def get_abs_path_from_redis_rel_path(redis_rel_path: str) -> str: '''根据redis中存储的相对路径，返回本机上该文件的绝对路径。 :将redis_rel_path连上NAS_mounted_path，然后替换分隔符为本机的 :param redis_rel_path: redis中存储的相对路径 :return str，文件在本机中的绝对路径''' global NAS_mounted_path, sys_kind abs_path = os.path.join(NAS_mounted_path, redis_rel_path) return replace_path_seperator(abs_path, sys_kind=sys_kind) def main(): '''更新文件信息。 :对于redis_key_set_wrong中记录的文件，把它们放到单独的目录里（压缩错误）； :对于redis_key_str_ongoing系列的文件，不作处理； :对于redis_key_str_done系列的文件，把它们放到单独的目录里（压缩完）； :检查 压缩中 目录下的所有文件，如果在另外三个键中没有记录，把它放到redis_key_set_todo里； :检查 redis_key_set_todo 中记录的文件，如果不存在则删除''' global NAS_mounted_path, r_cur, log, sys_kind global redis_key_set_todo, redis_key_set_wrong, redis_key_str_ongoing, redis_key_str_done # 对于redis_key_set_wrong中记录的文件，把它们放到单独的目录里 # 对于单独文件，直接剪切；对于文件夹，则尝试新建相同的目录、剪切 while r_cur.scard(redis_key_set_wrong) != 0: try: redis_rel_path = r_cur.spop(redis_key_set_wrong, count=1)[0] src_abs_path = replace_path_seperator(redis_rel_path, sys_kind=sys_kind) src_abs_path = os.path.join(NAS_mounted_path, src_abs_path) # 将中间的文件夹替换 dst_abs_path = src_abs_path.replace('压缩中', '压缩错误', 1) #需要更改 # 获得除了文件名以外，前面的目录路径 basename = os.path.basename(src_abs_path) pre_dirs = dst_abs_path[:-len(basename)] if not os.path.isdir(pre_dirs): os.makedirs(pre_dirs) os.rename(src_abs_path, dst_abs_path) msg = f'redis_key_set_wrong: 将{src_abs_path}移动为{dst_abs_path}' mylog.log_print(log, 'info', msg) except: r_cur.sadd(redis_key_set_wrong, redis_rel_path) msg = f'失败 redis_key_set_wrong: 移动{redis_rel_path}出错' mylog.log_print(log, 'error', msg) except_info = traceback.format_exc() mylog.log_print(log, 'error', except_info) break # 对于redis_key_str_done系列的文件，把它们放到单独的目录里 # 对于单独文件，直接剪切；对于文件夹，则尝试新建相同的目录、剪切 redis_rel_path_keys = r_cur.keys(redis_key_str_done + '*') for redis_rel_path_key in redis_rel_path_keys: try: # 获得压缩前后的源文件名 redis_rel_path_origin = redis_rel_path_key[len(redis_key_str_done ):] redis_rel_path_compressed = r_cur.get(redis_rel_path_key) src_abs_path_origin = replace_path_seperator(redis_rel_path_origin, sys_kind=sys_kind) src_abs_path_origin = os.path.join(NAS_mounted_path, src_abs_path_origin) src_abs_path_compressed = replace_path_seperator( redis_rel_path_compressed, sys_kind=sys_kind) src_abs_path_compressed = os.path.join(NAS_mounted_path, src_abs_path_compressed) # redis中存储的相对路径都是 压缩中 # 只有运行vimg_processor_multi中的delete_test时，会删除done系列键 # 有时来不及运行delete_test，这些剩余的done键不动，这里直接continue if not os.path.isfile(src_abs_path_origin): continue # 获得压缩前后的目标文件名 dst_abs_path_origin = src_abs_path_origin.replace('压缩中', '压缩完', 1) #需要更改 dst_abs_path_compressed = src_abs_path_compressed.replace( '压缩中', '压缩完', 1) #需要更改 # 获得除了文件名以外，前面的目录路径 basename = os.path.basename(src_abs_path_origin) pre_dirs = dst_abs_path_origin[:-len(basename)] if not os.path.isdir(pre_dirs): os.makedirs(pre_dirs) os.rename(src_abs_path_origin, dst_abs_path_origin) os.rename(src_abs_path_compressed, dst_abs_path_compressed) # r_cur.delete(redis_rel_path_key) 不要删除redis_key_str_done系列键，这些键在运行delete_test时删除 msg = f'redis_key_str_done: 移动{src_abs_path_origin}为{dst_abs_path_origin}，\n移动{src_abs_path_compressed}为{dst_abs_path_compressed}' mylog.log_print(log, 'info', msg) except: r_cur.set(redis_rel_path_key, redis_rel_path_compressed) msg = f'失败 redis_key_str_done: 移动{redis_rel_path_origin}和{redis_rel_path_compressed}时出错' mylog.log_print(log, 'error', msg) except_info = traceback.format_exc() mylog.log_print(log, 'error', except_info) break # 检查 压缩中 目录下的所有文件，如果在四个键中都没有记录，把它放到redis_key_set_todo里 dir_compressing = replace_path_seperator('xxx/xxx/压缩中', sys_kind=sys_kind) #需要更改 dir_compressing = os.path.join(NAS_mounted_path, dir_compressing) for dirpath, dirnames, filenames in os.walk(dir_compressing): for filename in filenames: # 跳过压缩后的文件 if not is_origin(filename): continue abs_path = os.path.join(dirpath, filename) if (is_video(abs_path) or is_img(abs_path)) and os.path.isfile(abs_path): redis_rel_path = get_redis_rel_path_from_abs_path(abs_path) condition = not r_cur.sismember(redis_key_set_todo, redis_rel_path) condition = condition and not r_cur.sismember( redis_key_set_wrong, redis_rel_path) condition = condition and r_cur.exists(redis_key_str_ongoing + redis_rel_path) == 0 condition = condition and r_cur.exists(redis_key_str_done + redis_rel_path) == 0 if condition: r_cur.sadd(redis_key_set_todo, redis_rel_path) msg = f'redis_key_set_todo: 将{redis_rel_path}添加到todo中' mylog.log_print(log, 'info', msg) # 如果有空的文件夹，删除掉。为了能一次删除完，需要深度优先遍历 for dirpath, dirnames, filenames in os.walk(dir_compressing, topdown=False): for dirname in dirnames: abs_dir_path = os.path.join(dirpath, dirname) dirpath_temp, dirnames_temp, filenames_temp = next( os.walk(abs_dir_path)) if len(dirnames_temp) == 0 and len( filenames_temp) == 0 and os.path.isdir(abs_dir_path): os.rmdir(abs_dir_path) msg = f'目录{abs_dir_path}为空，已删除' mylog.log_print(log, 'info', msg) # 检查 redis_key_set_todo 中记录的文件，如果不存在则删除redis记录 for redis_rel_path in r_cur.smembers(redis_key_set_todo): abs_path = get_abs_path_from_redis_rel_path(redis_rel_path) if not os.path.isfile(abs_path): r_cur.srem(redis_key_set_todo, redis_rel_path) msg = f'redis_key_set_todo: 文件{abs_path}不存在，已移除redis记录' mylog.log_print(log, 'info', msg) if __name__ == '__main__': try: main() except: except_info = traceback.format_exc() mylog.log_print(log, 'error', except_info) '''这个py文件用于更新vimg_processor需要使用的redis库，可以在任何连接到NAS的机器上使用。 推荐在直连NAS的机器上运行'''如果在linux上，可以用crontab把这个脚本设置成定时启动，比如10min运行一次；windows上也有类似功能的软件，请自行寻找。然后把 vimg_processor.py 修改为 vimg_processor_multi.py ，让它能适应不同的电脑和配置（所有 #需要更改 的前一行是可能需要自定义的地方）：#!/usr/bin/python3 # -*- coding: UTF-8 -*- import os, time, json, subprocess, re, traceback, redis, platform, sys from MyPythonLib import log as mylog from MyPythonLib import redis as myredis CPU_THREADS = (12, 8) # ffmpeg软编解码时线程数，根据CPU配置调整。分别为解码用的线程、编码用的线程，在压缩时，前者大于后者 #需要更改 # 可以用一个文件实验最佳的CPU_THREADS。软编解码的快慢看ffmpeg最右边的 speed=数字x NAS_mounted_path = '/xxx/xxx/xxx/' # NAS挂载路径。由于函数get_redis_rel_path_from_abs_path，最后一个分隔符必须带上 #需要更改 CWD = os.getcwd() r_pool = myredis.get_pool(host='192.168.1.1', port=56379) #需要更改 r_cur = redis.Redis(connection_pool=r_pool) log_path = os.path.join(CWD, 'logs') #需要更改 if not os.path.isdir(s=log_path): os.mkdir(path=log_path) log = mylog.get_logger(abs_path=log_path, log_file_name='vimg_processor_multi.log') TEMP_DIR = os.path.join(CWD, 'temp') # temp这个文件夹主要是在压缩图片时存放临时的调色板 #需要更改 if not os.path.isdir(s=TEMP_DIR): os.mkdir(path=TEMP_DIR) redis_key_set_todo = 'vimg_processor:set:todo' # 这个redis集合存放需要压缩的文件的相对路径（仅包含压缩前的文件名），路径分隔符为linux的/（下同） redis_key_set_wrong = 'vimg_processor:set:wrong' #集合，存放压缩时出现错误的文件的相对路径（仅包含压缩前的文件名） redis_key_str_ongoing = 'vimg_processor:str:ongoing:' # 存放正在压缩的文件的相对路径，键名包含压缩前的文件相对路径，值为压缩后的文件相对路径 redis_key_str_done = 'vimg_processor:str:done:' # 存放已压缩完的文件的相对路径，键名包含压缩前的文件相对路径，值为压缩后的文件相对路径 start_time = time.time() fail_msg_list = [] # 每次有错误信息时都放到这个列表中 sys_kind = platform.system() # 字符串，表示系统类型 def get_suffix(filename: str) -> str: '''返回文件后缀名。若文件无后缀名（以 . 结尾，或者不含 . ）则返回空字符串。 :param filename: 文件名（可带路径前缀） :return str，文件后缀名（不含 . ）''' suffix = os.path.splitext(filename)[-1] return (suffix[1:], '')[suffix == ''] def is_video(filename: str) -> bool: '''根据后缀名判断文件是否视频。 :param filename: 文件名（可带路径前缀） :return bool，True表示文件是视频，False表示非视频''' video_suffix_set = { "WMV", "ASF", "ASX", "RM", "RMVB", "MP4", "TS", "MPG", "3GP", "MOV", "M4V", "AVI", "MKV", "FIV", "VOB", "FLV", "MPEG", "ISO" } #需要更改 #iso文件不一定能用ffmpeg打开，可以自行取舍 return (False, True)[get_suffix(filename).upper() in video_suffix_set] def is_img(filename: str) -> bool: '''根据后缀名判断文件是否图片。 :param filename: 文件名（可带路径前缀） :return bool，True表示文件是图片，False表示非图片''' img_suffix_set = {"JPG", "JPEG", "BMP", "PNG", "WEBP"} #需要更改 return (False, True)[get_suffix(filename).upper() in img_suffix_set] def replace_path_seperator(path: str, sys_kind: str) -> str: '''根据操作系统，替换路径中的分隔符。 :param path: 需要替换的路径 :param sys_kind: 目标操作系统 :return str，替换好的路径''' if sys_kind == 'Windows': return path.replace('/', '\\') elif sys_kind == 'Linux': return path.replace('\\', '/') def is_origin(filename: str) -> bool: '''接受文件名，判断这个文件是源文件还是压缩后的文件。 :param filename: 文件名（可带路径前缀） :return bool，True表示该文件为源文件''' prefix = os.path.splitext(filename)[0] return (True, False)[re.search(r'#test#$', prefix) != None] def get_redis_rel_path_from_abs_path(abs_path: str) -> str: '''根据本机上文件的绝对路径，返回存储到redis中的相对路径。 :由于redis中存储的是使用linux分隔符的相对路径，所以需要这个函数转换： :将abs_path首先去掉NAS_mounted_path，然后分隔符换成linux的。由于这个函数，NAS_mounted_path最后需要带上一个分隔符 :param abs_path: 文件在本机的绝对路径 :return str，redis中存储的相对路径，即 XX/XX/XX 这样的形式。注意最前面没有分隔符''' global NAS_mounted_path redis_rel_path = abs_path[len(NAS_mounted_path):] return replace_path_seperator(redis_rel_path, sys_kind='Linux') def get_abs_path_from_redis_rel_path(redis_rel_path: str) -> str: '''根据redis中存储的相对路径，返回本机上该文件的绝对路径。 :将redis_rel_path连上NAS_mounted_path，然后替换分隔符为本机的 :param redis_rel_path: redis中存储的相对路径 :return str，文件在本机中的绝对路径''' global NAS_mounted_path, sys_kind abs_path = os.path.join(NAS_mounted_path, redis_rel_path) return replace_path_seperator(abs_path, sys_kind=sys_kind) def get_compressed_abs_path(abs_path: str, fmt: str = '') -> str: '''根据源文件的绝对路径，返回压缩后文件的绝对路径。这里的绝对路径都是本机上的。 :压缩后的图像或者视频将放在同路径下，所以需要给转换后的文件名中加上字符串#test#（在后缀名前）：1.mp4 -> 1#test#.mp4 :有些特殊情况下压缩后文件会重名，比如一个文件夹中同时存在1.mp4、1.rmvb时，又用fmt指定压缩后格式。此时会对压缩后的文件加上序号重命名（1#test#.mp4，1#test#(1).mp4，1#test#(2).mp4） :param abs_path: 需要压缩的文件的绝对路径 :param fmt: 指定转换后文件的格式，为空字符串时不改变格式 :return str，压缩后文件的绝对路径，根据操作系统调整''' prefix, suffix = os.path.splitext(abs_path) suffix = (fmt, suffix.lstrip('.'))[fmt == ''] compressed_abs_path = prefix + '#test#.' + suffix i = 1 while (os.path.exists(compressed_abs_path)): compressed_abs_path = prefix + '#test#(' + str(i) + ').' + suffix i += 1 return compressed_abs_path def get_origin_abs_path(abs_path: str) -> str: '''根据压缩后文件的绝对路径到redis中查询，返回源文件的绝对路径。这里的绝对路径都是本机上的。 :这个函数只应该用于已经压缩完的文件，函数将到redis_key_str_done的所有字符串中进行查询 :param abs_path: 压缩后文件的绝对路径 :return str，源文件的绝对路径，根据操作系统调整；若无法查询到，返回空字符串''' global r_cur, sys_kind, NAS_mounted_path, redis_key_str_done compressed_redis_rel_path = get_redis_rel_path_from_abs_path(abs_path) redis_rel_paths_keys = r_cur.keys(redis_key_str_done + '*') for redis_rel_path_key in redis_rel_paths_keys: if r_cur.get(redis_rel_path_key) == compressed_redis_rel_path: origin_abs_path = redis_rel_path_key[len(redis_key_str_done):] origin_abs_path = replace_path_seperator(origin_abs_path, sys_kind=sys_kind) origin_abs_path = os.path.join(NAS_mounted_path, origin_abs_path) return origin_abs_path return '' class vimg_processor(object): '''用于压缩的图像视频类。初始化时会读取图像视频的一些参数。''' def __init__(self, origin_abs_path: str, compressed_abs_path: str = "", video_size: str = '', img_max_pix: int = 0, fmt: str = ''): '''用文件的绝对路径等参数初始化对象。 :param origin_abs_path: 源文件的绝对路径 :param compressed_abs_path: 输出文件的绝对路径。默认为同目录下文件名末加#test#保存（1.mp4 -> 1#test#.mp4），有重名时会自动按 1#test#.mp4 、1#test#(1).mp4 、1#test#(2).mp4 的顺序重命名 :param video_size: 输出视频文件时，适配的屏幕大小。有 phone 、 pad 、 pc 、 空字符串 四种选择，空字符串表示保留原宽高 :param img_max_pix: 输出图片文件时，若源图片的宽高同时超过该值，宽高中较大的一个数值会被缩小到该值，另一个数值也等比例缩小；为0时，表示图片的宽高不缩小（宽高的单位为像素点） :param fmt: 输出文件的格式，即后缀名。为空字符串时，表示按原格式输出。其他可取值如 mp4 、 jpg 等''' self.origin_abs_path = origin_abs_path self.compressed_abs_path = (compressed_abs_path, get_compressed_abs_path( origin_abs_path, fmt=fmt))[compressed_abs_path == ""] self.width, self.height = 0, 1 if is_video(origin_abs_path): self.get_video_info() w, h = self.width, self.height ratio = w / h self.ratio = ratio definition = '' if video_size == 'phone': if ratio <= 0.6: definition = (f'{w}:{h}', '360:640')[w >= 360] elif ratio <= 1.0: definition = (f'{w}:{h}', '360:480')[w >= 360] elif ratio <= 1.66: definition = (f'{w}:{h}', '480:360')[w >= 480] elif ratio > 1.66: definition = (f'{w}:{h}', '640:360')[w >= 640] elif video_size == 'pad': if ratio <= 0.6: definition = (f'{w}:{h}', '720:1280')[w >= 720] elif ratio <= 1.0: definition = (f'{w}:{h}', '720:960')[w >= 720] elif ratio <= 1.66: definition = (f'{w}:{h}', '960:720')[w >= 960] elif ratio > 1.66: definition = (f'{w}:{h}', '1280:720')[w >= 1280] elif video_size == 'pc': if ratio <= 0.6: definition = (f'{w}:{h}', '1080:1920')[w >= 1080] elif ratio <= 1.0: definition = (f'{w}:{h}', '1080:1440')[w >= 1080] elif ratio <= 1.66: definition = (f'{w}:{h}', '1440:1080')[w >= 1440] elif ratio > 1.66: definition = (f'{w}:{h}', '1920:1080')[w >= 1920] elif video_size == '': definition = f'{w}:{h}' self.definition = definition elif is_img(origin_abs_path): self.pix_fmt = '' # pix_fmt即像素格式 fmt = (fmt, get_suffix(origin_abs_path))[fmt == ''] self.palette = os.path.join(TEMP_DIR, 'palette.' + fmt) self.temp_img = os.path.join(TEMP_DIR, 'temp_img.' + fmt) self.get_img_info() w, h = self.width, self.height img_max_pix = abs(img_max_pix) if img_max_pix == 0: pass elif w > img_max_pix and h > img_max_pix: ratio = max(w, h) / img_max_pix w, h = int(w / ratio), int(h / ratio) self.definition = f'{w}:{h}' def get_video_info(self): '''使用ffprobe获取视频的宽高（width、height）。''' global sys_kind command_probe = f'ffprobe -select_streams v -show_entries format=bit_rate -show_streams -v quiet -of csv="p=0" -of json -i "{self.origin_abs_path}"' if sys_kind == 'Windows': result = subprocess.Popen(command_probe, shell=False, stdout=subprocess.PIPE).stdout elif sys_kind == 'Linux': result = subprocess.Popen(command_probe, shell=True, stdout=subprocess.PIPE).stdout list_std = result.readlines() str_tmp = '' for item in list_std: str_tmp += bytes.decode(item.strip()) json_data = json.loads(str_tmp) self.width = int(json_data['streams'][0]['width']) self.height = int(json_data['streams'][0]['height']) # self.bitrate = int(json_data['format']['bit_rate']) / 1024 # 以后可能用到视频的比特率 # 视频的比特率有时不能用ffprobe读取到，建议自己根据视频大小和时长计算 def get_img_info(self): '''获取图片的width、height、pix_fmt。''' global sys_kind command_probe = f'ffprobe -hide_banner -v quiet -print_format json -show_format -show_streams "{self.origin_abs_path}"' if sys_kind == 'Windows': result = subprocess.Popen(command_probe, shell=False, stdout=subprocess.PIPE).stdout elif sys_kind == 'Linux': result = subprocess.Popen(command_probe, shell=True, stdout=subprocess.PIPE).stdout list_std = result.readlines() str_tmp = '' for item in list_std: str_tmp += bytes.decode(item.strip()) json_data = json.loads(str_tmp) self.pix_fmt = str(json_data['streams'][0]['pix_fmt']) self.width = int(json_data['streams'][0]['width']) self.height = int(json_data['streams'][0]['height']) def compress_video(self): '''通过ffmpeg压缩视频文件（输出的像素格式指定为yuv420p）。''' global CPU_THREADS # AMD显卡加速，硬编码 '''command_compress = 'ffmpeg -hwaccel dxva2 -i "{}" -y -r 24 -pix_fmt yuv420p -vf scale={} -c:v h264_amf -b:a 64k -ar 44100 "{}"'.format( self.origin_abs_path, self.definition, self.compressed_abs_path)''' # NVIDIA显卡加速，硬编解码 '''command_compress = 'ffmpeg -hwaccel cuvid -c:v h264_cuvid -i "{}" -threads 8 -y -r 24 -pix_fmt yuv420p -vf scale={} -c:v h264_nvenc -b:a 64k -ar 44100 "{}"'.format( self.origin_abs_path, self.definition, self.compressed_abs_path)''' # CPU软编解码。-threads 参数可限制线程数，控制CPU占用。 command_compress = 'ffmpeg -threads {} -i "{}" -threads {} -y -r 24 -pix_fmt yuv420p -vf scale={} -c:v h264 -b:a 64k -ar 44100 "{}"'.format( CPU_THREADS[0], self.origin_abs_path, CPU_THREADS[1], self.definition, self.compressed_abs_path) result = os.system(command_compress) if result != 0: raise Exception( f'vimg_processor.compress_video failed，视频压缩失败：{command_compress}' ) def compress_img(self): '''通过ffmpeg压缩图像文件。''' command_palette = 'ffmpeg -i "{}" -vf palettegen=max_colors=256:stats_mode=single -y "{}"'.format( self.origin_abs_path, self.palette) command_compress = 'ffmpeg -i "{}" -i "{}" -lavfi "[0][1:v] paletteuse" -pix_fmt "{}" -y "{}"'.format( self.origin_abs_path, self.palette, self.pix_fmt, self.temp_img) result = os.system(command_palette) if result != 0: raise Exception( f'vimg_processor.compress_img failed，无法获得调色板：{command_palette}' ) result = os.system(command_compress) if result != 0: raise Exception( f'vimg_processor.compress_img failed，生成中间图片失败：{command_compress}' ) command_compress = 'ffmpeg -i "{}" -vf scale={} -y "{}"'.format( self.temp_img, self.definition, self.compressed_abs_path) result = os.system(command_compress) if result != 0: raise Exception( f'vimg_processor.compress_img failed，生成最终图片失败：{command_compress}' ) '''------------------------------------------------------------主要功能区------------------------------------------------------------''' '''------------------------------------------------------------主要功能区------------------------------------------------------------''' '''------------------------------------------------------------主要功能区------------------------------------------------------------''' def get_definitions_video(target_dir: str): '''获取一个文件夹下所有视频文件的分辨率，按宽高比进行分类。 :param target_dir: 目标文件夹的绝对路径 :结果会输出到当前目录下的 definitions.txt 文件中''' global CWD ratio_dict = dict() # 字典，键为宽高的比值，值为列表，包括该比值下的具体宽高数值 # 例：1.7777778:['640x360', '1280x720', '1920x1080'] for dirpath, dirnames, filenames in os.walk(target_dir): for filename in filenames: abs_path = os.path.join(dirpath, filename) if is_video(abs_path): print('\r视频文件：{:200}'.format(abs_path), end='') vimg = vimg_processor(abs_path) ratio = vimg.ratio definition = "{}x{}".format(vimg.width, vimg.height) if ratio_dict.get(ratio) == None: ratio_dict[ratio] = [definition] else: if definition not in ratio_dict[ratio]: ratio_dict[ratio].append(definition) # 按宽高比值升序输出 ratio_list = [] for key, value in ratio_dict.items(): ratio_list.append(key) ratio_list.sort() with open(os.path.join(CWD, 'definitions.txt'), 'w', encoding='utf8') as f: for ratio in ratio_list: ratio_dict[ratio].sort() f.write('宽高比：{:.6}，成员：'.format(ratio)) for definition in ratio_dict[ratio][:-1]: f.write(definition + ' , ') f.write(ratio_dict[ratio][-1] + '\n') print('') def compress_videos(video_size: str = '', fmt: str = ''): '''从redis中得到需要压缩的视频文件，进行压缩，并完成压缩；直到redis中没有剩余任务。 :对应地，该项目会在redis的key中移动，从todo到ongoing，最后到done；若出错，则放到wrong。 :输出文件在源文件名末尾加 #test# 放在同目录下（1.mp4 -> 1#test#.mp4）；输出文件有重名时会自动按 1#test#.mp4 、1#test#(1).mp4 、1#test#(2).mp4 的顺序重命名。 :param video_size: 视频适配的屏幕大小。 phone、pad、pc、空字符串 分别表示适配手机、平板、电脑、不改变原大小 :param fmt: 指定输出的文件格式，空字符串表示原格式。建议用mp4 ''' global NAS_mounted_path, r_cur, log, start_time, fail_msg_list, sys_kind global redis_key_set_todo, redis_key_set_wrong, redis_key_str_ongoing, redis_key_str_done msg = '开始时间：{}'.format(time.strftime('%Y-%m-%d %H:%M:%S')) mylog.log_print(log, 'info', msg) while (True): if r_cur.scard(redis_key_set_todo) == 0: break try: redis_rel_path = r_cur.spop(redis_key_set_todo, count=1)[0] rel_path = replace_path_seperator(redis_rel_path, sys_kind=sys_kind) abs_path = os.path.join(NAS_mounted_path, rel_path) if is_video(abs_path) and os.path.isfile(abs_path): vimg = vimg_processor(origin_abs_path=abs_path, video_size=video_size, fmt=fmt) compressed_abs_path = vimg.compressed_abs_path if os.path.exists(compressed_abs_path): # 输出文件已存在时跳过 continue start_time = time.time() log.info('{}压缩中...'.format(abs_path)) compressed_redis_rel_path = get_redis_rel_path_from_abs_path( compressed_abs_path) r_cur.set(redis_key_str_ongoing + redis_rel_path, compressed_redis_rel_path) vimg.compress_video() r_cur.delete(redis_key_str_ongoing + redis_rel_path) r_cur.set(redis_key_str_done + redis_rel_path, compressed_redis_rel_path) log.info('视频压缩成功，耗时{:.2f}min'.format( (time.time() - start_time) / 60)) except: except_info = traceback.format_exc() fail_msg_list.append(except_info) mylog.log_print(log, 'error', except_info) if os.path.exists(compressed_abs_path): os.remove(compressed_abs_path) r_cur.srem(redis_key_set_todo, redis_rel_path) r_cur.delete(redis_key_str_ongoing + redis_rel_path) r_cur.delete(redis_key_str_done + redis_rel_path) exc_type, exc_value, exc_traceback = sys.exc_info() # 获得异常名 exc_type = exc_type.__name__ if exc_type == 'KeyboardInterrupt': # 在linux上时，无法得到KeyboardInterrupt r_cur.sadd(redis_key_set_todo, redis_rel_path) else: mylog.log_print(log, 'error', f'错误类型为{exc_type}') r_cur.sadd(redis_key_set_wrong, redis_rel_path) break def compress_imgs(fmt: str = '', img_max_pix: int = 0): '''从redis中得到需要压缩的图片文件，进行压缩，并完成压缩；直到redis中没有剩余任务。 :对应地，该项目会在redis的key中移动，从todo到ongoing，最后到done；若出错，则放到wrong。 :输出文件在源文件名末尾加 #test# 放在同目录下（1.jpg -> 1#test#.jpg）；输出文件有重名时会自动按 1#test#.jpg 、1#test#(1).jpg 、1#test#(2).jpg 的顺序重命名。 :param fmt: 指定输出的文件格式，空字符串表示原格式。建议用jpg :param img_max_pix: 输出图片文件时，若源图片的宽高同时超过该值，宽高中较大的一个数值会被缩小到该值，另一个数值也等比例缩小；为0时，表示图片的宽高不缩小（宽高的单位为像素点） ''' global NAS_mounted_path, TEMP_DIR, r_cur, log, start_time, fail_msg_list, sys_kind global redis_key_set_todo, redis_key_set_wrong, redis_key_str_ongoing, redis_key_str_done msg = '开始时间：{}'.format(time.strftime('%Y-%m-%d %H:%M:%S')) mylog.log_print(log, 'info', msg) while (True): if r_cur.scard(redis_key_set_todo) == 0: break try: redis_rel_path = r_cur.spop(redis_key_set_todo, count=1)[0] rel_path = replace_path_seperator(redis_rel_path, sys_kind=sys_kind) abs_path = os.path.join(NAS_mounted_path, rel_path) if is_img(abs_path) and os.path.isfile(abs_path): vimg = vimg_processor(origin_abs_path=abs_path, img_max_pix=img_max_pix, fmt=fmt) compressed_abs_path = vimg.compressed_abs_path if os.path.exists(compressed_abs_path): # 输出文件已存在时跳过 continue log.info('{}压缩中...'.format(abs_path)) compressed_redis_rel_path = get_redis_rel_path_from_abs_path( compressed_abs_path) r_cur.set(redis_key_str_ongoing + redis_rel_path, compressed_redis_rel_path) vimg.compress_img() r_cur.delete(redis_key_str_ongoing + redis_rel_path) r_cur.set(redis_key_str_done + redis_rel_path, compressed_redis_rel_path) except: except_info = traceback.format_exc() fail_msg_list.append(except_info) mylog.log_print(log, 'error', except_info) if os.path.exists(compressed_abs_path): os.remove(compressed_abs_path) r_cur.srem(redis_key_set_todo, redis_rel_path) r_cur.delete(redis_key_str_ongoing + redis_rel_path) r_cur.delete(redis_key_str_done + redis_rel_path) r_cur.sadd(redis_key_set_wrong, redis_rel_path) exc_type, exc_value, exc_traceback = sys.exc_info() # 获得异常名 exc_type = exc_type.__name__ if exc_type == 'KeyboardInterrupt': # 在linux上时，无法得到KeyboardInterrupt r_cur.sadd(redis_key_set_todo, redis_rel_path) else: mylog.log_print(log, 'error', f'错误类型为{exc_type}') r_cur.sadd(redis_key_set_wrong, redis_rel_path) break # 最后清理一下TEMP_DIR for dirpath, dirnames, filenames in os.walk(TEMP_DIR): for filename in filenames: os.remove(os.path.join(dirpath, filename)) def delete_test(): '''视频或图片压缩完毕后，比较原文件与压缩后的文件，留下较小且大小非0的文件。 :由于ffmpeg处理效果不确定，建议检查一下处理后的文件，确认无误后再执行，因为源文件会被彻底删除。具体见 下载-压缩-入库流程 :将到redis的redis_key_str_done中读取压缩前后的文件相对路径。删除#test#文件后，删除redis_key_str_done对应的键 ''' global r_cur, log, fail_msg_list global redis_key_str_done redis_rel_path_done_keys = r_cur.keys(redis_key_str_done + '*') for redis_rel_path_done_key in redis_rel_path_done_keys: origin_redis_rel_path = redis_rel_path_done_key[len(redis_key_str_done ):] compressed_redis_rel_path = r_cur.get(redis_rel_path_done_key) origin_abs_path = get_abs_path_from_redis_rel_path( origin_redis_rel_path) compressed_abs_path = get_abs_path_from_redis_rel_path( compressed_redis_rel_path) # redis中存储的相对路径都是 压缩中 ，但是现在这些文件已经手动放到了 检查中 里 origin_abs_path = origin_abs_path.replace('压缩中', '检查中', 1) #需要更改 compressed_abs_path = compressed_abs_path.replace('压缩中', '检查中', 1) #需要更改 if os.path.isfile(origin_abs_path) and os.path.isfile( compressed_abs_path): before_size = os.path.getsize(origin_abs_path) after_size = os.path.getsize(compressed_abs_path) if after_size < before_size and after_size != 0: try: os.remove(origin_abs_path) # 注意这里的压缩后文件名改回原名时是把最右边的第一个#test#去掉，而不是直接改成源文件名 # 因为如果压缩时用fmt指定输出格式，则可能当前目录下有重名文件 # 1.avi、1.wmv -> 1#test#.mp4、1#test#(1).mp4 -> 1.mp4、1(1).mp4 prefix, suffix = os.path.splitext(compressed_abs_path) reversed_prefix = prefix[::-1] reversed_prefix = reversed_prefix.replace('#tset#', '', 1) prefix = reversed_prefix[::-1] temp = prefix + suffix os.rename(compressed_abs_path, temp) r_cur.delete(redis_rel_path_done_key) log.info(f'将{origin_abs_path}替换为小文件') except PermissionError: r_cur.set(redis_rel_path_done_key, compressed_redis_rel_path) msg = f'delete_test failed，删除失败：{origin_abs_path}' mylog.log_print(log, 'warning', msg) fail_msg_list.append(msg) continue else: os.remove(compressed_abs_path) msg = f'{origin_abs_path}压缩后的文件比之前大（或大小为0），删掉了处理后的文件' log.info(msg) # 最后检查一下有没有剩余的处理后文件 dir_checking = replace_path_seperator('xxx/xxx/检查中', sys_kind=sys_kind) #需要更改 dir_checking = os.path.join(NAS_mounted_path, dir_checking) for dirpath, dirnames, filenames in os.walk(dir_checking): for filename in filenames: if '#test#' in filename: abs_path = os.path.join(dirpath, filename) msg = f'疑似还有#test#文件：{abs_path}' mylog.log_print(log, 'warning', msg) fail_msg_list.append(msg) def main(): '''执行入口。''' target_dir = r'D:\xxx\xxx' #需要更改 global sys_kind try: #get_definitions_video(target_dir) compress_videos(video_size='pad', fmt='mp4') #compress_imgs(fmt='jpg', img_max_pix=700) #delete_test() print('已完成') if len(fail_msg_list) != 0: print('本次压缩时出现了以下异常：') for i in fail_msg_list: print(i) if sys_kind == 'Windows': input() except: except_info = traceback.format_exc() mylog.log_print(log, 'error', except_info) if sys_kind == 'Windows': input() if __name__ == '__main__': main() '''下载-压缩-入库流程： 1.将需要压缩的文件先暂时放到 待压缩 中，进行人工筛选 2.筛选完后，将 待压缩 中的文件手动剪切到 压缩中 文件夹里 3.直连NAS的机器运行python脚本，更新redis库、移动文件 4.其他连接到NAS的机器从redis中获取任务、进行压缩 5.从 压缩完 中将文件剪切到 检查中 里，进行人工检查 6.检查完后，手动运行vimg_processor_multi里的delete_test，保留源文件或压缩后文件中的一个 7.手动将 检查中 里的文件放到其他目录，比如 待迁移 或 入库中 里 8.运行file_to_sql，将本地文件入库 '''在其他机器上运行这个脚本前先搭建环境，把需要的python库安装好（ pip3 install redis ），装好ffmpeg、把ffmpeg和ffprobe的二进制文件放到PATH里。如果有兴趣的话，可以写的更复杂一点，比如写一个分布式的，能通过网页UI管理。5.ffmpeg常用命令ffmpeg -i "in.mp4" "out.mp4" #可以稍微压缩一下视频 ffmpeg -i "in.mp4" -ss 00:00:10 -to 00:04:36 -c:v copy -c:a copy "out.mp4" #截取视频片段 ffmpeg -i "in.mp4" -vf scale=562:375 "out.mp4" #改变视频长宽大小 ffmpeg -i video1.mp4 -i video2.mp4 -i video3.mp4 -filter_complex "[0:v][0:a][1:v][1:a][2:v][2:a]concat=n=3:v=1:a=1[v][a]" -map "[v]" -map "[a]" output.mp4 #拼接不同格式的视频 ffmpeg -i video1.mp4 -i video2.mp4 -i video3.mp4 -c copy output.mp4 #拼接相同格式的视频
Python，爬虫与深度学习（16）——电影的信息爬取、下载与处理（一）
步骤/目录： 1.需求介绍 2.准备工作 （0）安装python库 （1）创建scrapy项目 （2）创建并运行mysql （3）创建并运行redis （4）创建python自建库 a.log.py b.redis.py c.mysql.py d.interaction.py e.torrent_parser.py f.其他 3.scrapy项目的修改 （1）items.py （2）middlewares.py （3）pipelines.py （4）settings.py （5）爬虫 4.使用说明本文首发于个人博客https://lisper517.top/index.php/archives/55/，转载请注明出处。本文的目的是使用scrapy爬取电影的信息。本文写作日期为2022年10月3日。运行的平台为win10和树莓派，编辑器为VS Code。1.需求介绍在日常生活中，我们常常会想要看一些精彩的电影，但是看电影又比较费时间，所以最好能把电影信息爬取下来，通过一些方法筛选出想要的电影，再通过一些标准筛选出对应的磁力链接或其他下载链接，下载电影到本地，最后对本地存储的电影信息进行入库、对占用空间较大的电影进行压缩等。本文是系列文章的第一篇，目的是使用scrapy从电影信息网站上爬取数据。为了方便，本文选择了一个能同时提供电影磁链的网站，该网站提供了演员主页，收录了每位演员出演的电影。由于笔者挑选电影时有演员偏好，所以打算收藏喜欢的演员的主页，根据演员来挑选电影。2.准备工作（0）安装python库笔者也搞不太清楚需要哪些库了，不过推荐安装的有以下这些：Scrapy（python-scrapy）、requests（处理网络请求）、bs4（对响应内容进行解析）、pymysql（连接mysql数据库）、lxml（网页解析器）、cryptography（连接mysql时密码加密）、selenium（操控浏览器）、redis（python-redis）。如果后续运行时有些依赖的库没安装，请自行安装。（1）创建scrapy项目在爬取磁链时，这些数据是通过javascript动态生成的，所以这个项目就命名为javaspider。在win10电脑上的cmd中运行如下命令：D: cd D:\spiders\scrapy scrapy startproject javaspider scrapy genspider java（2）创建并运行mysql这里选择在树莓派上通过docker运行mysql，具体的操作步骤见 Python，爬虫与深度学习（10）——scrapy+mysql构建ipool 这篇文章。mysql中主要有两张表，movie和torrent，分别存储电影和磁链的信息。库、表的创建将使用python，这里不单独创建。（3）创建并运行redis这里还要使用到redis，因为一个演员不止出演一部电影，一部电影通常也不止一个tag，并且筛选磁链时还可能用到磁链中的文件（也是一对多的关系）。这种一对多、无重复的表用redis的集合比较容易实现，而用mysql这样的关系型数据库比较复杂。由于redis对内存的要求比较大，这里就在win10机器上运行docker-redis。在windows上安装docker（即安装docker-desktop）的方法见 Docker入门（一）。在win10上运行容器的过程是一样的，比如cmd执行如下命令：D: md D:\docker\redis\data\redis然后新建一个 D:\docker\redis\docker-compose.yml compose模板文件，写入以下内容：version: "3.9" services: redis: image: redis ports: - "56379:6379" volumes: - D:\docker\redis\data\redis:/data logging: driver: json-file restart: always 最后运行：cd D:\docker\redis docker-compose config docker-compose up成功运行redis。注意这种方法运行的redis是完全没有安全措施的，不要开放到外网了。另外，为了使用redis-cli，可以在wsl环境（如果你是按照 Docker入门（一） 的方法在win10上运行docker，应该清楚wsl是什么）里安装redis。在cmd中进行如下操作：wsl -d Ubuntu-20.04 #如果你也用的是Ubuntu-20.04的话 cd /mnt/d/docker/redis wget https://download.redis.io/releases/redis-7.0.4.tar.gz #最新版本的redis tar -xzf redis-7.0.4.tar.gz cd redis-7.0.4 apt-get install pkg-config make gcc libssl-dev #一些依赖项 make BUILD_TLS=yes安装完成后，用这里的redis-cli连接本地的redis：cd /mnt/d/docker/redis/redis-7.0.4/src ./redis-cli -p 56379 ping #redis回应 PONG至此windows上redis、redis-cli安装完成。（4）创建python自建库创建python自建库的方法见 Python，爬虫与深度学习（7）——建立自己的库 。这里需要使用的自建库主要有以下几个文件：a.log.py这个主要是获取一个logger对象，如下：#!/usr/bin/python # -*- coding: UTF-8 -*- import logging, time, os def get_logger(abs_path: str, log_file_name: str = '', getLogger_name: str = ''): '''传入绝对路径和log文件名，返回log对象。 :param abs_path: log文件存储的绝对路径 :param log_file_name: log文件名，若log文件名为空，则使用默认的文件名 :param getLogger_name: logger对象名，如果在同一个程序中需要同时写多个log文件，建议这个参数不要相同 :return log对象''' try: formatter = logging.Formatter( '%(lineno)d : %(asctime)s : %(levelname)s : %(funcName)s : %(message)s' ) if log_file_name == '': log_file_name = 'log#{}.txt'.format(time.strftime('-%Y-%m-%d')) fileHandler = logging.FileHandler( (os.path.join(abs_path, log_file_name)), mode='w', encoding='utf-8') fileHandler.setFormatter(formatter) if getLogger_name == '': getLogger_name = 'logger' log = logging.getLogger(getLogger_name) log.setLevel(logging.DEBUG) log.addHandler(fileHandler) return log except: raise Exception('error: MyPythonLib.log.get_logger failed ')b.redis.py返回redis的连接池：#!/usr/bin/python # -*- coding: UTF-8 -*- import redis def get_pool(database_num: int = 0, decode_responses=True): '''指定数据库编号，返回redis连接池。 :param database_num: 数据库编号，不指定时默认用0号 :param decode_responses: 是否自动解析数据。如果只存string，设置为True；如果要存二进制对象，设置为False :return redis.ConnectionPool''' try: pool = redis.ConnectionPool( host='192.168.1.2', port=56379, db=database_num, decode_responses=decode_responses, ) return pool except: raise Exception('MyPythonLib.redis.get_pool failed ')记得把 redis.ConnectionPool 方法里的参数改成自己的。c.mysql.py主要是连接mysql、字符串转换。#!/usr/bin/python # -*- coding: UTF-8 -*- import pymysql, os, logging, time def get_conn(database_name: str = 'mysql'): '''指定数据库名，返回mysql连接对象。 :param database_name: 数据库名，不指定时默认用mysql库 :return Connection''' try: conn = pymysql.connect(host='192.168.1.1', port=53306, user='root', passwd='mypasswd', db=database_name, charset='utf8') return conn except: raise Exception('MyPythonLib.mysql.get_conn failed ') def str_into_mysql(string: str) -> str: '''输入一个字符串，将其转换为可以在mysql中储存的形式。 这个字符串的使用位置是列值处，比如 "SELECT * FROM 表名 WHERE 列名='字符串';" 中的字符串，若包含 \\ 、 ' 、 " 这三个字符，就需要用本函数处理。 :param string: 目标字符串 :return 可以存入mysql的字符串 ''' try: string = string.replace('\\', '\\\\') string = string.replace("'", "\\'") string = string.replace('"', '\\"') return string except: raise Exception('MyPythonLib.mysql.str_into_mysql failed ') 记得把 pymysql.connect 方法里的参数改成自己的。d.interaction.py这个文件目前只需要用到一个显示进度的函数，内容如下：#!/usr/bin/python # -*- coding: UTF-8 -*- import time, os def print_progress(start_time: float, progress: int, total: int, msg: str, cls=False): '''图形化打印进度及附加信息（清空屏幕）。 :param start_time: 开始时间，由time.time()得到的float :param progress: 整数，表示目前进度 :param total: 整数，表示总量 :param msg: 附加信息 :param cls: 是否清空屏幕（windows下）''' try: percent = 100. * progress / total x = int(2 * percent / 5) duration = time.time() - start_time h, m, s = duration // 3600, duration // 60 % 60, duration % 60 time_format = '{:0>2.0f}:{:0>2.0f}:{:0>2.0f}'.format(h, m, s) print('\r已运行' + time_format + ' [' + '#' * x + '.' * (40 - x) + ']', '{:.2f}%'.format(percent), str(progress) + '/' + str(total), msg, end='') if cls: os.system('cls') except: raise Exception( 'error: MyPythonLib.interaction.print_progress failed ') e.torrent_parser.py用于解析torrent文件，主要用于根据torrent文件得到磁链。#!/usr/bin/python # -*- coding: UTF-8 -*- import collections, hashlib def bencode(elem): if type(elem) == str: elem = str.encode(elem) if type(elem) == bytes: result = str.encode(str(len(elem))) + b":" + elem elif type(elem) == int: result = str.encode("i" + str(elem) + "e") elif type(elem) == list: result = b"l" for item in elem: result += bencode(item) result += b"e" elif type(elem) in [dict, collections.OrderedDict]: result = b"d" for key in elem: result += bencode(key) + bencode(elem[key]) result += b"e" return result def bdecode(bytestr, recursiveCall=False): startingChars = dict({b"i": int, b":": str, b"l": list, b"d": dict}) digits = [b"0", b"1", b"2", b"3", b"4", b"5", b"6", b"7", b"8", b"9"] started = ended = False curtype = None numstring = b"" # for str, int result = None # for list, dict key = None # for dict while len(bytestr) > 0: # reading and popping from the beginning char = bytestr[:1] if not started: bytestr = bytestr[1:] if char in digits: numstring += char elif char in startingChars: started = True curtype = startingChars[char] if curtype == str: size = int(bytes.decode(numstring)) # try to decode strings try: result = bytes.decode(bytestr[:size]) except UnicodeDecodeError: result = bytestr[:size] bytestr = bytestr[size:] ended = True break elif curtype == list: result = [] elif curtype == dict: result = collections.OrderedDict() else: raise ValueError("Expected starting char, got ‘" + bytes.decode(char) + "’") else: # if started if not char == b"e": if curtype == int: bytestr = bytestr[1:] numstring += char elif curtype == list: item, bytestr = bdecode(bytestr, recursiveCall=True) result.append(item) elif curtype == dict: if key == None: key, bytestr = bdecode(bytestr, recursiveCall=True) else: result[key], bytestr = bdecode(bytestr, recursiveCall=True) key = None else: # ending: char == b"e" bytestr = bytestr[1:] if curtype == int: result = int(bytes.decode(numstring)) ended = True break if ended: if recursiveCall: return result, bytestr else: return result else: raise ValueError("String ended unexpectedly") class torrent(): '''解析种子文件。''' def __init__(self, abs_torrent_file_path: str) -> None: ''':param abs_torrent_file_path: 种子文件的绝对路径''' self.abs_torrent_file_path = abs_torrent_file_path bytes_stream = '' with open(abs_torrent_file_path, 'rb') as f: bytes_stream = f.read() self.metadata = bdecode(bytes_stream) encodedInfo = bencode(self.metadata["info"]) self.hash_value = hashlib.sha1(encodedInfo).hexdigest().upper() if __name__ == '__main__': pass参考了github上的 这个项目 。不得不说稍微有点深度的问题还是github靠谱，csdn纯纯的互相乱抄。f.其他在之前的 Python，爬虫与深度学习（10）——scrapy+mysql构建ipool 一文中，笔者把mysql的api写到了自建库的mysql.py里，现在感觉这样不太合适，写到scrapy项目里更好一点，毕竟自建库应该写一些更基础、通用的功能。于是新建一个libs目录： D:\spiders\scrapy\javaspider\javaspider\libs ，里面放一个空的 __init__.py 文件，和一个 java_extensions.py ，它主要是为scrapy项目提供一些功能，但这些功能又不适合放在自建库里，比如mysql、redis的api。内容如下：#!/usr/bin/python # -*- coding: UTF-8 -*- import os, logging, time, redis, re, datetime from MyPythonLib import log as mylog from MyPythonLib import redis as myredis from MyPythonLib.torrent_parser import torrent import urllib.parse ORIGIN_URL_JAVA = r'https://www.java.com' #电影网站的url TEMP_URL_JAVA = r'https://www.seejava.com' #有时候该电影网站无法访问，需要换成临时网址 TORRENT_DIR = r'D:\torrents' #存放下载过的磁链的目录 def get_actor_main_pages() -> list[str]: '''收藏演员主页（或者搜索页，或者根页面）->复制网址到txt文件->根据相对url和TEMP_URL_JAVA提供待爬url :return 待爬的url列表''' with open(os.path.join(os.getcwd(), 'actor_main_pages.txt'), 'r', encoding='utf-8') as f: abs_urls = f.read().split('\n') rel_urls_set = set() for abs_url in abs_urls: if re.search(r'\w', abs_url) != None: base_url = re.search( r'((https://)|(http://)){0,1}(\w+\.){0,1}(\w+\.){1}\w+', abs_url).group() rel_url = abs_url.replace(base_url, '') if rel_url in rel_urls_set: print('重复网址：{}'.format(rel_url)) rel_urls_set.add(rel_url) abs_urls = [] for rel_url in rel_urls_set: abs_url = urllib.parse.urljoin(TEMP_URL_JAVA, rel_url) abs_urls.append(abs_url) return abs_urls class database_api_java(): """通过这个接口实现提交爬取到的作品信息。还可以更新数据、取出磁链。""" conn = mysql.get_conn() m_cur = conn.cursor() #这个cursor是类公用的，用于不需要实例化对象的mysql操作 pool = myredis.get_pool(database_num=0) r_cur = redis.Redis( connection_pool=pool) #这个cursor是类公用的，用于不需要实例化对象的redis操作 def __init__(self, log: logging.Logger = None): """:param log: 可选的log对象，若不提供，将在当前文件夹下新建logs目录、创建log文件""" if log == None: log_dir = os.path.join(os.getcwd(), 'logs') if not os.path.isdir(log_dir): os.mkdir(log_dir) log_file_name = 'database_api_java#{}.txt'.format( time.strftime('-%Y-%m-%d_%H_%M_%S')) log = mylog.get_logger(log_dir, log_file_name, 'mysql_api_java') self.log = log message = 'database_api_java object initialized' self.log.info(message) self.m_cur = database_api_java.conn.cursor() #对象自己的cur self.r_cur = redis.Redis(connection_pool=database_api_java.pool) database_api_java.check_table() message = 'database java and tables checked' self.log.info(message) def __del__(self): self.m_cur.close() self.r_cur.close() message = 'mysql_api_java object deleted' self.log.info(message) @staticmethod def check_table(): """检查一下mysql中是否已经建好了表，若表不存在则新建表。 :这里并没有检查：若表存在，表的格式是否正确。 mysql中movie表各列含义与默认值： :no，自增主键； :code，作品识别码（"unknown"）； :full_name，作品全名（"unknown"）； :release_date，发行日期（"1000-01-01"）； :duration，持续时间，单位为分钟（0）； :director，导演名（"unknown"）； :producer，制作商（"unknown"）； :publisher，发行商（"unknown"）； :series，系列（"unknown"）； :url，来源网页（"unknown"）； :downloaded，是否下载（0）； :file_path，如果已下载，文件位置（"unknown"）； :created，该行创建时间。 mysql中torrent表各列含义与默认值： :no，自增主键； :hash_value，hash值（"unknown"）； :movie_no，对应电影编号（0）； :name，名称（"unknown"）； :file_num，包含文件数量（0）； :size，文件大小（单位为MB，0）； :high_definition，是否高清（0）； :record_date，网站收录日期（"1000-01-01"）。 redis中actor_movie： 集合，键为 java:actor:名字 ，值为电影在mysql中的编号。 比较特殊的是 java:actor:unknown ，这个键里存的是演员未知的电影。 redis中tag_movie： 集合，键为 java:tag:标签名 ，值为电影在mysql中的编号。 比较特殊的是 java:tag:no ，这个键里存的是没有tag的电影。 redis中torrent_files： 集合，键为 java:torrent:编号 ，值为文件名。""" conn, m_cur = database_api_java.conn, database_api_java.m_cur command_movie = ''' CREATE TABLE IF NOT EXISTS movie ( no BIGINT UNSIGNED AUTO_INCREMENT, code VARCHAR(30) NOT NULL DEFAULT "unknown" COMMENT "作品识别码", full_name VARCHAR(400) NOT NULL DEFAULT "unknown" COMMENT "作品全名", release_date DATE NOT NULL DEFAULT "1000-01-01" COMMENT "发行日期", duration SMALLINT UNSIGNED NOT NULL DEFAULT 0 COMMENT "持续时间（min）", director VARCHAR(200) NOT NULL DEFAULT "unknown" COMMENT "导演名", producer VARCHAR(200) NOT NULL DEFAULT "unknown" COMMENT "制作商", publisher VARCHAR(200) NOT NULL DEFAULT "unknown" COMMENT "发行商", series VARCHAR(200) NOT NULL DEFAULT "unknown" COMMENT "系列", url VARCHAR(200) NOT NULL DEFAULT "unknown" COMMENT "来源网页", downloaded BIT(1) NOT NULL DEFAULT 0 COMMENT "是否下载", file_path VARCHAR(500) NOT NULL DEFAULT "unknown" COMMENT "如果已下载，文件位置", created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (no));''' command_movie = command_movie.replace('\n', '') command_movie = command_movie.replace(' ', '') command_torrent = ''' CREATE TABLE IF NOT EXISTS torrent ( no BIGINT UNSIGNED AUTO_INCREMENT, hash_value VARCHAR(200) NOT NULL DEFAULT "unknown" COMMENT "hash值", movie_no BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT "电影编号", name VARCHAR(400) NOT NULL DEFAULT "unknown" COMMENT "名称", file_num INT UNSIGNED NOT NULL DEFAULT 0 COMMENT "包含文件数量", size INT UNSIGNED NOT NULL DEFAULT 0 COMMENT "文件大小（MB）", high_definition BIT(1) NOT NULL DEFAULT 0 COMMENT "是否高清", record_date DATE NOT NULL DEFAULT "1000-01-01" COMMENT "网站收录日期", PRIMARY KEY (no));''' command_torrent = command_torrent.replace('\n', '') command_torrent = command_torrent.replace(' ', '') try: m_cur.execute("CREATE DATABASE IF NOT EXISTS java;") m_cur.execute("USE java;") m_cur.execute(command_movie) m_cur.execute(command_torrent) conn.commit() except: conn.rollback() raise Exception('java_extensions.database_api_java failed') def save_movie(self, movie_dict: dict): """存入一个movie（以字典形式提交）。注意这里只是对不存在的数据添加默认值，没有验证数据类型等。 工作流程：向mysql的movie表中存入数据；向redis的tag_movie存入数据；向redis的actor_movie存入数据。 :param movie_dict: 包含movie数据的字典。 :return movie_no: 电影的编号""" conn, m_cur, r_cur, log = database_api_java.conn, self.m_cur, self.r_cur, self.log if len(movie_dict) == 0: message = 'no movie data submitted' log.info(message) return command_movie = '''INSERT INTO movie (code,full_name,release_date,duration,director,producer,publisher,series,url,downloaded,file_path) VALUES ''' code, full_name, release_date, duration, director, producer, publisher, series, url, downloaded, file_path = 'unknown', 'unknown', "1000-01-01", 0, 'unknown', 'unknown', 'unknown', 'unknown', 'unknown', 0, 'unknown' tags, actors = set(['no']), set(['unknown']) if 'code' in movie_dict.keys(): code = mysql.str_into_mysql(movie_dict['code']) if 'full_name' in movie_dict.keys(): full_name = mysql.str_into_mysql(movie_dict['full_name']) if 'release_date' in movie_dict.keys(): release_date = movie_dict['release_date'] if release_date == '0000-00-00': release_date = '1000-01-01' if 'duration' in movie_dict.keys(): duration = movie_dict['duration'] if 'director' in movie_dict.keys(): director = mysql.str_into_mysql(movie_dict['director']) if 'producer' in movie_dict.keys(): producer = mysql.str_into_mysql(movie_dict['producer']) if 'publisher' in movie_dict.keys(): publisher = mysql.str_into_mysql(movie_dict['publisher']) if 'series' in movie_dict.keys(): series = mysql.str_into_mysql(movie_dict['series']) if 'url' in movie_dict.keys(): url = mysql.str_into_mysql(movie_dict['url']) if 'downloaded' in movie_dict.keys(): downloaded = movie_dict['downloaded'] if 'file_path' in movie_dict.keys(): file_path = mysql.str_into_mysql(movie_dict['file_path']) if 'tags' in movie_dict.keys(): tags = movie_dict['tags'] if tags == None: tags = set(['no']) if 'actors' in movie_dict.keys(): actors = movie_dict['actors'] if actors == None: actors = set(['unknown']) command_movie += '("{}","{}","{}",{},"{}","{}","{}","{}","{}",{},"{}"),'.format( code, full_name, release_date, duration, director, producer, publisher, series, url, downloaded, file_path) command_movie = command_movie[:-1] + ';' try: m_cur.execute(command_movie) conn.commit() m_cur.execute( 'SELECT MAX(no) FROM movie WHERE code="{}" AND full_name="{}";' .format(code, full_name)) movie_no = str(m_cur.fetchall()[0][0]) pipe = r_cur.pipeline() for tag in tags: key = 'java:tag:{}'.format(tag) value = movie_no pipe.sadd(key, value) for actor in actors: key = 'java:actor:{}'.format(actor) value = movie_no pipe.sadd(key, value) pipe.execute() message = 'saved movie successfully, code:full_name={}:{}'.format( code, full_name) log.info(message) return movie_no except: conn.rollback() log.info('mysql错误命令为：' + command_movie) raise Exception('java_extensions.save_movie failed') def save_torrent(self, torrent_dict: dict): """存入一个torrent（以字典形式提交）。注意这里只是对不存在的数据添加默认值，没有验证数据类型等。 工作流程：向mysql的torrent表中存入数据；向redis的torrent_files存入数据。 :param torrent_dict: 包含torrent数据的字典。""" conn, m_cur, r_cur, log = database_api_java.conn, self.m_cur, self.r_cur, self.log if len(torrent_dict) == 0: message = 'no torrent data submitted' log.info(message) return command_torrent = '''INSERT INTO torrent (hash_value,movie_no,name,file_num,size,high_definition,record_date) VALUES ''' hash_value, movie_no, name, file_num, size, high_definition, record_date = 'unknown', 0, 'unknown', 0, 0, 0, "1000-01-01" torrent_files = set() if 'hash_value' in torrent_dict.keys(): hash_value = mysql.str_into_mysql(torrent_dict['hash_value']) if 'movie_no' in torrent_dict.keys(): movie_no = int(torrent_dict['movie_no']) if 'name' in torrent_dict.keys(): name = mysql.str_into_mysql(torrent_dict['name']) if 'file_num' in torrent_dict.keys(): file_num = torrent_dict['file_num'] if 'size' in torrent_dict.keys(): size = torrent_dict['size'] if size == '': size = 0 if 'high_definition' in torrent_dict.keys(): high_definition = torrent_dict['high_definition'] if 'record_date' in torrent_dict.keys(): record_date = torrent_dict['record_date'] if record_date == '0000-00-00': record_date = '1000-01-01' if 'torrent_files' in torrent_dict.keys(): torrent_files = torrent_dict['torrent_files'] command_torrent += '("{}",{},"{}",{},{},{},"{}"),'.format( hash_value, movie_no, name, file_num, size, high_definition, record_date) command_torrent = command_torrent[:-1] + ';' try: m_cur.execute(command_torrent) conn.commit() m_cur.execute( 'SELECT MIN(no) FROM torrent WHERE hash_value="{}";'.format( hash_value)) torrent_no = str(m_cur.fetchall()[0][0]) key = 'java:torrent:{}'.format(torrent_no) pipe = r_cur.pipeline() for torrent_file in torrent_files: value = torrent_file pipe.sadd(key, value) pipe.execute() message = 'saved torrent successfully, hash_value={}'.format( hash_value) log.info(message) except: conn.rollback() log.info('mysql错误命令为：' + command_torrent) raise Exception('java_extensions.save_torrent failed') def update_movie(self, movie_dict: dict): """更新一个movie（以字典形式提交）。注意这里只是对不存在的数据添加默认值，没有验证数据类型等。 工作流程：向mysql的movie表中存入数据；向redis的tag_movie存入数据；向redis的actor_movie存入数据。 必须提供的数据有：no，即电影编号。未提供的部分不更新。 :param movie_dict: 包含movie数据的字典。""" conn, m_cur, r_cur, log = database_api_java.conn, self.m_cur, self.r_cur, self.log if len(movie_dict) <= 1: message = 'java_extensions.update_movie failed: movie_dict must include at least 2 items' log.warning(message) return try: no = movie_dict['no'] except: message = 'java_extensions.update_movie failed: no not offered' log.warning(message) return m_cur.execute('USE java;') command_movie = 'SELECT no FROM movie WHERE no={};'.format(no) m_cur.execute(command_movie) if len(m_cur.fetchall()) == 0: message = 'movie no.{} not exists'.format(no) log.warning(message) return command_movie = '''UPDATE movie SET ''' if 'code' in movie_dict.keys(): code = mysql.str_into_mysql(movie_dict['code']) command_movie += 'code="{}",'.format(code) if 'full_name' in movie_dict.keys(): full_name = mysql.str_into_mysql(movie_dict['full_name']) command_movie += 'full_name="{}",'.format(full_name) if 'release_date' in movie_dict.keys(): release_date = movie_dict['release_date'] command_movie += 'release_date="{}",'.format(release_date) if 'duration' in movie_dict.keys(): duration = movie_dict['duration'] command_movie += 'duration={},'.format(duration) if 'director' in movie_dict.keys(): director = mysql.str_into_mysql(movie_dict['director']) command_movie += 'director="{}",'.format(director) if 'producer' in movie_dict.keys(): producer = mysql.str_into_mysql(movie_dict['producer']) command_movie += 'producer="{}",'.format(producer) if 'publisher' in movie_dict.keys(): publisher = mysql.str_into_mysql(movie_dict['publisher']) command_movie += 'publisher="{}",'.format(publisher) if 'series' in movie_dict.keys(): series = mysql.str_into_mysql(movie_dict['series']) command_movie += 'series="{}",'.format(series) if 'url' in movie_dict.keys(): url = mysql.str_into_mysql(movie_dict['url']) command_movie += 'url="{}",'.format(url) if 'downloaded' in movie_dict.keys(): downloaded = movie_dict['downloaded'] command_movie += 'downloaded={},'.format(downloaded) if 'file_path' in movie_dict.keys(): file_path = mysql.str_into_mysql(movie_dict['file_path']) command_movie += 'file_path="{}",'.format(file_path) command_movie = command_movie[:-1] + ' WHERE no={};'.format(no) try: if command_movie.count('=') > 1: m_cur.execute(command_movie) conn.commit() pipe = r_cur.pipeline() if 'tags' in movie_dict.keys() and movie_dict['tags'] != None: pipe.srem('java:tag:no', str(no)) for tag in movie_dict['tags']: key = 'java:tag:{}'.format(tag) value = str(no) pipe.sadd(key, value) if 'actors' in movie_dict.keys() and movie_dict['actors'] != None: pipe.srem('java:actor:unknown', str(no)) for actor in movie_dict['actors']: key = 'java:actor:{}'.format(actor) value = str(no) pipe.sadd(key, value) pipe.execute() message = 'updated movie successfully, no={}'.format(no) log.info(message) except: conn.rollback() log.info('mysql错误命令为：' + command_movie) raise Exception('java_extensions.update_movie failed') def update_torrent(self, torrent_dict: dict): """更新一个torrent（以字典形式提交）。注意这里只是对不存在的数据添加默认值，没有验证数据类型等。 工作流程：向mysql的torrent表中存入数据；向redis的torrent_files存入数据。 必须提供的数据有：no和hash_value，即torrent编号及hash值。未提供的部分不更新。 :param torrent_dict: 包含torrent数据的字典。""" conn, m_cur, r_cur, log = database_api_java.conn, self.m_cur, self.r_cur, self.log if len(torrent_dict) <= 2: message = 'java_extensions.update_torrent failed: torrent_dict must include at least 3 items' log.warning(message) return try: no, hash_value = torrent_dict['no'], torrent_dict['hash_value'] except: message = 'java_extensions.update_torrent failed: no or hash_value not offered' log.warning(message) return m_cur.execute('USE java;') command_torrent = 'SELECT hash_value FROM torrent WHERE no={};'.format( no) m_cur.execute(command_torrent) if len(m_cur.fetchall()) == 0: message = 'torrent no.{} not exists'.format(no) log.warning(message) return if hash_value != m_cur.fetchall()[0][0]: message = 'torrent no.{} hash_value not match: {} versus {}'.format( no, hash_value, m_cur.fetchall()[0][0]) log.warning(message) return command_torrent = '''UPDATE torrent SET ''' if 'movie_no' in torrent_dict.keys(): movie_no = torrent_dict['movie_no'] command_torrent += 'movie_no={},'.format(movie_no) if 'name' in torrent_dict.keys(): name = mysql.str_into_mysql(torrent_dict['name']) command_torrent += 'name="{}",'.format(name) if 'file_num' in torrent_dict.keys(): file_num = torrent_dict['file_num'] command_torrent += 'file_num={},'.format(file_num) if 'size' in torrent_dict.keys(): size = torrent_dict['size'] command_torrent += 'size={},'.format(size) if 'high_definition' in torrent_dict.keys(): high_definition = torrent_dict['high_definition'] command_torrent += 'high_definition={},'.format(high_definition) if 'record_date' in torrent_dict.keys(): record_date = torrent_dict['record_date'] command_torrent += 'record_date="{}",'.format(record_date) command_torrent = command_torrent[:-1] + ' WHERE no={};'.format(no) try: if command_torrent.count('=') > 1: m_cur.execute(command_torrent) conn.commit() pipe = r_cur.pipeline() key = 'java:torrent:{}'.format(no) if 'torrent_files' in torrent_dict.keys(): for torrent_file in torrent_dict['torrent_files']: value = torrent_file pipe.sadd(key, value) pipe.execute() message = 'updated torrent successfully, no={}'.format(no) log.info(message) except: conn.rollback() log.info('mysql错误命令为：' + command_torrent) raise Exception('java_extensions.update_torrent failed') def check_repeat(self): """检查mysql的movie、torrent表里有没有重复的，只检查 (code和full_name) 、 (hash_value) 相同的。如果有重复的，保留no最小的一条记录。 检查redis的 java:actor: 、 java:tag: 、 java:torrent: ，是否对应的编号在mysql中还存在。""" conn, m_cur, r_cur, log = database_api_java.conn, self.m_cur, self.r_cur, self.log m_cur.execute('USE java;') #检查movie表 m_cur.execute( 'SELECT code,full_name FROM movie GROUP BY code,full_name HAVING COUNT(*)>1;' ) repeated_items = m_cur.fetchall() if len(repeated_items) == 0: message = 'no repeated code:full_name in movie' log.info(message) else: message = 'found repeated code:full_name {} kinds'.format( len(repeated_items)) log.info(message) command_movie = ''' DELETE FROM movie WHERE no IN (SELECT no FROM (SELECT no FROM movie WHERE (code,full_name) IN (SELECT code,full_name FROM movie GROUP BY code,full_name HAVING COUNT(*)>1) AND no NOT IN (SELECT MIN(no) FROM movie GROUP BY code,full_name HAVING COUNT(*)>1)) AS a);''' command_movie = command_movie.replace('\n', '') command_movie = command_movie.replace(' ', '') try: m_cur.execute(command_movie) conn.commit() message = 'repeated code:full_name deleted successfully. ' log.info(message) except: conn.rollback() log.info('mysql错误命令为：' + command_movie) raise Exception( 'java_extensions.check_repeat failed: table movie check_repeat failed' ) #检查torrent表 m_cur.execute( 'SELECT hash_value FROM torrent GROUP BY hash_value HAVING COUNT(*)>1;' ) repeated_items = m_cur.fetchall() if len(repeated_items) == 0: message = 'no repeated hash_value in torrent' log.info(message) else: message = 'found repeated hash_value {} kinds'.format( len(repeated_items)) log.info(message) command_torrent = ''' DELETE FROM torrent WHERE no IN (SELECT no FROM (SELECT no FROM torrent WHERE (hash_value) IN (SELECT hash_value FROM torrent GROUP BY hash_value HAVING COUNT(*)>1) AND no NOT IN (SELECT MIN(no) FROM torrent GROUP BY hash_value HAVING COUNT(*)>1)) AS a);''' command_torrent = command_torrent.replace('\n', '') command_torrent = command_torrent.replace(' ', '') try: m_cur.execute(command_torrent) conn.commit() command_torrent = 'DELETE FROM torrent WHERE hash_value="unknown";' m_cur.execute(command_torrent) conn.commit() message = 'repeated and unknown hash_value deleted successfully. ' log.info(message) except: conn.rollback() log.info('mysql错误命令为：' + command_torrent) raise Exception( 'java_extensions.check_repeat failed: table torrent check_repeat failed' ) try: pipe = r_cur.pipeline() #检查 java:actor:名字 actor_keys = r_cur.keys('java:actor:*') for actor_key in actor_keys: movie_nos = r_cur.smembers(actor_key) for movie_no in movie_nos: m_cur.execute( 'SELECT no FROM movie WHERE no={};'.format(movie_no)) if len(m_cur.fetchall()) == 0: pipe.srem(actor_key, movie_no) pipe.execute() #检查 java:tag:标签名 tag_keys = r_cur.keys('java:tag:*') for tag_key in tag_keys: movie_nos = r_cur.smembers(tag_key) for movie_no in movie_nos: m_cur.execute( 'SELECT no FROM movie WHERE no={};'.format(movie_no)) if len(m_cur.fetchall()) == 0: pipe.srem(tag_key, movie_no) pipe.execute() #检查 java:torrent:编号 torrent_keys = r_cur.keys('java:torrent:*') for torrent_key in torrent_keys: torrent_no = torrent_key.replace('java:torrent:', '') m_cur.execute( 'SELECT no FROM torrent WHERE no={};'.format(torrent_no)) if len(m_cur.fetchall()) == 0: pipe.delete(torrent_key) pipe.execute() except: raise Exception( 'java_extensions.check_repeat failed: redis check_repeat failed' ) def get_to_be_updated_torrents(self) -> list: '''对于未知name、file_num、文件名的torrent，取出它们。 :return [(no1,hash_value1), (no2,hash_value2), ...]''' m_cur, log = self.m_cur, self.log m_cur.execute('USE java;') command_torrent = '''SELECT no,hash_value FROM torrent WHERE name="unknown" UNION SELECT no,hash_value FROM torrent WHERE file_num=0 UNION SELECT no,hash_value FROM torrent WHERE size=0;''' command_torrent = command_torrent.replace('\n', '') command_torrent = command_torrent.replace(' ', '') try: m_cur.execute(command_torrent) message = 'to_be_updated_torrents selected. ' log.info(message) result = m_cur.fetchall() except: log.info('mysql错误命令为：' + command_torrent) raise Exception( 'java_extensions.get_to_be_updated_torrents failed: sql syntax wrong' ) return list(result) def torrent_selector(self, actors: list = None, tags: list = None, check_repeat=True, limit: int = 0): '''对电影、磁链进行筛选，每部电影筛选出一个最合适的磁链。结果保存在torrent_selector.txt中。 :param actors: 限定演员。多个演员时表示或(OR)，只要有一个演员出现即可。演员名为模糊搜索，只要包含即可 :param tags: 限定tag。多个tag时表示或(OR)，只要有一个标签出现即可 :param check_repeat: 是否去掉已经下载过的磁链。需要指定一个文件夹，该文件夹存放下载过的torrent文件。文件夹用TORRENT_DIR指定。 :param limit: 限定条数，0表示不限条数 演员和tag同时限定时，二者并不相关，就是说某部电影包含需要的演员、但不包含指定的tag（或者包含tag但不含演员），则该电影还是能被选上。''' conn, m_cur, r_cur, log = database_api_java.conn, self.m_cur, self.r_cur, self.log downloaded_hash = set() if check_repeat: for dirpath, dirnames, filenames in os.walk(TORRENT_DIR): for filename in filenames: abs_file_path = os.path.join(dirpath, filename) hash_value = torrent(abs_file_path).hash_value downloaded_hash.add(hash_value) print(f'\rdownloaded_hash added:{len(downloaded_hash)}', end='') m_cur.execute('USE java;') command_movie = 'SELECT no,release_date FROM movie WHERE downloaded=0 AND file_path="unknown"' movie_no_set = set() if actors != None: for actor in actors: actor_keys = r_cur.keys(f'java:actor:*{actor}*') for actor_key in actor_keys: movie_no = r_cur.smembers(actor_key) movie_no_set = movie_no_set.union(movie_no) if tags != None: for tag in tags: movie_no = r_cur.smembers(f'java:tag:{tag}') movie_no_set = movie_no_set.union(movie_no) if len(movie_no_set) != 0: movie_no_tuple = tuple(int(i) for i in movie_no_set) command_movie += ' AND no IN {}'.format(movie_no_tuple) if limit != 0: command_movie += f' LIMIT {limit}' command_movie += ';' m_cur.execute(command_movie) allowed_files = set() #allowed_files这个目录放的是一些文件，根据以往的经验，种子里带这些文件时电影的品质更好 for dirpath, dirnames, filenames in os.walk( os.path.join(os.getcwd(), 'allowed_files')): allowed_files.add(filenames) for movie_no, release_date in m_cur.fetchall(): command_torrent = 'SELECT * FROM torrent WHERE movie_no={};'.format( movie_no) m_cur.execute(command_torrent) torrent_tuples_list = list(m_cur.fetchall()) torrent_tuples_list.sort(key=lambda x: x[7]) #先根据record_date排序一次 torrent_score_list = [] for torrent_tuple in torrent_tuples_list: no, hash_value, movie_no, name, file_num, size, high_definition, record_date = torrent_tuple torrent_files = r_cur.smembers('java:torrent:{}'.format(no)) score = 0 #根据name对torrent进行打分，这也是最主要的打分手段 p_thz = re.compile('thz', flags=re.I) p_hnd = re.compile('hnd', flags=re.I) p_C = re.compile('CH?$', flags=re.I) p_HD = re.compile('(HD$)|(^HD)', flags=re.I) p_2048 = re.compile('^hjd-2048', flags=re.I) p_zip = re.compile('\.zip$', flags=re.I) p_mp4 = re.compile('[^C ]\.mp4$', flags=re.I) if re.search(p_thz, name) != None or re.search( p_hnd, name) != None or ("原版首发" in name) or re.search( p_mp4, name) != None: score += 50 if re.search(p_C, name) != None or re.search( p_2048, name) != None or re.search( p_zip, name) != None or re.search( p_HD, name) != None or name.startswith( '-') or name.startswith( '@') or name.startswith('_'): score -= 50 #对中文字幕的要求不是很强烈（而且中文字幕的经常带广告），所以这里是扣分项 letters = re.findall('[a-zA-Z]+', name) all_upper_letter = True for letter in letters: if letter == 'mp' or letter.isupper(): continue else: all_upper_letter = False break if all_upper_letter == True: score += 30 #file_num if file_num == 1: score += 100 #high_definition if high_definition == 1: score += 50 #record_date #如果电影早于2018年，这时的磁链以能下载为优先，时间近的分数更高；晚于2018年的电影则时间越早分数越高，因为晚出来的磁链经常被人处理过 #这里没有分的太细，比如有些磁链的日期相同，却没有给相同的分数 if release_date > datetime.date(2018, 1, 1): score += 100 / len(torrent_tuples_list) * ( len(torrent_tuples_list) - 1 - torrent_tuples_list.index(torrent_tuple)) else: score += 100 / len(torrent_tuples_list) * ( 1 + torrent_tuples_list.index(torrent_tuple)) #torrent_files for torrent_file in torrent_files: if torrent_file in allowed_files or 'hnd' in torrent_file: score += 100 break torrent_score_list.append((score, name, hash_value)) torrent_score_list.sort(key=lambda x: x[0]) if len(torrent_score_list) != 0: score, name, hash_value = torrent_score_list[-1] hash_value = hash_value.upper() if check_repeat: if hash_value not in downloaded_hash: with open(os.path.join(os.getcwd(), 'torrent_selector.txt'), 'a', encoding='utf-8') as f: f.write( f'magnet:?xt=urn:btih:{hash_value}&dn={name}' + '\n') else: message = f'torrent file downloaded, hash_value={hash_value}' log.info(message) else: with open(os.path.join(os.getcwd(), 'torrent_selector.txt'), 'a', encoding='utf-8') as f: f.write(f'magnet:?xt=urn:btih:{hash_value}&dn={name}' + '\n') else: message = f'cannot find torrent for movie_no={movie_no}' log.warning(message) if __name__ == '__main__': actors = ['1', '2', '3'] tags = [] database_api_java().torrent_selector(actors=actors, tags=tags, limit=0) 本来还有一个 btsow_extensions.py ，它的作用是查询一些磁链搜索网站，这些网站能根据hash值查询磁链的信息，但是这些网站的反爬比较严格，这个文件和对应的爬虫这里就不写了。因此，实际上 get_to_be_updated_torrents 这个方法没有用到，根据磁链内文件名对磁链打分的功能也是缺失的。3.scrapy项目的修改（1）items.py# Define here the models for your scraped items # # See documentation in: # https://docs.scrapy.org/en/latest/topics/items.html import scrapy class JavaspiderItem(scrapy.Item): # define the fields for your item here like: # name = scrapy.Field() movie_dict = scrapy.Field() torrent_dicts_list = scrapy.Field() class TorrentDetailItem(scrapy.Item): torrent_dict = scrapy.Field()（2）middlewares.py# Define here the models for your spider middleware # # See documentation in: # https://docs.scrapy.org/en/latest/topics/spider-middleware.html from scrapy import signals # useful for handling different item types with a single interface from itemadapter import is_item, ItemAdapter class JavaspiderSpiderMiddleware: # Not all methods need to be defined. If a method is not defined, # scrapy acts as if the spider middleware does not modify the # passed objects. @classmethod def from_crawler(cls, crawler): # This method is used by Scrapy to create your spiders. s = cls() crawler.signals.connect(s.spider_opened, signal=signals.spider_opened) return s def process_spider_input(self, response, spider): # Called for each response that goes through the spider # middleware and into the spider. # Should return None or raise an exception. return None def process_spider_output(self, response, result, spider): # Called with the results returned from the Spider, after # it has processed the response. # Must return an iterable of Request, or item objects. for i in result: yield i def process_spider_exception(self, response, exception, spider): # Called when a spider or process_spider_input() method # (from other spider middleware) raises an exception. # Should return either None or an iterable of Request or item objects. pass def process_start_requests(self, start_requests, spider): # Called with the start requests of the spider, and works # similarly to the process_spider_output() method, except # that it doesn’t have a response associated. # Must return only requests (not items). for r in start_requests: yield r def spider_opened(self, spider): spider.logger.info('Spider opened: %s' % spider.name) import random class JavaspiderDownloaderMiddleware: # Not all methods need to be defined. If a method is not defined, # scrapy acts as if the downloader middleware does not modify the # passed objects. UA_list = [ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36 Edg/105.0.1343.33', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:104.0) Gecko/20100101 Firefox/104.0' ] @classmethod def from_crawler(cls, crawler): # This method is used by Scrapy to create your spiders. s = cls() crawler.signals.connect(s.spider_opened, signal=signals.spider_opened) return s def process_request(self, request, spider): # Called for each request that goes through the downloader # middleware. # Must either: # - return None: continue processing this request # - or return a Response object # - or return a Request object # - or raise IgnoreRequest: process_exception() methods of # installed downloader middleware will be called request.headers['User-Agent'] = random.choice(self.UA_list) return None def process_response(self, request, response, spider): # Called with the response returned from the downloader. # Must either; # - return a Response object # - return a Request object # - or raise IgnoreRequest return response def process_exception(self, request, exception, spider): # Called when a download handler or a process_request() # (from other downloader middleware) raises an exception. # Must either: # - return None: continue processing this exception # - return a Response object: stops process_exception() chain # - return a Request object: stops process_exception() chain pass def spider_opened(self, spider): spider.logger.info('Spider opened: %s' % spider.name) （3）pipelines.py# Define your item pipelines here # # Don't forget to add your pipeline to the ITEM_PIPELINES setting # See: https://docs.scrapy.org/en/latest/topics/item-pipeline.html # useful for handling different item types with a single interface from itemadapter import ItemAdapter from javaspider.libs.java_extensions import database_api_java class JavaspiderPipeline: def open_spider(self, spider): self.database_api_java = database_api_java() def process_item(self, item, spider): movie_no = '0' if 'movie_dict' in item.keys(): movie_no = self.database_api_java.save_movie(item['movie_dict']) if 'torrent_dicts_list' in item.keys(): for torrent_dict in item['torrent_dicts_list']: if movie_no != None: torrent_dict['movie_no'] = int(movie_no) self.database_api_java.save_torrent(torrent_dict) if 'torrent_dict' in item.keys(): #self.database_api_java.update_torrent(torrent_dict) pass return item def close_spider(self, spider): self.database_api_java.check_repeat()（4）settings.py# Scrapy settings for javaspider project # # For simplicity, this file contains only settings considered important or # commonly used. You can find more settings consulting the documentation: # # https://docs.scrapy.org/en/latest/topics/settings.html # https://docs.scrapy.org/en/latest/topics/downloader-middleware.html # https://docs.scrapy.org/en/latest/topics/spider-middleware.html LOG_LEVEL = 'ERROR' BOT_NAME = 'javaspider' SPIDER_MODULES = ['javaspider.spiders'] NEWSPIDER_MODULE = 'javaspider.spiders' # Crawl responsibly by identifying yourself (and your website) on the user-agent #USER_AGENT = 'javaspider (+http://www.yourdomain.com)' # Obey robots.txt rules ROBOTSTXT_OBEY = False # Configure maximum concurrent requests performed by Scrapy (default: 16) #CONCURRENT_REQUESTS = 32 # Configure a delay for requests for the same website (default: 0) # See https://docs.scrapy.org/en/latest/topics/settings.html#download-delay # See also autothrottle settings and docs #DOWNLOAD_DELAY = 3 # The download delay setting will honor only one of: #CONCURRENT_REQUESTS_PER_DOMAIN = 16 #CONCURRENT_REQUESTS_PER_IP = 16 # Disable cookies (enabled by default) #COOKIES_ENABLED = False # Disable Telnet Console (enabled by default) #TELNETCONSOLE_ENABLED = False # Override the default request headers: #DEFAULT_REQUEST_HEADERS = { # 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', # 'Accept-Language': 'en', #} # Enable or disable spider middlewares # See https://docs.scrapy.org/en/latest/topics/spider-middleware.html #SPIDER_MIDDLEWARES = { # 'javaspider.middlewares.JavaspiderSpiderMiddleware': 543, #} # Enable or disable downloader middlewares # See https://docs.scrapy.org/en/latest/topics/downloader-middleware.html DOWNLOADER_MIDDLEWARES = { 'javaspider.middlewares.JavaspiderDownloaderMiddleware': 543, } # Enable or disable extensions # See https://docs.scrapy.org/en/latest/topics/extensions.html #EXTENSIONS = { # 'scrapy.extensions.telnet.TelnetConsole': None, #} # Configure item pipelines # See https://docs.scrapy.org/en/latest/topics/item-pipeline.html ITEM_PIPELINES = { 'javaspider.pipelines.JavaspiderPipeline': 300, } # Enable and configure the AutoThrottle extension (disabled by default) # See https://docs.scrapy.org/en/latest/topics/autothrottle.html #AUTOTHROTTLE_ENABLED = True # The initial download delay #AUTOTHROTTLE_START_DELAY = 5 # The maximum download delay to be set in case of high latencies #AUTOTHROTTLE_MAX_DELAY = 60 # The average number of requests Scrapy should be sending in parallel to # each remote server #AUTOTHROTTLE_TARGET_CONCURRENCY = 1.0 # Enable showing throttling stats for every response received: #AUTOTHROTTLE_DEBUG = False # Enable and configure HTTP caching (disabled by default) # See https://docs.scrapy.org/en/latest/topics/downloader-middleware.html#httpcache-middleware-settings #HTTPCACHE_ENABLED = True #HTTPCACHE_EXPIRATION_SECS = 0 #HTTPCACHE_DIR = 'httpcache' #HTTPCACHE_IGNORE_HTTP_CODES = [] #HTTPCACHE_STORAGE = 'scrapy.extensions.httpcache.FilesystemCacheStorage' （5）爬虫爬虫文件名为 java.py ，在之前已经创建过了，内容为：import scrapy, re, random, os, time import urllib.parse from javaspider.items import JavaspiderItem from javaspider.libs import java_extensions as j_e from MyPythonLib import interaction error_html_file_path = os.path.join(os.getcwd(), 'wrong_response_java.html') #这个html文件用于存储解析出错的页面，便于查找问题 RECURSIVE=True #如果演员主页都是第一次爬，那么就打开递归爬取的功能，自动爬取下一页；对于以前曾经爬过的演员，并且间隔时间不久（3个月之内），则只爬取第一页就行了，把这个值设为False。 class JavaSpider(scrapy.Spider): name = 'java' start_urls = j_e.get_actor_main_pages() custom_settings = custom_settings = { 'CONCURRENT_REQUESTS': 16, 'DOWNLOAD_DELAY': 2, 'CONCURRENT_REQUESTS_PER_DOMAIN': 1 }#控制爬取速度 origin_url = j_e.ORIGIN_URL_JAVA temp_url = j_e.TEMP_URL_JAVA start_time = time.time() total = len(start_urls) progress = 0 msg = '' def parse(self, response): try: movie_nodes = response.xpath( '//*[@id="waterfall"]/div/a[@class="movie-box" and @href]') for movie_node in movie_nodes: detail_url = movie_node.xpath('./@href').get() if isinstance(detail_url, str): detail_url = urllib.parse.urljoin(JavaSpider.temp_url, detail_url.strip()) movie_dict = {} code = movie_node.xpath('.//date[1]/text()').get() if isinstance(code, str): movie_dict['code'] = code.strip() full_name = movie_node.xpath('.//span/text()[1]').get() if isinstance(full_name, str): movie_dict['full_name'] = full_name.strip() release_date = movie_node.xpath('.//date[2]/text()').get() if isinstance(release_date, str): movie_dict['release_date'] = release_date.strip() movie_dict['url'] = detail_url.replace(JavaSpider.temp_url, JavaSpider.origin_url) item = JavaspiderItem() item['movie_dict'] = movie_dict request = scrapy.Request(url=detail_url, callback=self.parse_movie) request.cb_kwargs['item'] = item yield request next_url = response.xpath( '//*[@id="next" and text()="下一頁"]/@href').get() if RECURSIVE and isinstance(next_url, str): next_url = urllib.parse.urljoin(JavaSpider.temp_url, next_url.strip()) yield scrapy.Request(url=next_url, callback=self.parse) if response.url in JavaSpider.start_urls: JavaSpider.progress = 1 + JavaSpider.start_urls.index( response.url) JavaSpider.msg = response.url.replace( JavaSpider.temp_url, '') except: with open(error_html_file_path, 'a', encoding='utf-8') as f: f.write(response.text) message = 'JavaSpider.parse failed' raise Exception(message) def parse_movie(self, response, item): try: movie_dict = item['movie_dict'] info_node = response.xpath('/html/body/div[5]/div[1]/div[2]') duration = info_node.xpath( './p/span[text()="長度:"]/../text()').get() if isinstance(duration, str): duration = re.search('\d+', duration.strip()).group() movie_dict['duration'] = duration director = info_node.xpath( './p/span[text()="導演:"]/../a/text()').get() if isinstance(director, str): movie_dict['director'] = director.strip() producer = info_node.xpath( './p/span[text()="製作商:"]/../a/text()').get() if isinstance(producer, str): movie_dict['producer'] = producer.strip() publisher = info_node.xpath( './p/span[text()="發行商:"]/../a/text()').get() if isinstance(publisher, str): movie_dict['publisher'] = publisher.strip() series = info_node.xpath( './p/span[text()="系列:"]/../a/text()').get() if isinstance(series, str): movie_dict['series'] = series.strip() movie_dict['tags'] = info_node.xpath( './p[text()="類別:"]/following-sibling::p[1]/span//a/text()' ).getall() if movie_dict['tags'] == None or len(movie_dict['tags']) == 0: movie_dict['tags'] = set(['no']) movie_dict['actors'] = info_node.xpath( './p/span[text()="演員"]/../following-sibling::p[1]/span/a/text()' ).getall() if movie_dict['actors'] == None or len(movie_dict['actors']) == 0: movie_dict['actors'] = set(['unknown']) js = response.xpath(r'/html/body/script[3]/text()').get().strip() gid = re.search(r'\d+', re.search(r'gid.+;', js).group()).group() uc = re.search(r'\d+', re.search(r'uc.+;', js).group()).group() img = re.search(r"""['|"].+['|"]""", re.search(r'img.+;', js).group()).group()[1:-1] torrents_url = urllib.parse.urljoin(JavaSpider.temp_url, 'ajax/uncledatoolsbyajax.php') torrents_url = torrents_url + '?gid=' + gid + '&lang=zh&img=' + img + '&uc=' + uc + '&floor=' + str( random.randint(10, 999)) request = scrapy.Request(url=torrents_url, callback=self.parse_torrents) interaction.print_progress(self.start_time, self.progress, self.total, self.msg) request.cb_kwargs['item'] = item yield request except: with open(error_html_file_path, 'a', encoding='utf-8') as f: f.write(response.text) message = 'JavaSpider.parse_movie failed' raise Exception(message) def parse_torrents(self, response, item): try: torrent_dicts_list = [] torrent_nodes = response.xpath('//tr') for torrent_node in torrent_nodes: torrent_dict = {} hash_value = torrent_node.xpath('./td[1]/a/@href').get() if isinstance(hash_value, str): hash_value = hash_value.strip().replace( r'magnet:?xt=urn:btih:', '') torrent_dict['hash_value'] = hash_value.split('&')[0] name = torrent_node.xpath('./td[1]/a/text()').get() if isinstance(name, str): torrent_dict['name'] = name.strip() size = torrent_node.xpath('./td[2]/a/text()').get() #该网站的磁链size时有错误，常见的有：无单位，有2个小数点等 if isinstance(size, str): size = size.strip() p_gb = re.compile(r'gb$', flags=re.I) p_mb = re.compile(r'mb$', flags=re.I) p_tb = re.compile(r'tb$', flags=re.I) p_kb = re.compile(r'kb$', flags=re.I) if re.search(p_gb, size) != None: size = int(1024 * float(re.search(r'[\d|\.]+', size).group())) elif re.search(p_mb, size) != None: size = int(float(re.search(r'[\d|\.]+', size).group())) elif re.search(p_tb, size) != None: size = int(1024 * 1024 * float(re.search(r'[\d|\.]+', size).group())) elif re.search(p_kb, size) != None: size = int( float(re.search(r'[\d|\.]+', size).group()) / 1024) elif size == '': message = 'JavaSpider.parse_torrents warning: get size from empty string "{}"'.format( size) print(message) size = '0' else: message = 'JavaSpider.parse_torrents warning: size no unit "{}"'.format( size) print(message) try: size = int( 1024 * float(re.search(r'[\d|\.]+', size).group())) except: message = 'JavaSpider.parse_torrents warning: size wrong "{}"'.format( size) print(message) size = '0' torrent_dict['size'] = size record_date = torrent_node.xpath('./td[3]/a/text()').get() if isinstance(record_date, str): torrent_dict['record_date'] = record_date.strip() high_definition = torrent_node.xpath('.//a[text()="高清"]') torrent_dict['high_definition'] = ( 0, 1)[len(high_definition) != 0] torrent_dicts_list.append(torrent_dict) item['torrent_dicts_list'] = torrent_dicts_list yield item except: with open(error_html_file_path, 'a', encoding='utf-8') as f: f.write(response.text) message = 'JavaSpider.parse_torrents failed' raise Exception(message)4.使用说明首先用chrome或其他浏览器访问该电影网站，挑选自己偏好的演员，演员的主页保存到一个书签，比如说可以每50个演员主页放到一个书签文件夹中。要爬取时，进入书签管理器，用 ctrl+A 选中并复制一个书签文件夹内的所有演员主页，把这些网址复制到 D:\spiders\scrapy\javaspider\javaspider\actor_main_pages.txt 里，每行一个url。然后考虑一下，如果这些演员在3个月之内爬过，就到爬虫文件 java.py 里把 RECURSIVE = True 改成 False ，然后检查一下mysql和redis是否打开，最后在cmd中如下运行爬虫：D: cd D:\spiders\scrapy\javaspider\javaspider scrapy crawl java应该能看到一个图形化的进度显示条。等待爬取完成，按照笔者的经验，使用前文中限制爬虫的配置： custom_settings = custom_settings = { 'CONCURRENT_REQUESTS': 16, 'DOWNLOAD_DELAY': 2, 'CONCURRENT_REQUESTS_PER_DOMAIN': 1 }这样的话，爬取50个演员主页，大概需要4小时。爬取完成后，登录mysql和redis检查一下数据。对于redis-cli，记得添加 --raw 参数来显示utf-8字符，比如中文、日文字符。最后，在 java_extensions.py 的最后，有调用 torrent_selector 方法，调整一下演员、tag并运行该文件，会生成 D:\spiders\scrapy\javaspider\torrent_selector.txt 文件，里面是为每一部电影筛选出的磁链，每个磁链占一行，可以非常方便地复制到qbittorrent或aria2等客户端内进行下载（为了便捷性，不推荐使用迅雷下载）。至此，本文中完成了电影信息爬取（根据演员主页），磁链爬取，磁链筛选与下载。下一篇文章中将介绍对本地电影进行入库，和对电影进行一定压缩，以节约空间。