Files
sehuatang/background.js
2026-03-18 00:27:27 +08:00

3191 lines
101 KiB
JavaScript

var STORAGE_KEY_PREFIX = 'magnet-progress-state:';
var MAX_PROGRESS_ITEMS = 2000;
var SESSION_MARKER_KEY = 'magnet-progress-session-marker-v1';
var CACHE_DB_NAME = 'magnet-thread-cache';
var CACHE_DB_VERSION = 3;
var COVERAGE_LIMIT_PER_FORUM = 999;
var CLOUD_CACHE_ENABLED = true;
var CLOUD_CACHE_BASE_URL = 'https://s.52oai.com';
var CLOUD_CACHE_TIMEOUT = 6000;
var CLOUD_SHARED_CACHE_WRITE_TOKEN = 's52oai-shared-cache-write-v1';
var CLOUD_AUTH_STORAGE_KEY = 'magnet-cloud-auth-v1';
var CLOUD_DEVICE_ID_KEY = 'magnet-cloud-device-id-v1';
var CLOUD_CACHE_BACKFILL_KEY = 'magnet-cloud-cache-backfill-v1';
var CLOUD_UPLOAD_META_KEY = 'magnet-cloud-upload-meta-v1';
var CLOUD_VAULT_KEY_VERSION = 1;
var CLOUD_THREAD_UPLOAD_TTL_MS = 10 * 60 * 1000;
var CLOUD_PAGE_UPLOAD_TTL_MS = 30 * 60 * 1000;
var CLOUD_COVERAGE_UPLOAD_TTL_MS = 60 * 60 * 1000;
var CLOUD_UPLOAD_META_THREAD_LIMIT = 50000;
var CLOUD_UPLOAD_META_OTHER_LIMIT = 4000;
var cloudAuthStateCache = null;
var cloudUploadMetaCache = null;
var cloudBackfillPromise = null;
var isSessionReady = false;
var isPreparingSession = false;
var sessionReadyCallbacks = [];
function requestToPromise(request) {
return new Promise(function(resolve, reject) {
request.onsuccess = function(event) {
resolve(event.target.result);
};
request.onerror = function(event) {
reject(event.target.error || new Error('IndexedDB 请求失败'));
};
});
}
function transactionDone(transaction) {
return new Promise(function(resolve, reject) {
transaction.oncomplete = function() {
resolve();
};
transaction.onerror = function() {
reject(transaction.error || new Error('IndexedDB 事务失败'));
};
transaction.onabort = function() {
reject(transaction.error || new Error('IndexedDB 事务已中止'));
};
});
}
function normalizeCloudBaseUrl(url) {
return String(url || '').replace(/\/+$/, '');
}
function callCloudCache(path, payload, timeoutMs, extraHeaders) {
if (!CLOUD_CACHE_ENABLED) {
return Promise.resolve(null);
}
var controller = new AbortController();
var realTimeout = Math.max(2000, Math.min(Number(timeoutMs) || CLOUD_CACHE_TIMEOUT, 15000));
var timer = setTimeout(function() {
controller.abort();
}, realTimeout);
return fetch(normalizeCloudBaseUrl(CLOUD_CACHE_BASE_URL) + path, {
method: 'POST',
headers: Object.assign({
'Content-Type': 'application/json'
}, extraHeaders || {}),
body: JSON.stringify(payload || {}),
signal: controller.signal
}).then(function(response) {
if (!response.ok) {
throw new Error('云缓存 HTTP ' + response.status);
}
return response.json();
}).catch(function(error) {
if (error && error.name === 'AbortError') {
throw new Error('云缓存请求超时(' + realTimeout + 'ms)');
}
throw error;
}).finally(function() {
clearTimeout(timer);
});
}
function storageLocalGet(key) {
return new Promise(function(resolve, reject) {
chrome.storage.local.get(key, function(result) {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
}
resolve(result || {});
});
});
}
function storageLocalSet(data) {
return new Promise(function(resolve, reject) {
chrome.storage.local.set(data, function() {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
}
resolve();
});
});
}
function storageLocalRemove(key) {
return new Promise(function(resolve, reject) {
chrome.storage.local.remove(key, function() {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
}
resolve();
});
});
}
function textEncoder() {
return new TextEncoder();
}
function textDecoder() {
return new TextDecoder();
}
function bytesToBase64(bytes) {
var binary = '';
var chunkSize = 0x8000;
var index = 0;
for (index = 0; index < bytes.length; index += chunkSize) {
binary += String.fromCharCode.apply(null, bytes.subarray(index, index + chunkSize));
}
return btoa(binary);
}
function base64ToBytes(base64) {
var binary = atob(String(base64 || ''));
var bytes = new Uint8Array(binary.length);
var index = 0;
for (index = 0; index < binary.length; index++) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
}
function arrayBufferToBase64(buffer) {
return bytesToBase64(new Uint8Array(buffer));
}
function randomBase64(size) {
var bytes = new Uint8Array(size);
crypto.getRandomValues(bytes);
return bytesToBase64(bytes);
}
async function sha256Hex(value) {
var digest = await crypto.subtle.digest('SHA-256', textEncoder().encode(String(value || '')));
return Array.from(new Uint8Array(digest)).map(function(byte) {
return byte.toString(16).padStart(2, '0');
}).join('');
}
async function derivePasswordKey(password, saltBase64, keyUsages) {
var baseKey = await crypto.subtle.importKey(
'raw',
textEncoder().encode(String(password || '')),
'PBKDF2',
false,
['deriveKey']
);
return crypto.subtle.deriveKey({
name: 'PBKDF2',
salt: base64ToBytes(saltBase64),
iterations: 250000,
hash: 'SHA-256'
}, baseKey, {
name: 'AES-GCM',
length: 256
}, false, keyUsages || ['encrypt', 'decrypt']);
}
async function importVaultKey(vaultKeyBase64, keyUsages) {
return crypto.subtle.importKey(
'raw',
base64ToBytes(vaultKeyBase64),
{ name: 'AES-GCM', length: 256 },
false,
keyUsages || ['encrypt', 'decrypt']
);
}
function splitCipherAndTag(buffer) {
var bytes = new Uint8Array(buffer);
var tagLength = 16;
return {
ciphertext: bytesToBase64(bytes.slice(0, bytes.length - tagLength)),
tag: bytesToBase64(bytes.slice(bytes.length - tagLength))
};
}
function joinCipherAndTag(ciphertextBase64, tagBase64) {
var ciphertext = base64ToBytes(ciphertextBase64);
var tag = base64ToBytes(tagBase64);
var merged = new Uint8Array(ciphertext.length + tag.length);
merged.set(ciphertext, 0);
merged.set(tag, ciphertext.length);
return merged;
}
async function createVaultEnvelope(password) {
var saltBase64 = randomBase64(16);
var vaultKeyBase64 = randomBase64(32);
var ivBase64 = randomBase64(12);
var passwordKey = await derivePasswordKey(password, saltBase64, ['encrypt', 'decrypt']);
var encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: base64ToBytes(ivBase64) },
passwordKey,
base64ToBytes(vaultKeyBase64)
);
return {
vaultKeyBase64: vaultKeyBase64,
wrappedDek: JSON.stringify({
ciphertext: arrayBufferToBase64(encrypted),
iv: ivBase64
}),
kdfSalt: saltBase64,
kdfParams: {
algorithm: 'PBKDF2',
hash: 'SHA-256',
iterations: 250000
},
keyVersion: CLOUD_VAULT_KEY_VERSION
};
}
async function unlockVaultKey(password, keyring) {
var envelope = keyring && keyring.wrappedDek ? JSON.parse(keyring.wrappedDek) : null;
if (!envelope || !envelope.ciphertext || !envelope.iv || !keyring.kdfSalt) {
throw new Error('保险柜密钥无效');
}
var passwordKey = await derivePasswordKey(password, keyring.kdfSalt, ['decrypt']);
var decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: base64ToBytes(envelope.iv) },
passwordKey,
base64ToBytes(envelope.ciphertext)
);
return bytesToBase64(new Uint8Array(decrypted));
}
async function encryptVaultPayload(vaultKeyBase64, payload) {
var json = JSON.stringify(payload);
var ivBase64 = randomBase64(12);
var vaultKey = await importVaultKey(vaultKeyBase64, ['encrypt']);
var encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: base64ToBytes(ivBase64) },
vaultKey,
textEncoder().encode(json)
);
var split = splitCipherAndTag(encrypted);
return {
payloadCiphertext: split.ciphertext,
payloadIv: ivBase64,
payloadTag: split.tag,
payloadHash: await sha256Hex(json),
keyVersion: CLOUD_VAULT_KEY_VERSION
};
}
async function decryptVaultPayload(vaultKeyBase64, item) {
var vaultKey = await importVaultKey(vaultKeyBase64, ['decrypt']);
var decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: base64ToBytes(item.payloadIv) },
vaultKey,
joinCipherAndTag(item.payloadCiphertext, item.payloadTag)
);
return JSON.parse(textDecoder().decode(decrypted));
}
function normalizeCloudAuthState(state) {
state = state && typeof state === 'object' ? state : {};
return {
token: typeof state.token === 'string' ? state.token : '',
vaultKeyBase64: typeof state.vaultKeyBase64 === 'string' ? state.vaultKeyBase64 : '',
userId: Number(state.userId) || 0,
email: typeof state.email === 'string' ? state.email : '',
syncEnabled: !!state.syncEnabled,
healthy: !!state.healthy,
lastError: typeof state.lastError === 'string' ? state.lastError : '',
lastSyncAt: Number(state.lastSyncAt) || 0
};
}
async function loadCloudAuthState() {
if (cloudAuthStateCache) {
return cloudAuthStateCache;
}
var result = await storageLocalGet(CLOUD_AUTH_STORAGE_KEY);
cloudAuthStateCache = normalizeCloudAuthState(result[CLOUD_AUTH_STORAGE_KEY]);
return cloudAuthStateCache;
}
async function saveCloudAuthState(state) {
cloudAuthStateCache = normalizeCloudAuthState(state);
await storageLocalSet((function() {
var data = {};
data[CLOUD_AUTH_STORAGE_KEY] = cloudAuthStateCache;
return data;
})());
return cloudAuthStateCache;
}
async function clearCloudAuthState() {
cloudAuthStateCache = normalizeCloudAuthState(null);
await storageLocalRemove(CLOUD_AUTH_STORAGE_KEY);
return cloudAuthStateCache;
}
async function ensureCloudDeviceFingerprint() {
var result = await storageLocalGet(CLOUD_DEVICE_ID_KEY);
var existing = typeof result[CLOUD_DEVICE_ID_KEY] === 'string' ? result[CLOUD_DEVICE_ID_KEY] : '';
if (existing) {
return existing;
}
existing = 'device-' + randomBase64(18).replace(/[^a-zA-Z0-9_-]/g, '');
await storageLocalSet((function() {
var data = {};
data[CLOUD_DEVICE_ID_KEY] = existing;
return data;
})());
return existing;
}
function buildCloudStatus(state) {
var normalized = normalizeCloudAuthState(state);
var healthy = !!(normalized.token && normalized.syncEnabled && normalized.healthy);
return {
ok: true,
syncEnabled: !!normalized.syncEnabled,
authenticated: !!normalized.token,
healthy: healthy,
color: healthy ? 'green' : 'red',
email: normalized.email,
lastError: normalized.lastError,
text: healthy ? '云同步正常' : (normalized.token ? '云同步需确认' : '云同步未登录'),
accountText: healthy ? '账号状态正常' : (normalized.token ? '账号状态需确认' : '未登录')
};
}
function callCloudApi(path, options) {
var method = options && options.method ? options.method : 'GET';
var payload = options && Object.prototype.hasOwnProperty.call(options, 'payload') ? options.payload : null;
var token = options && options.token ? options.token : '';
var timeoutMs = options && options.timeoutMs ? options.timeoutMs : CLOUD_CACHE_TIMEOUT;
var controller = new AbortController();
var realTimeout = Math.max(2000, Math.min(Number(timeoutMs) || CLOUD_CACHE_TIMEOUT, 15000));
var timer = setTimeout(function() {
controller.abort();
}, realTimeout);
var headers = { 'Content-Type': 'application/json' };
if (token) {
headers.Authorization = 'Bearer ' + token;
}
return fetch(normalizeCloudBaseUrl(CLOUD_CACHE_BASE_URL) + path, {
method: method,
headers: headers,
body: payload !== null ? JSON.stringify(payload) : undefined,
signal: controller.signal
}).then(function(response) {
if (!response.ok) {
return response.text().then(function(text) {
var payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch (e) {
payload = null;
}
throw new Error(payload && payload.error ? payload.error : ('云接口 HTTP ' + response.status));
});
}
return response.json();
}).finally(function() {
clearTimeout(timer);
});
}
async function setCloudHealth(ok, errorMessage) {
var state = await loadCloudAuthState();
state.healthy = !!ok;
state.lastError = ok ? '' : String(errorMessage || '同步失败');
state.lastSyncAt = Date.now();
await saveCloudAuthState(state);
return buildCloudStatus(state);
}
async function cloudRegisterAccount(payload) {
var email = String(payload.email || '').trim().toLowerCase();
var password = String(payload.password || '');
var deviceFingerprint = await ensureCloudDeviceFingerprint();
var envelope = await createVaultEnvelope(password);
var response = await callCloudApi('/api/auth/register', {
method: 'POST',
payload: {
email: email,
password: password,
wrappedDek: envelope.wrappedDek,
kdfSalt: envelope.kdfSalt,
kdfParams: envelope.kdfParams,
keyVersion: envelope.keyVersion,
deviceName: 'Magnet Chrome Extension',
deviceFingerprint: deviceFingerprint
}
});
if (!response || !response.ok || !response.token) {
throw new Error(response && response.error ? response.error : '注册失败');
}
await saveCloudAuthState({
token: response.token,
vaultKeyBase64: envelope.vaultKeyBase64,
userId: response.user && response.user.id,
email: response.user && response.user.email ? response.user.email : email,
syncEnabled: true,
healthy: true,
lastError: '',
lastSyncAt: Date.now()
});
backfillLocalCacheToCloud({ force: true }).catch(function(error) {
console.warn('[MagnetPlugin][Cloud] register backfill failed:', error && error.message ? error.message : error);
});
return {
ok: true,
status: buildCloudStatus(await loadCloudAuthState()),
vaultItems: []
};
}
async function cloudPullVaultItems(itemTypes) {
var state = await loadCloudAuthState();
var response = null;
var items = [];
if (!state.token || !state.vaultKeyBase64) {
return [];
}
response = await callCloudApi('/api/vault/pull', {
method: 'POST',
token: state.token,
payload: {
itemTypes: Array.isArray(itemTypes) ? itemTypes : []
}
});
if (!response || !response.ok || !Array.isArray(response.items)) {
throw new Error(response && response.error ? response.error : '读取保险柜失败');
}
items = [];
for (var index = 0; index < response.items.length; index++) {
var item = response.items[index];
items.push({
itemType: item.itemType,
itemKey: item.itemKey,
data: await decryptVaultPayload(state.vaultKeyBase64, item),
updatedAt: item.updatedAt,
keyVersion: item.keyVersion
});
}
await setCloudHealth(true);
return items;
}
async function cloudLoginAccount(payload) {
var email = String(payload.email || '').trim().toLowerCase();
var password = String(payload.password || '');
var deviceFingerprint = await ensureCloudDeviceFingerprint();
var response = await callCloudApi('/api/auth/login', {
method: 'POST',
payload: {
email: email,
password: password,
deviceName: 'Magnet Chrome Extension',
deviceFingerprint: deviceFingerprint
}
});
var vaultKeyBase64 = null;
var vaultItems = [];
if (!response || !response.ok || !response.token || !response.keyring) {
throw new Error(response && response.error ? response.error : '登录失败');
}
vaultKeyBase64 = await unlockVaultKey(password, response.keyring);
await saveCloudAuthState({
token: response.token,
vaultKeyBase64: vaultKeyBase64,
userId: response.user && response.user.id,
email: response.user && response.user.email ? response.user.email : email,
syncEnabled: true,
healthy: true,
lastError: '',
lastSyncAt: Date.now()
});
backfillLocalCacheToCloud({ force: true }).catch(function(error) {
console.warn('[MagnetPlugin][Cloud] login backfill failed:', error && error.message ? error.message : error);
});
cloudPullVaultItems(['favorites', 'search_history', 'ui_settings', 'progress_state']).catch(function(error) {
console.warn('[MagnetPlugin][Cloud] login vault pull failed:', error && error.message ? error.message : error);
return [];
});
return {
ok: true,
status: buildCloudStatus(await loadCloudAuthState()),
vaultItems: vaultItems
};
}
async function cloudLogoutAccount() {
var state = await loadCloudAuthState();
if (state.token) {
try {
await callCloudApi('/api/auth/logout', {
method: 'POST',
token: state.token,
payload: {}
});
} catch (error) {
console.warn('[MagnetPlugin][Cloud] logout failed:', error && error.message ? error.message : error);
}
}
await clearCloudAuthState();
return {
ok: true,
status: buildCloudStatus(await loadCloudAuthState())
};
}
async function cloudGetSyncStatus() {
var state = await loadCloudAuthState();
if (!state.token) {
return buildCloudStatus(state);
}
try {
var response = await callCloudApi('/api/auth/me', {
method: 'GET',
token: state.token
});
if (response && response.ok) {
state.email = response.user && response.user.email ? response.user.email : state.email;
state.userId = response.user && response.user.id ? response.user.id : state.userId;
state.syncEnabled = true;
state.healthy = true;
state.lastError = '';
state.lastSyncAt = Date.now();
await saveCloudAuthState(state);
backfillLocalCacheToCloud({ force: false }).catch(function(error) {
console.warn('[MagnetPlugin][Cloud] background backfill failed:', error && error.message ? error.message : error);
});
return buildCloudStatus(state);
}
throw new Error(response && response.error ? response.error : '状态检查失败');
} catch (error) {
return {
ok: true,
syncEnabled: !!state.syncEnabled,
authenticated: !!state.token,
healthy: false,
color: 'red',
email: state.email,
lastError: error && error.message ? error.message : String(error),
text: state.token ? '云同步需确认' : '云同步未登录',
accountText: state.token ? '账号状态需确认' : '未登录'
};
}
}
async function cloudGetCacheStats() {
return callCloudApi('/api/shared-cache/stats', {
method: 'GET',
timeoutMs: 12000
});
}
async function getLocalCacheStats() {
var db = await openCacheDb();
var privateState = null;
var uploadMeta = null;
var threadCount = 0;
var magnetThreadCount = 0;
var coverageCount = 0;
var pageCount = 0;
var storageEstimate = null;
var allThreads = [];
try {
var tx = db.transaction(['threads', 'coverages', 'pageCoverages'], 'readonly');
threadCount = Number(await requestToPromise(tx.objectStore('threads').count()) || 0);
coverageCount = Number(await requestToPromise(tx.objectStore('coverages').count()) || 0);
pageCount = Number(await requestToPromise(tx.objectStore('pageCoverages').count()) || 0);
await transactionDone(tx);
allThreads = await readAllStoreRecords(db, 'threads');
magnetThreadCount = allThreads.filter(function(record) {
return Array.isArray(record.magnets) && normalizeMagnets(record.magnets).length > 0;
}).length;
} finally {
db.close();
}
privateState = await storageLocalGet('magnet-private-state-v1');
uploadMeta = await loadCloudUploadMetaState();
storageEstimate = await getStorageEstimate();
var privateData = privateState['magnet-private-state-v1'] && typeof privateState['magnet-private-state-v1'] === 'object'
? privateState['magnet-private-state-v1']
: {};
return {
ok: true,
counts: {
threads: threadCount,
magnetThreads: magnetThreadCount,
coverages: coverageCount,
pages: pageCount,
favorites: Array.isArray(privateData.favorites) ? privateData.favorites.length : 0,
history: Array.isArray(privateData.searchHistory) ? privateData.searchHistory.length : 0,
uploadMetaThreads: Object.keys(uploadMeta.threads || {}).length,
uploadMetaCoverages: Object.keys(uploadMeta.coverages || {}).length,
uploadMetaPages: Object.keys(uploadMeta.pages || {}).length
},
storage: {
usage: storageEstimate && Number(storageEstimate.usage) ? Number(storageEstimate.usage) : 0,
quota: storageEstimate && Number(storageEstimate.quota) ? Number(storageEstimate.quota) : 0
}
};
}
async function cloudPushVaultItems(payload) {
var state = await loadCloudAuthState();
var items = Array.isArray(payload.items) ? payload.items : [];
var preparedItems = [];
var index = 0;
var item = null;
if (!state.token || !state.vaultKeyBase64) {
return buildCloudStatus(state);
}
for (index = 0; index < items.length; index++) {
item = items[index];
if (!item || typeof item !== 'object') {
continue;
}
var itemType = String(item.itemType || '').trim();
var itemKey = String(item.itemKey || '').trim() || itemType;
if (!itemType) {
continue;
}
var encrypted = await encryptVaultPayload(state.vaultKeyBase64, item.data);
preparedItems.push({
itemType: itemType,
itemKey: itemKey,
payloadCiphertext: encrypted.payloadCiphertext,
payloadIv: encrypted.payloadIv,
payloadTag: encrypted.payloadTag,
payloadHash: encrypted.payloadHash,
keyVersion: encrypted.keyVersion
});
}
if (preparedItems.length === 0) {
return buildCloudStatus(state);
}
try {
await callCloudApi('/api/vault/push', {
method: 'POST',
token: state.token,
payload: { items: preparedItems }
});
return await setCloudHealth(true);
} catch (error) {
return await setCloudHealth(false, error && error.message ? error.message : error);
}
}
function openCacheDb() {
return new Promise(function(resolve, reject) {
var request = indexedDB.open(CACHE_DB_NAME, CACHE_DB_VERSION);
request.onupgradeneeded = function(event) {
var db = event.target.result;
if (!db.objectStoreNames.contains('threads')) {
var threadStore = db.createObjectStore('threads', { keyPath: 'cacheKey' });
threadStore.createIndex('forumKey', 'forumKey', { unique: false });
threadStore.createIndex('lastSeenAt', 'lastSeenAt', { unique: false });
threadStore.createIndex('forumKeyLastSeenAt', ['forumKey', 'lastSeenAt'], { unique: false });
} else {
var existingThreadStore = request.transaction.objectStore('threads');
if (!existingThreadStore.indexNames.contains('forumKeyLastSeenAt')) {
existingThreadStore.createIndex('forumKeyLastSeenAt', ['forumKey', 'lastSeenAt'], { unique: false });
}
}
if (!db.objectStoreNames.contains('coverages')) {
var coverageStore = db.createObjectStore('coverages', { keyPath: 'coverageKey' });
coverageStore.createIndex('forumKey', 'forumKey', { unique: false });
coverageStore.createIndex('crawledAt', 'crawledAt', { unique: false });
coverageStore.createIndex('forumKeyCrawledAt', ['forumKey', 'crawledAt'], { unique: false });
} else {
var existingCoverageStore = request.transaction.objectStore('coverages');
if (!existingCoverageStore.indexNames.contains('forumKeyCrawledAt')) {
existingCoverageStore.createIndex('forumKeyCrawledAt', ['forumKey', 'crawledAt'], { unique: false });
}
}
if (!db.objectStoreNames.contains('pageCoverages')) {
var pageCoverageStore = db.createObjectStore('pageCoverages', { keyPath: 'coverageKey' });
pageCoverageStore.createIndex('forumKey', 'forumKey', { unique: false });
pageCoverageStore.createIndex('crawledAt', 'crawledAt', { unique: false });
pageCoverageStore.createIndex('forumKeyCrawledAt', ['forumKey', 'crawledAt'], { unique: false });
} else {
var existingPageCoverageStore = request.transaction.objectStore('pageCoverages');
if (!existingPageCoverageStore.indexNames.contains('forumKeyCrawledAt')) {
existingPageCoverageStore.createIndex('forumKeyCrawledAt', ['forumKey', 'crawledAt'], { unique: false });
}
}
};
request.onsuccess = function(event) {
resolve(event.target.result);
};
request.onerror = function(event) {
reject(event.target.error || new Error('无法打开缓存数据库'));
};
});
}
function normalizeUrl(url) {
if (typeof url !== 'string' || !url) {
return '';
}
try {
var parsed = new URL(url);
parsed.hash = '';
return parsed.href;
} catch (e) {
return url;
}
}
function getThreadKeyFromUrl(url) {
var normalized = normalizeUrl(url);
if (!normalized) {
return '';
}
var match = normalized.match(/thread-(\d+)-/i) || normalized.match(/[?&]tid=(\d+)/i);
return match ? match[1] : normalized;
}
function normalizeTitle(title) {
return String(title || '').replace(/\s+/g, ' ').trim();
}
function normalizeMagnets(magnets) {
var seen = Object.create(null);
var result = [];
if (!Array.isArray(magnets)) {
return result;
}
magnets.forEach(function(magnet) {
if (typeof magnet !== 'string' || !magnet) {
return;
}
if (seen[magnet]) {
return;
}
seen[magnet] = true;
result.push(magnet);
});
return result;
}
function normalizeThreadEntry(thread, forumKey, crawledAt) {
if (!thread || typeof thread !== 'object') {
return null;
}
var normalizedUrl = normalizeUrl(thread.url);
var normalizedTitle = normalizeTitle(thread.title);
var threadKey = typeof thread.threadKey === 'string' && thread.threadKey
? thread.threadKey
: getThreadKeyFromUrl(normalizedUrl);
if (!forumKey || !normalizedUrl || !threadKey) {
return null;
}
return {
cacheKey: forumKey + '::' + threadKey,
forumKey: forumKey,
threadKey: threadKey,
url: normalizedUrl,
title: normalizedTitle,
titleNorm: normalizedTitle.toLowerCase(),
lastSeenAt: crawledAt,
firstSeenAt: crawledAt,
magnets: normalizeMagnets(thread.magnets),
magnetCount: Array.isArray(thread.magnets) ? normalizeMagnets(thread.magnets).length : 0,
lastMagnetSyncAt: Array.isArray(thread.magnets) && thread.magnets.length > 0 ? crawledAt : 0
};
}
function getCoverageKey(forumKey, startPage, endPage) {
return forumKey + '::' + startPage + '-' + endPage;
}
function getPageCoverageKey(forumKey, page) {
return forumKey + '::page::' + page;
}
function normalizeThreadsForCoverage(rawThreads, forumKey, crawledAt) {
var seen = Object.create(null);
var normalizedThreads = [];
(Array.isArray(rawThreads) ? rawThreads : []).forEach(function(thread) {
var normalized = normalizeThreadEntry(thread, forumKey, crawledAt);
if (!normalized || seen[normalized.cacheKey]) {
return;
}
seen[normalized.cacheKey] = true;
normalizedThreads.push({
threadKey: normalized.threadKey,
url: normalized.url,
title: normalized.title
});
});
return normalizedThreads;
}
function mergeCoverageThreadLists(forumKey, crawledAt, primaryThreads, secondaryThreads) {
var seen = Object.create(null);
var merged = [];
[primaryThreads, secondaryThreads].forEach(function(source) {
normalizeThreadsForCoverage(source, forumKey, crawledAt).forEach(function(thread) {
var key = String(thread.threadKey || '');
if (!key || seen[key]) {
return;
}
seen[key] = true;
merged.push({
threadKey: thread.threadKey,
url: thread.url,
title: thread.title
});
});
});
return merged;
}
function normalizeThreadsForCloudMagnets(rawThreads, forumKey, crawledAt) {
var seen = Object.create(null);
var normalizedThreads = [];
(Array.isArray(rawThreads) ? rawThreads : []).forEach(function(thread) {
var normalized = normalizeThreadEntry(thread, forumKey, crawledAt);
if (!normalized || seen[normalized.cacheKey]) {
return;
}
var magnets = normalizeMagnets(thread.magnets);
if (magnets.length === 0) {
return;
}
seen[normalized.cacheKey] = true;
normalizedThreads.push({
threadKey: normalized.threadKey,
url: normalized.url,
title: normalized.title,
magnets: magnets,
lastSeenAt: crawledAt
});
});
return normalizedThreads;
}
function normalizeThreadsForCloudIndex(rawThreads, forumKey, crawledAt) {
var seen = Object.create(null);
var normalizedThreads = [];
(Array.isArray(rawThreads) ? rawThreads : []).forEach(function(thread) {
var normalized = normalizeThreadEntry(thread, forumKey, crawledAt);
if (!normalized || seen[normalized.cacheKey]) {
return;
}
seen[normalized.cacheKey] = true;
normalizedThreads.push({
threadKey: normalized.threadKey,
url: normalized.url,
title: normalized.title,
magnets: normalizeMagnets(thread.magnets),
lastSeenAt: Number(thread.lastSeenAt) || crawledAt
});
});
return normalizedThreads;
}
function normalizeCloudCoveragePayload(coverage) {
if (!coverage || typeof coverage !== 'object') {
return null;
}
var forumKey = typeof coverage.forumKey === 'string' ? coverage.forumKey : '';
var startPage = Math.max(1, Number(coverage.startPage) || 1);
var endPage = Math.max(startPage, Number(coverage.endPage) || startPage);
if (!forumKey) {
return null;
}
return {
forumKey: forumKey,
startPage: startPage,
endPage: endPage,
crawledAt: Number(coverage.crawledAt) || Date.now(),
strategy: typeof coverage.strategy === 'string' && coverage.strategy ? coverage.strategy : 'full_live',
frontRefreshPages: Math.max(0, Number(coverage.frontRefreshPages) || 0),
threads: normalizeThreadsForCoverage(coverage.threads, forumKey, Number(coverage.crawledAt) || Date.now())
};
}
function normalizeCloudPageCoveragePayload(coverage) {
if (!coverage || typeof coverage !== 'object') {
return null;
}
var forumKey = typeof coverage.forumKey === 'string' ? coverage.forumKey : '';
var page = Math.max(1, Number(coverage.page) || 1);
if (!forumKey) {
return null;
}
return {
forumKey: forumKey,
page: page,
crawledAt: Number(coverage.crawledAt) || Date.now(),
threads: normalizeThreadsForCoverage(coverage.threads, forumKey, Number(coverage.crawledAt) || Date.now())
};
}
function normalizeCloudThreadMagnetsPayload(result) {
if (!result || typeof result !== 'object') {
return null;
}
var forumKey = typeof result.forumKey === 'string' ? result.forumKey : '';
var threadKey = typeof result.threadKey === 'string' ? result.threadKey : getThreadKeyFromUrl(result.url);
var url = normalizeUrl(result.url);
var magnets = normalizeMagnets(result.magnets);
if (!forumKey || !threadKey || !url || magnets.length === 0) {
return null;
}
return {
forumKey: forumKey,
threadKey: threadKey,
url: url,
title: normalizeTitle(result.title),
magnets: magnets,
lastSeenAt: Number(result.lastSeenAt) || Date.now()
};
}
async function cloudLookupThreadMagnets(payload) {
var response = await callCloudCache('/api/shared-cache/threads/lookup', {
threads: normalizeThreadsForCoverage(payload.threads, payload.forumKey, Date.now()).map(function(item) {
return {
forumKey: payload.forumKey,
threadKey: item.threadKey
};
})
});
if (!response || !response.ok || !Array.isArray(response.threads)) {
return [];
}
return response.threads.map(function(item) {
return normalizeCloudThreadMagnetsPayload({
forumKey: payload.forumKey,
threadKey: item.threadKey,
url: item.url,
title: item.title,
magnets: item.magnets,
lastSeenAt: item.lastSeenAt
});
}).filter(Boolean);
}
async function cloudUpsertThreadMagnets(payload) {
var allThreads = normalizeThreadsForCloudIndex(payload.threads, payload.forumKey, Number(payload.syncedAt) || Date.now()).map(function(item) {
return {
forumKey: payload.forumKey,
threadKey: item.threadKey,
url: item.url,
title: item.title,
magnets: item.magnets,
lastSeenAt: item.lastSeenAt
};
});
if (allThreads.length === 0) {
return { ok: true, savedCount: 0 };
}
return callCloudCache('/api/shared-cache/threads/upsert', {
threads: allThreads
}, CLOUD_CACHE_TIMEOUT, {
'x-shared-cache-write-token': CLOUD_SHARED_CACHE_WRITE_TOKEN
});
}
async function cloudLookupCoverageByStrategy(forumKey, startPage, endPage, strategy) {
var response = await callCloudCache('/api/shared-cache/coverages/lookup', {
forumKey: forumKey,
startPage: startPage,
endPage: endPage,
strategy: strategy
});
if (!response || !response.ok || !response.coverage) {
return null;
}
return normalizeCloudCoveragePayload(response.coverage);
}
async function cloudLookupCoverage(payload) {
var strategies = ['full_live', 'exact_cache', 'smart_incremental', 'assembled_pages'];
for (var index = 0; index < strategies.length; index++) {
var coverage = await cloudLookupCoverageByStrategy(payload.forumKey, payload.startPage, payload.endPage, strategies[index]);
if (coverage) {
return coverage;
}
}
return null;
}
async function cloudLookupCoveragePlan(payload) {
var response = await callCloudCache('/api/shared-cache/coverages/plan', {
forumKey: payload.forumKey,
startPage: payload.startPage,
endPage: payload.endPage,
frontRefreshPages: payload.frontRefreshPages
});
if (!response || response.ok === false) {
return null;
}
return response;
}
async function cloudUpsertCoverage(payload) {
return callCloudCache('/api/shared-cache/coverages/upsert', {
forumKey: payload.forumKey,
startPage: payload.startPage,
endPage: payload.endPage,
strategy: payload.strategy,
crawledAt: payload.crawledAt,
threads: normalizeThreadsForCoverage(payload.threads, payload.forumKey, payload.crawledAt)
}, CLOUD_CACHE_TIMEOUT, {
'x-shared-cache-write-token': CLOUD_SHARED_CACHE_WRITE_TOKEN
});
}
async function cloudLookupPageCoverage(payload) {
var response = await callCloudCache('/api/shared-cache/pages/lookup', {
forumKey: payload.forumKey,
page: payload.page
});
if (!response || !response.ok || !response.coverage) {
return null;
}
return normalizeCloudPageCoveragePayload(response.coverage);
}
async function cloudUpsertPageCoverage(payload) {
return callCloudCache('/api/shared-cache/pages/upsert', {
forumKey: payload.forumKey,
page: payload.page,
crawledAt: payload.crawledAt,
threads: normalizeThreadsForCoverage(payload.threads, payload.forumKey, payload.crawledAt)
}, CLOUD_CACHE_TIMEOUT, {
'x-shared-cache-write-token': CLOUD_SHARED_CACHE_WRITE_TOKEN
});
}
async function getCloudAssembledPageCoverage(forumKey, startPage, endPage) {
var requests = [];
for (var page = startPage; page <= endPage; page++) {
requests.push(cloudLookupPageCoverage({ forumKey: forumKey, page: page }));
}
var results = await Promise.all(requests);
if (results.some(function(item) { return !item; })) {
return null;
}
var merged = Object.create(null);
var threads = [];
var latestCrawledAt = 0;
results.forEach(function(item) {
latestCrawledAt = Math.max(latestCrawledAt, Number(item.crawledAt || 0));
(Array.isArray(item.threads) ? item.threads : []).forEach(function(thread) {
var key = typeof thread.threadKey === 'string' && thread.threadKey ? thread.threadKey : getThreadKeyFromUrl(thread.url);
if (!key || merged[key]) {
return;
}
merged[key] = true;
threads.push({
threadKey: key,
url: normalizeUrl(thread.url),
title: normalizeTitle(thread.title)
});
});
});
return {
forumKey: forumKey,
startPage: startPage,
endPage: endPage,
crawledAt: latestCrawledAt || Date.now(),
strategy: 'assembled_pages',
frontRefreshPages: 0,
threads: threads
};
}
async function upsertThreadEntries(threadStore, forumKey, rawThreads, crawledAt) {
var seenThreadKeys = Object.create(null);
var threadKeys = [];
for (var threadIndex = 0; threadIndex < rawThreads.length; threadIndex++) {
var thread = rawThreads[threadIndex];
var normalized = normalizeThreadEntry(thread, forumKey, crawledAt);
if (!normalized || seenThreadKeys[normalized.cacheKey]) {
continue;
}
seenThreadKeys[normalized.cacheKey] = true;
threadKeys.push(normalized.cacheKey);
var existingRecord = await requestToPromise(threadStore.get(normalized.cacheKey));
var incomingMagnets = normalizeMagnets(normalized.magnets);
var mergedMagnets = incomingMagnets.length > 0
? incomingMagnets
: (existingRecord && Array.isArray(existingRecord.magnets) ? existingRecord.magnets : []);
var mergedLastMagnetSyncAt = incomingMagnets.length > 0
? (Number(normalized.lastMagnetSyncAt) || crawledAt)
: (existingRecord && Number(existingRecord.lastMagnetSyncAt) ? Number(existingRecord.lastMagnetSyncAt) : (normalized.lastMagnetSyncAt || 0));
threadStore.put({
cacheKey: normalized.cacheKey,
forumKey: normalized.forumKey,
threadKey: normalized.threadKey,
url: normalized.url,
title: normalized.title,
titleNorm: normalized.titleNorm,
firstSeenAt: existingRecord && Number(existingRecord.firstSeenAt)
? Number(existingRecord.firstSeenAt)
: normalized.firstSeenAt,
lastSeenAt: normalized.lastSeenAt,
magnets: normalizeMagnets(mergedMagnets),
magnetCount: normalizeMagnets(mergedMagnets).length,
lastMagnetSyncAt: mergedLastMagnetSyncAt
});
}
return threadKeys;
}
async function getAssembledPageCoverage(db, forumKey, startPage, endPage) {
var pageTx = db.transaction('pageCoverages', 'readonly');
var pageStore = pageTx.objectStore('pageCoverages');
var pageRequests = [];
for (var page = startPage; page <= endPage; page++) {
pageRequests.push(requestToPromise(pageStore.get(getPageCoverageKey(forumKey, page))));
}
var pageRecords = await Promise.all(pageRequests);
await transactionDone(pageTx);
if (pageRecords.some(function(record) { return !record; })) {
return null;
}
var merged = Object.create(null);
var mergedThreadKeys = [];
var latestCrawledAt = 0;
pageRecords.forEach(function(record) {
latestCrawledAt = Math.max(latestCrawledAt, Number(record.crawledAt || 0));
(Array.isArray(record.threadKeys) ? record.threadKeys : []).forEach(function(threadKey) {
if (merged[threadKey]) {
return;
}
merged[threadKey] = true;
mergedThreadKeys.push(threadKey);
});
});
return getCoverageWithThreads(db, {
forumKey: forumKey,
startPage: startPage,
endPage: endPage,
crawledAt: latestCrawledAt,
strategy: 'assembled_pages',
frontRefreshPages: 0,
threadKeys: mergedThreadKeys
});
}
async function getCachedPageCoverageBlocks(db, forumKey, startPage, endPage) {
var tx = db.transaction('pageCoverages', 'readonly');
var store = tx.objectStore('pageCoverages');
var index = store.index('forumKey');
var request = index.openCursor(IDBKeyRange.only(forumKey));
var pageRecords = [];
var records = await new Promise(function(resolve, reject) {
request.onsuccess = function(event) {
var cursor = event.target.result;
var record = null;
if (!cursor) {
resolve(pageRecords);
return;
}
record = cursor.value;
if (record && Number(record.page) >= startPage && Number(record.page) <= endPage) {
pageRecords.push(record);
}
cursor.continue();
};
request.onerror = function(event) {
reject(event.target.error || new Error('读取页缓存块失败'));
};
});
await transactionDone(tx);
if (!records.length) {
return [];
}
records.sort(function(a, b) {
return Number(a.page || 0) - Number(b.page || 0);
});
var blocks = [];
var currentBlock = null;
var latestCrawledAt = 0;
var mergedThreadKeys = null;
records.forEach(function(record) {
var page = Number(record.page || 0);
if (!currentBlock) {
currentBlock = {
forumKey: forumKey,
startPage: page,
endPage: page,
pages: [record],
crawledAt: Number(record.crawledAt || 0)
};
return;
}
if (page === currentBlock.endPage + 1) {
currentBlock.endPage = page;
currentBlock.pages.push(record);
currentBlock.crawledAt = Math.max(currentBlock.crawledAt, Number(record.crawledAt || 0));
return;
}
blocks.push(currentBlock);
currentBlock = {
forumKey: forumKey,
startPage: page,
endPage: page,
pages: [record],
crawledAt: Number(record.crawledAt || 0)
};
});
if (currentBlock) {
blocks.push(currentBlock);
}
return blocks.map(function(block) {
var merged = Object.create(null);
mergedThreadKeys = [];
latestCrawledAt = 0;
block.pages.forEach(function(record) {
latestCrawledAt = Math.max(latestCrawledAt, Number(record.crawledAt || 0));
(Array.isArray(record.threadKeys) ? record.threadKeys : []).forEach(function(threadKey) {
if (merged[threadKey]) {
return;
}
merged[threadKey] = true;
mergedThreadKeys.push(threadKey);
});
});
return {
forumKey: forumKey,
startPage: block.startPage,
endPage: block.endPage,
crawledAt: latestCrawledAt,
strategy: 'assembled_pages',
frontRefreshPages: 0,
threadKeys: mergedThreadKeys
};
});
}
async function getCoverageWithThreads(db, coverageRecord) {
if (!coverageRecord) {
return null;
}
var threadKeys = Array.isArray(coverageRecord.threadKeys) ? coverageRecord.threadKeys : [];
var threads = [];
if (threadKeys.length > 0) {
var threadTx = db.transaction('threads', 'readonly');
var threadStore = threadTx.objectStore('threads');
var threadRequests = threadKeys.map(function(threadKey) {
return requestToPromise(threadStore.get(threadKey));
});
var threadResults = await Promise.all(threadRequests);
await transactionDone(threadTx);
threads = threadResults.filter(function(record) {
return !!record;
}).map(function(record) {
return {
url: record.url,
title: record.title,
threadKey: record.threadKey
};
});
}
return {
forumKey: coverageRecord.forumKey,
startPage: coverageRecord.startPage,
endPage: coverageRecord.endPage,
crawledAt: coverageRecord.crawledAt,
strategy: coverageRecord.strategy || 'full_live',
frontRefreshPages: coverageRecord.frontRefreshPages || 0,
threads: threads
};
}
async function listForumCoverages(db, forumKey) {
return new Promise(function(resolve, reject) {
var tx = db.transaction('coverages', 'readonly');
var store = tx.objectStore('coverages');
var index = store.index('forumKey');
var request = index.openCursor(IDBKeyRange.only(forumKey));
var results = [];
request.onsuccess = function(event) {
var cursor = event.target.result;
if (!cursor) {
resolve(results);
return;
}
results.push(cursor.value);
cursor.continue();
};
request.onerror = function(event) {
reject(event.target.error || new Error('读取覆盖快照失败'));
};
});
}
async function getIntersectingCoverageBlocks(db, forumKey, startPage, endPage) {
var coverages = await listForumCoverages(db, forumKey);
var filtered = coverages.filter(function(record) {
var recordStart = Number(record.startPage || 0);
var recordEnd = Number(record.endPage || 0);
return recordStart <= endPage && recordEnd >= startPage;
}).sort(function(a, b) {
var aStart = Number(a.startPage || 0);
var bStart = Number(b.startPage || 0);
if (aStart !== bStart) {
return aStart - bStart;
}
return Number(b.endPage || 0) - Number(a.endPage || 0);
});
var result = [];
var seenRanges = Object.create(null);
var index = 0;
var record = null;
var hydrated = null;
var clippedStart = 0;
var clippedEnd = 0;
var rangeKey = '';
for (index = 0; index < filtered.length; index++) {
record = filtered[index];
hydrated = await getCoverageWithThreads(db, record);
if (!hydrated || !Array.isArray(hydrated.threads) || hydrated.threads.length === 0) {
continue;
}
clippedStart = Math.max(startPage, Number(hydrated.startPage || startPage));
clippedEnd = Math.min(endPage, Number(hydrated.endPage || endPage));
if (clippedStart > clippedEnd) {
continue;
}
rangeKey = String(clippedStart) + '-' + String(clippedEnd);
if (seenRanges[rangeKey]) {
continue;
}
seenRanges[rangeKey] = true;
result.push({
forumKey: hydrated.forumKey,
startPage: clippedStart,
endPage: clippedEnd,
crawledAt: hydrated.crawledAt,
strategy: hydrated.strategy || 'full_live',
frontRefreshPages: hydrated.frontRefreshPages || 0,
threads: hydrated.threads
});
}
return result;
}
async function pruneOldCoverages(db, forumKey) {
var coverages = await listForumCoverages(db, forumKey);
if (coverages.length <= COVERAGE_LIMIT_PER_FORUM) {
return;
}
coverages.sort(function(a, b) {
return Number(b.crawledAt || 0) - Number(a.crawledAt || 0);
});
var stale = coverages.slice(COVERAGE_LIMIT_PER_FORUM);
if (stale.length === 0) {
return;
}
var tx = db.transaction('coverages', 'readwrite');
var store = tx.objectStore('coverages');
stale.forEach(function(record) {
store.delete(record.coverageKey);
});
await transactionDone(tx);
}
async function saveCoverageSnapshot(payload) {
var forumKey = typeof payload.forumKey === 'string' ? payload.forumKey : '';
var startPage = Math.max(1, Number(payload.startPage) || 1);
var endPage = Math.max(startPage, Number(payload.endPage) || startPage);
var crawledAt = Number(payload.crawledAt) || Date.now();
var frontRefreshPages = Math.max(0, Number(payload.frontRefreshPages) || 0);
var strategy = typeof payload.strategy === 'string' ? payload.strategy : 'full_live';
var skipCloudSync = !!payload.skipCloudSync;
var rawThreads = Array.isArray(payload.threads) ? payload.threads : [];
var normalizedThreadsForCoverage = normalizeThreadsForCoverage(rawThreads, forumKey, crawledAt);
var intersectingBlocks = null;
var historicalThreads = [];
var mergedCoverageThreads = null;
var result = null;
var db = await openCacheDb();
try {
intersectingBlocks = await getIntersectingCoverageBlocks(db, forumKey, startPage, endPage);
historicalThreads = [];
intersectingBlocks.forEach(function(block) {
(Array.isArray(block.threads) ? block.threads : []).forEach(function(thread) {
historicalThreads.push(thread);
});
});
mergedCoverageThreads = mergeCoverageThreadLists(forumKey, crawledAt, rawThreads, historicalThreads);
normalizedThreadsForCoverage = normalizeThreadsForCoverage(mergedCoverageThreads, forumKey, crawledAt);
var tx = db.transaction(['threads', 'coverages'], 'readwrite');
var threadStore = tx.objectStore('threads');
var coverageStore = tx.objectStore('coverages');
var threadKeys = await upsertThreadEntries(threadStore, forumKey, mergedCoverageThreads, crawledAt);
coverageStore.put({
coverageKey: getCoverageKey(forumKey, startPage, endPage),
forumKey: forumKey,
startPage: startPage,
endPage: endPage,
crawledAt: crawledAt,
strategy: strategy,
frontRefreshPages: frontRefreshPages,
threadKeys: threadKeys,
threadCount: threadKeys.length
});
await transactionDone(tx);
await pruneOldCoverages(db, forumKey);
result = {
ok: true,
threadCount: threadKeys.length,
coverageKey: getCoverageKey(forumKey, startPage, endPage)
};
} finally {
db.close();
}
if (!skipCloudSync && forumKey && normalizedThreadsForCoverage.length > 0) {
try {
var coverageThreadUploadPlan = await filterThreadUploadsForCloud(
forumKey,
normalizeThreadsForCloudIndex(mergedCoverageThreads, forumKey, crawledAt),
{ ttlMs: CLOUD_THREAD_UPLOAD_TTL_MS }
);
if (coverageThreadUploadPlan.uploads.length > 0) {
var threadUpsertResult = await cloudUpsertThreadMagnets({
forumKey: forumKey,
syncedAt: crawledAt,
threads: coverageThreadUploadPlan.uploads
});
if (threadUpsertResult && threadUpsertResult.ok) {
await markUploadMetaEntries('thread', coverageThreadUploadPlan.pendingMeta);
}
}
var coverageUploadCheck = await shouldUploadCoverageToCloud({
forumKey: forumKey,
startPage: startPage,
endPage: endPage,
strategy: strategy,
crawledAt: crawledAt,
threads: normalizedThreadsForCoverage
});
if (coverageUploadCheck.shouldUpload) {
var coverageUpsertResult = await cloudUpsertCoverage({
forumKey: forumKey,
startPage: startPage,
endPage: endPage,
strategy: strategy,
crawledAt: crawledAt,
threads: normalizedThreadsForCoverage
});
if (coverageUpsertResult && coverageUpsertResult.ok) {
await markUploadMetaEntries('coverage', [coverageUploadCheck]);
}
}
} catch (error) {
console.warn('[MagnetPlugin][Cloud] save coverage failed:', error && error.message ? error.message : error);
}
backfillLocalCacheToCloud({ force: false }).catch(function(backfillErr) {
console.warn('[MagnetPlugin][Cloud] post-coverage backfill failed:', backfillErr && backfillErr.message ? backfillErr.message : backfillErr);
});
}
return result;
}
async function savePageCoverageSnapshot(payload) {
var forumKey = typeof payload.forumKey === 'string' ? payload.forumKey : '';
var page = Math.max(1, Number(payload.page) || 1);
var crawledAt = Number(payload.crawledAt) || Date.now();
var skipCloudSync = !!payload.skipCloudSync;
var rawThreads = Array.isArray(payload.threads) ? payload.threads : [];
var normalizedThreadsForCoverage = normalizeThreadsForCoverage(rawThreads, forumKey, crawledAt);
var result = null;
var db = await openCacheDb();
try {
var tx = db.transaction(['threads', 'pageCoverages'], 'readwrite');
var threadStore = tx.objectStore('threads');
var pageCoverageStore = tx.objectStore('pageCoverages');
var threadKeys = await upsertThreadEntries(threadStore, forumKey, rawThreads, crawledAt);
pageCoverageStore.put({
coverageKey: getPageCoverageKey(forumKey, page),
forumKey: forumKey,
page: page,
crawledAt: crawledAt,
threadKeys: threadKeys,
threadCount: threadKeys.length
});
await transactionDone(tx);
result = {
ok: true,
page: page,
threadCount: threadKeys.length,
coverageKey: getPageCoverageKey(forumKey, page)
};
} finally {
db.close();
}
if (!skipCloudSync && forumKey && normalizedThreadsForCoverage.length > 0) {
try {
var pageThreadUploadPlan = await filterThreadUploadsForCloud(
forumKey,
normalizeThreadsForCloudIndex(rawThreads, forumKey, crawledAt),
{ ttlMs: CLOUD_THREAD_UPLOAD_TTL_MS }
);
if (pageThreadUploadPlan.uploads.length > 0) {
var pageThreadUpsertResult = await cloudUpsertThreadMagnets({
forumKey: forumKey,
syncedAt: crawledAt,
threads: pageThreadUploadPlan.uploads
});
if (pageThreadUpsertResult && pageThreadUpsertResult.ok) {
await markUploadMetaEntries('thread', pageThreadUploadPlan.pendingMeta);
}
}
var pageUploadCheck = await shouldUploadPageToCloud({
forumKey: forumKey,
page: page,
crawledAt: crawledAt,
threads: normalizedThreadsForCoverage
});
if (pageUploadCheck.shouldUpload) {
var pageUpsertResult = await cloudUpsertPageCoverage({
forumKey: forumKey,
page: page,
crawledAt: crawledAt,
threads: normalizedThreadsForCoverage
});
if (pageUpsertResult && pageUpsertResult.ok) {
await markUploadMetaEntries('page', [pageUploadCheck]);
}
}
} catch (error) {
console.warn('[MagnetPlugin][Cloud] save page coverage failed:', error && error.message ? error.message : error);
}
}
return result;
}
async function getCachedThreadMagnets(payload) {
var forumKey = typeof payload.forumKey === 'string' ? payload.forumKey : '';
var rawThreads = Array.isArray(payload.threads) ? payload.threads : [];
var db = await openCacheDb();
var misses = [];
var resultMap = Object.create(null);
try {
var tx = db.transaction('threads', 'readonly');
var threadStore = tx.objectStore('threads');
var results = [];
for (var threadIndex = 0; threadIndex < rawThreads.length; threadIndex++) {
var thread = rawThreads[threadIndex];
var normalized = normalizeThreadEntry(thread, forumKey, Date.now());
if (!normalized) {
continue;
}
var record = await requestToPromise(threadStore.get(normalized.cacheKey));
if (!record) {
results.push({
threadKey: normalized.threadKey,
url: normalized.url,
title: normalized.title,
magnets: []
});
misses.push({
threadKey: normalized.threadKey,
url: normalized.url,
title: normalized.title
});
continue;
}
var recordMagnets = normalizeMagnets(record.magnets);
if (recordMagnets.length === 0 && !Number(record.lastMagnetSyncAt)) {
misses.push({
threadKey: record.threadKey,
url: record.url,
title: record.title
});
}
results.push({
threadKey: record.threadKey,
url: record.url,
title: record.title,
magnets: recordMagnets,
lastMagnetSyncAt: Number(record.lastMagnetSyncAt) || 0
});
}
await transactionDone(tx);
results.forEach(function(item) {
if (item && item.threadKey) {
resultMap[item.threadKey] = item;
}
});
if (misses.length > 0) {
try {
var cloudThreads = await cloudLookupThreadMagnets({
forumKey: forumKey,
threads: misses
});
if (cloudThreads.length > 0) {
await saveThreadMagnets({
forumKey: forumKey,
syncedAt: Date.now(),
threads: cloudThreads,
skipCloudSync: true
});
cloudThreads.forEach(function(item) {
if (!item || !item.threadKey) {
return;
}
resultMap[item.threadKey] = {
threadKey: item.threadKey,
url: item.url,
title: item.title,
magnets: normalizeMagnets(item.magnets),
lastMagnetSyncAt: Number(item.lastSeenAt) || Date.now()
};
});
}
} catch (error) {
console.warn('[MagnetPlugin][Cloud] lookup thread magnets failed:', error && error.message ? error.message : error);
}
}
return {
ok: true,
threads: results.map(function(item) {
return item && item.threadKey && resultMap[item.threadKey] ? resultMap[item.threadKey] : item;
})
};
} finally {
db.close();
}
}
async function saveThreadMagnets(payload) {
var forumKey = typeof payload.forumKey === 'string' ? payload.forumKey : '';
var rawThreads = Array.isArray(payload.threads) ? payload.threads : [];
var syncedAt = Number(payload.syncedAt) || Date.now();
var skipCloudSync = !!payload.skipCloudSync;
var cloudThreads = [];
var result = null;
var db = await openCacheDb();
try {
var tx = db.transaction('threads', 'readwrite');
var threadStore = tx.objectStore('threads');
var savedCount = 0;
for (var threadIndex = 0; threadIndex < rawThreads.length; threadIndex++) {
var thread = rawThreads[threadIndex];
var normalized = normalizeThreadEntry(thread, forumKey, syncedAt);
if (!normalized) {
continue;
}
var existingRecord = await requestToPromise(threadStore.get(normalized.cacheKey));
var normalizedMagnets = normalizeMagnets(thread.magnets);
var finalMagnets = normalizedMagnets.length > 0
? normalizedMagnets
: (existingRecord && Array.isArray(existingRecord.magnets) ? normalizeMagnets(existingRecord.magnets) : []);
threadStore.put({
cacheKey: normalized.cacheKey,
forumKey: normalized.forumKey,
threadKey: normalized.threadKey,
url: normalized.url,
title: normalized.title || (existingRecord ? existingRecord.title : ''),
titleNorm: normalizeTitle(normalized.title || (existingRecord ? existingRecord.title : '')).toLowerCase(),
firstSeenAt: existingRecord && Number(existingRecord.firstSeenAt)
? Number(existingRecord.firstSeenAt)
: normalized.firstSeenAt,
lastSeenAt: Math.max(Number(existingRecord && existingRecord.lastSeenAt || 0), Number(normalized.lastSeenAt || 0)) || Date.now(),
magnets: finalMagnets,
magnetCount: finalMagnets.length,
lastMagnetSyncAt: finalMagnets.length > 0 ? syncedAt : (existingRecord && Number(existingRecord.lastMagnetSyncAt) ? Number(existingRecord.lastMagnetSyncAt) : 0)
});
if (finalMagnets.length > 0) {
cloudThreads.push({
threadKey: normalized.threadKey,
url: normalized.url,
title: normalized.title || (existingRecord ? existingRecord.title : ''),
magnets: finalMagnets,
lastSeenAt: syncedAt
});
}
savedCount += 1;
}
await transactionDone(tx);
result = {
ok: true,
savedCount: savedCount
};
} finally {
db.close();
}
if (!skipCloudSync && forumKey && cloudThreads.length > 0) {
try {
var threadUploadPlan = await filterThreadUploadsForCloud(forumKey, cloudThreads, {
ttlMs: CLOUD_THREAD_UPLOAD_TTL_MS
});
if (threadUploadPlan.uploads.length > 0) {
var directThreadUpsertResult = await cloudUpsertThreadMagnets({
forumKey: forumKey,
syncedAt: syncedAt,
threads: threadUploadPlan.uploads
});
if (directThreadUpsertResult && directThreadUpsertResult.ok) {
await markUploadMetaEntries('thread', threadUploadPlan.pendingMeta);
}
}
} catch (error) {
console.warn('[MagnetPlugin][Cloud] save thread magnets failed:', error && error.message ? error.message : error);
}
backfillLocalCacheToCloud({ force: false }).catch(function(backfillErr) {
console.warn('[MagnetPlugin][Cloud] post-thread-save backfill failed:', backfillErr && backfillErr.message ? backfillErr.message : backfillErr);
});
}
return result;
}
function estimateTextSize(value) {
try {
return JSON.stringify(value).length;
} catch (e) {
return 0;
}
}
async function getStorageEstimate() {
if (!navigator.storage || !navigator.storage.estimate) {
return null;
}
try {
return await navigator.storage.estimate();
} catch (e) {
return null;
}
}
async function readRecentThreadRecords(db, forumKey, limit) {
var tx = db.transaction('threads', 'readonly');
var store = tx.objectStore('threads');
var index = forumKey ? store.index('forumKeyLastSeenAt') : store.index('lastSeenAt');
var range = forumKey
? IDBKeyRange.bound([forumKey, 0], [forumKey, Number.MAX_SAFE_INTEGER])
: null;
var request = index.openCursor(range, 'prev');
return new Promise(function(resolve, reject) {
var items = [];
request.onsuccess = function(event) {
var cursor = event.target.result;
if (!cursor || items.length >= limit) {
resolve(items);
return;
}
items.push(cursor.value);
cursor.continue();
};
request.onerror = function(event) {
reject(event.target.error || new Error('读取最近帖子缓存失败'));
};
}).finally(function() {
return transactionDone(tx).catch(function() {
return null;
});
});
}
async function readRecentCoverageRecords(db, forumKey, limit) {
var tx = db.transaction('coverages', 'readonly');
var store = tx.objectStore('coverages');
var index = forumKey ? store.index('forumKeyCrawledAt') : store.index('crawledAt');
var range = forumKey
? IDBKeyRange.bound([forumKey, 0], [forumKey, Number.MAX_SAFE_INTEGER])
: null;
var request = index.openCursor(range, 'prev');
return new Promise(function(resolve, reject) {
var items = [];
request.onsuccess = function(event) {
var cursor = event.target.result;
if (!cursor || items.length >= limit) {
resolve(items);
return;
}
items.push(cursor.value);
cursor.continue();
};
request.onerror = function(event) {
reject(event.target.error || new Error('读取最近范围快照失败'));
};
}).finally(function() {
return transactionDone(tx).catch(function() {
return null;
});
});
}
async function readAllStoreRecords(db, storeName) {
return new Promise(function(resolve, reject) {
var tx = db.transaction(storeName, 'readonly');
var store = tx.objectStore(storeName);
var request = store.openCursor();
var items = [];
request.onsuccess = function(event) {
var cursor = event.target.result;
if (!cursor) {
resolve(items);
return;
}
items.push(cursor.value);
cursor.continue();
};
request.onerror = function(event) {
reject(event.target.error || new Error('读取 ' + storeName + ' 失败'));
};
});
}
async function getThreadsByKeys(db, threadKeys) {
var keys = Array.isArray(threadKeys) ? threadKeys.filter(Boolean) : [];
var threadTx = null;
var threadStore = null;
var threadRequests = null;
var threadResults = null;
if (keys.length === 0) {
return [];
}
threadTx = db.transaction('threads', 'readonly');
threadStore = threadTx.objectStore('threads');
threadRequests = keys.map(function(threadKey) {
return requestToPromise(threadStore.get(threadKey));
});
threadResults = await Promise.all(threadRequests);
await transactionDone(threadTx);
return threadResults.filter(Boolean).map(function(record) {
return {
threadKey: record.threadKey,
url: record.url,
title: record.title,
magnets: normalizeMagnets(record.magnets),
lastSeenAt: Number(record.lastSeenAt) || Date.now()
};
});
}
function chunkArray(items, size) {
var chunks = [];
var index = 0;
var safeSize = Math.max(1, Number(size) || 1);
for (index = 0; index < items.length; index += safeSize) {
chunks.push(items.slice(index, index + safeSize));
}
return chunks;
}
async function getCloudBackfillState() {
var result = await storageLocalGet(CLOUD_CACHE_BACKFILL_KEY);
return result[CLOUD_CACHE_BACKFILL_KEY] || null;
}
async function saveCloudBackfillState(state) {
var data = {};
data[CLOUD_CACHE_BACKFILL_KEY] = state;
await storageLocalSet(data);
}
function normalizeCloudUploadMetaState(state) {
state = state && typeof state === 'object' ? state : {};
return {
threads: state.threads && typeof state.threads === 'object' ? state.threads : {},
coverages: state.coverages && typeof state.coverages === 'object' ? state.coverages : {},
pages: state.pages && typeof state.pages === 'object' ? state.pages : {}
};
}
async function loadCloudUploadMetaState() {
var result = null;
if (cloudUploadMetaCache) {
return cloudUploadMetaCache;
}
result = await storageLocalGet(CLOUD_UPLOAD_META_KEY);
cloudUploadMetaCache = normalizeCloudUploadMetaState(result[CLOUD_UPLOAD_META_KEY]);
return cloudUploadMetaCache;
}
function pruneUploadMetaStore(store, limit) {
var keys = Object.keys(store || {});
var safeLimit = Math.max(100, Number(limit) || 1000);
var staleKeys = null;
if (keys.length <= safeLimit) {
return store;
}
staleKeys = keys.sort(function(a, b) {
return Number((store[a] && store[a].lastUploadedAt) || 0) - Number((store[b] && store[b].lastUploadedAt) || 0);
}).slice(0, keys.length - safeLimit);
staleKeys.forEach(function(key) {
delete store[key];
});
return store;
}
async function saveCloudUploadMetaState(state) {
var data = {};
var normalized = normalizeCloudUploadMetaState(state);
pruneUploadMetaStore(normalized.threads, CLOUD_UPLOAD_META_THREAD_LIMIT);
pruneUploadMetaStore(normalized.coverages, CLOUD_UPLOAD_META_OTHER_LIMIT);
pruneUploadMetaStore(normalized.pages, CLOUD_UPLOAD_META_OTHER_LIMIT);
cloudUploadMetaCache = normalized;
data[CLOUD_UPLOAD_META_KEY] = normalized;
await storageLocalSet(data);
return normalized;
}
function getUploadMetaBucket(state, type) {
if (type === 'thread') {
return state.threads;
}
if (type === 'coverage') {
return state.coverages;
}
return state.pages;
}
function buildThreadUploadMetaKey(forumKey, threadKey) {
return String(forumKey || '') + '::' + String(threadKey || '');
}
function buildCoverageUploadMetaKey(forumKey, startPage, endPage, strategy) {
return String(forumKey || '') + '::' + String(startPage || 1) + '-' + String(endPage || 1) + '::' + String(strategy || 'full_live');
}
function buildPageUploadMetaKey(forumKey, page) {
return String(forumKey || '') + '::page::' + String(page || 1);
}
function shouldSkipUploadMetaEntry(entry, payloadHash, ttlMs, now) {
return !!(
entry &&
entry.payloadHash === payloadHash &&
Number(entry.lastUploadedAt || 0) > 0 &&
now - Number(entry.lastUploadedAt || 0) < ttlMs
);
}
function sortThreadsForHash(threads) {
return (Array.isArray(threads) ? threads : []).map(function(thread) {
return {
threadKey: String(thread.threadKey || ''),
url: normalizeUrl(thread.url),
title: normalizeTitle(thread.title),
magnets: normalizeMagnets(thread.magnets).slice().sort()
};
}).sort(function(a, b) {
return String(a.threadKey).localeCompare(String(b.threadKey));
});
}
async function buildThreadUploadHash(forumKey, thread) {
return sha256Hex(JSON.stringify({
forumKey: String(forumKey || ''),
threadKey: String(thread.threadKey || ''),
url: normalizeUrl(thread.url),
title: normalizeTitle(thread.title),
magnets: normalizeMagnets(thread.magnets).slice().sort()
}));
}
async function buildCoverageUploadHash(payload) {
return sha256Hex(JSON.stringify({
forumKey: String(payload.forumKey || ''),
startPage: Number(payload.startPage || 1),
endPage: Number(payload.endPage || payload.startPage || 1),
strategy: String(payload.strategy || 'full_live'),
threads: sortThreadsForHash(payload.threads)
}));
}
async function buildPageUploadHash(payload) {
return sha256Hex(JSON.stringify({
forumKey: String(payload.forumKey || ''),
page: Number(payload.page || 1),
threads: sortThreadsForHash(payload.threads)
}));
}
async function filterThreadUploadsForCloud(forumKey, threads, options) {
var state = await loadCloudUploadMetaState();
var bucket = getUploadMetaBucket(state, 'thread');
var now = Date.now();
var ttlMs = options && Number(options.ttlMs) >= 0 ? Number(options.ttlMs) : CLOUD_THREAD_UPLOAD_TTL_MS;
var uploads = [];
var pendingMeta = [];
var index = 0;
var item = null;
var key = '';
var payloadHash = '';
for (index = 0; index < threads.length; index++) {
item = threads[index];
if (!item || !item.threadKey) {
continue;
}
key = buildThreadUploadMetaKey(forumKey, item.threadKey);
payloadHash = await buildThreadUploadHash(forumKey, item);
if (shouldSkipUploadMetaEntry(bucket[key], payloadHash, ttlMs, now)) {
continue;
}
uploads.push(item);
pendingMeta.push({ key: key, payloadHash: payloadHash, lastUploadedAt: now });
}
return { uploads: uploads, pendingMeta: pendingMeta };
}
async function shouldUploadCoverageToCloud(payload, options) {
var state = await loadCloudUploadMetaState();
var bucket = getUploadMetaBucket(state, 'coverage');
var now = Date.now();
var ttlMs = options && Number(options.ttlMs) >= 0 ? Number(options.ttlMs) : CLOUD_COVERAGE_UPLOAD_TTL_MS;
var key = buildCoverageUploadMetaKey(payload.forumKey, payload.startPage, payload.endPage, payload.strategy);
var payloadHash = await buildCoverageUploadHash(payload);
if (shouldSkipUploadMetaEntry(bucket[key], payloadHash, ttlMs, now)) {
return { shouldUpload: false, key: key, payloadHash: payloadHash, lastUploadedAt: now };
}
return { shouldUpload: true, key: key, payloadHash: payloadHash, lastUploadedAt: now };
}
async function shouldUploadPageToCloud(payload, options) {
var state = await loadCloudUploadMetaState();
var bucket = getUploadMetaBucket(state, 'page');
var now = Date.now();
var ttlMs = options && Number(options.ttlMs) >= 0 ? Number(options.ttlMs) : CLOUD_PAGE_UPLOAD_TTL_MS;
var key = buildPageUploadMetaKey(payload.forumKey, payload.page);
var payloadHash = await buildPageUploadHash(payload);
if (shouldSkipUploadMetaEntry(bucket[key], payloadHash, ttlMs, now)) {
return { shouldUpload: false, key: key, payloadHash: payloadHash, lastUploadedAt: now };
}
return { shouldUpload: true, key: key, payloadHash: payloadHash, lastUploadedAt: now };
}
async function markUploadMetaEntries(type, entries) {
var state = await loadCloudUploadMetaState();
var bucket = getUploadMetaBucket(state, type);
(Array.isArray(entries) ? entries : []).forEach(function(entry) {
if (!entry || !entry.key) {
return;
}
bucket[entry.key] = {
payloadHash: entry.payloadHash,
lastUploadedAt: Number(entry.lastUploadedAt) || Date.now()
};
});
await saveCloudUploadMetaState(state);
}
async function backfillLocalCacheToCloud(options) {
var settings = options && typeof options === 'object' ? options : {};
var force = !!settings.force;
var state = null;
var authState = null;
var db = null;
var threadRecords = [];
var coverageRecords = [];
var pageRecords = [];
var groupedThreads = Object.create(null);
var forumKeys = [];
var forumKeySeen = Object.create(null);
var coverageIndex = 0;
var pageIndex = 0;
var chunkIndex = 0;
var chunks = null;
var currentChunk = null;
var counts = {
threadCount: 0,
coverageCount: 0,
pageCount: 0
};
if (cloudBackfillPromise) {
return cloudBackfillPromise;
}
cloudBackfillPromise = (async function() {
authState = await loadCloudAuthState();
if (!authState.authenticated && !authState.token) {
return { ok: true, skipped: true, reason: 'not_authenticated' };
}
state = await getCloudBackfillState();
if (!force && state && Number(state.lastRunAt || 0) > Date.now() - 10 * 60 * 1000) {
return { ok: true, skipped: true, reason: 'recently_ran', state: state };
}
db = await openCacheDb();
try {
threadRecords = await readAllStoreRecords(db, 'threads');
coverageRecords = await readAllStoreRecords(db, 'coverages');
pageRecords = await readAllStoreRecords(db, 'pageCoverages');
threadRecords.forEach(function(record) {
if (!record || !record.forumKey || !record.threadKey) {
return;
}
var recordMagnets = normalizeMagnets(record.magnets);
if (!groupedThreads[record.forumKey]) {
groupedThreads[record.forumKey] = [];
}
groupedThreads[record.forumKey].push({
threadKey: record.threadKey,
url: record.url,
title: record.title,
magnets: recordMagnets,
lastSeenAt: Number(record.lastSeenAt) || Date.now()
});
if (!forumKeySeen[record.forumKey]) {
forumKeySeen[record.forumKey] = true;
forumKeys.push(record.forumKey);
}
});
forumKeys.forEach(function(fk) {
if (groupedThreads[fk]) {
groupedThreads[fk].sort(function(a, b) {
return (b.magnets.length > 0 ? 1 : 0) - (a.magnets.length > 0 ? 1 : 0);
});
}
});
for (chunkIndex = 0; chunkIndex < forumKeys.length; chunkIndex++) {
chunks = chunkArray(groupedThreads[forumKeys[chunkIndex]] || [], 200);
for (pageIndex = 0; pageIndex < chunks.length; pageIndex++) {
currentChunk = chunks[pageIndex];
var threadUploadPlan = null;
if (currentChunk.length === 0) {
continue;
}
threadUploadPlan = await filterThreadUploadsForCloud(forumKeys[chunkIndex], currentChunk, {
ttlMs: CLOUD_THREAD_UPLOAD_TTL_MS
});
if (threadUploadPlan.uploads.length === 0) {
continue;
}
var backfillThreadResult = await cloudUpsertThreadMagnets({
forumKey: forumKeys[chunkIndex],
syncedAt: Date.now(),
threads: threadUploadPlan.uploads
});
if (backfillThreadResult && backfillThreadResult.ok) {
await markUploadMetaEntries('thread', threadUploadPlan.pendingMeta);
counts.threadCount += threadUploadPlan.uploads.length;
}
}
}
for (coverageIndex = 0; coverageIndex < coverageRecords.length; coverageIndex++) {
var coverageRecord = coverageRecords[coverageIndex];
var coverageThreads = await getThreadsByKeys(db, coverageRecord.threadKeys);
var coverageUploadCheck = null;
if (!coverageRecord || !coverageRecord.forumKey || coverageThreads.length === 0) {
continue;
}
coverageUploadCheck = await shouldUploadCoverageToCloud({
forumKey: coverageRecord.forumKey,
startPage: Number(coverageRecord.startPage) || 1,
endPage: Number(coverageRecord.endPage) || Number(coverageRecord.startPage) || 1,
strategy: coverageRecord.strategy || 'full_live',
crawledAt: Number(coverageRecord.crawledAt) || Date.now(),
threads: coverageThreads
}, {
ttlMs: CLOUD_COVERAGE_UPLOAD_TTL_MS
});
if (!coverageUploadCheck.shouldUpload) {
continue;
}
var backfillCoverageResult = await cloudUpsertCoverage({
forumKey: coverageRecord.forumKey,
startPage: Number(coverageRecord.startPage) || 1,
endPage: Number(coverageRecord.endPage) || Number(coverageRecord.startPage) || 1,
strategy: coverageRecord.strategy || 'full_live',
crawledAt: Number(coverageRecord.crawledAt) || Date.now(),
threads: coverageThreads
});
if (backfillCoverageResult && backfillCoverageResult.ok) {
await markUploadMetaEntries('coverage', [coverageUploadCheck]);
counts.coverageCount += 1;
}
}
for (pageIndex = 0; pageIndex < pageRecords.length; pageIndex++) {
var pageRecord = pageRecords[pageIndex];
var pageThreads = await getThreadsByKeys(db, pageRecord.threadKeys);
var pageUploadCheck = null;
if (!pageRecord || !pageRecord.forumKey || pageThreads.length === 0) {
continue;
}
pageUploadCheck = await shouldUploadPageToCloud({
forumKey: pageRecord.forumKey,
page: Number(pageRecord.page) || 1,
crawledAt: Number(pageRecord.crawledAt) || Date.now(),
threads: pageThreads
}, {
ttlMs: CLOUD_PAGE_UPLOAD_TTL_MS
});
if (!pageUploadCheck.shouldUpload) {
continue;
}
var backfillPageResult = await cloudUpsertPageCoverage({
forumKey: pageRecord.forumKey,
page: Number(pageRecord.page) || 1,
crawledAt: Number(pageRecord.crawledAt) || Date.now(),
threads: pageThreads
});
if (backfillPageResult && backfillPageResult.ok) {
await markUploadMetaEntries('page', [pageUploadCheck]);
counts.pageCount += 1;
}
}
await saveCloudBackfillState({
lastRunAt: Date.now(),
counts: counts
});
await setCloudHealth(true);
return { ok: true, counts: counts };
} finally {
if (db) {
db.close();
}
}
})().catch(async function(error) {
console.warn('[MagnetPlugin][Cloud] backfill failed:', error && error.message ? error.message : error);
throw error;
}).finally(function() {
cloudBackfillPromise = null;
});
return cloudBackfillPromise;
}
async function getCacheOverview(payload) {
var forumKey = typeof payload.forumKey === 'string' ? payload.forumKey : '';
var limit = Math.max(1, Math.min(Number(payload.limit) || 12, 50));
var db = await openCacheDb();
try {
var threadTx = db.transaction('threads', 'readonly');
var coverageTx = db.transaction(['coverages', 'pageCoverages'], 'readonly');
var threadStore = threadTx.objectStore('threads');
var coverageStore = coverageTx.objectStore('coverages');
var pageCoverageStore = coverageTx.objectStore('pageCoverages');
var threadForumIndex = threadStore.index('forumKey');
var coverageForumIndex = coverageStore.index('forumKey');
var pageCoverageForumIndex = pageCoverageStore.index('forumKey');
var totals = await Promise.all([
requestToPromise(threadStore.count()),
requestToPromise(coverageStore.count()),
requestToPromise(pageCoverageStore.count()),
forumKey ? requestToPromise(threadForumIndex.count(IDBKeyRange.only(forumKey))) : Promise.resolve(0),
forumKey ? requestToPromise(coverageForumIndex.count(IDBKeyRange.only(forumKey))) : Promise.resolve(0),
forumKey ? requestToPromise(pageCoverageForumIndex.count(IDBKeyRange.only(forumKey))) : Promise.resolve(0)
]);
await Promise.all([transactionDone(threadTx), transactionDone(coverageTx)]);
var recentThreads = await readRecentThreadRecords(db, forumKey, limit);
var recentCoverages = await readRecentCoverageRecords(db, forumKey, Math.min(limit, 8));
var usedBytes = recentThreads.reduce(function(sum, item) {
return sum + estimateTextSize(item);
}, 0);
var storageEstimate = await getStorageEstimate();
var magnetCachedThreads = recentThreads.filter(function(item) {
return Number(item.magnetCount || 0) > 0;
}).length;
var magnetCount = recentThreads.reduce(function(sum, item) {
return sum + Number(item.magnetCount || 0);
}, 0);
return {
ok: true,
summary: {
forumKey: forumKey,
totalThreadCount: Number(totals[0] || 0),
totalCoverageCount: Number(totals[1] || 0),
totalPageCoverageCount: Number(totals[2] || 0),
forumThreadCount: Number(totals[3] || 0),
forumCoverageCount: Number(totals[4] || 0),
forumPageCoverageCount: Number(totals[5] || 0),
sampleBytes: usedBytes,
storageUsage: storageEstimate && Number(storageEstimate.usage) ? Number(storageEstimate.usage) : 0,
storageQuota: storageEstimate && Number(storageEstimate.quota) ? Number(storageEstimate.quota) : 0,
recentMagnetCachedThreads: magnetCachedThreads,
recentMagnetCount: magnetCount
},
recentThreads: recentThreads.map(function(item) {
return {
title: item.title,
url: item.url,
threadKey: item.threadKey,
magnetCount: Number(item.magnetCount || 0),
lastSeenAt: Number(item.lastSeenAt || 0),
lastMagnetSyncAt: Number(item.lastMagnetSyncAt || 0)
};
}),
recentCoverages: recentCoverages.map(function(item) {
return {
forumKey: item.forumKey,
startPage: Number(item.startPage || 0),
endPage: Number(item.endPage || 0),
threadCount: Number(item.threadCount || 0),
crawledAt: Number(item.crawledAt || 0),
strategy: item.strategy || 'full_live'
};
})
};
} finally {
db.close();
}
}
async function clearCacheData() {
var db = await openCacheDb();
try {
var tx = db.transaction(['threads', 'coverages', 'pageCoverages'], 'readwrite');
tx.objectStore('threads').clear();
tx.objectStore('coverages').clear();
tx.objectStore('pageCoverages').clear();
await transactionDone(tx);
return { ok: true };
} finally {
db.close();
}
}
async function getCachedCoveragePlan(payload) {
var forumKey = typeof payload.forumKey === 'string' ? payload.forumKey : '';
var startPage = Math.max(1, Number(payload.startPage) || 1);
var endPage = Math.max(startPage, Number(payload.endPage) || startPage);
var frontRefreshPages = Math.max(0, Number(payload.frontRefreshPages) || 0);
var cloudCoverage = null;
var cloudAssembledCoverage = null;
var cachedBlocks = [];
var db = await openCacheDb();
try {
var exactKey = getCoverageKey(forumKey, startPage, endPage);
var serverPlan = null;
var exactTx = db.transaction('coverages', 'readonly');
var exactStore = exactTx.objectStore('coverages');
var exactRecord = await requestToPromise(exactStore.get(exactKey));
await transactionDone(exactTx);
var exactCoverage = exactRecord ? await getCoverageWithThreads(db, exactRecord) : null;
if (!exactCoverage) {
exactCoverage = await getAssembledPageCoverage(db, forumKey, startPage, endPage);
}
if (!exactCoverage) {
try {
cloudCoverage = await cloudLookupCoverage({
forumKey: forumKey,
startPage: startPage,
endPage: endPage
});
if (cloudCoverage && Array.isArray(cloudCoverage.threads) && cloudCoverage.threads.length > 0) {
await saveCoverageSnapshot({
forumKey: cloudCoverage.forumKey,
startPage: cloudCoverage.startPage,
endPage: cloudCoverage.endPage,
crawledAt: cloudCoverage.crawledAt,
strategy: cloudCoverage.strategy,
frontRefreshPages: cloudCoverage.frontRefreshPages,
threads: cloudCoverage.threads,
skipCloudSync: true
});
exactCoverage = cloudCoverage;
}
} catch (error) {
console.warn('[MagnetPlugin][Cloud] lookup exact coverage failed:', error && error.message ? error.message : error);
}
}
if (!exactCoverage) {
try {
cloudAssembledCoverage = await getCloudAssembledPageCoverage(forumKey, startPage, endPage);
if (cloudAssembledCoverage && Array.isArray(cloudAssembledCoverage.threads) && cloudAssembledCoverage.threads.length > 0) {
await saveCoverageSnapshot({
forumKey: cloudAssembledCoverage.forumKey,
startPage: cloudAssembledCoverage.startPage,
endPage: cloudAssembledCoverage.endPage,
crawledAt: cloudAssembledCoverage.crawledAt,
strategy: cloudAssembledCoverage.strategy,
frontRefreshPages: cloudAssembledCoverage.frontRefreshPages,
threads: cloudAssembledCoverage.threads,
skipCloudSync: true
});
exactCoverage = cloudAssembledCoverage;
}
} catch (error) {
console.warn('[MagnetPlugin][Cloud] assemble page coverage failed:', error && error.message ? error.message : error);
}
}
if (!exactCoverage) {
try {
serverPlan = await cloudLookupCoveragePlan({
forumKey: forumKey,
startPage: startPage,
endPage: endPage,
frontRefreshPages: frontRefreshPages
});
if (serverPlan && serverPlan.exactCoverage && Array.isArray(serverPlan.exactCoverage.threads) && serverPlan.exactCoverage.threads.length > 0) {
await saveCoverageSnapshot({
forumKey: serverPlan.exactCoverage.forumKey,
startPage: serverPlan.exactCoverage.startPage,
endPage: serverPlan.exactCoverage.endPage,
crawledAt: serverPlan.exactCoverage.crawledAt,
strategy: serverPlan.exactCoverage.strategy,
frontRefreshPages: serverPlan.exactCoverage.frontRefreshPages,
threads: serverPlan.exactCoverage.threads,
skipCloudSync: true
});
exactCoverage = serverPlan.exactCoverage;
}
} catch (error) {
console.warn('[MagnetPlugin][Cloud] coverage plan failed:', error && error.message ? error.message : error);
}
}
if (exactCoverage) {
return {
ok: true,
exactCoverage: exactCoverage,
shiftedCoverage: null,
cachedBlocks: []
};
}
try {
var localBlocks = await getCachedPageCoverageBlocks(db, forumKey, startPage, endPage);
var intersectingCoverages = await getIntersectingCoverageBlocks(db, forumKey, startPage, endPage);
for (var blockIndex = 0; blockIndex < localBlocks.length; blockIndex++) {
var hydratedBlock = await getCoverageWithThreads(db, localBlocks[blockIndex]);
if (hydratedBlock && Array.isArray(hydratedBlock.threads) && hydratedBlock.threads.length > 0) {
cachedBlocks.push(hydratedBlock);
}
}
intersectingCoverages.forEach(function(block) {
cachedBlocks.push(block);
});
if (serverPlan && Array.isArray(serverPlan.cachedBlocks)) {
serverPlan.cachedBlocks.forEach(function(block) {
if (block && Array.isArray(block.threads) && block.threads.length > 0) {
cachedBlocks.push(block);
}
});
}
cachedBlocks.sort(function(a, b) {
var startDiff = Number(a.startPage || 0) - Number(b.startPage || 0);
if (startDiff !== 0) {
return startDiff;
}
return Number(b.endPage || 0) - Number(a.endPage || 0);
});
} catch (error) {
console.warn('[MagnetPlugin][Cache] build local cached blocks failed:', error && error.message ? error.message : error);
}
var shiftedCoverage = serverPlan && serverPlan.shiftedCoverage ? serverPlan.shiftedCoverage : null;
if (!shiftedCoverage && startPage === 1 && frontRefreshPages > 0) {
var coverages = await listForumCoverages(db, forumKey);
coverages = coverages.filter(function(record) {
return Number(record.startPage) === 1 && Number(record.endPage) >= 1;
}).sort(function(a, b) {
var crawlDiff = Number(b.crawledAt || 0) - Number(a.crawledAt || 0);
if (crawlDiff !== 0) {
return crawlDiff;
}
return Number(b.endPage || 0) - Number(a.endPage || 0);
});
if (coverages.length > 0) {
var bestCoverage = coverages[0];
var hydratedCoverage = await getCoverageWithThreads(db, bestCoverage);
if (hydratedCoverage && Array.isArray(hydratedCoverage.threads) && hydratedCoverage.threads.length > 0) {
shiftedCoverage = {
forumKey: hydratedCoverage.forumKey,
sourceStartPage: hydratedCoverage.startPage,
sourceEndPage: hydratedCoverage.endPage,
crawledAt: hydratedCoverage.crawledAt,
strategy: hydratedCoverage.strategy,
threads: hydratedCoverage.threads,
reusedStartPage: Math.min(endPage, startPage + frontRefreshPages),
reusedEndPage: Math.min(endPage, bestCoverage.endPage + frontRefreshPages)
};
if (shiftedCoverage.reusedStartPage > shiftedCoverage.reusedEndPage) {
shiftedCoverage = null;
}
}
}
}
return {
ok: true,
exactCoverage: null,
shiftedCoverage: shiftedCoverage,
cachedBlocks: cachedBlocks
};
} finally {
db.close();
}
}
function isProgressStateKey(key) {
return typeof key === 'string' && key.indexOf(STORAGE_KEY_PREFIX) === 0;
}
function flushSessionReadyCallbacks() {
var callbacks = sessionReadyCallbacks.slice();
sessionReadyCallbacks = [];
callbacks.forEach(function(callback) {
try {
callback();
} catch (e) {
// ignore callback errors
}
});
}
function finishSessionPrepare() {
isSessionReady = true;
isPreparingSession = false;
flushSessionReadyCallbacks();
}
function clearAllProgressStates(done) {
chrome.storage.local.get(null, function(result) {
if (chrome.runtime.lastError) {
done();
return;
}
var keys = Object.keys(result || {}).filter(isProgressStateKey);
if (keys.length === 0) {
done();
return;
}
chrome.storage.local.remove(keys, function() {
done();
});
});
}
function ensureSessionReady(callback) {
if (isSessionReady) {
callback();
return;
}
sessionReadyCallbacks.push(callback);
if (isPreparingSession) {
return;
}
isPreparingSession = true;
if (!chrome.storage || !chrome.storage.session) {
finishSessionPrepare();
return;
}
chrome.storage.session.get(SESSION_MARKER_KEY, function(result) {
if (chrome.runtime.lastError) {
finishSessionPrepare();
return;
}
if (result && result[SESSION_MARKER_KEY]) {
finishSessionPrepare();
return;
}
clearAllProgressStates(function() {
var marker = {};
marker[SESSION_MARKER_KEY] = Date.now();
chrome.storage.session.set(marker, function() {
finishSessionPrepare();
});
});
});
}
function clampTimeout(timeoutMs) {
var parsed = Number(timeoutMs);
if (!Number.isFinite(parsed)) return 15000;
return Math.max(3000, Math.min(parsed, 30000));
}
function getStorageKeyBySender(sender) {
if (!sender || !sender.tab || typeof sender.tab.id !== 'number') {
return null;
}
return STORAGE_KEY_PREFIX + sender.tab.id;
}
function normalizeProgressState(state) {
if (!state || typeof state !== 'object') {
return null;
}
var speed = typeof state.speedMode === 'string' ? state.speedMode : 'fast';
if (speed !== 'slow' && speed !== 'medium' && speed !== 'fast' && speed !== 'ultrafast') {
speed = 'fast';
}
var startPage = Number(state.startPage);
var endPage = Number(state.endPage);
if (!Number.isFinite(startPage) || startPage < 1) startPage = 1;
if (!Number.isFinite(endPage) || endPage < 1) endPage = startPage;
var resumeFromPage = Number(state.resumeFromPage);
if (!Number.isFinite(resumeFromPage) || resumeFromPage < 1) {
resumeFromPage = startPage;
}
var isRunning = !!state.isRunning;
var stoppedByUser = !!state.stoppedByUser;
var linkSet = new Set();
var links = [];
if (Array.isArray(state.links)) {
state.links.forEach(function(link) {
if (typeof link !== 'string' || !link) return;
if (linkSet.has(link)) return;
linkSet.add(link);
links.push(link);
});
}
var items = [];
if (Array.isArray(state.items)) {
state.items.forEach(function(item) {
if (!item || typeof item !== 'object') return;
var link = typeof item.link === 'string' ? item.link : '';
if (!link) return;
var title = typeof item.title === 'string' ? item.title : '恢复记录';
if (!linkSet.has(link)) {
linkSet.add(link);
links.push(link);
}
items.push({
title: title.slice(0, 200),
link: link
});
});
}
if (links.length > MAX_PROGRESS_ITEMS) {
links = links.slice(links.length - MAX_PROGRESS_ITEMS);
}
if (items.length > MAX_PROGRESS_ITEMS) {
items = items.slice(items.length - MAX_PROGRESS_ITEMS);
}
return {
links: links,
items: items,
keyword: typeof state.keyword === 'string' ? state.keyword.slice(0, 200) : '',
speedMode: speed,
startPage: startPage,
endPage: endPage,
resumeFromPage: resumeFromPage,
isRunning: isRunning,
stoppedByUser: stoppedByUser,
statusText: typeof state.statusText === 'string' ? state.statusText.slice(0, 300) : '',
statusType: typeof state.statusType === 'string' ? state.statusType.slice(0, 30) : '',
pageUrl: typeof state.pageUrl === 'string' ? state.pageUrl.slice(0, 1000) : '',
updatedAt: Date.now()
};
}
function fetchPageHtml(url, timeoutMs) {
var controller = new AbortController();
var realTimeout = clampTimeout(timeoutMs);
var timer = setTimeout(function() {
controller.abort();
}, realTimeout);
return fetch(url, { signal: controller.signal })
.then(function(response) {
if (!response.ok) {
throw new Error('HTTP ' + response.status + ' ' + response.statusText);
}
return response.text();
})
.catch(function(err) {
if (err && err.name === 'AbortError') {
throw new Error('请求超时(' + realTimeout + 'ms)');
}
throw err;
})
.finally(function() {
clearTimeout(timer);
});
}
function extractMagnetsFromHtml(html) {
const magnetPattern = /magnet:\?xt=urn:btih:[a-fA-F0-9]{32,40}/gi;
const magnets = html.match(magnetPattern) || [];
return [...new Set(magnets)];
}
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.action === 'openCloudSyncPage') {
chrome.tabs.create({ url: chrome.runtime.getURL('popup.html?view=cloud') }, function(tab) {
if (chrome.runtime.lastError) {
sendResponse({ ok: false, error: chrome.runtime.lastError.message });
return;
}
sendResponse({ ok: true, tabId: tab && typeof tab.id === 'number' ? tab.id : null });
});
return true;
}
if (request.action === 'cloudRegister') {
cloudRegisterAccount(request)
.then(function(result) {
sendResponse(result);
})
.catch(function(err) {
sendResponse({ ok: false, error: err && err.message ? err.message : String(err) });
});
return true;
}
if (request.action === 'cloudLogin') {
cloudLoginAccount(request)
.then(function(result) {
sendResponse(result);
})
.catch(function(err) {
sendResponse({ ok: false, error: err && err.message ? err.message : String(err) });
});
return true;
}
if (request.action === 'cloudLogout') {
cloudLogoutAccount()
.then(function(result) {
sendResponse(result);
})
.catch(function(err) {
sendResponse({ ok: false, error: err && err.message ? err.message : String(err) });
});
return true;
}
if (request.action === 'cloudGetSyncStatus') {
cloudGetSyncStatus()
.then(function(result) {
sendResponse(result);
})
.catch(function(err) {
sendResponse({ ok: false, authenticated: false, healthy: false, color: 'red', text: '云同步异常', error: err && err.message ? err.message : String(err) });
});
return true;
}
if (request.action === 'cloudPullVaultItems') {
cloudPullVaultItems(request.itemTypes)
.then(function(items) {
sendResponse({ ok: true, items: items, status: buildCloudStatus(cloudAuthStateCache) });
})
.catch(function(err) {
sendResponse({ ok: false, items: [], error: err && err.message ? err.message : String(err) });
});
return true;
}
if (request.action === 'cloudPushVaultItems') {
cloudPushVaultItems(request)
.then(function(status) {
sendResponse({ ok: true, status: status });
})
.catch(function(err) {
sendResponse({ ok: false, error: err && err.message ? err.message : String(err) });
});
return true;
}
if (request.action === 'cloudBackfillLocalCache') {
backfillLocalCacheToCloud({ force: !!request.force })
.then(function(result) {
sendResponse({ ok: true, result: result, status: buildCloudStatus(cloudAuthStateCache) });
})
.catch(function(err) {
sendResponse({ ok: false, error: err && err.message ? err.message : String(err) });
});
return true;
}
if (request.action === 'cloudGetCacheStats') {
cloudGetCacheStats()
.then(function(result) {
sendResponse(result);
})
.catch(function(err) {
sendResponse({ ok: false, error: err && err.message ? err.message : String(err) });
});
return true;
}
if (request.action === 'localGetCacheStats') {
getLocalCacheStats()
.then(function(result) {
sendResponse(result);
})
.catch(function(err) {
sendResponse({ ok: false, error: err && err.message ? err.message : String(err) });
});
return true;
}
if (request.action === 'cacheSaveCoverage') {
saveCoverageSnapshot(request)
.then(function(result) {
sendResponse(result);
})
.catch(function(err) {
sendResponse({ ok: false, error: err && err.message ? err.message : String(err) });
});
return true;
}
if (request.action === 'cacheGetPageThreadKeys') {
(async function() {
var forumKey = typeof request.forumKey === 'string' ? request.forumKey : '';
var page = Math.max(1, Number(request.page) || 1);
if (!forumKey) {
return { ok: true, threadKeys: [], crawledAt: 0 };
}
var db = await openCacheDb();
try {
var tx = db.transaction('pageCoverages', 'readonly');
var store = tx.objectStore('pageCoverages');
var record = await requestToPromise(store.get(getPageCoverageKey(forumKey, page)));
await transactionDone(tx);
if (!record || !Array.isArray(record.threadKeys)) {
return { ok: true, threadKeys: [], crawledAt: 0 };
}
return {
ok: true,
threadKeys: record.threadKeys,
crawledAt: Number(record.crawledAt || 0)
};
} finally {
db.close();
}
})()
.then(function(result) { sendResponse(result); })
.catch(function(err) { sendResponse({ ok: false, threadKeys: [], error: err && err.message ? err.message : String(err) }); });
return true;
}
if (request.action === 'cacheGetCoveragePlan') {
getCachedCoveragePlan(request)
.then(function(result) {
sendResponse(result);
})
.catch(function(err) {
sendResponse({ ok: false, error: err && err.message ? err.message : String(err) });
});
return true;
}
if (request.action === 'cacheGetThreadMagnets') {
getCachedThreadMagnets(request)
.then(function(result) {
sendResponse(result);
})
.catch(function(err) {
sendResponse({ ok: false, error: err && err.message ? err.message : String(err) });
});
return true;
}
if (request.action === 'cacheSaveThreadMagnets') {
saveThreadMagnets(request)
.then(function(result) {
sendResponse(result);
})
.catch(function(err) {
sendResponse({ ok: false, error: err && err.message ? err.message : String(err) });
});
return true;
}
if (request.action === 'cacheSavePageCoverage') {
savePageCoverageSnapshot(request)
.then(function(result) {
sendResponse(result);
})
.catch(function(err) {
sendResponse({ ok: false, error: err && err.message ? err.message : String(err) });
});
return true;
}
if (request.action === 'cacheGetOverview') {
getCacheOverview(request)
.then(function(result) {
sendResponse(result);
})
.catch(function(err) {
sendResponse({ ok: false, error: err && err.message ? err.message : String(err) });
});
return true;
}
if (request.action === 'cacheClearAll') {
clearCacheData()
.then(function(result) {
sendResponse(result);
})
.catch(function(err) {
sendResponse({ ok: false, error: err && err.message ? err.message : String(err) });
});
return true;
}
if (request.action === 'saveProgressState') {
ensureSessionReady(function() {
const saveKey = getStorageKeyBySender(sender);
if (!saveKey) {
sendResponse({ ok: false, error: '无法识别标签页' });
return;
}
const normalizedState = normalizeProgressState(request.state);
if (!normalizedState) {
sendResponse({ ok: false, error: '状态数据无效' });
return;
}
chrome.storage.local.set({ [saveKey]: normalizedState }, function() {
if (chrome.runtime.lastError) {
sendResponse({ ok: false, error: chrome.runtime.lastError.message });
return;
}
cloudPushVaultItems({
items: [{
itemType: 'progress_state',
itemKey: 'latest',
data: normalizedState
}]
}).catch(function() {
return null;
});
sendResponse({ ok: true });
});
});
return true;
}
if (request.action === 'loadProgressState') {
ensureSessionReady(function() {
const loadKey = getStorageKeyBySender(sender);
if (!loadKey) {
sendResponse({ ok: false, error: '无法识别标签页', state: null });
return;
}
chrome.storage.local.get(loadKey, function(result) {
if (chrome.runtime.lastError) {
sendResponse({ ok: false, error: chrome.runtime.lastError.message, state: null });
return;
}
sendResponse({ ok: true, state: result && result[loadKey] ? result[loadKey] : null });
});
});
return true;
}
if (request.action === 'clearProgressState') {
ensureSessionReady(function() {
const clearKey = getStorageKeyBySender(sender);
if (!clearKey) {
sendResponse({ ok: false, error: '无法识别标签页' });
return;
}
chrome.storage.local.remove(clearKey, function() {
if (chrome.runtime.lastError) {
sendResponse({ ok: false, error: chrome.runtime.lastError.message });
return;
}
sendResponse({ ok: true });
});
});
return true;
}
if (request.action === 'fetchHtml') {
fetchPageHtml(request.url, request.timeoutMs)
.then(html => {
sendResponse({ html: html });
})
.catch(err => {
sendResponse({ html: '', error: err.message });
});
return true;
}
if (request.action === 'openAndFetch') {
fetchPageHtml(request.url, request.timeoutMs)
.then(html => {
sendResponse({ magnets: extractMagnetsFromHtml(html) });
})
.catch(err => {
sendResponse({ magnets: [], error: err.message });
});
return true;
}
return false;
});
if (chrome.tabs && chrome.tabs.onRemoved) {
chrome.tabs.onRemoved.addListener(function(tabId) {
var key = STORAGE_KEY_PREFIX + tabId;
chrome.storage.local.remove(key, function() {
// ignore cleanup errors
});
});
}