Skip to content

Commit a2788cf

Browse files
committed
bro: 搜索引擎抓取改用 Offscreen Document,全程静默无界面
- 删除 fetchViaTab / waitForTabComplete 方案:旧方案用 chrome.tabs.create 在标签栏 创建了可见的后台标签,与"静默访问"目标不符 - 新增 Offscreen Document(offscreen.html + offscreen.js): 完全无界面,不在标签栏出现任何条目,不抢占焦点 - fetchPage 改为 fetchPageSilent: 先 Service Worker fetch(最轻量,零痕迹), 若返回验证页或空结果再降级 Offscreen(同样零痕迹) - manifest 新增 offscreen 权限;bro-bridge.zip 包含 6 个文件
1 parent 0215bba commit a2788cf

5 files changed

Lines changed: 199 additions & 89 deletions

File tree

‎bro/bro-bridge.zip‎

2.42 KB
Binary file not shown.

‎bro/extension/background.js‎

Lines changed: 106 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
// bro bridge —— 后台 service worker
22
// ---------------------------------------------------------------------------
3-
// 只做两件事:
4-
// 1) 特权抓取:借 host_permissions,代表前端读取搜索引擎等站点的响应内容(不受 CORS 限制);
5-
// 2) 站点代理:在本机后台标签页中托管 Kimi / DeepSeek,并通过 content script 驱动其登录与对话。
3+
// 职责:
4+
// 1) 搜索引擎抓取:先用 Service Worker fetch(最轻量,零痕迹),若返回验证页或解析不到
5+
// 结果则降级到 Offscreen Document(依然完全静默,无任何标签/窗口)。
6+
// 全程对用户不可见,没有任何跳转或弹窗。
7+
// 2) Kimi / DeepSeek 会话:在本机后台标签页托管(登录态不能放在 Offscreen),通过
8+
// content script 驱动。这部分会创建标签但不抢占焦点,与搜索抓取链路解耦。
69
// 全部运行在浏览器内部,不依赖 Node / npm / Chromium 等外部环境。
710
// ---------------------------------------------------------------------------
811

@@ -11,75 +14,118 @@ const SITES = {
1114
deepseek: { url: 'https://chat.deepseek.com/' },
1215
};
1316

14-
const siteTabs = new Map(); // site -> tabId
17+
const siteTabs = new Map(); // site -> tabId(仅 Kimi / DeepSeek 使用)
1518
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
1619

17-
/* ------------------------------ 特权抓取 ------------------------------ */
20+
/* ---------------------------- Offscreen 文档管理 ---------------------------- */
21+
const OFFSCREEN_PATH = 'offscreen.html';
22+
const OFFSCREEN_CHANNEL = 'bro-offscreen-v1';
23+
const offscreenReady = new Promise((resolve) => {
24+
const listener = (msg) => {
25+
if (msg && msg.channel === OFFSCREEN_CHANNEL && msg.dir === 'ready') {
26+
chrome.runtime.onMessage.removeListener(listener);
27+
resolve();
28+
}
29+
};
30+
chrome.runtime.onMessage.addListener(listener);
31+
});
32+
33+
// 确保 Offscreen 文档存在(完全无界面、不出现任何标签)
34+
async function ensureOffscreen() {
35+
try {
36+
// Chrome 116+ 支持;老版本抛错时回退到 fetch
37+
const has = await chrome.offscreen.hasDocument();
38+
if (has) return;
39+
} catch (_) { /* 不支持 offscreen,走纯 fetch 路径 */ }
40+
41+
try {
42+
await chrome.offscreen.createDocument({
43+
url: OFFSCREEN_PATH,
44+
reasons: ['DOM_PARSER'],
45+
justification: '为 bro 搜索引擎后台静默抓取提供 DOM 环境,避免裸 fetch 被站点防爬拦截。',
46+
});
47+
await offscreenReady;
48+
} catch (err) {
49+
throw new Error(`无法创建 Offscreen 文档:${err?.message || err}`);
50+
}
51+
}
52+
53+
// 通过 Service Worker → Offscreen 发请求并拿回渲染后的 HTML
54+
function callOffscreen(url, timeout = 25000) {
55+
const id = Date.now() + '-' + Math.random().toString(36).slice(2, 8);
56+
return new Promise((resolve, reject) => {
57+
const timer = setTimeout(() => {
58+
chrome.runtime.onMessage.removeListener(onMsg);
59+
reject(new Error('Offscreen 文档未响应'));
60+
}, timeout);
61+
function onMsg(msg) {
62+
if (!msg || msg.channel !== OFFSCREEN_CHANNEL || msg.dir !== 'res' || msg.id !== id) return;
63+
clearTimeout(timer);
64+
chrome.runtime.onMessage.removeListener(onMsg);
65+
if (msg.ok) resolve(msg.data);
66+
else reject(new Error(msg.error || 'Offscreen 返回错误'));
67+
}
68+
chrome.runtime.onMessage.addListener(onMsg);
69+
chrome.runtime.sendMessage({
70+
channel: OFFSCREEN_CHANNEL, dir: 'req', id, method: 'fetch',
71+
params: { url, timeout: 20000, settle: 600 },
72+
});
73+
});
74+
}
75+
76+
/* ------------------------------ 搜索引擎抓取 ------------------------------ */
1877
async function fetchText(url) {
78+
// Service Worker fetch:最轻量,扩展身份(带完整 Cookie + UA + host_permissions)
1979
const res = await fetch(url, {
2080
method: 'GET',
21-
credentials: 'include', // 带上本机浏览器已有的 Cookie,提高结果页可用性
81+
credentials: 'include',
2282
redirect: 'follow',
2383
cache: 'no-store',
24-
headers: { 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8' },
84+
headers: {
85+
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
86+
'User-Agent': navigator.userAgent,
87+
},
2588
});
2689
const text = await res.text();
2790
if (!res.ok) throw new Error(`目标站点返回 ${res.status}`);
2891
return { status: res.status, finalUrl: res.url, text };
2992
}
3093

31-
// 等待标签页加载完成(status === 'complete'),带超时
32-
function waitForTabComplete(tabId, timeout = 25000) {
33-
return new Promise((resolve, reject) => {
34-
let settled = false;
35-
const finish = (err) => {
36-
if (settled) return;
37-
settled = true;
38-
chrome.tabs.onUpdated.removeListener(onUpdated);
39-
clearTimeout(timer);
40-
err ? reject(err) : resolve();
41-
};
42-
const onUpdated = (id, info) => { if (id === tabId && info.status === 'complete') finish(); };
43-
chrome.tabs.onUpdated.addListener(onUpdated);
44-
const timer = setTimeout(() => finish(new Error('页面加载超时')), timeout);
45-
// 可能监听前就已加载完成
46-
chrome.tabs.get(tabId).then((t) => { if (t && t.status === 'complete') finish(); }).catch(() => {});
47-
});
94+
// 判断 HTML 是否为搜索引擎返回的安全验证页(而非真正的搜索结果页)
95+
function looksLikeCaptcha(html) {
96+
const h = html || '';
97+
return /wappass\.baidu\.com|百度安全验证|请输入验证码|滑动验证|人机验证|unusual traffic|security check|captcha/i.test(h);
4898
}
4999

50-
// 后台标签页真实加载目标页,读取渲染后的 DOM。
51-
// 搜索引擎的防爬校验(如百度安全验证、必应 JS 校验)会拦截裸 fetch;
52-
// 用真实标签页加载等价于用户在浏览器里打开,可带回完整请求头、Cookie 与脚本渲染结果。
53-
async function fetchViaTab(url, { timeout = 25000, settle = 700 } = {}) {
54-
const tab = await chrome.tabs.create({ url, active: false });
100+
// 后台静默抓取:先 fetch(最轻量),若拿到验证页或空结果再降级 Offscreen。
101+
// 全程对用户不可见——没有跳转、没有标签闪动、没有任何窗口。
102+
async function fetchPageSilent(url) {
103+
// 第一优先级:Service Worker fetch(零痕迹)
55104
try {
56-
await waitForTabComplete(tab.id, timeout);
57-
await sleep(settle); // 等待结果脚本渲染进 DOM
58-
const injected = await chrome.scripting.executeScript({
59-
target: { tabId: tab.id },
60-
func: () => document.documentElement.outerHTML,
61-
});
62-
const html = injected && injected[0] ? injected[0].result : '';
63-
if (!html) throw new Error('未能读取页面内容');
64-
return { status: 200, finalUrl: url, text: html };
65-
} finally {
66-
chrome.tabs.remove(tab.id).catch(() => {});
105+
const res = await fetchText(url);
106+
if (!looksLikeCaptcha(res.text)) return { ...res, via: 'sw-fetch' };
107+
// fetch 拿到了验证页,降级到 Offscreen
108+
} catch (_) { /* fetch 失败直接走 Offscreen */ }
109+
110+
// 第二优先级:Offscreen Document(同样零痕迹)
111+
try {
112+
await ensureOffscreen();
113+
const res = await callOffscreen(url);
114+
return { ...res, via: 'offscreen' };
115+
} catch (err) {
116+
throw new Error(`静默抓取失败:${err?.message || err}`);
67117
}
68118
}
69119

70-
/* ------------------------------ 站点代理 ------------------------------ */
120+
/* ------------------------------ 站点代理(Kimi / DeepSeek) ------------------------------ */
71121
async function ensureTab(site) {
72122
const cfg = SITES[site];
73123
if (!cfg) throw new Error(`未知站点:${site}`);
74124

75125
const existing = siteTabs.get(site);
76126
if (existing != null) {
77-
try {
78-
await chrome.tabs.get(existing);
79-
return existing;
80-
} catch {
81-
siteTabs.delete(site);
82-
}
127+
try { await chrome.tabs.get(existing); return existing; }
128+
catch { siteTabs.delete(site); }
83129
}
84130

85131
const tab = await chrome.tabs.create({ url: cfg.url, active: false });
@@ -91,12 +137,8 @@ async function ensureTab(site) {
91137
async function sendToTab(tabId, message, { retries = 25, delay = 800 } = {}) {
92138
let lastErr;
93139
for (let i = 0; i < retries; i++) {
94-
try {
95-
return await chrome.tabs.sendMessage(tabId, message);
96-
} catch (err) {
97-
lastErr = err;
98-
await sleep(delay);
99-
}
140+
try { return await chrome.tabs.sendMessage(tabId, message); }
141+
catch (err) { lastErr = err; await sleep(delay); }
100142
}
101143
throw new Error(`无法与站点页面通信:${lastErr?.message || lastErr}`);
102144
}
@@ -109,41 +151,18 @@ async function aiCall(site, cmd, payload = {}) {
109151
/* --------------------------- 消息分发入口 --------------------------- */
110152
async function handle(method, params = {}) {
111153
switch (method) {
112-
case 'ping':
113-
return { pong: true };
114-
case 'fetch':
115-
return fetchText(params.url);
116-
117-
// 搜索引擎抓取主通道:后台标签页真实加载,规避防爬校验与 JS 渲染问题
118-
case 'fetchPage':
119-
try {
120-
return await fetchViaTab(params.url);
121-
} catch (err) {
122-
// 标签页方式失败时回退到网络抓取,尽量给出结果
123-
const r = await fetchText(params.url);
124-
return { ...r, fallback: String(err?.message || err) };
125-
}
126-
127-
case 'ai.open':
128-
return aiCall(params.site, 'state');
129-
case 'ai.state':
130-
return aiCall(params.site, 'state');
131-
case 'ai.login':
132-
return aiCall(params.site, 'login', {
133-
username: params.username,
134-
password: params.password,
135-
captcha: params.captcha,
136-
});
137-
case 'ai.chat':
138-
return aiCall(params.site, 'chat', { text: params.text, images: params.images || [] });
139-
case 'ai.newChat':
140-
return aiCall(params.site, 'newChat');
154+
case 'ping': return { pong: true };
155+
case 'fetch': return fetchText(params.url);
156+
case 'fetchPage': return fetchPageSilent(params.url); // 搜索引擎:全程静默
157+
158+
case 'ai.open': return aiCall(params.site, 'state');
159+
case 'ai.state': return aiCall(params.site, 'state');
160+
case 'ai.login': return aiCall(params.site, 'login', { username: params.username, password: params.password, captcha: params.captcha });
161+
case 'ai.chat': return aiCall(params.site, 'chat', { text: params.text, images: params.images || [] });
162+
case 'ai.newChat': return aiCall(params.site, 'newChat');
141163
case 'ai.reset': {
142164
const tabId = siteTabs.get(params.site);
143-
if (tabId != null) {
144-
await chrome.tabs.remove(tabId).catch(() => {});
145-
siteTabs.delete(params.site);
146-
}
165+
if (tabId != null) { await chrome.tabs.remove(tabId).catch(() => {}); siteTabs.delete(params.site); }
147166
return { ok: true };
148167
}
149168
default:
@@ -159,9 +178,8 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
159178
return true; // 异步响应
160179
});
161180

162-
// 用户手动关闭托管标签页时同步清理映射
163181
chrome.tabs.onRemoved.addListener((tabId) => {
164182
for (const [site, id] of siteTabs.entries()) {
165183
if (id === tabId) siteTabs.delete(site);
166184
}
167-
});
185+
});

‎bro/extension/manifest.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"name": "bro bridge",
44
"version": "1.0.0",
55
"description": "为 bro 搜索页提供浏览器原生特权接口:静默访问搜索引擎与 Kimi/DeepSeek,无需任何外部运行时。",
6-
"permissions": ["tabs", "scripting", "storage"],
6+
"permissions": ["tabs", "scripting", "storage", "offscreen"],
77
"host_permissions": [
88
"https://*.bing.com/*",
99
"https://www.baidu.com/*",

‎bro/extension/offscreen.html‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
<title>bro offscreen</title>
6+
</head>
7+
<body>
8+
<!-- Offscreen 文档:完全无界面,仅用于后台静默加载目标页面并返回渲染后的 DOM。
9+
不出现任何标签、不抢占焦点、不打扰用户。 -->
10+
<script src="offscreen.js"></script>
11+
</body>
12+
</html>

‎bro/extension/offscreen.js‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// bro bridge —— Offscreen 文档(无界面后台抓取)
2+
// ---------------------------------------------------------------------------
3+
// 整个文档在浏览器内完全静默运行:
4+
// - 不在标签栏出现任何条目
5+
// - 不抢占用户焦点、不弹任何窗口
6+
// - 带上扩展身份的 Cookie 与完整 UA,真实加载目标结果页并等待其渲染完成
7+
// 作用:解决搜索引擎(百度安全验证、必应 JS 校验等)拦截 Service Worker 裸 fetch 的问题。
8+
// ---------------------------------------------------------------------------
9+
10+
let seq = 0;
11+
const pending = new Map();
12+
13+
// 消息通道:Service Worker <-> Offscreen 文档
14+
// 发: { channel, dir:'req', id, method:'fetch', params:{ url, timeout, settle } }
15+
// 回: { channel, dir:'res', id, ok:true/false, data/error }
16+
const CHANNEL = 'bro-offscreen-v1';
17+
18+
async function fetchAndRender({ url, timeout = 20000, settle = 600 }) {
19+
// 在 Offscreen 上下文中 fetch,会自带扩展身份的 Cookie 与完整 UA
20+
const res = await fetch(url, {
21+
method: 'GET',
22+
credentials: 'include',
23+
redirect: 'follow',
24+
cache: 'no-store',
25+
headers: {
26+
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
27+
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
28+
'User-Agent': navigator.userAgent,
29+
},
30+
});
31+
32+
// 拿到文档后,把它写入当前 Offscreen 文档以便浏览器引擎完成渲染
33+
const html = await res.text();
34+
document.open();
35+
document.write(html);
36+
document.close();
37+
38+
// 等待渲染完成
39+
const waitLoad = new Promise((resolve, reject) => {
40+
const timer = setTimeout(() => reject(new Error('Offscreen 页面渲染超时')), timeout);
41+
const check = () => {
42+
if (document.readyState === 'complete') {
43+
clearTimeout(timer);
44+
resolve();
45+
} else {
46+
setTimeout(check, 80);
47+
}
48+
};
49+
check();
50+
});
51+
await waitLoad;
52+
await new Promise((r) => setTimeout(r, settle)); // 再等结果 DOM 稳定
53+
54+
return {
55+
status: res.status,
56+
finalUrl: res.url,
57+
text: document.documentElement.outerHTML,
58+
};
59+
}
60+
61+
self.addEventListener('message', async (ev) => {
62+
const msg = ev.data;
63+
if (!msg || msg.channel !== CHANNEL || msg.dir !== 'req') return;
64+
65+
const id = msg.id;
66+
if (msg.method !== 'fetch') {
67+
self.postMessage({ channel: CHANNEL, dir: 'res', id, ok: false, error: `Offscreen 未知方法:${msg.method}` });
68+
return;
69+
}
70+
71+
try {
72+
const data = await fetchAndRender(msg.params || {});
73+
self.postMessage({ channel: CHANNEL, dir: 'res', id, ok: true, data });
74+
} catch (err) {
75+
self.postMessage({ channel: CHANNEL, dir: 'res', id, ok: false, error: String(err?.message || err) });
76+
}
77+
});
78+
79+
// 就绪时告知 Service Worker
80+
chrome.runtime.sendMessage({ channel: CHANNEL, dir: 'ready' });

0 commit comments

Comments
 (0)