212 lines
7.2 KiB
JavaScript
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';
|
|
}
|