const DEFAULT_CONFIG = { serverUrl: "http://localhost:3000", shopId: "", pollInterval: 20, dailyLimit: 20, publishIntervalMin: 30, publishIntervalMax: 90, maxConsecutiveFailures: 3, enabled: false, }; let state = { polling: false, timerId: null, todayCount: 0, consecutiveFailures: 0, lastPublishTime: 0, pendingCount: 0, recentResults: [], rapidFailPaused: false, statusError: "", runNextImmediately: false, }; // Load state from storage async function loadState() { const data = await chrome.storage.local.get(["todayCount", "consecutiveFailures", "todayDate"]); const today = new Date().toISOString().slice(0, 10); if (data.todayDate !== today) { state.todayCount = 0; state.consecutiveFailures = 0; } else { state.todayCount = data.todayCount || 0; state.consecutiveFailures = data.consecutiveFailures || 0; } } async function saveState() { const today = new Date().toISOString().slice(0, 10); await chrome.storage.local.set({ todayCount: state.todayCount, consecutiveFailures: state.consecutiveFailures, todayDate: today, }); } async function getConfig() { const data = await chrome.storage.local.get(["config"]); return { ...DEFAULT_CONFIG, ...(data.config || {}) }; } async function setConfig(config) { await chrome.storage.local.set({ config }); return config; } async function resolveShopId(shopInput) { const q = String(shopInput || "").trim(); if (!q) throw new Error("未配置店铺ID"); try { const resolved = await apiFetch(`/api/shops/resolve?q=${encodeURIComponent(q)}`); if (resolved && resolved.id) return resolved.id; } catch (e) { throw new Error(`未找到店铺:${q}。请确认输入的是系统中的店铺ID/店铺号/店铺名称`); } throw new Error(`未找到店铺:${q}`); } // Fetch with error handling async function apiFetch(path, opts = {}) { const config = await getConfig(); const url = `${config.serverUrl}${path}`; try { const res = await fetch(url, { headers: { "Content-Type": "application/json", ...opts.headers }, ...opts, }); if (!res.ok) { const err = await res.json().catch(() => ({ error: res.statusText })); throw new Error(err.error || `HTTP ${res.status}`); } return res.json(); } catch (e) { throw new Error(`网络错误: ${e.message}`); } } function getWorkerId() { return `extension-${chrome.runtime.id.slice(0, 8)}`; } // Claim next task from server async function claimTask(resolvedShopId) { try { const data = await apiFetch( `/api/tasks/claim`, { method: "POST", body: JSON.stringify({ shop_id: resolvedShopId, claimed_by: getWorkerId(), }), } ); return data; } catch (e) { // "No pending tasks" is normal, not an error if (e.message.includes("No pending tasks") || e.message.includes("404")) { return null; } throw e; } } // Report task result async function reportComplete(taskId, noteId, result = {}) { await apiFetch(`/api/tasks/${taskId}/complete`, { method: "POST", body: JSON.stringify({ result, claimed_by: getWorkerId() }), }); } async function reportFail(taskId, noteId, errorMessage) { await apiFetch(`/api/tasks/${taskId}/fail`, { method: "POST", body: JSON.stringify({ error_message: errorMessage, claimed_by: getWorkerId() }), }); } // Random delay between min and max seconds function randomDelayMs(minSec, maxSec) { return Math.floor(Math.random() * (maxSec - minSec + 1) + minSec) * 1000; } function getItemSelectUrl() { return "https://ark.xiaohongshu.com/app-note/publish-item-select?source=noteManagement-GoodsList¬eFrom=NOTE_TAB&step=itemSelect"; } async function waitForTabComplete(tabId, timeoutMs = 20000) { const startedAt = Date.now(); while (Date.now() - startedAt < timeoutMs) { const tab = await chrome.tabs.get(tabId); if (tab.status === "complete") return tab; await new Promise((resolve) => setTimeout(resolve, 500)); } return chrome.tabs.get(tabId); } async function ensureQianfanTab() { const tabs = await chrome.tabs.query({ url: "https://ark.xiaohongshu.com/*" }); const publishTab = tabs.find((tab) => { const url = tab.url || ""; return url.includes("publish-item-select") || url.includes("/app-note/publish"); }); if (publishTab) return publishTab; console.log("[千帆助手] 未找到千帆发布页,自动打开选品页"); const tab = await chrome.tabs.create({ url: getItemSelectUrl(), active: true }); return waitForTabComplete(tab.id); } // Main publish loop async function publishLoop() { const config = await getConfig(); if (!config.enabled || !config.shopId) { state.polling = false; return; } // Check daily limit if (state.todayCount >= config.dailyLimit) { console.log(`[千帆助手] 今日已发布 ${state.todayCount} 条,达到上限 ${config.dailyLimit}`); updateBadge("达限", "#f59e0b"); return; } let resolvedShopId; try { resolvedShopId = await resolveShopId(config.shopId); } catch (e) { console.error("[千帆助手] 店铺解析失败:", e.message); notify("店铺未找到", e.message); updateBadge("店铺错", "#ef4444"); scheduleNext(); return; } // 先确保发布/选品页可用,再领取 task,避免任务被领走后没有页面可执行。 const qianfanTab = await ensureQianfanTab(); const tabs = [qianfanTab]; let taskData = null; try { taskData = await claimTask(resolvedShopId); if (!taskData || !taskData.note) { console.log("[千帆助手] 暂无待发布任务"); updateBadge("等待", "#6b7280"); return; } console.log(`[千帆助手] 领取任务: ${taskData.note.title}`); updateBadge("发布中", "#3b82f6"); // Find a tab that is on the千帆 page (item-select or publish) let targetTab = null; for (const tab of tabs) { try { const pingResult = await chrome.tabs.sendMessage(tab.id, { type: "PING" }); if (pingResult && (pingResult.isPublishPage || pingResult.isItemSelectPage)) { targetTab = tab; break; } } catch (e) { // Content script not loaded on this tab, skip continue; } } if (!targetTab) { // content script 未注入(页面在插件加载前已打开),主动注入后重试 PING console.log("[千帆助手] content script 未响应,尝试主动注入"); for (const tab of tabs) { try { await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ["content_scripts/qianfan_publish.js"], }); const pingResult = await chrome.tabs.sendMessage(tab.id, { type: "PING" }); if (pingResult && (pingResult.isPublishPage || pingResult.isItemSelectPage)) { targetTab = tab; console.log("[千帆助手] 主动注入后 content script 已就绪"); break; } } catch (e) { continue; } } } if (!targetTab) { throw new Error("千帆页内容脚本未加载,请刷新千帆页(Ctrl+R)后重试"); } // Execute publish const response = await chrome.tabs.sendMessage(targetTab.id, { type: "EXECUTE_PUBLISH", task: taskData, }); if (response && response.success) { await reportComplete(taskData.task_id, taskData.note.id, response.result); if (response.result && response.result.continuedToItemSelect === false) { console.warn("[千帆助手] 发布成功后未确认回到选品页,主动导航回选品页"); await chrome.tabs.update(targetTab.id, { url: getItemSelectUrl() }); await waitForTabComplete(targetTab.id); } state.todayCount++; state.consecutiveFailures = 0; state.recentResults = []; state.runNextImmediately = true; await saveState(); console.log(`[千帆助手] 发布成功: ${taskData.note.title}`); updateBadge("续发", "#22c55e"); } else { throw new Error(response?.error || "发布执行失败"); } } catch (e) { console.error(`[千帆助手] 任务失败:`, e.message, e.stack); state.consecutiveFailures++; await saveState(); if (taskData) { try { await reportFail(taskData.task_id, taskData.note.id, e.message); } catch (_) {} } updateBadge("失败", "#ef4444"); state.recentResults.push({ success: false, ts: Date.now() }); state.recentResults = state.recentResults.slice(-5); if (state.recentResults.length >= 5) { const elapsed = state.recentResults[4].ts - state.recentResults[0].ts; const allFail = state.recentResults.every((r) => !r.success); if (allFail && elapsed < 60000) { console.warn(`[千帆助手] 检测到连续快速失败(${elapsed}ms内5次),已暂停`); state.rapidFailPaused = true; state.polling = false; notify("已自动暂停", "检测到连续5次快速失败(60秒内),请检查千帆页面后重置计数再启动"); updateBadge("暂停", "#ef4444"); return; } } } finally { scheduleNext(); } } function scheduleNext() { if (state.polling) { getConfig().then((cfg) => { const delay = state.runNextImmediately ? 1000 : randomDelayMs(cfg.publishIntervalMin, cfg.publishIntervalMax); state.runNextImmediately = false; if (state.timerId) clearTimeout(state.timerId); state.timerId = setTimeout(publishLoop, delay); }); } } function updateBadge(text, color) { chrome.action.setBadgeText({ text }); chrome.action.setBadgeBackgroundColor({ color }); } function notify(title, message) { chrome.notifications.create({ type: "basic", iconUrl: "icons/icon128.png", title: `千帆助手 - ${title}`, message, }); } // Start/stop polling async function startPolling() { if (state.polling) return; state.polling = true; state.rapidFailPaused = false; // Persist enabled state so it survives service worker restart const config = await getConfig(); await chrome.storage.local.set({ config: { ...config, enabled: true } }); await loadState(); console.log("[千帆助手] 开始轮询发布"); updateBadge("运行", "#22c55e"); publishLoop(); } async function stopPolling() { state.polling = false; if (state.timerId) { clearTimeout(state.timerId); state.timerId = null; } // Persist disabled state const config = await getConfig(); await chrome.storage.local.set({ config: { ...config, enabled: false } }); console.log("[千帆助手] 停止轮询"); updateBadge("停止", "#6b7280"); } // Message handler chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { if (msg.type === "GET_STATUS") { loadState().then(async () => { try { const config = await getConfig(); if (config.shopId) { const shopId = await resolveShopId(config.shopId); const notes = await apiFetch(`/api/notes?shop_id=${shopId}&status=approved`); state.pendingCount = Array.isArray(notes) ? notes.length : 0; state.statusError = ""; } } catch (e) { state.pendingCount = 0; state.statusError = e.message || "状态查询失败"; } sendResponse({ polling: state.polling, todayCount: state.todayCount, consecutiveFailures: state.consecutiveFailures, pendingCount: state.pendingCount, statusError: state.statusError || "", }); }).catch(() => { sendResponse({ polling: state.polling, todayCount: state.todayCount, consecutiveFailures: state.consecutiveFailures, pendingCount: 0, statusError: state.statusError || "", }); }); return true; } if (msg.type === "START_POLLING") { // Fire and forget - start polling without blocking the message channel startPolling().catch(e => console.error("[千帆助手] 启动失败:", e.message)); // Respond immediately sendResponse({ ok: true, polling: true }); return false; } if (msg.type === "STOP_POLLING") { stopPolling().then(() => sendResponse({ ok: true, polling: false })); return true; } if (msg.type === "SET_CONFIG") { setConfig(msg.config).then(() => sendResponse({ ok: true })).catch(() => sendResponse({ ok: false })); return true; } if (msg.type === "GET_CONFIG") { getConfig().then((c) => sendResponse(c)).catch(() => sendResponse({ ...DEFAULT_CONFIG })); return true; } if (msg.type === "SELECTOR_FAILURE") { console.error("[千帆助手] 选择器失效告警:", msg.step, msg.selector); notify("选择器失效", msg.step + " 步骤的选择器未匹配到元素,请检查千帆页面是否改版。URL: " + msg.url); updateBadge("告警", "#f59e0b"); sendResponse({ ok: true }); return false; } if (msg.type === "TEST_CONNECTION") { apiFetch("/api/health") .then((data) => sendResponse({ ok: true, data })) .catch((e) => sendResponse({ ok: false, error: e.message })); return true; } if (msg.type === "FETCH_IMAGE") { (async () => { try { const config = await getConfig(); const imageUrl = msg.url; let fetchUrl; if (imageUrl.startsWith("http")) { fetchUrl = imageUrl; } else if (imageUrl.startsWith("/")) { fetchUrl = config.serverUrl + imageUrl; } else { throw new Error("Invalid image URL: " + imageUrl); } const parsedUrl = new URL(fetchUrl); const serverUrl = new URL(config.serverUrl); const allowedServerImage = parsedUrl.origin === serverUrl.origin && (parsedUrl.pathname.startsWith("/images/") || parsedUrl.pathname.startsWith("/api/images/")); if (!allowedServerImage && parsedUrl.protocol !== "https:") { throw new Error("只允许获取本地服务图片或 HTTPS 图片"); } const res = await fetch(fetchUrl); if (!res.ok) throw new Error("HTTP " + res.status); const contentType = res.headers.get("Content-Type") || ""; if (!contentType.startsWith("image/")) throw new Error("URL 返回的不是图片"); const blob = await res.blob(); const buffer = await blob.arrayBuffer(); const bytes = new Uint8Array(buffer); let binary = ""; for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); const base64 = btoa(binary); const mimeType = blob.type || "image/jpeg"; sendResponse({ ok: true, dataUrl: "data:" + mimeType + ";base64," + base64, mimeType }); } catch (e) { sendResponse({ ok: false, error: e.message }); } })(); return true; } // Unknown message type sendResponse({ error: "unknown message type" }); return false; }); // Alarm-based backup polling (in case service worker restarts) chrome.alarms.create("poll", { periodInMinutes: 1 }); chrome.alarms.onAlarm.addListener(async (alarm) => { if (alarm.name === "poll") { const config = await getConfig(); if (config.enabled && !state.polling) { startPolling(); } } }); // Restore state on service worker startup loadState().then(async () => { const config = await getConfig(); if (config.enabled) { startPolling(); } });