上一篇写了给 NAS 造的夸克自动追番流水线:发个链接就自动下载入库。这篇是它的上层——让 NAS 根据我的观看数据主动推荐,我回一个编号就把整部番下好、刮削好、配好字幕入库。
效果如下,主动触发:

被动触发:

系统现状
当前的能力边界:
-
每周五定时推 6 部候选(异世界 / 恋爱 / 福利各 2,带海报拼图与推荐理由);
-
“看完触发”:检测到某部全集已看完且已完结,立即推 3 部相似;
-
我回编号(如
1 2 6),后台执行转存 → aria2c 下载 → TMDB 刮削改名 → 配简体外挂字幕 → 入库,每个阶段单独推送进度; -
报片名直接下;说类型(“推荐点修仙的”)按类型筛;说**“给 X 加字幕”**给已入库的番补外挂字幕;
-
下载时自动选版本(清晰度 / 体积 / 字幕可用性),生肉自动配简体外挂字幕,同分享内的剧场版一并下到电影库;
-
完结番下完即从追更列表删除,连载番自动设截止日期到完结停。
落地形态是 fnOS NAS 上的一组 Docker 容器 + cron 脚本 + 一个常驻的 LLM agent(openclaw,负责微信/QQ 收发与技能调度),最终入 Jellyfin,用 Infuse 观看。
一、整体架构与运行时分工
架构上有两条原则贯穿始终。
第一,人是闸门。 推荐由系统做,但”下不下”默认等我点头。只有当契合度高过一个保守阈值,才允许先下载、后告知;同时每周自动下载的数量和体积有上限,防止把盘塞爆。
第二,按成本分工运行时。 这套东西如果全用大模型跑会很贵,所以拆成两层:
-
便宜的定时脚本(cron)干确定性的苦力:拉 Jellyfin 数据、算口味向量、解析目录、向量粗筛、转存、下载、刮削、推送。这些是数据搬运和数学,不需要智能。
-
大模型(NAS 上常驻的 openclaw agent)只在三个点被调用:① 粗筛后对约 50 个候选做精判并写一句推荐理由;② 解析我发的自然语言意图;③ 对话审批。
具体组件:转存用 Docker 里的 quark-auto-save;下载编排是我自己的 quark_sync.py(每小时 cron);上层编排器 quark_ctl.py 暴露 probe / tmdb / addtask / sync / autodl 等子命令,由 agent 以 sudo 调用;推荐运行时是 anime_reco.py;媒体落在 mergerfs 合并卷上(动漫 / 剧集 / 电影 / 动漫电影,写入策略 mfs 自动落最空的盘)。
二、口味画像:用观看数据建模
推荐的前提是回答一个问题:凭什么判断我会喜欢某部番? 不靠猜,靠我自己的观看记录。
信号来源与权重
数据来自 Jellyfin API:每部剧的 UserData(IsPlayed、未看集数、LastPlayedDate)给出”看没看完、看到哪、最近在看啥”。我按”投入度”给每部样本一个权重,再把它的标签按权重累加成一个向量:
def build_taste_profile(jellyfin):
vector = defaultdict(float)
for show in jellyfin.watched_shows(): # 拉 UserData
weight = (1.0 if show.finished and show.recent
else 0.8 if show.in_progress
else -0.3 if show.dropped
else 0.0)
for tag in anilist_tags(show): # 该剧的 AniList 标签
vector[tag] += weight
return normalize(vector), summarize_in_words(vector)
实读我的 32 部样本(16 看完 + 16 在追),跑出来两簇势均力敌:一簇是异世界·奇幻爽番(标签 Isekai / Magic / Super Power / Swordplay / Dungeon / Male Protagonist / Female Harem),一簇是青春恋爱·治愈日常(Romance / School / Slice of Life / Iyashikei / Kuudere / Cohabitation)。
校准规则
向量是统计,但有些偏好统计不出来,需要我口述成规则、写进打分逻辑。这几条直接影响后面 recommend.py 的权重:
- 感情线是核心吸引力——我能为男女主的感情忍受致郁剧情(Re:Zero 是唯一例外,不据此推致郁番)。
- 拒绝降智——“无脑爽”不要,要”有质量的爽”,用评分门槛卡掉低分番。
- 重口/致郁负权:
Tragedy / Gore / Horror / Survival / Boys' Love / Yuri / Male Harem全部减分。 - 福利 ≠ 重口:
Ecchi / Harem / Fanservice+ 爽向复仇是正权。 - 男主视角 + 异性恋强偏好:
Male Protagonist +1、Female Protagonist −0.8、Heterosexual +。
三、目录摄取:把”我有哪些番”喂进去
口味是”我喜欢什么类型”,还需要一个货架——来自这位大佬的无私分享:https://www.kdocs.cn/l/cnOBXUamMBJJ
为什么没法直接爬
这一步比预期折腾。该文档是 .otl(在线表格的私有协作格式),我依次试过:
-
抓 Cookie 调 API:补齐登录态(
wps_sid / kso_sid / uid)后,GET 文件元信息能通,能读到文件名、ID、大小、作者;但.otl没有简单导出接口——/download返回unSupport (403),各 export 端点全404。它是私有协作格式,数据不内联在分享页 HTML 里。 -
逆向渲染协议:weboffice 的渲染走私有协议,前端
preload-o.js动态构造请求,逆向成本太高,且我对这份文档只有只读权、没有导出/编辑权。
解法:另存为 docx 后解析
最后走的是最朴素的一条:在浏览器里把表格另存为 .docx,再解析。.docx 本质是个 zip,里面是 OOXML。解析时踩了一个点:
坑|超链接藏在域代码里。 Word 把单元格里的超链接存成
<w:instrText>HYPERLINK "..."</w:instrText>域代码,而不是可见文字。如果只读单元格的可见文本(w:t节点),会只拿到番名、丢掉链接。正确做法是把整个单元格序列化成 XML(ET.tostring(tc))再正则搜HYPERLINK。
def parse_catalog(docx):
rows = []
for cell in docx.all_table_cells():
name = cell.visible_text() # w:t 拼接
link = extract_hyperlink(cell.raw_xml()) # 搜整段 XML 的 HYPERLINK 域代码
if name and link:
rows.append({"name": name, "quark": link})
return rows
解析出 2273 部,0 空名,抽验正确。目录以后更新就是重复”另存 docx → 跑解析器”,再 diff 出新增、只富化增量。
四、元数据富化:把每部番打上标签(四轮)
货架有了名字和链接,但推荐需要”每部是什么”——类型、标签、口碑评分。中文名直接搜动漫数据库命中率很差,这一步打了四轮,是整个项目里最反复的部分。
关键工程细节
-
匹配策略:中文名搜 AniList 命中率几乎为零(连《葬送的芙莉莲》都搜不到)。所以先用 TMDB 做中文匹配(命中 96%),从 TMDB 拿到日文原名,再用日文原名去查 AniList 的细标签。
-
Cloudflare:AniList / TMDB 的请求头
User-Agent必须是纯浏览器 UA。带my-script/1.0这类脚本标识会被 Cloudflare 拦成 404。AniList 的 GraphQL 端点还要求 URL 带尾斜杠。 -
GraphQL 的坑:AniList 批量查询里只要有一个 null,会返回 HTTP 404 但 body 仍带 data——得捕获 404 后照样读 body,否则整批丢弃。
-
网络位置:
api.themoviedb.org在国内直连被墙,TMDB 请求必须在 NAS 上发(NAS 挂了代理);AniList 反而本机能直连。两边分开跑。 -
节流:第三轮对 873 部批量重查,
time.sleep(1.3)限速,遇到 429 退避 60 秒,每 40 条原子落盘,非原名匹配加 ±3 年的年份校验防误配。
第三轮的动机来自一个判断:“没识别到的番不可能没收录,多半是匹配姿势不对。” 于是把日文原名做清洗(去副标题、括号、季数)重查,免费白捡回 681 部。
第四轮:LLM 并行归类
剩 192 部是硬骨头:2025 新番尚未收录、复杂续季名(“第四季 丧失篇”)、剧场版分章、别名一堆(“蓝箱 / 青春之箱 / 青之箱”)、国创原名是中文。这批用规则匹配不动了,改用 LLM:开 6 个 Agent 并行,每个负责 32 部,读各自的清单文件,用知识 + 实时查站(MAL / AniList / Bangumi / 维基 / 萌娘),产出固定 schema——al_genres(AniList 类型)、al_tags(贴推荐打分词表的英文标签)、al_score(0–100),再合并写回。
结果:192 部 0 漏配、177 部高置信,catalog 100% 归类。 抽查准确(幼女战记 → Military/Isekai 81、电锯人 → Gore/Tragedy 83、双城之战 → 89)。
坑|异体字导致漏配。 LLM 把番名逐字写回时,有一条把”亚刻奧特曼”(异体”奧”)写成了”亚刻奥特曼”(简体”奥”),按名匹配失败。所以写回数据库前加了一步校验:拿结果的 name 和原清单求交集,列出对不上的(最终只有这 1 条),修正后再写。任何”按名 join”的环节都要先验证主键。
五、推荐引擎
两段式:先用口味向量对全量打分粗筛(几千 → 约 50),再过质量门槛、分三簇出 Top。打分函数大致是:
def score(show, taste):
s = 0
for tag in show.al_tags:
s += taste.weight.get(tag, 0) # 口味标签加权
s += GENRE_BONUS(show.al_genres) # 类型偏好(异世界/恋爱/福利)
if show.al_score: s += (show.al_score - 70)*0.1 # 口碑微调
if show.year < 2010: s -= 3 # 老番降权
elif show.year < 2015: s -= 1
if is_sequel_without_prequel(show): return SKIP # 没看前作的续集不推
return s
去重与状态管理(anime_reco_state.json):
-
pushed = {番名: 日期}——30 天内不重推,过期可再推; -
blocked——黑名单永久不推(我说”别再推 X”时执行anime_reco.py block "X"); -
owned——NAS 库里已有的(不管看没看)一律不推; -
finished_seen——看完基线,用于”看完触发”判重。
两条触发腿:每周五定时推一次;看完触发是高频轮询 Jellyfin,检测”某部刚从未看完变成全看完,且 TMDB 状态为 Ended/Canceled”——连载追平最新集不算(还有更新)。
推送做了工程优化:6 部合成一段文字 + 6 张海报用 PIL 拼成一张 2 列长图,每张叠黄色编号对应文字。这是为了绕开两件事:微信连发多图会限流;QQ 偶尔丢单张图。海报由 NAS curl 拉 image.tmdb.org 到本地再发(实测远程 URL 直接喂发送脚本会 fetch failed,必须先落地)。
坑|PIL 的 import。 Pillow 装在 agent 用户的
~/.local,cron 跑的时候必须带env HOME=...,否则 import 不到。微信掉线则是另一回事:发送返回iLink ret=-2= 会话失效,得我主动给机器人发条消息激活,代码层改不了;所以各推送渠道独立 try,微信挂不影响 QQ。
六、下载流水线:从编号到入库
回一个编号后,autodl 在后台逐步推进。夸克这部分的 API 细节是整条线最容易出错的地方。
夸克 API 的三个关键点
转存和下载都调夸克的 web API(封装在容器内的 qsync_api.py,因为容器才挂着夸克账号的 cookie):
- 转存子文件夹:分享的子目录要用
<分享链接>#/list/share/<folder_fid>-x这种 URL 形式定位,再save_file进自己账号。 - token 会话绑定(41020):
save_file(fid_list, fid_token_list, ...)里的share_fid_token必须和取它的那次get_stoken来自同一个 stoken 会话,否则报41020 token 校验异常。所以”取 token”和”save”要在同一段脚本/会话里完成,不能跨调用。 - 下载直链的 403:
acc.download([fid])返回(响应, dlcookie)两段。喂给 aria2c 时,Cookie头必须是账号 cookie + ”; ” + 这个 per-file dlcookie,少了后半段就 403。
# 转存后不要立刻信"成功"——校验该季账号里确实有文件,防竞态漏季
ok = False
for _ in range(10): # 重试 10 次,每次间隔 20s
run_quark_auto_save() # 触发转存
if account_season_filecount(savepath) > 0:
ok = True; break
sleep(20)
if not ok:
push("❌ 转存失败,交给整点 cron 重试"); return
push("✅ 已转存,开始下载入库…")
run_with_flock(SYNC) # 下载/解压/刮削/推送
这个”转存后校验 + 重试”是被一个真实 bug 逼出来的:多季番一次性提交转存会竞态漏季(某部 4 季里 S4 没转上),改成逐季转存、每季校验账号里确有文件再继续。
异步与并发
最初流水线是同步的——回编号后在对话里一直跑到下载完才回,但一部番几十分钟,对话会超时、中途没反馈。改成异步:
def on_approve(nums):
reply("收到,后台下载中…") # 立即回
spawn_detached(f"autodl {nums}") # setsid 脱离当前进程,后台跑
end_turn() # 结束这轮对话
坑|并发。 ①
autodl和每小时整点的 cron 都会触发下载,没加锁时两个aria2c撞一起,对同一文件-x1反复重下卡了两小时。加flock /tmp/quark_sync.lock解决——但不能双重加锁:外层 flock 包一次、内层 sync 又包同一把锁,会自己等自己、死锁。
坑|TMDB 刮削偶发失败要重试。 改名偶尔撞上 TMDB 的 SSL EOF,导致集名没补上、只剩
SxxExx。加了”刮削失败重试,上限 3 次、间隔 5 秒”,再没出现裸集号。
七、版本选择
同一部番,分享里常并存多个字幕组/画质版本。选择规则是我口述、AI 落地成打分函数的。
def select_version(folders):
ok = [f for f in folders if sub_ok(f) > 0] # 滤掉只内嵌繁体/非中文且不可换的
pool = ok or folders # 全是差字幕才退而求其次
return max(pool, key=lambda f: (
len(f.episodes), # ① 覆盖完整(底线): 防选到只有后半季的分卷
quality_score(f), # ② 清晰: 关键词 2160/4K > 1080 > 720, +10bit/超分/HEVC
f.total_size, # ③ 同清晰选大文件: 1G 系列优先于 512M 系列
sub_ok(f))) # ④ 字幕可用性
quality_score 从文件夹名和样本文件名里抓关键词(2160/4K、10bit/Hi10p、HEVC/x265、FLAC);sub_ok 判断字幕可换性——含”简体/外挂/内封”为好,“只内嵌繁体或非中文”为差(不可换,直接过滤)。覆盖完整放在第一位,是因为吃过亏:早期版本按”最大集号”选,结果对一部分卷番选中了只有 14–26 集的后半季,漏了前半。
八、压缩包与解压密码
部分番(尤其里番)在分享里不是裸视频,而是重命名成 .exe 的压缩包(不是真自解压,7z 按文件头识别后缀无所谓),并带解压密码。密码的位置不固定:文件夹名里、同目录某个 txt 里、或就是分享链接/番名本身。
def archive_passwords(name, folder):
cands = decoy_passwords_in_folder_name(folder) # 文件夹名里写的诱饵密码
cands += read_password_txt(folder) # 密码.txt
cands += [name, share_link_id] # 番名 / 链接 id
cands += [p.split("@")[0] for p in cands if "@" in p] # 变体: ruach@66 → ruach
return dedup(cands)
def extract(archive, cands):
for pw in cands:
if seven_zip(archive, pw): # 7z x -y -p<pw>,按文件头识别真实格式
remember_good_password(pw); return True
return False
解压后按集号改名入库时,要先用一组 SPECIAL_RE 把 NCED / NCOP / Menu / SP / Secret Video 这类非正片过滤掉,再按 SxxExx 命名。
坑|诱饵密码。 有个密码标的是
ruach@66,逐个试全部”密码错误”。实际真密码是ruach,@66是诱饵后缀。于是加了”密码变体”:对x@y形式,把@前的部分也作为候选。多密码逐个试、并记住第一个成功的,后续同名压缩包直接用。
九、外挂字幕与剧场版
一个后期补的缺口:高画质 BDRip(如 VCB-Studio)的视频常是无字幕生肉,字幕在单独的”外挂字幕”子文件夹里。早期只下视频、没下字幕,等于下了个看不了的版本。另外同一分享里常还塞着剧场版,也漏了。
这块做成 autodl 的两个后置步骤,且都包了 try/except——失败只跳过,不影响主下载。容器侧加了两个 action:subsfor(在视频文件夹及子目录里找简体字幕、save_file 到临时目录、返回直链)、movieget(剧场版文件夹的主影片 + 简体字幕直链)。宿主机侧下载并归位。
字幕命名有个硬约束:Jellyfin 要求外挂字幕与视频完整同名(含刮削加上的集名),只差后缀和语言标记。所以字幕必须在视频改名之后处理——按集号配对库里已改好名的视频,取那个视频的完整 stem 加 .zh.ass:
def fetch_subs(folder, season):
subs = pick_simplified_subs(folder) # .sc/.简/chs 优先, .tc/繁次之
for s in subs:
video = find_lib_video(season, s.episode) # 按 SxxExx 找已改名的视频
if not video or has_any_sub(video): # 已有字幕(含原版裸.ass)就跳过
continue
place(s, dest=f"{video.stem}.zh.ass") # 完整同名 + .zh, Jellyfin 才认
剧场版判定走正则,但刻意不匹配裸 ZERO/movie——否则会把《Re:Zero》之类 TV 正片误判成剧场版下进电影库。只认”剧场版 / 劇場版 / 劇場 / the movie / gekijou”这类明确标记,命中后在候选里按清晰度+体积选主影片(同时排除 SP/Menu/CM/NC),落到 动漫电影/<片名> (年)/。
坑|字幕重复。 有部番原下载就自带裸
.ass(无语言标记),补字幕时只检测了<stem>.zh.ass是否存在、没检测裸<stem>.ass,结果整季又加一遍,每集两条字幕。修法是has_any_sub()检测任意形式的同名字幕(裸.ass/.srt/ 带标记.zh.ass)就跳过。原则:原下载自带、跟视频同包的字幕时轴必对,优先于外部字幕组的。
十、完结判定与任务回收
追更列表早期堆了一批早已完结、却永久挂着、没截止日期的任务。补上 TMDB 状态判定后:
def after_download(show, season):
info = tmdb_status(show.tmdb_id, season) # status / next_episode_to_air / 各集播出日
if info.completed: # Ended/Canceled 或本季全播完
delete_task(show) # 下完即删,不长期占用
elif info.airing:
set_enddate(show, info.finale + buffer) # 连载: 到完结日+缓冲自动停
else:
set_enddate(show, today + 90*DAY) # 查不到 tmdb: 兜底 90 天,绝不永久挂
completed 的判定不只看 status,还结合本季各集的播出日期:已播集数 == 总集数、且 next_episode_to_air 为空、无未来集,才算完结。这样对”季终但全剧未完结”的情况也能正确处理。存量也清了一遍:6 个完结番的常驻任务,TMDB 确认后全删。
十一、对话交互
最后一层是自然语言入口。agent 解析意图,路由到对应的轻量技能:
| 输入 | 动作 | 实现 |
|---|---|---|
回编号 126 | 下推荐过的那几部 | 查 last_push 映射拿链接 → autodl |
我想看 XXX | 目录模糊搜 → 下 | anime_reco.py find → autodl --link |
推荐点修仙的 | 按类型筛选推荐 | recco 中文类型 → AniList 标签映射 |
给 XXX 加字幕 | 已入库的补简体字幕 | find → quark_ctl subs --link |
别再推 X | 加黑名单 | anime_reco.py block |
每个意图对应一个技能描述文件,agent 按描述触发,秒回 + 后台执行。
十二、传输与运行环境的坑
这套东西跨”本机 ↔ NAS ↔ 容器 ↔ agent 用户”四层,环境差异本身就是一类 bug 源:
-
CJK 经 SSH/heredoc 会乱码:远程执行带中文的脚本时,中文和反斜杠经 heredoc 会被改写。解决办法是 payload 全程走 base64:本地
base64 -w0→ 远程base64 -d落地再执行,中文/特殊字符零损耗。 -
宿主机
python3对 agent 用户是 “Permission denied”:直接python3 /tmp/x.py跑不了,得走docker exec python3或sudo python3或包成 bash。 -
容器看不到宿主机大部分路径:转存容器只挂了自己的 config 目录,看不到宿主机的媒体库;所以”转存”在容器里做、“下载落盘/改名/配字幕”在宿主机做,两边用账号 cookie 衔接。
-
FRP 隧道会抖:NAS 经 frp 中转暴露 SSH,偶发
Connection closed by <relay>。狂重试会触发 fail2ban 越封越久,正确做法是缓一下再连。
十三、协作模式与现状
这套系统有十几个脚本、跨容器、带定时任务、会自己收发消息。它的产生方式是一个固定的循环:
值得一提的是几个判断本身的价值——它们是统计和模型给不出、需要人来定的:把”覆盖完整”放在选版第一位、“没识别到的不可能没收录”催生第三轮重查、“完结的别永久挂着”触发任务回收、“内嵌繁体不可换的直接跳过”。这些是需求,不是代码。
工程上每一步都带验证:部署后抽查、批处理后报数核对、出错回滚备份。涉及外部写入(写库、改媒体库、发布)前,都先和原始数据核对主键、留时间戳备份。
现状与待办
-
catalog:2273 部全部归类,推荐池满载;
-
下载:异步、逐季、转存校验重试、完结判定、外挂字幕 + 剧场版、版本选择规则全部就位;
-
对话:回编号 / 报片名 / 说类型 / 加字幕 / 拉黑都能走。
待办:把”外挂字幕 + 剧场版”这套拿一部全新番做一次端到端实跑验证;清掉目录里混进来的少量真人影视;给推荐加”探索位”防信息茧房。