fix: make browser history sync reliable

This commit is contained in:
JianFeeeee
2026-09-10 22:24:43 +08:00
parent 5d4cd64b30
commit e48fcc53cd
6 changed files with 129 additions and 63 deletions

View File

@ -71,13 +71,13 @@ function hostFrom(url) {
}
// ── 拉取最近浏览历史(增量)──
async function collectRecent() {
const since = Math.max(0, Date.now() - FETCH_WINDOW);
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 cutoff = Date.now() - DEDUP_SECONDS * 1000;
const seen = new Set();
const collected = [];
@ -88,7 +88,7 @@ async function collectRecent() {
if (seen.has(key)) continue;
seen.add(key);
const ts = item.lastVisitTime || Date.now();
if (ts < cutoff) continue; // 只收集最近窗口
if (ts <= cutoff) continue; // 只上报上次成功同步之后的新访问
collected.push({
url: item.url,
title: item.title || '',
@ -137,16 +137,27 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
async function syncNow() {
ensureAlarm();
const { serverUrl, syncToken, deviceName } = await new Promise((resolve) => {
chrome.storage.local.get(['serverUrl', 'syncToken', 'deviceName'], resolve);
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(); } catch (e) { recent = []; }
try { recent = await collectRecent(lastSyncTime); } catch (e) { recent = []; }
const merged = dedup(recent.concat(pendingItems));
if (merged.length === 0) return { ok: true, skipped: true, reason: 'nothing new' };
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) => ({
@ -167,11 +178,13 @@ async function syncNow() {
});
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 = Date.now();
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) {

View File

@ -24,28 +24,10 @@ button{margin-top:14px;padding:8px 20px;border:none;border-radius:8px;font-weigh
<label>设备名称</label>
<input id="device" placeholder="desktop-chrome">
<div>
<button class="save" onclick="save()">保存</button>
<button class="sync" onclick="doSync()">立即同步</button>
<button class="save" id="save">保存</button>
<button class="sync" id="sync">立即同步</button>
</div>
<div class="status" id="status"></div>
<script>
chrome.runtime.sendMessage({action:'getConfig'},(d)=>{
document.getElementById('url').value=d.serverUrl||'';
document.getElementById('token').value=d.syncToken||'';
document.getElementById('device').value=d.deviceName||'';
});
function save(){
chrome.runtime.sendMessage({action:'setConfig',data:{
serverUrl:document.getElementById('url').value.replace(/\/$/,''),
syncToken:document.getElementById('token').value,
deviceName:document.getElementById('device').value,
}},()=>{document.getElementById('status').textContent='已保存 OK'});
}
function doSync(){
chrome.runtime.sendMessage({action:'sync'},(r)=>{
document.getElementById('status').textContent=r.skipped?'无待同步':r.ok?`同步 ${r.count} 条,共 ${r.total}`:`失败: ${r.error||r.status}`;
});
}
</script>
<script src="options.js"></script>
</body>
</html>

View File

@ -0,0 +1,38 @@
'use strict';
const api = chrome;
const urlInput = document.getElementById('url');
const tokenInput = document.getElementById('token');
const deviceInput = document.getElementById('device');
const status = document.getElementById('status');
api.runtime.sendMessage({ action: 'getConfig' }, (config) => {
urlInput.value = config.serverUrl || '';
tokenInput.value = config.syncToken || '';
deviceInput.value = config.deviceName || '';
});
document.getElementById('save').addEventListener('click', () => {
api.runtime.sendMessage({
action: 'setConfig',
data: {
serverUrl: urlInput.value.replace(/\/$/, ''),
syncToken: tokenInput.value,
deviceName: deviceInput.value,
},
}, () => { status.textContent = '已保存'; });
});
document.getElementById('sync').addEventListener('click', () => {
api.runtime.sendMessage({ action: 'sync' }, (result) => {
if (!result) {
status.textContent = '失败: 扩展后台无响应';
return;
}
status.textContent = result.skipped
? '无待同步'
: result.ok
? `同步 ${result.count} 条,共 ${result.total}`
: `失败: ${result.error || result.status}`;
});
});

View File

@ -61,12 +61,13 @@ function hostFrom(url) {
try { return new URL(url).hostname.replace(/^www\./, ''); } catch { return ''; }
}
async function collectRecent() {
const since = Math.max(0, Date.now() - FETCH_WINDOW);
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) => {
browser.history.search({ text: '', startTime: since, maxResults: 10000 }, resolve);
});
const cutoff = Date.now() - DEDUP_SECONDS * 1000;
const seen = new Set();
const collected = [];
for (const item of items) {
@ -75,7 +76,7 @@ async function collectRecent() {
if (seen.has(key)) continue;
seen.add(key);
const ts = item.lastVisitTime || Date.now();
if (ts < cutoff) continue;
if (ts <= cutoff) continue;
collected.push({
url: item.url,
title: item.title || '',
@ -113,15 +114,26 @@ browser.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
async function syncNow() {
ensureAlarm();
const { serverUrl, syncToken, deviceName } = await new Promise((resolve) => {
browser.storage.local.get(['serverUrl', 'syncToken', 'deviceName'], resolve);
const syncStartedAt = Date.now();
const stored = await new Promise((resolve) => {
browser.storage.local.get(
['serverUrl', 'syncToken', 'deviceName', 'pending', 'lastSyncTime'],
resolve,
);
});
const { serverUrl, syncToken, deviceName } = stored;
if (!serverUrl) return { ok: false, reason: 'not configured' };
pendingItems = Array.isArray(stored.pending) ? stored.pending : [];
lastSyncTime = Number(stored.lastSyncTime) || 0;
let recent;
try { recent = await collectRecent(); } catch (e) { recent = []; }
try { recent = await collectRecent(lastSyncTime); } catch (e) { recent = []; }
const merged = dedup(recent.concat(pendingItems));
if (merged.length === 0) return { ok: true, skipped: true, reason: 'nothing new' };
if (merged.length === 0) {
lastSyncTime = syncStartedAt;
await new Promise((res) => browser.storage.local.set({ lastSyncTime }, res));
return { ok: true, skipped: true, reason: 'nothing new' };
}
const body = JSON.stringify({
items: merged.map((it) => ({
@ -142,11 +154,13 @@ async function syncNow() {
});
if (!resp.ok) {
const text = await resp.text().catch(() => '');
pendingItems = merged;
await new Promise((res) => browser.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 = Date.now();
lastSyncTime = syncStartedAt;
await new Promise((res) => browser.storage.local.set({ pending: [], lastSyncTime }, res));
return { ok: true, count: merged.length, total: data.total || 0 };
} catch (e) {

View File

@ -14,7 +14,6 @@ button{margin-top:14px;padding:8px 20px;border:none;border-radius:8px;font-weigh
.sync{background:#22c55e;color:#fff;margin-left:8px}
.status{margin-top:12px;font-size:12px;color:#64748b}
</style>
<script>if (typeof browser !== "undefined" && typeof chrome === "undefined") { window.chrome = browser; } else if (typeof browser !== "undefined") { window.chrome = window.chrome || browser; }</script>
</head>
<body>
<h1> JianF Nav Sync</h1>
@ -25,28 +24,10 @@ button{margin-top:14px;padding:8px 20px;border:none;border-radius:8px;font-weigh
<label>设备名称</label>
<input id="device" placeholder="desktop-chrome">
<div>
<button class="save" onclick="save()">保存</button>
<button class="sync" onclick="doSync()">立即同步</button>
<button class="save" id="save">保存</button>
<button class="sync" id="sync">立即同步</button>
</div>
<div class="status" id="status"></div>
<script>
chrome.runtime.sendMessage({action:'getConfig'},(d)=>{
document.getElementById('url').value=d.serverUrl||'';
document.getElementById('token').value=d.syncToken||'';
document.getElementById('device').value=d.deviceName||'';
});
function save(){
chrome.runtime.sendMessage({action:'setConfig',data:{
serverUrl:document.getElementById('url').value.replace(/\/$/,''),
syncToken:document.getElementById('token').value,
deviceName:document.getElementById('device').value,
}},()=>{document.getElementById('status').textContent='已保存 OK'});
}
function doSync(){
chrome.runtime.sendMessage({action:'sync'},(r)=>{
document.getElementById('status').textContent=r.skipped?'无待同步':r.ok?`同步 ${r.count} 条,共 ${r.total}`:`失败: ${r.error||r.status}`;
});
}
</script>
<script src="options.js"></script>
</body>
</html>

View File

@ -0,0 +1,38 @@
'use strict';
const api = typeof browser !== 'undefined' ? browser : chrome;
const urlInput = document.getElementById('url');
const tokenInput = document.getElementById('token');
const deviceInput = document.getElementById('device');
const status = document.getElementById('status');
api.runtime.sendMessage({ action: 'getConfig' }, (config) => {
urlInput.value = config.serverUrl || '';
tokenInput.value = config.syncToken || '';
deviceInput.value = config.deviceName || '';
});
document.getElementById('save').addEventListener('click', () => {
api.runtime.sendMessage({
action: 'setConfig',
data: {
serverUrl: urlInput.value.replace(/\/$/, ''),
syncToken: tokenInput.value,
deviceName: deviceInput.value,
},
}, () => { status.textContent = '已保存'; });
});
document.getElementById('sync').addEventListener('click', () => {
api.runtime.sendMessage({ action: 'sync' }, (result) => {
if (!result) {
status.textContent = '失败: 扩展后台无响应';
return;
}
status.textContent = result.skipped
? '无待同步'
: result.ok
? `同步 ${result.count} 条,共 ${result.total}`
: `失败: ${result.error || result.status}`;
});
});