diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5c1858e8..e4589c94 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -30,7 +30,7 @@ B站 页面上的颜色是 `tokens.css` 的第三份拷贝:`content.css` 里 - 后台的 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` 查别处有没有再写一份默认提示词或这些函数。 +- 设置的默认值和整理函数只写一份:`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)`),所以改了默认值,没改过这项的人都会拿到新的;以前写进去的不迁移。例外是 AI 提示词(视频和网页的系统提示词、AI 总结要求):设置页保存时会把没改过的也写进去,所以存着的值和某个旧版默认值一字不差时,读出来就是现在的默认值(`OLD_DEFAULTS`、`currentDefault`);改默认提示词时把被换掉的那份加进 `OLD_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_`);别的页面不读,所有收藏夹的快照太大。 - 写入 Obsidian 只有一个写入者:后台的 `extension/obsidian.js`(`background.js` 在 `triage-bg.js` 之前 `importScripts` 它)。读写库(Local REST API)、错误分类(`obsidianError`)、把封面存进库、记住每个视频写到哪篇笔记都在这里,页面不碰库。视频页、侧边栏、摘读记录页和「逐个视频笔记」都发同一条消息 `write-video-note { ref, mode?, conversation?, page? }`,同名函数 `writeVideoNote` 走一条流程:设置(没开、没配置、Key 不对是 `OBSIDIAN`,没打开是 `OBSIDIAN_DOWN`)→ 路径(先用记住的路径,没有再用 `MdgNote.videoNotePath`。记住的路径按 `MdgSites.buildContextKey` 存,键里带 cid,也记下分 P 和存进库的封面(`cover { url, link }`);「逐个视频笔记」只知道 BV,按 BV 和分 P 找到 P1 的那篇(`recordedNoteKey`,只按 BV 前缀会找成同一个视频 P2 的笔记;只有视频键按前缀找,到 `|` 为止,网页键 `page:…/post` 不会找到 `page:…/post-2`),所以不勾「覆盖已有笔记」时只更新 AI 问答、不再读 B站,结果里也不带字幕提示;记分 P 之前写的那篇读一次 B站 后补上。AI 笔记用 `MdgNote.conversationNotePath`)→ 正文(有 `page` 用 `page.markdown`,结果开头的字幕提示用视频页给的 `page.warning`,和后台生成时同一句:`MdgNote.noteSubtitleWarning`;没有就用 `triageBuildNote` 生成 B站 P1 笔记;YouTube、分 P 没带 `page` 时先找开着的视频页(见本条末尾),都没有时记住的笔记还在就照样问,回复带 `fullReason`,确认框把「整篇覆盖」灰掉、原因写「整篇覆盖要先打开视频页」,只能「只更新 AI 问答」;没有记住的笔记时先回 `askPage`;选了「只写 AI 笔记」或已失效、没有记住笔记的视频时用户自己写的对话,写成单独的 AI 笔记;已失效、有记住的笔记时和没开页面一样带 `fullReason`,`full` 报「视频已失效,整篇覆盖无法重建」、什么都不写;关了「附带 AI 问答」时不写单独的 AI 笔记,`askPage` 带 `own: false` 不给这个选项)→ 读一次库 → 回 `ask { path, hasAiSection, aiOff? }`(`aiOff`:关了「附带 AI 问答」,确认框照实说,不说没有 AI 问答)、`ok { path, kind, text }` 或 `failed { error, code }`。`mode` 不给时笔记已存在就回 `ask`;`full` 整篇写(AI 问答段换成这次的对话、封面存进库、一次 PUT、记住路径);`ai` 只换 AI 问答段,笔记不存在时整篇写。结果那句话(字幕提示 + 已写入 Obsidian / 已更新 AI 问答 + 路径)只在这里拼。取消靠结构:页面收到 `ask`、用户取消,就不发第二条,什么都不写。「逐个视频笔记」不带对话,按「覆盖已有笔记」发 `full` 或 `ai`,所以从不问。侧边栏每次回答完发 `sync-video-note-ai`,只更新记住路径的那篇,从不新建;回来的「已更新 AI 问答:路径」和笔记已不在时的「Obsidian 里的视频笔记已不存在,未写入」也在这里拼(笔记 404 时先探测一次库根,地址对才算笔记不在、忘掉记住的路径,地址错报地址错),侧边栏照显示 `text`。`obsidian-sync.selftest.js` 用假的 Local REST API 测这条流程,并查页面里没有别的库请求、`resolveFolderTemplate(` 只在 `note.js`、`obsidian.js` 和 `triage-export` 里。开跑前的检查(`triageObsidianCheck`)和设置页的「测试连接」共用一个探测 `probeObsidian`,回答不是带服务名(`service`)的 JSON 的地址不算 Local REST API;「一篇摘录」和索引(`triage-export`)直接写。它们都用 `obsidian.js` 的读写和错误分类,所以 404 在哪条路上都是 `OBSIDIAN`。网页和后台生成不了笔记的视频(YouTube、B站 分 P)的 `write-video-note` 没带 `page` 时(摘读记录、侧边栏里页面不在当前标签页),`writeVideoNote` 先用 `background.js` 的 `tabNote` 在开着的标签页里找:网页比网址(不算 #),经 `popupPageNote` 读正文;视频比站点、视频 ID 和第几 P,稍后再看页按 oid 对 cid(`MdgSites.sameContext`,侧边栏和摘读记录的「继续问」也用它),向那个标签页的主框架发 `popup-refresh`,回来的 `markdown`、`cover`、`noteWarning` 当 `page`,`noteRef`(内容脚本的 `noteRef()`,视频页自己写时也用它)换掉传来的 `ref`,所以路径和记住路径的键(带 cid)都和在视频页上写的一样。找不到又没有记过的笔记,回 `{ askPage: true, url, video? }`(视频的 `url` 是 `MdgSites.videoUrl`),调用方(经 `MdgNoteActions`)用 `MdgOverwriteDialog.choosePage(url, video)` 问过后带 `pageChoice` 再发:`open` 由后台开一个不抢焦点的标签页读完再写(视频页先开空白页,静音和载入在同一次 `tabs.update` 里,不会先出声),新开的视频页内容脚本还没就绪时重试,读不到笔记就报原因;这个新开的标签页读完就关(成功失败都关,`finally`),本来开着的不关;`own` 照旧写【AI笔记】。复制 Markdown 和下载 .md(侧边栏、摘读记录页)发 `export-note { ref, conversation, page?, pageChoice? }`,`obsidian.js` 的 `exportNote` 和 `writeVideoNote` 共用 `resolveNote`(先读标签页、选对话、选目标笔记、要不要 `askPage` 都在这里),只是不写库:回 `{ filename, markdown }`,和 `full` 写进去的一样(封面不存进库,留远程地址);Obsidian 没开或连不上也能导出,读不到记住的笔记就当没有。记住的笔记在、又没有页面时,回库里那篇按新导出的规则改:AI 问答段按「附带 AI 问答」换上这次的或去掉(`withAi`,写入也用它),封面链接按记住的 `cover` 换回原地址(`unlinkCover`)。`pageChoice: "own"` 在关掉「附带 AI 问答」时也收(摘读记录页打包时每条都带它,回 `noPage: true` 的是没打开页面的【AI笔记】)。确认框 `MdgOverwriteDialog.choosePage(url, video, own, verb)` 的 `verb` 是「写」「复制」「下载」。复制在对话框和开标签页之后才写剪贴板:扩展页面里 `navigator.clipboard.writeText` 只要页面有焦点就能写,不要求刚点过(无头 Chrome 里用没有用户激活的合成点击,过了对话框和后台开标签页照样写进剪贴板)。 - 处处都用的纯函数和常量在 `extension/common.js`(`MdgCommon`):本地日期(`localDate`,导出文件名、笔记的文件名和 `created`、分拣台的 `fmtDate`、侧边栏的对话时间都用它,`withTime` 加时分)、文件名清理(`fileName`:Obsidian 不认的字符换成 `_`、空白合成一个空格、去掉开头的点(Obsidian 不收点开头的文件,文件夹模板的每一层也去,`resolveFolderTemplate`)、最多 120 字(按字符数,不会切开 emoji),笔记、弹窗下载、分拣台导出都用它)、时分秒解析(`parseTimestamp`,不合法回 -1,调用方决定怎么算)、等一会儿(`sleep`,弹窗、侧边栏、后台和 `MdgSites.waitTabLoaded` 用;`content.js` 的 `sleep` 留着,测试要替换它)、计数文字解析(`parseCount`,YouTube 和 B站动态的「1.2K」「12.5万」「1,234」)、扩展页面路径(`PAGES`,给 `chrome.runtime.getURL`;html 里的 `` 写相对自己的路径)、YouTube 字幕语言(`SUBTITLE_LANGS`,设置页的下拉项由它生成)、请求专注模式的网址参数(`READER_PARAM`)、侧边栏聊天端口名(`CHAT_PORT`)。只放不依赖别的文件、没有状态和副作用、至少两个文件用的东西:要读站点表的(视频上下文转网址 `MdgSites.videoUrl`)留在 `sites.js`,只有一个文件用的留在那个文件。它是每个运行环境第一个加载的文件:每个页面的第一个 `