Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ MoonDigest 的隐私政策。适用于 Chrome 应用商店版和 GitHub 发布

- 设置、分拣结果、备注和对话都存在浏览器本地。API Key 也存在本地,不会出现在任何导出文件里。
- 插件只连这些地方:B站和 YouTube、你配置的 AI 平台、你本机的 Obsidian。允许读取网页以后,侧边栏开着时会在本机读它跟随的每个网页的正文;只有你提问或点 AI 总结时,正文才发给你选的 AI 平台。没有作者的服务器,没有统计。
- B站 和 YouTube 视频页上的网页脚本(包括你用的 AI agent)可以向插件要这个视频的信息、字幕、章节和热门评论,这些都是网站本来就给这个页面的数据。打开「允许 MCP 连接」后,插件还会连本机 `127.0.0.1:47823` 上的 MCP 服务(`mcp/`),只接受打开 B站 和 YouTube 视频页的请求。见 README 的「给 AI agent 用」。
- B站 和 YouTube 视频页上的网页脚本(包括你用的 AI agent)可以向插件要这个视频的信息、字幕、章节和热门评论,这些都是网站本来就给这个页面的数据。打开「允许 MCP 连接」后,插件还会连本机 `127.0.0.1:47823` 上的 MCP 服务(`mcp/`),只接受打开 B站 和 YouTube 视频页、在这两个网站搜索视频的请求。再打开它下面的「允许 AI 工具读我的收藏、关注和历史」后,还接受读你 B站 的收藏夹和里面的视频、稍后再看、关注和分组、观看历史的请求,连同分拣台的标签、备注和 AI 判断交给本机的 MCP 服务,再由它交给你用的 AI 工具。只读,每次返回一页,不改 B站 和插件里的任何东西,不含 API Key 和 AI 对话。见 README 的「给 AI agent 用」。
- 分拣台只读取你勾选的收藏夹的内容。为了列出可勾选和可移动到的收藏夹,会读收藏夹的名称和数量。
- 分拣台的「关注」读你的关注列表、关注分组、每个人的粉丝数,以及关注的人新发的视频。只在你打开「关注」或点刷新时读。
- **观看进度**不是「关」时,会读你自己的 B站历史记录:只在你打开分拣台或 B站页面时读,最多 10 分钟一次,只读上次之后新增的部分。每个视频只在本地存两个数:看到百分之多少、最后观看时间,留 1 年、最多 5000 个(勾选收藏夹里的视频不删)。这些数据不发往任何地方。选「关」就不读。
Expand Down
24 changes: 20 additions & 4 deletions docs/AGENT.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# MoonDigest agent interface v1

How AI agents read a video through MoonDigest: page messages for agents that drive the browser (the skill, `skills/moondigest/SKILL.md`), and a local MCP server (`mcp/`) for any MCP client. Read-only: no keys, notes, conversations or 分拣 results.
How AI agents read a video through MoonDigest: page messages for agents that drive the browser (the skill, `skills/moondigest/SKILL.md`), and a local MCP server (`mcp/`) for any MCP client, which can also search for videos (§2 `search`). The page messages cannot search. Read-only. Never API keys or AI conversations. The user's own Bilibili favorites, follows and watch history, with their 分拣 tags, notes and AI verdicts, only through the MCP server and only while the user allows it (§2, `favorites` / `follows` / `history`).

Supported pages: Bilibili video pages (`www.bilibili.com/video/…`, `/bangumi/play/…`, `/list/…`, `/festival/…`) and YouTube watch pages (`www.youtube.com/watch?v=…`).

Expand Down Expand Up @@ -64,19 +64,35 @@ Server → extension: `{ id, method, params }`. Extension → server: `{ id, ok:
|---|---|---|
| `get` | `{ url }` | Same `data` as `moondigest-get`, for the video at `url` |
| `frame` | `{ url, at, maxWidth? }` | Same `data` as `moondigest-frame` |
| `search` | `{ site, query, page? }` | A video search on `site` (`"bilibili"` \| `"youtube"`), `page` default 1; see below |
| `favorites` | `{ folderId?, page?, query? }` | The user's Bilibili favorites, see below |
| `follows` | `{ page?, query? }` | The UP 主 the user follows, see below |
| `history` | `{ cursor? }` | The user's Bilibili watch history, see below |

Both find a tab already showing that video (same site and video id; Bilibili `/video/` also the part `p`, default 1; `/list/` and `/festival/` match on the `bvid` query, else the path; `/bangumi/` on its ep/ss id). A discarded tab (no content script until shown) is never picked. Else they load `url` in the extension's own tab: at most one, opened muted with `active: false` on first need and navigated to the next video afterwards, so calls never pile up tabs. Calls that need it run one at a time, and a call still queued when the server has given up (55 s after it arrived) is dropped with `TIMEOUT` instead of loading anything. A tab that no call has answered in yet counts as the requested video only while it is still loading. The extension waits up to 50 s for that tab's content script to answer (a missing reply or one without `ok` means not loaded yet), then fails with `TIMEOUT`, before the server's own 60 s. The extension rejects any `url` that is not a supported page with code `BAD_URL`. Then they run the same handler the page messages run, in that tab's content script (`chrome.tabs.sendMessage`), so both paths return identical results. A `frame` that comes back `TAB_HIDDEN` (Chrome loads no media in a tab never shown) is asked again with that tab in front, and the tab that was in front of its window is brought back afterwards, unless the user switched tabs meanwhile; `get` never switches tabs. Timeout: 60 s per call, code `TIMEOUT`.
`get` and `frame` find a tab already showing that video (same site and video id; Bilibili `/video/` also the part `p`, default 1; `/list/` and `/festival/` match on the `bvid` query, else the path; `/bangumi/` on its ep/ss id). A discarded tab (no content script until shown) is never picked. Else they load `url` in the extension's own tab: at most one, opened muted with `active: false` on first need and navigated to the next video afterwards, so calls never pile up tabs. Calls that need it run one at a time, and a call still queued when the server has given up (55 s after it arrived) is dropped with `TIMEOUT` instead of loading anything. A tab that no call has answered in yet counts as the requested video only while it is still loading. The extension waits up to 50 s for that tab's content script to answer (a missing reply or one without `ok` means not loaded yet), then fails with `TIMEOUT`, before the server's own 60 s. The extension rejects any `url` that is not a supported page with code `BAD_URL`. Then they run the same handler the page messages run, in that tab's content script (`chrome.tabs.sendMessage`), so both paths return identical results. A `frame` that comes back `TAB_HIDDEN` (Chrome loads no media in a tab never shown) is asked again with that tab in front, and the tab that was in front of its window is brought back afterwards, unless the user switched tabs meanwhile; `get` never switches tabs. Timeout: 60 s per call, code `TIMEOUT`.

`search` opens no tab: the service worker fetches with the user's cookies. Bilibili: the WBI-signed `api.bilibili.com/x/web-interface/wbi/search/type?search_type=video` through the same channel as 分拣台 (`triageBiliGet`). YouTube: the `www.youtube.com/results?search_query=…` page, read from its `ytInitialData`; only the result list's own videos, no shorts, ads, channels, playlists or shelves. YouTube has page 1 only (about 20 videos); a `page` above 1 is an error. Searches run one at a time, at least 2 s apart; one still queued at the call's deadline is dropped with `TIMEOUT`. HTTP 412, B站's risk-control codes and YouTube's 429 are `THROTTLED`. No results is `items: []`, not an error.

`data`: `{ site, query, page, items }`, each item `{ site, videoId, url, title, author, duration, views, uploadDate, description }`: `url` is `https://www.bilibili.com/video/BV…` or `https://www.youtube.com/watch?v=…` (ready for `get`); `title`, `author`, `description` plain text (B站's `<em class="keyword">` highlights and HTML entities removed), `description` at most 200 characters; `duration` seconds, `0` when unknown (a live stream); `views` a number or `null`; `uploadDate` B站 `YYYY-MM-DD` (local day), YouTube as the page writes it ("2 years ago", in the browser's language), `""` when missing.

`favorites`, `follows` and `history` (`extension/agent-mydata.js`) read the user's own Bilibili account in the service worker, no tab. Off by default: while 设置 → 高级 → 允许 AI 工具读我的收藏、关注和历史 (`mcpPersonalData`, under 允许 MCP 连接 and greyed out while that is off) is off they fail with `PERSONAL_DATA_OFF`. They only GET Bilibili's read endpoints (the 分拣台's channel, `triageBiliGet`) and write nothing, to Bilibili or to storage. They run one at a time, a second apart, and return one page per call. Bilibili is asked for one page, except the folder list (up to 20 pages of folders) and 稍后再看 (all of it in one request); a ticked folder's local copy is read whole, then paged.

- `favorites` without `folderId`: `{ folders: [{ id, title, count }] }`, 稍后再看 first with id `"toview"`, then the user's own folders (Bilibili's folder list, read live). With `folderId`: `{ folderId, source, total, page, hasMore, items }`, 40 a page. A folder ticked in the 分拣台 is read from its local copy (`source: "local"`, as of the 分拣台's last read, no Bilibili request); `toview` is read whole from Bilibili in one request; any other folder is one Bilibili page (`source: "bilibili"`). `query`: words that must all appear, case-insensitive, matched on title, author, tags, note and summary for the local copy and `toview`; for other folders Bilibili's own favorites search gets it as `keyword`. An item: `{ bvid, url, title, author, duration, favTime, invalid?, progress?, tags?, note?, verdict?, summary?, kept? }`: `tags` are the user's 分拣 tags (and 摘读记录 tags), `note` the 备注, `verdict` `keep` / `drop` / `unsure` (`MdgTagCore.verdictOf`), `summary` the 细看's one-liner, `kept` the 保留 mark, `progress` seconds watched in 稍后再看 (-1 finished).
- `follows`: `{ source, total, page, hasMore, items: [{ mid, name, sign, groups, tags? }] }`, 50 a page. With a synced 关注 list (the 分拣台's 关注) it is read from there (`source: "local"`, with the user's UP 标签 as `tags`, and `query` matched on name, sign, groups and tags); else one Bilibili page of the followings plus the group names (`source: "bilibili"`, `query` is not applied and `queryIgnored: true`). `groups` are 关注分组 names, 特别关注 and 默认分组 included.
- `history`: `{ items: [{ bvid?, url, title, author, viewAt, progress, duration, kind }], cursor }`, 30 a call, newest first. `cursor` is opaque; pass it back for the next page, `""` at the end. `kind` is Bilibili's business (`archive` for a video, also `pgc`, `live`, `article`…); `progress` is seconds, -1 finished; `viewAt` seconds since the epoch.

Keepalive: the server sends `{ type: "ping" }` every 20 s and the extension answers `{ type: "pong" }` (WebSocket traffic keeps an MV3 service worker alive in Chrome 116+).

## 3. Code

- `extension/agent-page.js` (content script): `MdgAgentPage.bind({ read, signal })`. `read()` is content.js's `agentRead`, the side panel's read of the page's video, resolving to §1's `get` data or rejecting with an `Error` carrying `code`. It answers the page messages and the bridge's `chrome.runtime` messages `{ type: "agent-call", method, params }` (`params` without `url`) with `{ ok, data }` or `{ ok: false, error, code }`, and runs `frame` itself.
- `extension/agent-bridge.js` (service worker): `MdgAgentBridge.start({ extensionVersion, isEnabled, onEnabledChange })`, §2's socket and tab handling.
- `extension/agent-bridge.js` (service worker): `MdgAgentBridge.start({ extensionVersion, isEnabled, onEnabledChange })`, §2's socket and tab handling. `MdgAgentBridge.addMethods({ name: (params) => reply })` adds methods answered without a tab.
- `extension/agent-search.js` (service worker): `MdgAgentSearch.search(params, deadline)`, §2's `search`.
- `extension/agent-mydata.js` (service worker, loaded after the 分拣台's `triage-bg.js` and `follow-core.js`): §2's `favorites`, `follows`, `history`.
- `mcp/server.js`: the MCP server, `npm test` in `mcp/`.

## 4. Codes

`NOT_VIDEO_PAGE`, `TAB_HIDDEN`, `THROTTLED` (Bilibili 412 / YouTube 429: stop, do not retry soon), `NO_VIDEO`, `SEEK_TIMEOUT`, `AD_PLAYING`, `FRAME_BLOCKED`, `BAD_URL`, `TIMEOUT`, `NOT_CONNECTED` (MCP server: no extension connected), `PORT_IN_USE` (MCP server: another moondigest-mcp holds the port; it takes over when that one exits).
`NOT_VIDEO_PAGE`, `TAB_HIDDEN`, `THROTTLED` (Bilibili 412 / YouTube 429: stop, do not retry soon), `NO_VIDEO`, `SEEK_TIMEOUT`, `AD_PLAYING`, `FRAME_BLOCKED`, `BAD_URL`, `TIMEOUT`, `NOT_CONNECTED` (MCP server: no extension connected), `PERSONAL_DATA_OFF` (the user has not allowed `favorites` / `follows` / `history`: ask them to turn on 设置 → 高级 → 允许 AI 工具读我的收藏、关注和历史), `NOT_LOGGED_IN` (not logged in to Bilibili in that browser), `NETWORK` (Bilibili did not answer), `PORT_IN_USE` (MCP server: another moondigest-mcp holds the port; it takes over when that one exits).

The port is fixed at 47823 on both sides. The server's `MOONDIGEST_MCP_PORT` is for tests only.
1 change: 1 addition & 0 deletions docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,7 @@ B站 页面上的 AI 判断照上表:值得留绿、可清理红、拿不准
- 说明文字不要散在外面。改成按钮后面加一个琥珀色小点,点开以后说明写在里面,通常放进输入框的 placeholder。

- 设置页「AI 平台」的「视频」「网页」是页签,照 B站 空间页的页签:16px,当前的加粗、下面 3px 粉线,旁边小灰字写用在哪(「B站 · YouTube」「其他网站」),一次只显示一组;左右方向键切换。每次打开设置页在「视频」。为什么:两组的项同名,摆在一起分不清在改哪一组(2026-10-22 用户定)。
- 设置页里只在另一个开关开着时才起作用的开关(「允许 AI 工具读我的收藏、关注和历史」之于「允许 MCP 连接」)放在那一行下面,左边缩进 24px,两行之间没有分隔线。上面那个关着时它变灰、不能点,悬停写「先打开「允许 MCP 连接」」,说明文字里也写这一句;它自己的开关状态照存,上面那个再打开时恢复原样,不自动打开也不清掉。为什么:看得出从属关系,又不让关掉上层就丢了下层的选择(2026-11-06 用户定)。

#### 定下来的决定(改之前先问用户)

Expand Down
8 changes: 7 additions & 1 deletion extension/agent-bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,13 @@

// deadline: when the server stops waiting for this call (its 60 s TIMEOUT). A call still queued then is dropped, so
// nothing loads a video or asks the site for a call nobody waits for any more.
// Methods other files add (agent-mydata.js): each takes params and the deadline and resolves to a reply, without a tab.
const added = {};
const addMethods = (methods) => Object.assign(added, methods);

async function handle(method, params, deadline = Date.now() + CALL_BUDGET_MS) {
if (method === "search") return MdgAgentSearch.search(params, deadline); // agent-search.js, no tab
if (Object.hasOwn(added, method)) return added[method](params, deadline);
if (method !== "get" && method !== "frame") return { ok: false, error: `Unknown method ${method}.` };
const url = params && params.url;
const key = typeof url === "string" ? videoKey(url) : null;
Expand Down Expand Up @@ -242,5 +248,5 @@
Promise.resolve(o.isEnabled()).then(apply, () => apply(false));
}

globalThis.MdgAgentBridge = { start };
globalThis.MdgAgentBridge = { start, addMethods };
})();
12 changes: 12 additions & 0 deletions extension/agent-bridge.selftest.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ function makeEnv({ enabled, tabs = [], session = {}, answer = null, commitNow =
onEnabledChange: (cb) => { env.enabledCb = cb; },
});
env.tabs = tabs;
env.addMethods = (m) => ctx.MdgAgentBridge.addMethods(m);
return env;
}

Expand Down Expand Up @@ -139,6 +140,17 @@ check("BAD_URL for unsupported pages", async () => {
assert.equal(env.sent.length, 0);
});

check("an added method answers without a tab, before the url check", async () => {
const env = makeEnv({ enabled: true });
env.addMethods({ favorites: async (p) => ({ ok: true, data: { got: p } }), history: async () => ({ ok: false, code: "PERSONAL_DATA_OFF", error: "off" }) });
const s = await openSocket(env);
assert.deepEqual(await rpc(s, "m1", "favorites", { folderId: "11" }), { id: "m1", ok: true, data: { got: { folderId: "11" } } });
assert.deepEqual(await rpc(s, "m2", "history", {}), { id: "m2", ok: false, error: "off", code: "PERSONAL_DATA_OFF" });
assert.equal((await rpc(s, "m3", "toString", {})).error, "Unknown method toString.");
assert.equal(env.created.length, 0);
assert.equal(env.sent.length, 0);
});

check("reuses a tab showing the same video", async () => {
const env = makeEnv({ enabled: true, tabs: [{ id: 7, url: "https://www.youtube.com/watch?v=abc&t=30" }] });
const s = await openSocket(env);
Expand Down
Loading
Loading