xiaohongshufabu/extension/background.js
2026-07-21 21:17:20 +08:00

489 lines
15 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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();
}
});