Files
HomeDash/extension/chrome/background.js
JianFeeeee ff0e6418f8 feat(nav): auto-detect browser extension and offer one-click install
导航页现在会自动检测浏览历史插件是否已安装:

- 扩展在用户配置的服务地址上动态注册 content script
  (Chrome: chrome.scripting.registerContentScripts,
   Firefox: browser.contentScripts.register),
  于 document_start 给 <html> 打上 data-navi-ext 标记,
  不在其他站点运行。
- history-server 始终把「安装浏览历史插件」放在「历史访问」第一条
  (入口 url=/extension/install.html),启动时也会重建一次,
  不再需要等下一次同步。
- web/navi.js 读到标记后隐藏该入口(连 .item-wrapper 一起隐藏,
  避免留下空网格位)。
- 新增 /extension/install.html:自动识别浏览器,给出对应安装包与步骤;
  支持在 data 卷放 extension-store.json 配置商店链接,
  有商店链接时显示真正的「一键安装」。
- 段落名匹配同时兼容中英文(常用服务/Favorites、历史访问/History),
  修掉了公开示例 conf.yml 用英文名时重建逻辑完全失效的问题。
- 扩展升到 1.1.0(新增 scripting 权限与 detect.js)。

Note: Chrome/Edge 不允许从网站直接安装非商店扩展,
因此自托管场景下只能下载后手动加载;发布到商店后才会出现一键安装。
2026-09-11 10:41:10 +08:00

260 lines
8.7 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 = [];
// ── 检测标记:只向用户配置的导航站注入 content script ──
// 已安装的浏览器上,导航站会拿到 data-navi-ext 属性,从而隐藏安装入口。
const DETECT_SCRIPT_ID = 'navi-detect';
const DETECT_FILE = 'detect.js';
function hostMatchPattern(serverUrl) {
try {
const host = new URL(serverUrl).hostname;
return host ? '*://' + host + '/*' : null;
} catch (e) {
return null;
}
}
async function syncDetectScript(serverUrl) {
if (!chrome.scripting || !chrome.scripting.registerContentScripts) return;
try {
const existing = await chrome.scripting.getRegisteredContentScripts({ ids: [DETECT_SCRIPT_ID] });
if (existing && existing.length) {
await chrome.scripting.unregisterContentScripts({ ids: [DETECT_SCRIPT_ID] });
}
} catch (e) { /* 从未注册过 */ }
const pattern = hostMatchPattern(serverUrl);
if (!pattern) return;
try {
await chrome.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';
}