xiaohongshufabu/extension/background.js

489 lines
15 KiB
JavaScript
Raw Permalink Normal View History

2026-07-21 21:17:20 +08:00
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&noteFrom=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();
}
});