From dadc9ace8c21e3975dad314beeb818db15a00021 Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:08:54 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=86=E6=8B=A3=E5=8F=B0=EF=BC=9A=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E6=98=BE=E7=A4=BA=20B=E7=AB=99=20=E5=88=86=E5=8C=BA?= =?UTF-8?q?=EF=BC=8C=E6=90=9C=E7=B4=A2=E8=83=BD=E6=90=9C=E5=88=86=E5=8C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 收藏夹的视频按 50 个一批补取 B站 分区(medialist/gateway/base/resource/infos 返回旧版 tid,静态表映射成大分区名),存进新键 triage_zones。卡片小字末尾显示分区名,搜索匹配分区名。分区不进筛选区:太粗,又是 UP 主自己选的。 --- docs/ARCHITECTURE.md | 1 + docs/DESIGN.md | 3 + extension/limits.js | 3 + extension/limits.selftest.js | 2 +- extension/messages.js | 1 + extension/triage/fav-base.js | 11 ++- extension/triage/fav-export.js | 2 +- extension/triage/fav-folders.js | 53 +++++++++++++++ extension/triage/fav-page.js | 7 +- extension/triage/fav-render.js | 2 +- extension/triage/shared.js | 4 +- extension/triage/triage-bg.js | 12 ++++ extension/triage/triage-bg.selftest.js | 20 +++++- extension/triage/triage-core.js | 32 ++++++++- extension/triage/triage.selftest.js | 92 ++++++++++++++++++++++++++ 15 files changed, 232 insertions(+), 13 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6adef479..f2a6b571 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -28,6 +28,7 @@ B站 页面上的颜色是 `tokens.css` 的第三份拷贝:`content.css` 里 - 进度胶囊只从它画:`runs.pill(顺序)` 取第一个在跑、在等或有行的任务,都没有再取跑完留下的绿色结果行;等限流时一律在进度后面接「· B站限流卡住,m:ss 后重试」。别处来的状态(关注的后台刷新和它的「B站限流变慢」、观看进度、刷新中、标签建议待确认、「所有收藏夹」的加载)用 `runs.put` 放一行进去。收藏夹的顺序是 `fav-base.js` 的 `PILL`,按钮是 `PILL_BTN`;关注的在 `follow.js` 的 `renderSync` 旁边。第 3 行的「暂停粗看 N/M」也读 `runs.get`。 - 限流等多久只有 `hold`:收藏夹是 `fav-base.js` 的 `THROTTLES`(`MoonTriage.hold`),关注的 B站限流是 90 秒的 `TriageUi.BILI_HOLD_MS`。后台的队列暂停(`follow_jobs.hold`、`follow_hold`)和观看进度的 30 分钟来自后台存储,不走它。`runs.selftest.js` 在 node 里用假时钟测它。 - 后台的 B站 GET 只走 `triage-bg.js` 的 `triageBiliGetJson`(带 B站 的 referrer、不缓存),30 秒没回应算断网(「网络断了:30 秒没有回应」,时长和文字是 `MdgErrors.NET_TIMEOUT_MS` / `netTimeout`,视频页自己发的请求 `fetchOk` 也用这一份)。细看(`triageAnalyze`)、生成笔记(`triageBuildNote`)和侧边栏接着问 AI 的上下文(`resolveAiSidepanelContext`)都经 `TRIAGE_BILI_IO` 走它(WBI 签名)。视频页的 `fetch-json` 代理也走它(不带 pace,只试一次,由视频页重试;视频页不等了就发 `fetch-json-cancel`,后台按发送方标签页和请求 id 中止那一个请求),失败时把 `code` 和 HTTP 状态一起回给视频页,所以视频页和分拣台、侧边栏读同一个视频失败时写的是同一句(「B站请求失败 HTTP 403」「B站返回的不是 JSON(…)」)。认 B站 限流只有一处:`sites.js` 的 B站 读取都经 `biliJson`,把 HTTP 412、HTTP 429、风控码和「播放器接口回了却没有字幕对象」算成 `THROTTLED`(文字是 `MdgErrors.TEXT.biliThrottled`),视频页、分拣台和侧边栏读同一个视频都经它,所以 B站 限流报成限流,不会变成「没有字幕」,也不再去请求备用接口。收藏夹每次只试一次,限流由页面退避;关注传入自己的队列(间隔、断网重试、风控整队暂停)。写操作 `triageBiliPost` 也有这 30 秒超时,但只发一次、从不自动重试;超时报「不确定 B站 是否已改」,调用方按失败处理,不改计数、不记撤销。风控码统一用 `BILI_RISK_CODES`,WBI 签名用 `sites.js`。 +- 收藏夹视频的分区只有一处取:后台消息 `triage-zones { items: [{ bvid, aid }] }`,一次一个 `resource/infos` 请求,最多 `TriageUi.ZONE_BATCH`(50,超过 B站 回空列表)个,不用登录;后台只回 `{ bvid: tid }`(`tid` 是旧版子分区编号;B站 没返回的视频,如已删除,记 0,不再请求;整批回空时回空),不写存储。页面在 `loadResults` 里启动 `fav-folders.js` 的 `fillZones`:`runs` 的 `"zones"` 任务,每批之间隔 1 秒,限流按 `THROTTLES` 等,换了视图就停、丢掉在途的回答;回答由页面经 `storeEdit` 并进 `KEYS.triageZones`(`triage_zones`),在锁里滤掉本页清理过的视频(`zoneGone`),所以在途的回答不会把清理掉的写回去;失败或回空的那批记进 `zoneFailed`,本页不再重发,刷新页面后再试。不改收藏夹快照。编号到大分区名是 `TriageCore.zoneName` 里的静态表(旧编号已冻结,来自 `app.bilibili.com/x/v2/region/index`),运行时不再请求。页面只在卡片信息行末尾和搜索里用它(`fav-base.js` 的 `zoneOf`),不进筛选。这份数据是 B站 的,缺了再取:不进完整备份,清理视频时一起删掉,`storageUsage` 不单列。 - 页面怎么显示一个错误只写一份:`extension/errors.js`(`MdgErrors`)。后台负责分类(`triage-bg.js` 的 `triageAiHttpError`、HTTP 200 里的错误 `triageAiBodyError`、AI 平台没填的项 `aiProviderProblem`,侧边栏、测试连接和分拣台都用它们;`obsidian.js` 的 `obsidianError`),回 `{ ok: false, error, code }`;页面用 `MdgErrors.action(code)` 决定给「去设置」还是「去登录」(Obsidian 没启用、没配置、Key 不对或地址回 404 是 `OBSIDIAN`,给「去设置」;没打开是 `OBSIDIAN_DOWN`,不给),`MdgErrors.text` / `fromResp` 读出要显示的话(B站 的 62004 换成「视频审核中,过后再试」;没登录 B站 一律是 `TEXT.biliLogin`:后台的 `NOT_LOGGED_IN` 由 `triage-bg.js` 的 `triageNotLoggedIn` 建,关注那边 B站 回 -101 时 `follow-bg.js` 也换成它,所以各阶段都停下,不当成「暂时没查到」),缺 AI 平台地址的访问权限时后台回 code `AI_ACCESS`(分拣台按它停下,不认文字;请求没回应时是 `AI_ACCESS` 还是 `AI_NETWORK` 只在 `triage-bg.js` 的 `aiFetchError` 分),模型输出被截断回 `AI_TRUNCATED`(分拣台按它提示调大输出上限),`grantOrigin` 从「未授权访问 …」读出地址并给出授权按钮的请求,`reloaded` 认出扩展更新后内容脚本失效。视频页的内容脚本没回应也只认一处:`chrome.tabs.sendMessage` 的英文错误(「Receiving end does not exist」「Could not establish connection」、端口先关了、标签页或框架没了)由 `MdgErrors.noReceiver`(只认没有内容脚本,这时注入再问才有用)和 `MdgErrors.pageSilent`(这些错、发送方标了 `chrome: true` 的错、超时都换成 `TEXT.pageSilent`「视频页没有回应,请刷新网页后重试」,带 code `PAGE_SILENT`,`CODES` 里它不给「重试」)认,弹窗、侧边栏和后台都用它;发消息只有一份:`MdgErrors.tabMessage`(Chrome 的错标上 `chrome: true`;传 `inject` 时遇到 `noReceiver` 让后台 `ensure-reader-content` 补注入,再问一次,弹窗和侧边栏时间跳转这样用)。重试几次、隔多久各自留着(弹窗注入后问一次,后台专注模式问 12 次、新开的视频页等内容脚本,侧边栏时间跳转问 12 次)。弹窗状态行红不红只看视频页给的 `statusError`(读视频失败,或读完但字幕、评论有一项失败),不从文字猜。后台注入内容脚本失败(`ensureReaderContentReady`)也是这一句。表里的错误码按名字导出成 `MdgErrors.CODE`(写或比较 `THROTTLED`、`AI_SETUP` 的地方用它,不手打),超时的 HTTP 状态 408 是 `MdgErrors.TIMEOUT_STATUS`。HTML 转义也只有这里一份:`MdgErrors.escapeHtml` 转 `& < > " '` 五个字符,页面、视频页和分拣台(`TriageUi.esc` 就是它)拼 HTML 都用它,`errors.selftest.js` 查别处有没有再写一份;`badges.js` 用 `textContent` 建 DOM,不需要它。Obsidian 的几句话(包括取消时那句)和「扩展刚刚更新,请刷新网页」只写在 `MdgErrors.TEXT` 里,后台也读它。它也是全扩展的文字表:多个页面都显示的句子在 `TEXT`,按钮名和页面名(复制 Markdown、下载 .md、写入 Obsidian、去设置、重试、刷新、摘读记录、B站分拣台、已保留、已取消收藏、新标签名称)在 `LABEL`,html 的静态副本标 `data-text="键"`;规则见 DESIGN.md 术语表一节。内容脚本、后台和每个用到它的页面都在用它的脚本之前加载 `errors.js`(`load-order.selftest.js` 核对),`wording.selftest.js` 查别处有没有再写一份这些话(`TriageUi`、`MdgLimits` 里有名字的句子常量也一样),也查同一句话有没有在两个页面各写一份(现有的在文件里的 `DUPLICATES`,每条写明文件和「待修」或「例外」的理由,新出现的失败)和术语表里「不用」的词(只查能和普通文字分开的那些,见 `TERMS`)。按钮灰掉写原因(`disabled`、tooltip 和 `aria-description`)也只有这里一份:`MdgErrors.setReason`,分拣台(`TriageUi.setReason` 就是它)、弹窗、侧边栏和摘读记录页都用它,`errors.selftest.js` 查别处有没有再写一份。 - 设置的默认值和整理函数只写一份:`extension/settings.js`(`MdgSettings`)。`DEFAULTS`(sync)和 `LOCAL_DEFAULTS`(Obsidian 的 Key)是唯一一份默认值,默认的 AI 提示词也只写在这里;`normalizeSyncSettings` 用它整理全部 sync 设置,后台存和读、设置页从表单取值都经它。get-settings 失败时,设置页、弹窗和视频页报「读取设置失败」(`TEXT.settingsReadFailed`),不拿 `DEFAULTS` 接着做;其余页面这时才拿 `DEFAULTS` 兜底。设置页的开关和输入框以设置键为 id,`options.js` 按 `DEFAULTS` 里的类型(布尔、字符串和数字)成批读写(`SWITCH_KEYS`、`VALUE_KEYS`),列表、Obsidian 地址和 Key 单写。安装和更新不把默认值写进 sync,读的时候补(`getMergedSettings`、`sync.get(DEFAULTS)`),所以改了默认值,没改过这项的人都会拿到新的;以前写进去的不迁移。几个页面都要的整理函数(API Key、下载格式、AI 总结要求、快捷追问和它的 12 条上限、自定属性(属性名的规则 `FIXED_PROPERTY_KEY_RE` 和笔记自己写的名字 `RESERVED_PROPERTY_KEYS`:笔记字段、星标的 `starred`、网页的 `published`,`note.js` 不写、设置页拒收并说「与自带属性重复」)、正文附加段落、专注模式的主题和四个档位:合法值只在 `READER_OPTIONS`,默认值在 `DEFAULTS`,视频页的档位按钮和主题切换都读它)也在这里,字符串一律按 `String(v ?? "")` 处理,数字保留。开关(`DEFAULTS` 里是布尔值的设置)怎么取值也只在这里(`normalizeSwitches`):默认开的只有存的是 `false` 才关,默认关的只有存的是 `true` 才开,导入文件里的 `"true"` 这类非布尔值一律按默认值,后台 `normalizeSyncSettings` 存和读都先过它,笔记(`note.js` 的封面、AI 问答、热门评论、文件名日期)和专注模式的章节开关也经它读,专注模式的初始值读 `DEFAULTS`。设置文件的导出和导入都经后台的 `withoutApiKeys`,去掉 Obsidian 和 AI 平台的 Key(包括平台条目里带的 `apiKey`),Key 只存本机;导入后某个 AI 平台(按 id)或 Obsidian 的地址换了 origin,本机存的那个 Key 也删掉,用户重新填(`dropMovedKeys`,和设置页的 `savedKeyApplies` 同一条规则:Key 跟着它保存时的地址)。数字设置不对时怎么取值(`intIn`:空、不是数字、小于下限用默认值,大于上限用上限,四舍五入)也只在这里,`TriageUi.intIn` 就是它,`normalizeSeenThreshold` 也用它,所以凡是加载 `shared.js` 的地方都先加载 `settings.js`。设置页自己的表单检查留在 `options.js`。`note.js` 也读它,所以凡是加载 `note.js` 的地方都先加载 `settings.js`;加载顺序由 `load-order.selftest.js` 核对;`settings.selftest.js` 查别处有没有再写一份默认提示词或这些函数。 - AI 判断的三类(值得留 / 可清理 / 拿不准)和怎么判只写一份:`extension/tag-core.js` 的 `VERDICTS` 和 `verdictOf`(细看优先于粗看,已失效一律可清理)。分拣台(`fav-*.js`)、B站 页面(`badges.js`)、后台(`triage-bg.js` 认模型的回答)和笔记里的「AI 判断:…」(`note.js`)都读它,所以凡是加载 `note.js` 的地方也先加载 `tag-core.js`,加载顺序由 `load-order.selftest.js` 核对,`tag-core.selftest.js` 查别处有没有再写一份。B站 页面只在收藏夹页知道哪些视频已失效(读这个收藏夹的 `triage_snapshot_`);别的页面不读,所有收藏夹的快照太大。 diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 8b801a8f..ed51fba0 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -35,6 +35,7 @@ | 导出入口 | 导出 | 批量导出(作为入口名时) | | | 导出菜单里的一项 | 摘录或笔记…(导出菜单里那一项,按产出命名) | 批量导出… | | | 收藏夹的判断依据 | 判断标准 | 标准、规则 | | +| 视频在 B站 的大类(舞蹈、生活、知识、时尚…) | 分区 | 子分区、B站标签(指它时) | 2026-11-11 用户定:只用大分区,不用子分区;视频自己的 B站 标签太杂,不用 | | 临时的待看清单 | 待播(临时的待看清单,跨收藏夹,不参与导出;卡片上写「+ 待播」「✓ 待播」) | 播放列表、优先看 | | | 分拣台页面 | B站分拣台 | 收藏夹分拣台 | | | 关注的第二个页签 | 视频投稿(关注的那个页签:只读 B站 的视频投稿,不含图文和转发) | 动态 | | @@ -158,6 +159,7 @@ AI 打标签的建议在应用或放弃前一直存着,刷新或重开分拣 行的样子、间距和窄屏排法见「三、视觉设计 · 分拣台」。 - 收藏夹和关注的第 4 行开头是自己的「全部」,接着「未打标签 / 已打标签」(视频投稿按视频的 UP 算,写「UP 未打标签 / UP 已打标签」),再是标签。收藏夹按卡片上显示的标签算:「所有收藏夹」里只算视频所在收藏夹的标签。2026-11-10 用户定:以前这组在第 3 行。为什么:有没有标签是你自己打的,和标签放一行。 +- 分区(B站 给的大分区:舞蹈、生活、知识、时尚…)只显示在收藏夹卡片的信息行里,能被搜索(搜「舞蹈」筛出舞蹈区的视频),不放进筛选区。2026-11-11 用户定。为什么:分区太粗,又是 UP 主自己选的,拿来筛选意义不大。只用大分区,不用子分区和视频自己的 B站 标签(太杂)。番剧和已失效的视频没有分区。分区由后台按 50 个一批向 B站 补取,每批之间有间隔,遇到限流在进度胶囊里倒计时再试;补取失败只是卡片上没有分区。 - 第 3、4 行用细竖线分组。组内单选(标签片多选,「并且」),再点一次已选中的就取消;组和组之间、两行之间都是「并且」。第 3 行的「全部」只清空第 3 行,第 4 行的「全部」只清空第 4 行(有没有标签和标签片),都不碰左栏。两个「全部」样子一样,所在行什么都没开时是按下的。2026-11-10 用户定。 - 收藏夹第 3 行的「已保留」「待决定」和三个 AI 判断是同一个单选。保留过的视频只算「已保留」,不再算 AI 判断,和卡片一致(保留过的卡片不显示 AI 判断)。「待决定」是还没保留的视频,三个 AI 判断是它的细分,套在它后面的细框里,个数加起来等于它(「全部」和「未分析」里还有 AI 没看过的,所以会少)。「未分析」没有 AI 判断,只有「已保留」「待决定」。2026-11-10 用户定。为什么:人工判断过就不需要 AI 判断;上一版两组能叠加,「已保留 + 可清理」这种组合没有意义。框而不用括号或箭头:箭头在页签行表示 AI 往前走,括号像文字。 - 第 3 行按 AI 判断的批量按钮(「取消收藏(AI:可清理)N 个」这类)只算待决定的视频。 @@ -180,6 +182,7 @@ AI 打标签的建议在应用或放弃前一直存着,刷新或重开分拣 - 收藏夹和关注的 UP 主卡片:点卡片任何地方(包括封面、头像)都是选中或取消这一张;点视频标题在分拣台里播放(收藏夹卡片的标题、UP 主卡片里的投稿标题);关注里点 UP 名(UP 主卡片和「视频投稿」卡片上的)在右侧播放框打开这个 UP 的空间,这时 T 和播放框上方那行都对着这个 UP。空间页在播放框里去掉 B站 顶栏和右侧「个人资料」,缩到播放框的宽度,不横向滚动;空间页里不再贴 MoonDigest 的标记(和视频播放框一样),UP 标签看播放框上方那行和左边的卡片。为什么:清理久不更新的 UP 时要看一眼主页才想得起他是做什么的,不用离开分拣台。Shift 点选中从上一次点的那张到这张的一段;卡片里的按钮、输入框、标签片、「+ 标签」各管各的;拖选文字不选中。封面、标题、UP 名仍是去 B站 的真链接:右键、⌘/Ctrl 点、中键照常在 B站 打开。选中的卡片是蓝框加蓝底,卡片上没有勾选框;正在播放的卡片是粉框。X 键选中当前卡片,「已出分拣范围」里也一样(那里的「清理选中的」「收藏选中的…」「重新关注选中的」都对着选中)。判断在 `TriageUi.cardPickClick` 和 `pickCard`,两种模式共用。 - 「视频投稿」的卡片和待播列表没有多选:点卡片任何地方都在分拣台里播放(「视频投稿」走 `TriageUi.cardPlayClick`),卡片里的按钮、输入框、标签片、UP 主链接各管各的,拖选文字不播放。键盘用标题链接回车播放,卡片本身不另加 Tab 停靠点。 +- 收藏夹卡片的信息行(UP 主 · 时间 · 播放量 …)最后写分区名,不另起一行。2026-11-11 用户定。 #### 筛选和计数 diff --git a/extension/limits.js b/extension/limits.js index a8d089ad..74529c52 100644 --- a/extension/limits.js +++ b/extension/limits.js @@ -65,6 +65,9 @@ triageAgentLog: "triage_agent_log", triageAgentProposals: "triage_agent_proposals", triageBasket: "triage_basket", + // { [bvid]: tid }: B站's old sub-zone number of each listed video (0: B站 gave none), asked by triage-bg.js's + // triage-zones and written by the 分拣台 (fav-folders.js fillZones), which shows its 分区. B站's, read again when missing: not in 完整备份. + triageZones: "triage_zones", // 摘读记录's 星标 and tags: { tags: [{ id, name, color, rule }], items: { [record key]: { star, tags, ref, updatedAt } } }. // The record key is MdgNote.readingKey; ref keeps the title and address, so a record stays after its conversations go. readingMarks: "mdg_reading_marks_v1", diff --git a/extension/limits.selftest.js b/extension/limits.selftest.js index 2bd40785..67c5b471 100644 --- a/extension/limits.selftest.js +++ b/extension/limits.selftest.js @@ -81,7 +81,7 @@ const names = Object.values(L.KEYS).filter((v) => typeof v === "string"); assert.deepStrictEqual([...names].sort(), [ "aiProviderKeys", "aiProviders", "follow_", "mdg_ai_conversations_v1", "mdg_ai_selected_provider", "mdg_obsidian_note_paths_v1", "mdg_player_ai_quick_action_v1", "mdg_reading_ai_history_v1", "mdg_reading_ai_proposal_v1", "mdg_reading_ai_recent_v1", "mdg_reading_ai_settings_v1", "mdg_reading_marks_v1", "mdg_subtitle_cache_", "seen_", "triage_agent_log", "triage_agent_proposals", "triage_analysis_", "triage_basket", "triage_decisions_", "triage_folder_criteria", "triage_folder_list", "triage_included_folders", "triage_kept", "triage_notes", "triage_removed", - "triage_seen_meta", "triage_snapshot_", "triage_tags", "triage_title_", "triage_video_tags" + "triage_seen_meta", "triage_snapshot_", "triage_tags", "triage_title_", "triage_video_tags", "triage_zones" ]); assert.strictEqual(L.KEYS.triageDecisions(7), "triage_decisions_7"); assert.strictEqual(L.KEYS.triageAnalysis("BV1"), "triage_analysis_BV1"); diff --git a/extension/messages.js b/extension/messages.js index 2ad98e17..c063078f 100644 --- a/extension/messages.js +++ b/extension/messages.js @@ -84,6 +84,7 @@ "triage-folder-page": { from: [TRIAGE_BG], to: "triage/fav-page.js", request: ["mediaId", "page"], response: [] }, "triage-folder-ids": { from: ["triage/fav-folders.js", "triage/fav-writes.js"], to: TRIAGE_BG, request: ["mediaId"], response: data }, "triage-fav-where": { from: ["triage/fav-folders.js"], to: TRIAGE_BG, request: ["aid", "mid"], response: data }, + "triage-zones": { from: ["triage/fav-folders.js"], to: TRIAGE_BG, request: ["items"], response: data }, "triage-folder-create": { from: ["triage/fav-writes.js"], to: TRIAGE_BG, request: ["title", "privacy"], response: data }, "triage-unfav": { from: ["triage/fav-writes.js"], to: TRIAGE_BG, request: ["mediaId", "aids"], response: data }, "triage-refav": { from: ["triage/fav-writes.js"], to: TRIAGE_BG, request: ["mediaId", "aid"], response: data }, diff --git a/extension/triage/fav-base.js b/extension/triage/fav-base.js index a5e2ffee..9bc3f75f 100644 --- a/extension/triage/fav-base.js +++ b/extension/triage/fav-base.js @@ -31,6 +31,7 @@ const K = { folderCriteria: KEYS.triageFolderCriteria, // { [mediaId]: 判断标准 } videoTags: KEYS.triageVideoTags, basket: KEYS.triageBasket, + zones: KEYS.triageZones, notes: KEYS.triageNotes, decisions: KEYS.triageDecisions, // 取消收藏 only: it changes one Bilibili folder kept: KEYS.triageKept, // { [bvid]: { action: "keep", at } }: 保留 belongs to the video, so it shows in every folder @@ -122,6 +123,7 @@ const S = { seenCfg: MdgSurfaces.seenConfig(MdgSurfaces.SEEN_DEFAULTS), // 设置页「观看进度 → 封面显示」 seenPct: {}, // bvid → [percent, view_at] from the history, null when it has none noteOpen: new Set(), // empty notes the user opened for editing + zones: {}, // K.zones (bvid → B站's tid); fillZones adds the listed videos' missing ones settings: { // Until triage-settings-get answers: the defaults (TriageUi.NUM_SETTINGS). triageTagLimit: tags per folder, only // creating a new one is refused past it; triageAiNewTagMax: new tags one 批量打 may propose. @@ -177,7 +179,7 @@ const S = { const runs = MdgRuns.create({ redraw: () => renderStatus(), hold: (r) => THROTTLES[r.code] || null, sleep: (ms, ok) => sleepWhile(ms, ok) }); // What runs or waits, in this order on every tab (the first wins), then the finished lines. A run's row is its own; // these are mirrored from elsewhere. -const PILL = ["group", "stage1", "fav", "write", "load", "check", "sync", "ai", "proposal", "aiOther", "agent", "seen", "seenRead"]; +const PILL = ["group", "stage1", "fav", "write", "load", "check", "sync", "ai", "proposal", "aiOther", "agent", "seen", "seenRead", "zones"]; runs.put("load", () => loadAllActivity()); // S.syncing is the view run of the folder being read: one left meanwhile no longer shows it. const ownSync = () => Boolean(S.syncing?.alive); @@ -521,10 +523,13 @@ function verdictOf(it) { return { ...v, failed: it.invalid ? "" : a?.status === "error" ? MdgErrors.text(a) : failedCoarse(it) ? S.coarseFailed.get(it.bvid) || MdgErrors.TEXT.unknown : "" }; } -// Search covers title, uploader, the AI one-liner and points, the note and tag names; every word must match. +// 分区: B站's top-level zone, at the end of the card's meta line and searched; never a filter (the UP picks it, too coarse). +const zoneOf = (it) => TriageCore.zoneName(S.zones[it.bvid]); + +// Search covers title, uploader, 分区, the AI one-liner and points, the note and tag names; every word must match. function searchText(it) { const a = S.analyses[it.bvid]; - return [it.title, it.upper, a?.oneLiner, ...(a?.points || []), S.notes[it.bvid]?.text, ...tagIdsOf(it.bvid).map((id) => tagById(id).name)] + return [it.title, it.upper, zoneOf(it), a?.oneLiner, ...(a?.points || []), S.notes[it.bvid]?.text, ...tagIdsOf(it.bvid).map((id) => tagById(id).name)] .filter(Boolean) .join("\n") .toLowerCase(); diff --git a/extension/triage/fav-export.js b/extension/triage/fav-export.js index 9a4d4133..d2527e65 100644 --- a/extension/triage/fav-export.js +++ b/extension/triage/fav-export.js @@ -220,7 +220,7 @@ async function runWrite(md = false) { } // ---------- data export ---------- -// 待播 stays out: a list to watch from, not something to keep. +// 待播 stays out: a list to watch from, not something to keep. So does 分区 (K.zones): B站's, read again when missing. const { triageSnapshotPrefix: SNAPSHOT, triageTitlePrefix: TITLE, triageAnalysisPrefix: ANALYSIS, triageDecisionsPrefix: DECISIONS } = KEYS; const BACKUP_PREFIXES = [K.kept, K.removed, K.left, K.tags, K.folderCriteria, K.videoTags, K.notes, KEYS.readingMarks, SNAPSHOT, DECISIONS, TITLE, ANALYSIS]; diff --git a/extension/triage/fav-folders.js b/extension/triage/fav-folders.js index 9853bc4d..a8f9e20e 100644 --- a/extension/triage/fav-folders.js +++ b/extension/triage/fav-folders.js @@ -597,6 +597,7 @@ async function cleanRemoved(list) { return lockRecords(async () => { const bvids = list.map((it) => it.bvid); const set = new Set(bvids); + bvids.forEach((b) => (zoneGone.add(b), delete S.zones[b])); const rec = await storeGet(K.removed, {}); for (const b of bvids) { for (const map of [rec, S.notes, S.analyses, S.titleRes]) delete map[b]; @@ -618,6 +619,8 @@ async function cleanRemoved(list) { // 保留 and 待播 are every tab's: each by read-modify-write, only these videos taken out. await patchKept(Object.fromEntries(bvids.map((b) => [b, null]))); await editBasket((b) => b.filter((x) => !set.has(x.bvid))); + // Their 分区 too, so the map holds only videos the 分拣台 still has (B站's, so the dialog does not name it). + await storeEdit(K.zones, {}, untag); S.removedCount = Object.keys(rec).length; setItems(S.items.filter((it) => !set.has(it.bvid))); toast(`已清理 ${list.length} 个视频`); @@ -629,6 +632,7 @@ async function cleanRemoved(list) { async function loadResults(run) { await loadSeen(run); if (!run.alive) return false; + fillZones(run); const missing = S.items.map((it) => it.bvid).filter((b) => !(b in S.titleRes) && !(b in S.analyses)); if (!missing.length) return true; const [t, a] = await Promise.all([ @@ -659,6 +663,55 @@ async function loadResults(run) { return true; } +// 分区 of the listed videos not known yet (no 番剧, nothing invalid or without an aid), UI.ZONE_BATCH a request, as runs' +// "zones": a gap between requests, a B站 limit counts down in the pill, another view opened ends it and drops the answer +// in flight. A failure only leaves those cards without a 分区. Each step takes the ones still missing, so videos listed +// while it runs (所有收藏夹 loading folder by folder) join the same run. +// The page writes what comes back (K.zones), so a video 清理 took out meanwhile (zoneGone) is not written back. +const ZONE_GAP_MS = 1000; +let zoneRun = null; // the view run the "zones" job fills for +// This page's videos whose batch failed or came back empty: not asked again until the page is reloaded. +const zoneFailed = new Set(); +// Videos 清理 took out in this page: an answer in flight for them lands nowhere. +const zoneGone = new Set(); +async function fillZones(run) { + if (zoneRun === run) return; + zoneRun = run; + // One of an earlier view ends once its request in flight lands. + while (runs.busy("zones") && run.alive) await sleepWhile(50, () => true); + if (!run.alive) return; + const asked = new Set(); + let cur = null; // the step in flight: a failure marks its videos + const wanted = () => S.items.filter((it) => it.aid && !it.ogv && !it.invalid && !(it.bvid in S.zones) && !asked.has(it.bvid) && !zoneFailed.has(it.bvid)); + const r = await runs.run("zones", { + gap: ZONE_GAP_MS, + alive: () => run.alive, + // Another tab may have read some meanwhile. + plan: async () => (Object.assign(S.zones, await storeGet(K.zones, {})), run.alive && wanted().length ? {} : null), + line: (d) => `读取分区 ${d}/${asked.size + wanted().length}`, + next() { + const items = wanted().slice(0, UI.ZONE_BATCH); + items.forEach((it) => asked.add(it.bvid)); + return items.length ? { items, n: items.length } : null; + }, + act: (step) => ((cur = step), send({ type: "triage-zones", items: step.items.map(({ bvid, aid }) => ({ bvid, aid })) })), + async done({ items }, x) { + const zones = x.data?.zones || {}; + if (!Object.keys(zones).length) return items.forEach((it) => zoneFailed.add(it.bvid)); + const kept = (z) => Object.fromEntries(Object.entries(z).filter(([b]) => !zoneGone.has(b))); + Object.assign(S.zones, kept(zones)); + // Filtered again under the key's lock: a 清理 may have run while this waited for it. + await storeEdit(K.zones, {}, (stored) => ({ ...stored, ...kept(zones) })); + render(); + } + }); + if (zoneRun === run) zoneRun = null; + if (r.error) { + cur?.items.forEach((it) => zoneFailed.add(it.bvid)); + console.warn("[triage] 分区 not read", r.error); + } +} + // ---------- 所有收藏夹 ---------- // Folders with a cached list (triage_snapshot_*.items) show at once, then each is checked against the folder's video ids // (one light request) and re-fetched if they differ. Folders without a cache are fetched one by one in the background diff --git a/extension/triage/fav-page.js b/extension/triage/fav-page.js index 1f78f9f9..f5b811d0 100644 --- a/extension/triage/fav-page.js +++ b/extension/triage/fav-page.js @@ -41,7 +41,7 @@ async function start() { bindEvents(); // Read up front: sidePanel.open must run inside the click's user gesture, before any await. chrome.tabs.getCurrent().then((tab) => (ownTabId = tab?.id)); - const [{ tags, videoTags, folderCriteria }, kept, basket, notes, settingsResp, aiHistory, aiRecent, sortBy, readAt, aiProposals, agentLog, agentProposals] = await Promise.all([ + const [{ tags, videoTags, folderCriteria }, kept, basket, notes, settingsResp, aiHistory, aiRecent, sortBy, readAt, aiProposals, agentLog, agentProposals, zones] = await Promise.all([ loadTagsAndCriteria(), storeGet(K.kept, {}), storeGet(K.basket, []), @@ -53,9 +53,10 @@ async function start() { storeGet(K.readAt, {}), storeGet(K.aiProposals, {}), storeGet(K.agentLog, []), - storeGet(K.agentProposals, []) + storeGet(K.agentProposals, []), + storeGet(K.zones, {}) ]); - Object.assign(S, { tags, videoTags, folderCriteria, kept, notes, aiHistory, aiRecent, sortBy, readAt, agentProposals }); + Object.assign(S, { tags, videoTags, folderCriteria, kept, notes, aiHistory, aiRecent, sortBy, readAt, agentProposals, zones }); followAgentLog(agentLog, false); S.ai.proposals = aiProposals; pruneAiProposals(); diff --git a/extension/triage/fav-render.js b/extension/triage/fav-render.js index 54cb9f9a..cc162f00 100644 --- a/extension/triage/fav-render.js +++ b/extension/triage/fav-render.js @@ -497,7 +497,7 @@ function cardHtml(it, expanded, mark) { : ``; const askBtn = ``; // The 发布时间 is relative, the date on hover; the rest is text. - const meta = [esc(it.upper), it.pubdate && UI.agoHtml(it.pubdate), Number.isFinite(it.play) && esc(`▶ ${fmtCount(it.play)}`), ["", "粗看", "细看"][v.stage], done && basisText(a), esc(seenText(it)), it.invalid && KIND_NAME.invalid, it.folders?.length && esc(`收藏夹:${folderNames(it)}`)].filter(Boolean); + const meta = [esc(it.upper), it.pubdate && UI.agoHtml(it.pubdate), Number.isFinite(it.play) && esc(`▶ ${fmtCount(it.play)}`), ["", "粗看", "细看"][v.stage], done && basisText(a), esc(seenText(it)), it.invalid && KIND_NAME.invalid, it.folders?.length && esc(`收藏夹:${folderNames(it)}`), esc(zoneOf(it))].filter(Boolean); const left = removed && [originHtml(it), it.removedAt && `${esc(leftText(it))}`].filter(Boolean).join(""); const verdict = decision ? "" : verdictBadge(b, v); diff --git a/extension/triage/shared.js b/extension/triage/shared.js index c695c2b8..403487e8 100644 --- a/extension/triage/shared.js +++ b/extension/triage/shared.js @@ -52,6 +52,8 @@ const videoUrl = (bvid) => `https://www.bilibili.com/video/${bvid}`; // A favorites page: B站 gives at most 40 videos (triage-bg.js reads them; the page counts the pages). const FAV_PAGE_SIZE = 40; + // Videos per triage-zones request: B站's resource/infos answers an empty list past 50. + const ZONE_BATCH = 50; // The view of what left the 分拣 (收藏夹 and 关注 alike). const removedName = MdgErrors.LABEL.removed; // B站's image URLs come as http://, https:// or protocol-relative //: always https (the pages and the worker). @@ -879,5 +881,5 @@ }; } - globalThis.TriageUi = { https, gearBtn, keepScroll, UP_RECENT, upTitles, AI_RECENT, AI_RECENT_CHECK, aiRef, esc, pad, dayText, fmtDate, fmtAgo, agoHtml, setSearchScope, confirmList, pickTitle, fmtDuration, fmtCount, cleanTagName, plainClick, cardPlayClick, cardPickClick, pickCard, img, toCsv, cardTagChip, untagStep, changedFields, syncedText, headMeta, titleHtml, AI_SPARK, byValue, dirWords, sortControl, filterBtn, labeledRow, tagFilterChip, kindTabs, originChip, stateGroup, tagRowHead, aiRecentChip, searchBox, bindSearch, resultCount, rowButtons, menuItem, BACKUP_ITEM, CLEAN_BACKUP, reasonAttrs, setReason, WARN_DOT, selectAllState, selectAllBox, toggleAll, aiTagsLabel, READING_AI_CHARS, setActivity, BILI_STUCK, waitText, BILI_HOLD_MS, WRITE_STOPPING, busyText, syncPill, setSync, bindSync, tagButtons, sideFoot, actBtn, cleanBtn, HIDDEN_ALL, selbar, askConfirm, askTransfer, sideItem, emptyState, refreshEmpty, noMatch, fillSetRows, fillNumHints, numBox, NUM_SETTINGS, TITLES_PER_REQUEST, INTRO_CHARS, DEEP_COMMENTS, autoMaxTokens, numSetting, clampNumber, intIn, clampNumbers, mergeAiBatch, aiChanges, aiProposalOk, previewId, aiTally, nextTagColor, TAG_RULE_MAX, AI_TAGS_PENDING, tagNameError, deleteTagAsk, editedTag, withoutTag, tagUses, undoAsk, tagRowHtml, sp, tagsWord, AI_RECENT_RULES, aiRecentUndo, tagPlusBtn, viewerLine, viewerKeyFrom, headFold, FOLLOW_WRITE_GAP_MS, AI_TAGS_DONE, aiTagsFailed, AI_TAG_GONE, AI_APPLY_FAILED, NEW_TAG_MAX_ZERO, aiApplied, aiHistoryAdd, GROUP_NAME_MAX, groupNameError, UNDO, untagged, searchScope, cardCleanBtn, REFRESHING, stopWords, upWho, withFeedInProgress, UP_GONE_CODES, FOLLOW_SPECIAL, FEED_CACHE_MS, aiRoomHint, scopeLabel, batchesText, videoUrl, FAV_PAGE_SIZE, removedName, transferVerb: TRANSFER_VERB, tagManage: TAG_MANAGE }; + globalThis.TriageUi = { https, gearBtn, keepScroll, UP_RECENT, upTitles, AI_RECENT, AI_RECENT_CHECK, aiRef, esc, pad, dayText, fmtDate, fmtAgo, agoHtml, setSearchScope, confirmList, pickTitle, fmtDuration, fmtCount, cleanTagName, plainClick, cardPlayClick, cardPickClick, pickCard, img, toCsv, cardTagChip, untagStep, changedFields, syncedText, headMeta, titleHtml, AI_SPARK, byValue, dirWords, sortControl, filterBtn, labeledRow, tagFilterChip, kindTabs, originChip, stateGroup, tagRowHead, aiRecentChip, searchBox, bindSearch, resultCount, rowButtons, menuItem, BACKUP_ITEM, CLEAN_BACKUP, reasonAttrs, setReason, WARN_DOT, selectAllState, selectAllBox, toggleAll, aiTagsLabel, READING_AI_CHARS, setActivity, BILI_STUCK, waitText, BILI_HOLD_MS, WRITE_STOPPING, busyText, syncPill, setSync, bindSync, tagButtons, sideFoot, actBtn, cleanBtn, HIDDEN_ALL, selbar, askConfirm, askTransfer, sideItem, emptyState, refreshEmpty, noMatch, fillSetRows, fillNumHints, numBox, NUM_SETTINGS, TITLES_PER_REQUEST, INTRO_CHARS, DEEP_COMMENTS, autoMaxTokens, numSetting, clampNumber, intIn, clampNumbers, mergeAiBatch, aiChanges, aiProposalOk, previewId, aiTally, nextTagColor, TAG_RULE_MAX, AI_TAGS_PENDING, tagNameError, deleteTagAsk, editedTag, withoutTag, tagUses, undoAsk, tagRowHtml, sp, tagsWord, AI_RECENT_RULES, aiRecentUndo, tagPlusBtn, viewerLine, viewerKeyFrom, headFold, FOLLOW_WRITE_GAP_MS, AI_TAGS_DONE, aiTagsFailed, AI_TAG_GONE, AI_APPLY_FAILED, NEW_TAG_MAX_ZERO, aiApplied, aiHistoryAdd, GROUP_NAME_MAX, groupNameError, UNDO, untagged, searchScope, cardCleanBtn, REFRESHING, stopWords, upWho, withFeedInProgress, UP_GONE_CODES, FOLLOW_SPECIAL, FEED_CACHE_MS, aiRoomHint, scopeLabel, batchesText, videoUrl, FAV_PAGE_SIZE, ZONE_BATCH, removedName, transferVerb: TRANSFER_VERB, tagManage: TAG_MANAGE }; })(); diff --git a/extension/triage/triage-bg.js b/extension/triage/triage-bg.js index 9f02da7f..9a1625bd 100644 --- a/extension/triage/triage-bg.js +++ b/extension/triage/triage-bg.js @@ -1073,6 +1073,18 @@ const TRIAGE_HANDLERS = { return { mid, folders: (data?.list || []).filter((f) => f.fav_state === 1).map((f) => ({ id: String(f.id), title: f.title })) }; }, + // The 分区 of up to TriageUi.ZONE_BATCH videos ([{ bvid, aid }], no 番剧) in one request, which needs no login. Each + // one's tid is returned (the page stores it, fav-folders.js fillZones); one B站 leaves out (deleted) gets 0, so it is + // not asked again. An empty answer gives none: it may be B站 refusing the batch, not 50 deleted videos. + "triage-zones": async ({ items }) => { + const list = (Array.isArray(items) ? items : []).filter((it) => triageNumId(it?.aid) && typeof it.bvid === "string").slice(0, TriageUi.ZONE_BATCH); + if (!list.length) throw triageError("缺少 items"); + const data = await triageBiliGet(`${TRIAGE_BILI_API}/medialist/gateway/base/resource/infos?resources=${list.map((it) => `${it.aid}:2`).join(",")}`); + if (!Array.isArray(data) || !data.length) return { zones: {} }; + const tids = new Map(data.map((m) => [String(m.id), Number(m.tid) || 0])); + return { zones: Object.fromEntries(list.map((it) => [it.bvid, tids.get(String(it.aid)) || 0])) }; + }, + "triage-analysis-get": async ({ bvids }) => { const list = Array.isArray(bvids) ? bvids : []; const stored = await chrome.storage.local.get(list.map(MdgLimits.KEYS.triageAnalysis)); diff --git a/extension/triage/triage-bg.selftest.js b/extension/triage/triage-bg.selftest.js index 4e350245..74bd1c8d 100644 --- a/extension/triage/triage-bg.selftest.js +++ b/extension/triage/triage-bg.selftest.js @@ -510,10 +510,27 @@ assert.throws(() => t.triageParseCommand('{"new_tags":[', cmdItems, cmdTags, {}) assert.deepStrictEqual(plain((await ids({ mediaId: 1 })).bvids), ["BV1", "397236:24"]); t.chrome = {}; assert.deepStrictEqual(plain((await ids({ mediaId: 1 })).bvids), ["BV1", "397236:24"]); - t.chrome = realChrome; } assert.deepStrictEqual(["BV1GJ411x7h7", "BV17x411w7KC", "x"].map(vm.runInContext("triageBvToAv", ctx)), ["80433022", "170001", ""]); + // 分区: one resource/infos request of at most 50 videos; each tid returned, 0 for one B站 left out; an + // empty answer gives none; risk control is THROTTLED for the page to wait. + { + const zonesOf = vm.runInContext("TRIAGE_HANDLERS", ctx)["triage-zones"]; + const urls = []; + t.fetch = async (url) => (urls.push(url), jsonRes({ code: 0, data: [{ id: 1, tid: 129 }, { id: 3, tid: 21 }] })()); + const asked = Array.from({ length: 60 }, (_, i) => ({ bvid: `BV${i + 1}`, aid: i + 1 })); + const got = await zonesOf({ items: asked }); + const res = new URL(urls[0]).searchParams.get("resources").split(","); + assert.deepStrictEqual([new URL(urls[0]).pathname, res.length, res[0]], ["/medialist/gateway/base/resource/infos", 50, "1:2"], "50 at most"); + assert.deepStrictEqual([got.zones.BV1, got.zones.BV2, got.zones.BV3, Object.keys(got.zones).length], [129, 0, 21, 50]); + t.fetch = jsonRes({ code: 0, data: [] }); + assert.deepStrictEqual(plain((await zonesOf({ items: asked.slice(0, 2) })).zones), {}, "an empty answer marks nothing"); + t.fetch = async () => ({ ok: false, status: 412, json: async () => ({}) }); + await assert.rejects(zonesOf({ items: asked.slice(0, 2) }), (e) => e.code === "THROTTLED"); + await assert.rejects(zonesOf({ items: [{ bvid: "BVx", aid: "1:42" }] }), /缺少 items/); + } + // Analysis reads B站 through sites.js and shares the video page's subtitle cache. const store = {}; t.chrome = { @@ -1120,7 +1137,6 @@ assert.throws(() => t.triageParseCommand('{"new_tags":[', cmdItems, cmdTags, {}) assert.deepStrictEqual(plain(await askB({ type: "y-ok" })), { ok: true, x: 1 }); assert.strictEqual(await askB({ type: "x-ok" }), "unanswered"); assert.strictEqual(await askB({ type: "constructor" }), "unanswered"); - t.chrome = realChrome; } console.log("triage-bg selftest: all passed"); diff --git a/extension/triage/triage-core.js b/extension/triage/triage-core.js index 104760c3..8b2d329c 100644 --- a/extension/triage/triage-core.js +++ b/extension/triage/triage-core.js @@ -155,5 +155,35 @@ return `[[${target}|${oneLine(String(title ?? "").replace(/[|[\]]/g, " ")) || target}]]`; } - globalThis.TriageCore = { refavorited, mergeFolderItems, updateRemoved, moveToRemoved, inferFrom, forgetLeft, noteLeft, keepInvalidInfo, idsChanged, mergeHead, refreshElsewhere, unfavOnly, mdLinkText, oneLine, mdLink, wikiLink }; + // 分区 (pure): B站's top-level zone of an old sub-zone number (tid, from resource/infos; triage-bg.js triage-zones), "" + // for none or one not here. The old numbers are frozen, so the table is static: from app.bilibili.com/x/v2/region/index. + const ZONE_TIDS = { + 动画: [1, 24, 25, 27, 47, 86, 210, 253, 257], + 音乐: [3, 28, 29, 30, 31, 59, 130, 193, 243, 244, 265, 266, 267], + 游戏: [4, 17, 19, 65, 121, 136, 171, 172, 173], + 娱乐: [5, 71, 137, 241, 242, 262, 263, 264], + 电视剧: [11, 185, 187], + 番剧: [13, 32, 33, 51, 152], + 舞蹈: [20, 129, 154, 156, 198, 199, 200, 255], + 生活: [21, 138, 160, 161, 162, 239, 250, 251, 254], + 鬼畜: [22, 26, 119, 126, 127, 216], + 电影: [23, 83, 145, 146, 147], + 知识: [36, 122, 124, 201, 207, 208, 209, 228, 229], + 纪录片: [37, 177, 178, 179, 180], + 动物圈: [75, 217, 218, 219, 220, 221, 222], + 美食: [76, 211, 212, 213, 214, 215], + 剧情: [85], + 科技: [95, 188, 230, 231, 232, 233], + 国创: [153, 167, 169, 170, 195], + 时尚: [155, 157, 158, 159, 252], + 运动: [164, 234, 235, 236, 237, 238, 249], + 国创相关: [168], + 汽车: [176, 223, 227, 240, 245, 246, 247, 248, 258], + 影视: [181, 182, 183, 184, 256, 259, 260, 261], + 资讯: [202, 203, 204, 205, 206] + }; + const ZONES = new Map(Object.entries(ZONE_TIDS).flatMap(([name, tids]) => tids.map((tid) => [tid, name]))); + const zoneName = (tid) => ZONES.get(Number(tid)) || ""; + + globalThis.TriageCore = { refavorited, mergeFolderItems, updateRemoved, moveToRemoved, inferFrom, forgetLeft, noteLeft, keepInvalidInfo, idsChanged, mergeHead, refreshElsewhere, unfavOnly, mdLinkText, oneLine, mdLink, wikiLink, zoneName }; })(); diff --git a/extension/triage/triage.selftest.js b/extension/triage/triage.selftest.js index 334628db..0ffcedd3 100644 --- a/extension/triage/triage.selftest.js +++ b/extension/triage/triage.selftest.js @@ -108,6 +108,9 @@ for (const [name, key, empty] of [["tags", t.K.tags, []], ["videoTags", t.K.vide Object.defineProperty(t.S, name, { get: () => store[key] ?? empty, set: (v) => (store[key] = v), enumerable: true }); } Object.assign(t, { render() {}, setFocus() {}, toast: (m) => toasts.push(m), askConfirm: async () => true }); +// Every video's 分区 counts as known, so loadResults starts no 分区 fill (runs' "zones") outside the tests of it. +const ALL_ZONES_KNOWN = new Proxy({}, { has: () => true }); +t.S.zones = ALL_ZONES_KNOWN; // The picker as 收藏夹 opens it: po = the last open's options; pickWith ticks (fn gets the Map of Sets) and closes. let po = null; @@ -4876,6 +4879,95 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = delete store[t.K.agentLog]; delete handlers["agent-undo"]; } + + // 分区: B站's top-level zone of the old sub-zone number, searched and at the end of the card's meta line; not in row 4. + { + assert.deepStrictEqual([129, "21", 0, 9999, undefined].map(t.zoneName), ["舞蹈", "生活", "", "", ""]); + openFake("Z", [1, 2, 3, 4].map(item)); + Object.assign(t.S, { titleRes: {}, analyses: {}, tab: "read", classFilter: { none: "all", coarse: "all", fine: "all", read: "all" }, query: "", tagState: "" }); + t.S.zones = { BV1: 20, BV2: 129, BV3: 21 }; + t.S.tagFilter.clear(); + t.S.query = "舞蹈"; + assert.deepStrictEqual(plain(t.visibleItems().map((it) => it.bvid)), ["BV1", "BV2"], "search finds the 分区"); + t.S.query = ""; + assert.ok(t.cardHtml(t.S.itemMap.get("BV1"), false, "").includes(" · 舞蹈"), "the meta line ends with it"); + assert.ok(!t.cardHtml(t.S.itemMap.get("BV4"), false, "").includes("舞蹈")); + t.S.zones = ALL_ZONES_KNOWN; + } + + // The fill: listed videos without a 分区 (no 番剧, invalid or aid-less one), 50 a request with a gap, each answer + // merged; a B站 limit waits and sends the same batch again; a failure only leaves them without. + { + const list = Array.from({ length: 60 }, (_, i) => item(i + 1)); + Object.assign(list[0], { ogv: true }); + Object.assign(list[1], { invalid: true }); + delete list[2].aid; + openFake("Z", list); + t.S.zones = { BV4: 20 }; + const calls = []; + let limited = true; + handlers["triage-zones"] = (m) => { + calls.push(m.items.map((x) => x.bvid)); + if (calls.length === 1 && limited) return { ok: false, code: "THROTTLED", error: "412" }; + return { ok: true, data: { zones: Object.fromEntries(m.items.map((x) => [x.bvid, 21])) } }; + }; + await t.fillZones(t.S.view.current); + assert.deepStrictEqual(calls.map((c) => c.length), [50, 50, 6], "limited, the same 50 again; then the rest"); + assert.ok(!calls.flat().some((b) => ["BV1", "BV2", "BV3", "BV4"].includes(b)), "番剧, invalid, no aid and known ones are not asked"); + assert.deepStrictEqual(plain(sent.filter((m) => m.type === "triage-zones")[0].items[0]), { bvid: "BV5", aid: 1005 }); + assert.strictEqual(Object.keys(t.S.zones).length, 57); + assert.strictEqual(Object.keys(store[t.K.zones]).length, 56, "the page stores what came back"); + // A failure: nothing lands, the list stays as it is, and the failed batch is not sent again in this page (所有收藏夹 + // calls loadResults per folder); the rest still are. + openFake("F", Array.from({ length: 60 }, (_, i) => item(201 + i))); + delete store[t.K.zones]; + t.S.zones = {}; + calls.length = 0; + handlers["triage-zones"] = (m) => (calls.push(m.items.map((x) => x.bvid)), { ok: false, error: "B站请求失败 HTTP 500" }); + await t.fillZones(t.S.view.current); + assert.deepStrictEqual([calls.length, Object.keys(t.S.zones).length, t.S.items.length], [1, 0, 60], "one failure ends the fill"); + await t.fillZones(t.S.view.current); + await t.fillZones(t.S.view.current); + assert.deepStrictEqual(calls.map((c) => c.length), [50, 10], "a failed batch is not sent again"); + // An empty answer (B站 refusing the batch) likewise. + openFake("E", [item(301), item(302)]); + calls.length = 0; + handlers["triage-zones"] = (m) => (calls.push(m.items), { ok: true, data: { zones: {} } }); + await t.fillZones(t.S.view.current); + await t.fillZones(t.S.view.current); + assert.deepStrictEqual([calls.length, "BV301" in t.S.zones], [1, false], "an empty answer is not asked again"); + // L4: an answer that comes back after another view opened lands nowhere, and nothing more is sent. + openFake("L", [item(401), item(402)]); + let answer; + calls.length = 0; + handlers["triage-zones"] = (m) => (calls.push(m.items), new Promise((r) => (answer = () => r({ ok: true, data: { zones: { BV401: 20 } } })))); + const fill = t.fillZones(t.S.view.current); + await until(() => answer); + openFake("Y", []); + answer(); + await fill; + assert.deepStrictEqual([calls.length, t.S.zones.BV401, store[t.K.zones]?.BV401], [1, undefined, undefined], "the old view's answer is dropped"); + // 清理 while a batch is in flight: its answer, arriving after, does not write the cleaned video back. + openFake("removed", [{ ...item(501), inFolder: null }, { ...item(502), inFolder: null }]); + answer = null; + handlers["triage-zones"] = () => new Promise((r) => (answer = () => r({ ok: true, data: { zones: { BV501: 20, BV502: 21 } } }))); + const late = t.fillZones(t.S.view.current); + await until(() => answer); + await t.cleanRemoved([t.S.items[0]]); + answer(); + await late; + assert.deepStrictEqual([store[t.K.zones]?.BV501, t.S.zones.BV501, store[t.K.zones]?.BV502], [undefined, undefined, 21], "the cleaned one stays out"); + delete handlers["triage-zones"]; + t.S.zones = ALL_ZONES_KNOWN; + } + // 清理 deletes its videos' 分区 and no other's. + { + store[t.K.zones] = { BV601: 20, BV602: 21 }; + openFake("removed", [{ ...item(601), inFolder: null }]); + await t.cleanRemoved([t.S.items[0]]); + assert.deepStrictEqual(plain(store[t.K.zones]), { BV602: 21 }); + delete store[t.K.zones]; + } console.log("triage selftest: all passed"); })().catch((e) => { console.error(e);