Files
HomeDash/extension/chrome/background.js
2026-09-10 22:24:43 +08:00

212 lines
7.2 KiB
JavaScript

// 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 = [];
// ── 初始化 ──
chrome.runtime.onInstalled.addListener(() => {
ensureAlarm();
chrome.storage.local.get(['pending', 'lastSyncTime'], (data) => {
pendingItems = data.pending || [];
lastSyncTime = data.lastSyncTime || 0;
});
});
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') {
chrome.storage.local.set(msg.data || {}, () => 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';
}