Files
HomeDash/extension/chrome/background.js

313 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// JianF Nav Sync — Chrome/Edge MV3 service worker
// 定期用 chrome.history.search 拉取最近访问,本地过滤搜索引擎后批量上报。
'use strict';
const ALARM_NAME = 'navi-sync';
const SYNC_MINUTES = 0.5; // 30 秒一轮
const FETCH_WINDOW = 2 * 60 * 60 * 1000; // 每次拉取过去 2 小时
const MAX_BATCH = 200; // 单批最多上报条数
const DEDUP_SECONDS = 600; // 10 分钟内同一 URL 只记一次
const SEARCH_HOSTS = new Set([
'google.com', 'www.google.com', 'googleusercontent.com',
'bing.com', 'www.bing.com', 'cn.bing.com',
'baidu.com', 'www.baidu.com', 'm.baidu.com',
'duckduckgo.com', 'www.duckduckgo.com', 'lite.duckduckgo.com',
'sogou.com', 'www.sogou.com',
'so.com', 'www.so.com',
'yahoo.com', 'www.yahoo.com', 'search.yahoo.com',
'ecosia.org', 'www.ecosia.org',
'qwant.com', 'www.qwant.com',
'startpage.com', 'www.startpage.com',
'brave.com', 'search.brave.com',
'yandex.com', 'yandex.ru',
'360.cn', 'www.360.cn',
'sm.cn', 'quark.sm.cn',
'searx.be', 'searxng.site', 'paulgo.io',
]);
let lastSyncTime = 0;
let pendingItems = [];
// ── 检测标记:只向用户配置的导航站注入 content script ──
// 已安装的浏览器上,导航站会拿到 data-navi-ext 属性,从而隐藏安装入口。
//
// MV3 与 MV2 的注册方式不同,必须分别处理(此前只写了 MV3 那条,
// Firefox 走 chrome.scripting 早退,于是 data-navi-ext 永不写入,
// 安装入口在 Firefox 上永远藏不住):
// · MV3 / Chromium:chrome.scripting.registerContentScripts,callback 风格
// · MV2 / Firefox :browser.contentScripts.register,返回 Promise,
// 注销只能靠 register() 返回的句柄上的 .unregister()(无按 id 注销的 API)
const DETECT_SCRIPT_ID = 'navi-detect';
const DETECT_FILE = 'detect.js';
// MV2 句柄:MV2 没有「按 id 查询已注册脚本」的 API,只能自己留住上次注册
// 返回的对象,换地址时调它的 unregister()。MV3 另有 storage 字段标记。
let mv2RegisteredHandle = null;
function extApi() {
// Firefox 注入 browser.* 兼 chrome.* 兼容层;优先用它拿 MV2 的 contentScripts。
return (typeof browser !== 'undefined' && browser && browser.contentScripts)
? browser
: (typeof chrome !== 'undefined' ? chrome : null);
}
function hostMatchPattern(serverUrl) {
try {
const host = new URL(serverUrl).hostname;
return host ? '*://' + host + '/*' : null;
} catch (e) {
return null;
}
}
// mv2HasContentScripts 判断当前运行的是 Firefox MV2 那条路。
function mv2HasContentScripts(api) {
return !!(api && api.contentScripts && typeof api.contentScripts.register === 'function');
}
async function unregisterMV2() {
if (!mv2RegisteredHandle) return;
const h = mv2RegisteredHandle;
mv2RegisteredHandle = null;
try {
if (typeof h.unregister === 'function') await h.unregister();
} catch (e) { /* 从未注册 / 已被注销 */ }
}
async function syncDetectScript(serverUrl) {
const api = extApi();
if (!api) return;
// ① Firefox MV2:contentScripts.register(Promise)
if (mv2HasContentScripts(api)) {
await unregisterMV2();
const pattern = hostMatchPattern(serverUrl);
if (!pattern) return;
try {
mv2RegisteredHandle = await api.contentScripts.register({
matches: [pattern],
js: [DETECT_FILE],
runAt: 'document_start',
allFrames: false,
});
} catch (e) {
// 静默降级:未装扩展时只是多显示一个安装入口,不影响同步
}
return;
}
// ② Chromium MV3:chrome.scripting.registerContentScripts(callback)
if (!api.scripting || !api.scripting.registerContentScripts) return;
try {
const existing = await api.scripting.getRegisteredContentScripts({ ids: [DETECT_SCRIPT_ID] });
if (existing && existing.length) {
await api.scripting.unregisterContentScripts({ ids: [DETECT_SCRIPT_ID] });
}
} catch (e) { /* 从未注册过 */ }
const pattern = hostMatchPattern(serverUrl);
if (!pattern) return;
try {
await api.scripting.registerContentScripts([{
id: DETECT_SCRIPT_ID,
matches: [pattern],
js: [DETECT_FILE],
runAt: 'document_start',
allFrames: false,
persistAcrossSessions: true,
}]);
} catch (e) {
// 静默降级:未装扩展时只是多显示一个安装入口,不影响同步
}
}
// ── 初始化 ──
chrome.runtime.onInstalled.addListener(() => {
ensureAlarm();
chrome.storage.local.get(['pending', 'lastSyncTime', 'serverUrl'], (data) => {
pendingItems = data.pending || [];
lastSyncTime = data.lastSyncTime || 0;
syncDetectScript(data.serverUrl);
});
});
chrome.runtime.onStartup.addListener(() => {
chrome.storage.local.get(['serverUrl'], (d) => syncDetectScript(d.serverUrl));
});
function ensureAlarm() {
chrome.alarms.get(ALARM_NAME, (alarm) => {
if (!alarm) chrome.alarms.create(ALARM_NAME, { periodInMinutes: SYNC_MINUTES });
});
}
// ── 本地过滤:搜索引擎与无意义页面 ──
function isJunk(url) {
try {
const u = new URL(url);
if (u.protocol !== 'http:' && u.protocol !== 'https:') return true;
const host = u.hostname.toLowerCase().replace(/^www\./, '');
if (SEARCH_HOSTS.has(host)) return true;
if (host.endsWith('.search.yahoo.com')) return true;
// 常见搜索引擎结果特征:路径 /search、/s、/results 且带 q 参数
const q = u.searchParams.get('q');
const path = u.pathname.toLowerCase();
if (q && (path === '/search' || path === '/s' || path === '/results' || path === '/web')) return true;
// 浏览器内部页 / 空白页 / 下载页
if (u.hostname === 'newtab' || u.hostname === 'chrome-search' ||
url.startsWith('chrome://') || url.startsWith('edge://') ||
url.startsWith('about:')) return true;
return false;
} catch (e) {
return true;
}
}
function hostFrom(url) {
try { return new URL(url).hostname.replace(/^www\./, ''); } catch { return ''; }
}
// ── 拉取最近浏览历史(增量)──
async function collectRecent(previousSyncTime = 0) {
const initialCutoff = Date.now() - DEDUP_SECONDS * 1000;
const cutoff = previousSyncTime > 0 ? previousSyncTime : initialCutoff;
const since = Math.max(0, Date.now() - FETCH_WINDOW, cutoff);
const items = await new Promise((resolve) => {
chrome.history.search({ text: '', startTime: since, maxResults: 10000 }, resolve);
});
const seen = new Set();
const collected = [];
// 按时间从新到旧去重:同一 host+path 最近 10 分钟内只保留一次
for (const item of items) {
if (isJunk(item.url)) continue;
const key = hostFrom(item.url) + '|' + (item.url.split('?')[0] || '');
if (seen.has(key)) continue;
seen.add(key);
const ts = item.lastVisitTime || Date.now();
if (ts <= cutoff) continue; // 只上报上次成功同步之后的新访问
collected.push({
url: item.url,
title: item.title || '',
timestamp: Math.floor(ts / 1000),
visit_count: item.visitCount || 1,
});
if (collected.length >= MAX_BATCH) break;
}
return collected;
}
// ── 定时同步 ──
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === ALARM_NAME) syncNow();
});
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (msg && msg.action === 'sync') {
syncNow().then((r) => sendResponse(r)).catch((e) => sendResponse({ ok: false, error: e.message }));
return true;
}
if (msg && msg.action === 'getConfig') {
chrome.storage.local.get(['serverUrl', 'syncToken', 'deviceName'], (d) => sendResponse(d));
return true;
}
if (msg && msg.action === 'setConfig') {
const data = msg.data || {};
chrome.storage.local.set(data, () => {
syncDetectScript(data.serverUrl);
sendResponse({ ok: true });
});
return true;
}
if (msg && msg.action === 'clearPending') {
pendingItems = [];
chrome.storage.local.set({ pending: [] }, () => sendResponse({ ok: true }));
return true;
}
if (msg && msg.action === 'getStatus') {
chrome.storage.local.get(['serverUrl', 'syncToken'], (d) => {
sendResponse({
serverConfigured: !!(d.serverUrl),
pendingCount: pendingItems.length,
device: msg.deviceName || '',
});
});
return true;
}
});
async function syncNow() {
ensureAlarm();
const syncStartedAt = Date.now();
const stored = await new Promise((resolve) => {
chrome.storage.local.get(
['serverUrl', 'syncToken', 'deviceName', 'pending', 'lastSyncTime'],
resolve,
);
});
const { serverUrl, syncToken, deviceName } = stored;
if (!serverUrl) return { ok: false, reason: 'not configured' };
// 每次从持久化存储恢复游标和失败队列,兼容 MV3 service worker 休眠重启。
pendingItems = Array.isArray(stored.pending) ? stored.pending : [];
lastSyncTime = Number(stored.lastSyncTime) || 0;
let recent;
try { recent = await collectRecent(lastSyncTime); } catch (e) { recent = []; }
const merged = dedup(recent.concat(pendingItems));
if (merged.length === 0) {
lastSyncTime = syncStartedAt;
await new Promise((res) => chrome.storage.local.set({ lastSyncTime }, res));
return { ok: true, skipped: true, reason: 'nothing new' };
}
const body = JSON.stringify({
items: merged.map((it) => ({
url: it.url,
title: it.title || '',
device: deviceName || defaultDevice(),
timestamp: it.timestamp,
visit_count: it.visit_count || 1,
})),
});
const headers = { 'Content-Type': 'application/json' };
if (syncToken) headers['Authorization'] = 'Bearer ' + syncToken;
try {
const resp = await fetch(serverUrl.replace(/\/$/, '') + '/api/history/sync', {
method: 'POST', headers, body,
});
if (!resp.ok) {
const text = await resp.text().catch(() => '');
pendingItems = merged;
await new Promise((res) => chrome.storage.local.set({ pending: merged }, res));
return { ok: false, status: resp.status, error: text.slice(0, 200) };
}
const data = await resp.json().catch(() => ({}));
pendingItems = [];
lastSyncTime = syncStartedAt;
await new Promise((res) => chrome.storage.local.set({ pending: [], lastSyncTime }, res));
return { ok: true, count: merged.length, total: data.total || 0 };
} catch (e) {
// 网络失败:保留队列下次重试
await new Promise((res) => chrome.storage.local.set({ pending: merged }, res));
return { ok: false, error: e.message };
}
}
function dedup(list) {
const seen = new Set();
const out = [];
for (const it of list) {
const key = it.url;
if (seen.has(key)) continue;
seen.add(key);
out.push(it);
}
return out;
}
function defaultDevice() {
return 'desktop-browser';
}