Skip to content

Commit faec963

Browse files
committed
feat(web): REQ-304 扩展资产/契约文档/收件服务面板
1 parent 2d76c02 commit faec963

8 files changed

Lines changed: 334 additions & 0 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// 服务 worker:剪藏动作由 popup 发起(本文件保持最小——消息中继与安装日志)。
2+
chrome.runtime.onInstalled.addListener(() => {
3+
console.log("熵减 Web 剪藏已安装(开发期加载)——先到熵减设置页开启本地收件服务");
4+
});
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// content script:已登录 DOM → Markdown + 元数据 + 图 data URI(零依赖轻量规则;
2+
// 无 readability/turndown 依赖——规则只做常见文章结构,精确度不承诺,登录墙
3+
// 优势=读已渲染 DOM)。
4+
5+
function collectMeta() {
6+
const pick = (sel) => document.querySelector(sel)?.content?.trim() || null;
7+
return {
8+
title: document.title || pick("meta[property='og:title']"),
9+
url: location.href,
10+
site: pick("meta[property='og:site_name']") || location.hostname,
11+
author: pick("meta[name='author']") || pick("meta[property='article:author']"),
12+
};
13+
}
14+
15+
function mdText(node) {
16+
let out = "";
17+
for (const child of node.childNodes) {
18+
if (child.nodeType === Node.TEXT_NODE) out += child.textContent;
19+
else if (child.nodeType === Node.ELEMENT_NODE) out += mdText(child);
20+
}
21+
return out;
22+
}
23+
24+
function nodeToMd(node, depth) {
25+
if (!node || depth > 8) return "";
26+
const tag = node.tagName ? node.tagName.toLowerCase() : "";
27+
if (["script", "style", "noscript", "svg", "iframe", "template", "nav", "aside"].includes(tag)) return "";
28+
if (tag === "img") {
29+
const src = node.currentSrc || node.src || "";
30+
if (src.startsWith("data:")) {
31+
const alt = node.alt || "";
32+
return alt ? `![${alt}](${src})\n` : "";
33+
}
34+
// 远程图:扩展无跨域读字节权限(fetch 被 CORS 拦)——跳过并保留 alt 文本
35+
return node.alt ? `${node.alt}\n` : "";
36+
}
37+
if (tag === "h1" || tag === "h2" || tag === "h3" || tag === "h4") {
38+
const t = mdText(node).trim();
39+
return t ? `${"#".repeat(Number(tag[1]))} ${t}\n` : "";
40+
}
41+
if (tag === "li") {
42+
const t = mdText(node).trim();
43+
return t ? `- ${t}\n` : "";
44+
}
45+
if (tag === "p" || tag === "blockquote" || tag === "pre") {
46+
const t = mdText(node).trim();
47+
return t ? `${t}\n\n` : "";
48+
}
49+
let inner = "";
50+
for (const child of node.children || []) inner += nodeToMd(child, depth + 1);
51+
if (tag === "article" || tag === "section" || tag === "main") inner += "\n";
52+
return inner;
53+
}
54+
55+
function extract() {
56+
const main =
57+
document.querySelector("article") ||
58+
document.querySelector("main") ||
59+
document.querySelector(".article-content") ||
60+
document.querySelector("#js_content") ||
61+
document.body;
62+
const markdown = nodeToMd(main, 0).replace(/\n{3,}/g, "\n\n").trim();
63+
return { ...collectMeta(), markdown: markdown || "" };
64+
}
65+
66+
chrome.runtime.onMessage.addListener((req, _sender, sendResponse) => {
67+
if (req && req.type === "entropy-extract") sendResponse(extract());
68+
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"manifest_version": 3,
3+
"name": "熵减 Web 剪藏(本地收件)",
4+
"version": "0.20.4",
5+
"description": "登录态 DOM 抽取 → 熵减本地收件服务(127.0.0.1 + token 单向投递)。仅开发期加载:Edge/Chrome 扩展管理页开启开发者模式后“加载已解压的扩展程序”。",
6+
"permissions": ["activeTab", "scripting", "storage"],
7+
"host_permissions": ["http://127.0.0.1/*"],
8+
"action": {
9+
"default_title": "剪藏到熵减",
10+
"default_popup": "popup.html"
11+
},
12+
"background": {
13+
"service_worker": "background.js"
14+
},
15+
"content_scripts": [
16+
{
17+
"matches": ["http://*/*", "https://*/*"],
18+
"js": ["content.js"],
19+
"run_at": "document_idle"
20+
}
21+
]
22+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<!doctype html>
2+
<html lang="zh-CN">
3+
<head>
4+
<meta charset="utf-8" />
5+
<title>熵减 Web 剪藏</title>
6+
<style>
7+
body { font-family: system-ui, sans-serif; width: 280px; margin: 0; padding: 12px; font-size: 13px; }
8+
h1 { font-size: 14px; margin: 0 0 8px; }
9+
label { display: block; margin: 6px 0 2px; font-size: 12px; color: #444; }
10+
input { width: 100%; box-sizing: border-box; padding: 3px 6px; font-size: 12px; }
11+
button { margin-top: 10px; width: 100%; padding: 6px; border: none; border-radius: 6px;
12+
background: #0d9488; color: #fff; cursor: pointer; }
13+
#result { margin-top: 8px; font-size: 12px; color: #0f766e; white-space: pre-wrap; }
14+
#result.err { color: #dc2626; }
15+
.hint { font-size: 11px; color: #999; margin-top: 4px; }
16+
</style>
17+
</head>
18+
<body>
19+
<h1>熵减 Web 剪藏</h1>
20+
<label>收件端口(设置页 web 采集 显示)</label>
21+
<input id="port" type="text" placeholder="如 38921" />
22+
<label>Token</label>
23+
<input id="token" type="password" placeholder="粘贴设置页 token" />
24+
<button id="clip">✂ 剪藏当前页到熵减</button>
25+
<div id="result"></div>
26+
<div class="hint">登录态页面(公众号/知乎/付费墙)在已登录 DOM 内直取正文——不经第三方。</div>
27+
<script src="popup.js"></script>
28+
</body>
29+
</html>

‎app/extension-web-clipper/popup.js‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// popup 逻辑:保存收件参数 → 抽取当前页 DOM → 投递本地服务。
2+
3+
const $ = (id) => document.getElementById(id);
4+
5+
function result(text, err) {
6+
const el = $("result");
7+
el.textContent = text;
8+
el.className = err ? "err" : "";
9+
}
10+
11+
$("clip").addEventListener("click", async () => {
12+
const port = $("port").value.trim();
13+
const token = $("token").value.trim();
14+
if (!port || !token) {
15+
result("请先填写端口与 token(设置页 web 采集→本地收件)", true);
16+
return;
17+
}
18+
await chrome.storage.local.set({ port, token });
19+
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
20+
if (tab?.id == null) {
21+
result("未找到活动页", true);
22+
return;
23+
}
24+
result("抽取当前页正文…");
25+
let payload;
26+
try {
27+
payload = await chrome.tabs.sendMessage(tab.id, { type: "entropy-extract" });
28+
} catch (e) {
29+
result(`抽取失败:请刷新页面后重试(${e.message ?? e})`, true);
30+
return;
31+
}
32+
if (!payload || !payload.markdown || payload.markdown.trim().length < 10) {
33+
result("页面无正文可剪藏(框架隔离站点请刷新重试)", true);
34+
return;
35+
}
36+
result("投递本地收件服务…");
37+
try {
38+
const resp = await fetch(`http://127.0.0.1:${port}/ingest`, {
39+
method: "POST",
40+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
41+
body: JSON.stringify(payload),
42+
});
43+
const body = await resp.json().catch(() => ({}));
44+
if (resp.ok) result(`✓ 已投递 kind=web 会话(#${body.sessionId})`);
45+
else result(`投递失败 ${resp.status}:${body.error ?? "未知"}`, true);
46+
} catch (e) {
47+
result(`连接失败:熵减未开启收件服务?(${e.message ?? e})`, true);
48+
}
49+
});
50+
51+
document.addEventListener("DOMContentLoaded", async () => {
52+
const { port, token } = await chrome.storage.local.get(["port", "token"]);
53+
if (port) $("port").value = port;
54+
if (token) $("token").value = token;
55+
});
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/**
2+
* WebInboxPanel — web 扩展收件服务(v0.20.4 / REQ-304)。
3+
*
4+
* @ai-context: 只绑 127.0.0.1 随机端口 + 随机 token(首启生成持久化);扩展在
5+
* 已登录 DOM 抽取(公众号/知乎正解)→ POST /ingest 单向投递;
6+
* 本面板=启动/停止/参数展示(端口+token 复制给扩展安装配置)。
7+
*/
8+
import { useCallback, useEffect, useState } from "react";
9+
import { invoke } from "@tauri-apps/api/core";
10+
11+
interface InboxView {
12+
running: boolean;
13+
port: number | null;
14+
token: string | null;
15+
inbox_url: string | null;
16+
}
17+
18+
const btn: React.CSSProperties = { padding: "5px 10px", cursor: "pointer", fontSize: 12, borderRadius: 6 };
19+
const okBtn: React.CSSProperties = { ...btn, background: "#0d9488", color: "#fff", border: "none" };
20+
const ghostBtn: React.CSSProperties = { ...btn, background: "#fff", border: "1px solid #e5e7eb", color: "#374151" };
21+
22+
export function WebInboxPanel() {
23+
const [view, setView] = useState<InboxView | null>(null);
24+
const [msg, setMsg] = useState("");
25+
const [err, setErr] = useState("");
26+
27+
const reload = useCallback(async () => {
28+
try {
29+
setErr("");
30+
setView(await invoke<InboxView>("web_inbox_status"));
31+
} catch (e) {
32+
setErr(String(e));
33+
}
34+
}, []);
35+
36+
useEffect(() => {
37+
void reload();
38+
}, [reload]);
39+
40+
const start = async () => {
41+
try {
42+
setView(await invoke<InboxView>("web_inbox_start"));
43+
setMsg("✓ 收件服务已启动——仅本机回环可达(随机端口 + token 鉴权)");
44+
} catch (e) {
45+
setErr(String(e));
46+
}
47+
};
48+
49+
const stop = async () => {
50+
try {
51+
await invoke("web_inbox_stop");
52+
setMsg("已停止(token 保留——下次启动同 token,扩展零重配)");
53+
await reload();
54+
} catch (e) {
55+
setErr(String(e));
56+
}
57+
};
58+
59+
const copy = async () => {
60+
if (!view?.token) return;
61+
try {
62+
await navigator.clipboard.writeText(
63+
`端口:${view.port}\nToken:${view.token}`,
64+
);
65+
setMsg("已复制端口与 token——粘贴到浏览器扩展弹窗(app/extension-web-clipper 手动加载)");
66+
} catch (e) {
67+
setErr(`复制失败: ${e}`);
68+
}
69+
};
70+
71+
return (
72+
<div>
73+
<div style={{ fontSize: 12, color: "#374151", marginBottom: 6 }}>
74+
<b>🛬 本地收件(浏览器扩展投递)</b>{" "}
75+
<span style={{ fontSize: 11, color: "#9ca3af" }}>
76+
登录墙正解:扩展读已登录 DOM → 本服务(127.0.0.1 + token 单向投递)→ kind=web 会话
77+
</span>
78+
</div>
79+
{msg && <div style={{ fontSize: 11, color: "#047857", marginBottom: 4 }}>{msg}</div>}
80+
{err && <div style={{ fontSize: 11, color: "#dc2626", marginBottom: 4 }}>{err}</div>}
81+
<div style={{ display: "flex", gap: 6, alignItems: "center", flexWrap: "wrap" }}>
82+
{!view?.running ? (
83+
<button style={okBtn} onClick={() => void start()}>▶ 启动收件服务</button>
84+
) : (
85+
<>
86+
<span style={{ fontSize: 12 }}>端口 <b>{view.port}</b></span>
87+
<code style={{ fontSize: 11, background: "#f9fafb", padding: "2px 6px", borderRadius: 4 }}>{view.token}</code>
88+
<button style={ghostBtn} onClick={() => void copy()}>复制参数</button>
89+
<button style={{ ...ghostBtn, color: "#dc2626" }} onClick={() => void stop()}>⏹ 停止</button>
90+
</>
91+
)}
92+
</div>
93+
<div style={{ fontSize: 11, color: "#9ca3af", marginTop: 4 }}>
94+
安装扩展:Edge/Chrome 扩展管理页开启开发者模式 → 「加载已解压的扩展程序」→ 选 app/extension-web-clipper(投递契约见 docs/Foresight/web-capture-extension-protocol.md)
95+
</div>
96+
</div>
97+
);
98+
}
99+
100+
export default WebInboxPanel;

‎app/src/pages/SettingsPage.tsx‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ import SealToggle from "../components/SealToggle";
3131
import AsrConfusionPanel from "../components/AsrConfusionPanel";
3232
// v0.20.2(REQ-270):可选 LLM 文本校对开关(默认关双闸门之二)
3333
import ProofreadToggle from "../components/ProofreadToggle";
34+
// v0.20.4(REQ-304):web 扩展收件服务面板
35+
import WebInboxPanel from "../components/WebInboxPanel";
3436

3537
const panel: React.CSSProperties = { border: "1px solid #e5e7eb", borderRadius: 8, padding: 12 };
3638
const groupTitle: React.CSSProperties = {
@@ -123,6 +125,12 @@ export default function SettingsPage({ active = true }: Props) {
123125
<div style={{ ...panel, marginBottom: 4 }}>
124126
<AsrConfusionPanel />
125127
</div>
128+
129+
{/* v0.20.4(REQ-304):web 采集(URL 采集在课堂助手动线;本组=扩展收件) */}
130+
<GroupTitle>web 采集</GroupTitle>
131+
<div style={{ ...panel, marginBottom: 4 }}>
132+
<WebInboxPanel />
133+
</div>
126134

127135
{/* ── 功能预览(默认关的新能力开关,v4 §11.3)── */}
128136
<GroupTitle>功能预览</GroupTitle>
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Web 采集:扩展投递契约(v0.20.4 / REQ-304 阶段 2 薄壳)
2+
3+
> 状态:落地文档(2026-09-06,与服务 `web_inbox_start/status/stop` 及扩展骨架 `app/extension-web-clipper/` 同批)
4+
> 背景:[web-capture-ingestion-path.md](./web-capture-ingestion-path.md)(三件套机制定案)· REQ-303~305
5+
6+
## 一、安全边界(与 AGENTS.md 红线对齐)
7+
8+
- 服务只绑 `127.0.0.1` 随机端口(应用启动期由用户显式开启);
9+
- 首启生成随机 token(24 位字母数字,`data_dir/web_inbox.json` 持久化——重启同 token,扩展零重配);
10+
- 单向投递:服务只收不发;`GET /ping` 仅返回 `{"ok":true}`(Joplin 端口探测范式);
11+
- 鉴权:`Authorization: Bearer <token>`,恒定时间比较;缺失/错误 → 401;
12+
- CORS:仅需预检放行(`Authorization`/`content-type` 头 + POST/OPTIONS);扩展服务上下文非同源页面,浏览器 CORS 强制下正常投递;
13+
- 校验:正文 ≤2MB、URL 仅 http(s) 且 ≤2048、图 ≤50 张且单张 ≤8MB、data URI 必须 `data:image/`、文件名禁路径字符(`../` 等穿越拒绝);
14+
- 体量护栏:请求头+体总量 8MB 上限(慢速/超大直断)。
15+
16+
## 二、投递契约(POST /ingest)
17+
18+
```json
19+
{
20+
"title": "文章标题",
21+
"url": "https://mp.weixin.qq.com/s/xxx",
22+
"site": "公众号名",
23+
"author": "作者",
24+
"markdown": "# 标题\n正文段落…\n![图1](data:image/png;base64,…)",
25+
"images": [{ "name": "图1", "data_base64": "data:image/png;base64,…" }]
26+
}
27+
```
28+
29+
响应:`200 {"ok":true,"sessionId":7}`(已建 kind=web 会话 + 页面);`422 {"error":"…"}` 校验失败;`500` 落库失败;`401` 未授权。
30+
31+
落库语义:图 base64 解码落 `notes-images/web-{sessionId}-{hash}.{ext}`,md 内 `![name](data:…)` 改写为相对引用(编辑器同解析基座);标题/URL/站点/作者入会话与页面元数据;投递成功广播 `data:sessions`。
32+
33+
## 三、扩展骨架(app/extension-web-clipper/)
34+
35+
- `manifest.json`:MV3、content script(读已登录 DOM)、popup(端口/token 配置 + 投递按钮);
36+
- `content.js`:零依赖轻量 DOM→MD(article/main/公众号 `#js_content` 等选区;标题层级/列表/段落;图:data URI 直带,远程图保留 alt 文本——跨域字节不可读,截图/快照路径补);
37+
- `popup.js/html`:参数存 `chrome.storage.local`,点击 → sendMessage 抽取 → fetch `http://127.0.0.1:{port}/ingest`(Bearer token)。
38+
- 发布:开发期手动加载(扩展管理页→开发者模式→加载已解压)。商店发布(Edge/Chrome/Firefox)周期与审核不确定性登记开放项(v0.20.md 风险 1)。
39+
40+
## 四、与阶段 1/3 的关系
41+
42+
- 阶段 1(URL 静态直取)覆盖无登录墙静态站;本契约同一 `kind=web` 收口(扩展投递与 URL 采集产物同构——会话列表无感同列,Foresight 决策 4);
43+
- 阶段 3(整页快照)的渲染型 DOM 内联为独立命令(REQ-305),投递失败不阻断剪藏主路径。
44+
45+
## 五、测试与回归
46+
47+
- 纯逻辑层(web_inbox.rs 5 测:头解析/token 恒定比较/载荷白名单/base64 摘要)+ 收口函数(commands_web_inbox_tests 1 测:会话+图落盘改写);
48+
- 网络面人工冒烟:启动服务 → curl -H "Authorization: Bearer x" -d '{"markdown":"正文"}' http://127.0.0.1:{port}/ingest。

0 commit comments

Comments
 (0)