const VERSION = chrome.runtime.getManifest().version const PLATFORM_URLS = Object.freeze({ xiaohongshu: 'https://creator.xiaohongshu.com/publish/publish?source=official', douyin: 'https://creator.douyin.com/creator-micro/content/upload', wechat_channels: 'https://channels.weixin.qq.com/platform/post/create', bilibili: 'https://member.bilibili.com/platform/upload/video/frame', xianyu: 'https://www.goofish.pro/sale/product/add?from=%2Fon-sale' }) const PLATFORM_HOSTS = Object.freeze({ xiaohongshu: 'creator.xiaohongshu.com', douyin: 'creator.douyin.com', wechat_channels: 'channels.weixin.qq.com', bilibili: 'member.bilibili.com', xianyu: 'goofish.pro' }) const TERMINAL_STATUSES = new Set(['READY_TO_PUBLISH', 'SUCCEEDED', 'FAILED', 'CANCELLED']) const BATCH_ADVANCE_STATUSES = new Set(['READY_TO_PUBLISH', 'FAILED', 'LOGIN_REQUIRED', 'CANCELLED']) const BATCH_WATCHDOG_PREFIX = 'publisher_batch_watchdog:' const advancingBatches = new Set() chrome.runtime.onInstalled.addListener(() => { chrome.storage.local.set({ publisherExtensionVersion: VERSION }) configureSidePanel().catch(() => {}) recoverStoredBatches().catch(() => {}) }) chrome.runtime.onStartup.addListener(() => { configureSidePanel().catch(() => {}) recoverStoredBatches().catch(() => {}) }) configureSidePanel().catch(() => {}) chrome.alarms.onAlarm.addListener(alarm => { if (!alarm || !alarm.name.startsWith(BATCH_WATCHDOG_PREFIX)) return const batchId = alarm.name.slice(BATCH_WATCHDOG_PREFIX.length) continueTimedOutBatch(batchId).catch(() => {}) }) chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (!message || typeof message.type !== 'string') return false if (message.type === 'ADMIN_PING') { sendResponse({ ok: isAllowedAdmin(sender.url), version: VERSION }) return false } if (message.type === 'ADMIN_START_PUBLISH') { startPublish(message.payload, sender) .then(result => sendResponse({ ok: true, ...result })) .catch(error => sendResponse({ ok: false, message: readableError(error) })) return true } if (message.type === 'ADMIN_START_PUBLISH_BATCH') { startPublishBatch(message.payload, sender) .then(result => sendResponse({ ok: true, ...result })) .catch(error => sendResponse({ ok: false, message: readableError(error) })) return true } if (message.type === 'PLATFORM_READY') { resumeTaskForTab(sender.tab, message.platform) .then(result => sendResponse(result)) .catch(error => sendResponse({ ok: false, message: readableError(error) })) return true } if (message.type === 'PLATFORM_FILL_CONTENT') { fillPlatformContent(message.payload, sender) .then(result => sendResponse({ ok: true, ...result })) .catch(error => sendResponse({ ok: false, message: readableError(error) })) return true } if (message.type === 'PUBLISH_STATUS') { receivePlatformStatus(message.payload, sender) .then(() => sendResponse({ ok: true })) .catch(error => sendResponse({ ok: false, message: readableError(error) })) return true } if (message.type === 'SIDEPANEL_GET_TASKS') { listSidePanelTasks() .then(tasks => sendResponse({ ok: true, tasks })) .catch(error => sendResponse({ ok: false, message: readableError(error) })) return true } if (message.type === 'SIDEPANEL_RETRY_TASK') { retryTask(message.taskId) .then(result => sendResponse({ ok: true, ...result })) .catch(error => sendResponse({ ok: false, message: readableError(error) })) return true } if (message.type === 'SIDEPANEL_ACTIVATE_TASK') { activateTaskTab(message.taskId) .then(result => sendResponse({ ok: true, ...result })) .catch(error => sendResponse({ ok: false, message: readableError(error) })) return true } return false }) chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { if (changeInfo.status !== 'complete') return getContextForTab(tabId).then(context => { if (!context || !isExpectedPlatformUrl(tab.url, context.platform)) return sendTaskMessage(tabId, context) }).catch(() => {}) }) chrome.tabs.onRemoved.addListener(tabId => { getContextForTab(tabId).then(async context => { if (!context) return if (!TERMINAL_STATUSES.has(context.status)) { await updateStatus(context, 'CANCELLED', '平台页面已关闭', '') await advanceBatch(context) } context.platformTabId = null context.closedAt = Date.now() await chrome.storage.local.set({ [taskKey(context.taskId)]: context }) await chrome.storage.local.remove(tabKey(tabId)) }).catch(() => {}) }) async function configureSidePanel() { if (!chrome.sidePanel || !chrome.sidePanel.setPanelBehavior) return await chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }) } async function listSidePanelTasks() { const stored = await chrome.storage.local.get(null) const expiry = Date.now() - 4 * 60 * 60 * 1000 const contexts = Object.keys(stored) .filter(key => key.startsWith('publisher_task_')) .map(key => stored[key]) .filter(context => context && context.taskId && context.platform && context.createdAt >= expiry) .sort((left, right) => (right.createdAt || 0) - (left.createdAt || 0)) const latestByPlatform = new Map() for (const context of contexts) { if (!latestByPlatform.has(context.platform)) latestByPlatform.set(context.platform, context) } return Array.from(latestByPlatform.values()).map(context => ({ taskId: context.taskId, platform: context.platform, platformName: platformName(context.platform), title: context.task && context.task.title || '', status: context.status || 'PENDING', statusMessage: context.statusMessage || '', platformTabId: context.platformTabId || null, createdAt: context.createdAt || 0, canRetry: context.status !== 'SUCCEEDED' })) } async function activateTaskTab(taskId) { const context = await requireStoredTask(taskId) if (!context.platformTabId) throw new Error('平台页面已关闭,可点击刷新按钮重新打开') const tab = await chrome.tabs.get(context.platformTabId).catch(() => null) if (!tab) throw new Error('平台页面已关闭,可点击刷新按钮重新打开') await focusTab(tab) return { taskId: context.taskId, tabId: tab.id } } async function retryTask(taskId) { const context = await requireStoredTask(taskId) if (context.status === 'SUCCEEDED') throw new Error('已发布成功的任务不能重试,以免重复发布') context.status = 'OPENING' context.statusMessage = '正在从侧边栏重试' context.closedAt = null await updateStatus(context, 'OPENING', '正在重试' + platformName(context.platform) + '发布任务', '') let tab = context.platformTabId ? await chrome.tabs.get(context.platformTabId).catch(() => null) : null if (tab) { await chrome.storage.local.set({ [tabKey(tab.id)]: context.taskId }) tab = await chrome.tabs.update(tab.id, { url: PLATFORM_URLS[context.platform], active: true }) } else { tab = await chrome.tabs.create({ url: PLATFORM_URLS[context.platform], active: true }) } context.platformTabId = tab.id await chrome.storage.local.set({ [taskKey(context.taskId)]: context, [tabKey(tab.id)]: context.taskId }) await focusTab(tab) return { taskId: context.taskId, tabId: tab.id } } async function requireStoredTask(taskId) { if (typeof taskId !== 'string' || !/^publish_[a-f0-9]{32}$/.test(taskId)) { throw new Error('发布任务编号无效') } const stored = await chrome.storage.local.get(taskKey(taskId)) const context = stored[taskKey(taskId)] if (!context) throw new Error('发布任务已过期,请从后台重新创建') return context } async function focusTab(tab) { if (tab.windowId) await chrome.windows.update(tab.windowId, { focused: true }).catch(() => {}) await chrome.tabs.update(tab.id, { active: true }) } async function startPublish(payload, sender) { if (!isAllowedAdmin(sender.url) || !sender.tab) { throw new Error('发布请求不是来自允许的后台页面') } validatePayload(payload) const apiBaseUrl = normalizeApiBase(payload.apiBaseUrl, sender.url) const task = await fetchBridgeTask(apiBaseUrl, payload.taskId, payload.token) if (task.platform !== payload.platform || !PLATFORM_URLS[task.platform]) { throw new Error('任务平台与页面请求不一致') } const context = { taskId: payload.taskId, token: payload.token, apiBaseUrl, platform: task.platform, task, adminTabId: sender.tab.id, batchId: payload.batchId || '', status: 'OPENING', createdAt: Date.now() } await updateStatus(context, 'OPENING', '正在打开' + platformName(task.platform) + '创作者中心', '') const tab = await chrome.tabs.create({ url: PLATFORM_URLS[task.platform], active: true }) context.platformTabId = tab.id await chrome.storage.local.set({ [taskKey(context.taskId)]: context, [tabKey(tab.id)]: context.taskId }) await sendTaskMessageWithRetry(tab.id, context, 15000) return { taskId: context.taskId, tabId: tab.id } } async function startPublishBatch(payload, sender) { if (!isAllowedAdmin(sender.url) || !sender.tab) { throw new Error('批量发布请求不是来自允许的后台页面') } if (!payload || typeof payload.batchId !== 'string' || !/^publish_batch_[a-zA-Z0-9_]+$/.test(payload.batchId) || !Array.isArray(payload.tasks) || !payload.tasks.length || payload.tasks.length > 5) { throw new Error('多平台发布批次参数不正确') } const platforms = new Set() const tasks = payload.tasks.map(item => { const task = { taskId: item.taskId, token: item.token, platform: item.platform, apiBaseUrl: payload.apiBaseUrl } validatePayload(task) if (platforms.has(task.platform)) throw new Error('多平台发布中存在重复平台') platforms.add(task.platform) return task }) const batch = { batchId: payload.batchId, tasks, nextIndex: 0, currentTaskId: '', adminTabId: sender.tab.id, adminUrl: sender.url, createdAt: Date.now() } await chrome.storage.local.set({ [batchKey(batch.batchId)]: batch }) await processNextBatchTask(batch.batchId) return { batchId: batch.batchId, taskCount: tasks.length } } async function processNextBatchTask(batchId) { const stored = await chrome.storage.local.get(batchKey(batchId)) const batch = stored[batchKey(batchId)] if (!batch || batch.currentTaskId) return if (batch.nextIndex >= batch.tasks.length) { await chrome.storage.local.remove(batchKey(batchId)) return } const task = batch.tasks[batch.nextIndex] batch.nextIndex += 1 batch.currentTaskId = task.taskId await chrome.storage.local.set({ [batchKey(batchId)]: batch }) await scheduleBatchWatchdog(batchId) try { await startPublish( { ...task, batchId }, { url: batch.adminUrl, tab: { id: batch.adminTabId } } ) } catch (error) { const context = { taskId: task.taskId, token: task.token, apiBaseUrl: task.apiBaseUrl, platform: task.platform, adminTabId: batch.adminTabId, batchId } try { await updateStatus(context, 'FAILED', '平台任务启动失败', readableError(error)) } catch (ignored) {} await advanceBatch(context) } } async function advanceBatch(context) { if (!context || !context.batchId) return if (advancingBatches.has(context.batchId)) return advancingBatches.add(context.batchId) let shouldProcessNext = false try { const key = batchKey(context.batchId) const stored = await chrome.storage.local.get(key) const batch = stored[key] if (!batch || batch.currentTaskId !== context.taskId) return await chrome.alarms.clear(batchWatchdogName(context.batchId)) batch.currentTaskId = '' await chrome.storage.local.set({ [key]: batch }) shouldProcessNext = true } finally { advancingBatches.delete(context.batchId) } // 启动下一项前必须先释放批次锁。否则下一项在启动阶段失败时, // 它的补偿推进会因同一把锁仍被占用而直接返回,整个批次就会停住。 if (shouldProcessNext) await processNextBatchTask(context.batchId) } async function scheduleBatchWatchdog(batchId) { await chrome.alarms.create(batchWatchdogName(batchId), { delayInMinutes: 4 }) } async function continueTimedOutBatch(batchId) { const stored = await chrome.storage.local.get(batchKey(batchId)) const batch = stored[batchKey(batchId)] if (!batch || !batch.currentTaskId) return const taskId = batch.currentTaskId const taskStored = await chrome.storage.local.get(taskKey(taskId)) const context = taskStored[taskKey(taskId)] || { taskId, batchId, token: '', apiBaseUrl: '', platform: '' } context.batchId = batchId if (context.token && context.apiBaseUrl && !TERMINAL_STATUSES.has(context.status)) { try { await updateStatus( context, 'FAILED', '当前平台处理超时,批次已自动继续', '扩展超过4分钟未收到完成信号,请检查该平台页面' ) } catch (ignored) {} } await advanceBatch({ batchId, taskId }) } async function recoverStoredBatches() { const stored = await chrome.storage.local.get(null) const batches = Object.keys(stored) .filter(key => key.startsWith('publisher_batch_')) .map(key => stored[key]) .filter(Boolean) for (const batch of batches) { if (!batch.currentTaskId) { await processNextBatchTask(batch.batchId) continue } const context = stored[taskKey(batch.currentTaskId)] if (context && BATCH_ADVANCE_STATUSES.has(context.status)) { await advanceBatch(context) } else { await scheduleBatchWatchdog(batch.batchId) } } } async function resumeTaskForTab(tab, platform) { if (!tab || !tab.id) return { ok: false, message: '无法识别平台标签页' } const context = await getContextForTab(tab.id) if (!context) return { ok: false, message: '当前标签页没有待执行任务' } if (context.platform !== platform || !isExpectedPlatformUrl(tab.url, platform)) { return { ok: false, message: '平台标签页与发布任务不匹配' } } if (['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(context.status)) { return { ok: false, message: '发布任务已经结束' } } await sendTaskMessage(tab.id, context) return { ok: true, taskId: context.taskId } } async function sendTaskMessage(tabId, context) { try { await chrome.tabs.sendMessage(tabId, { type: context.status === 'READY_TO_PUBLISH' ? 'WATCH_PUBLISH_OUTCOME' : 'RUN_PUBLISH', payload: { task: context.task, taskId: context.taskId, token: context.token, apiBaseUrl: context.apiBaseUrl, batchId: context.batchId || '' } }) return true } catch (error) { // 页面脚本可能仍在加载,PLATFORM_READY 会再次触发发送。 return false } } async function sendTaskMessageWithRetry(tabId, context, timeout) { const started = Date.now() while (Date.now() - started < timeout) { if (await sendTaskMessage(tabId, context)) return true await sleep(500) } // 登录页或较慢的页面仍可在加载完成后通过 PLATFORM_READY 恢复任务。 return false } async function receivePlatformStatus(payload, sender) { if (!payload || !payload.taskId || !payload.status || !sender.tab) { throw new Error('平台状态参数不完整') } const context = await getContextForTab(sender.tab.id) if (!context || context.taskId !== payload.taskId) { throw new Error('未找到当前平台标签页对应的发布任务') } if (!isExpectedPlatformUrl(sender.url, context.platform)) { throw new Error('平台状态来源不可信') } let statusError = null try { await updateStatus(context, payload.status, payload.message || '', payload.error || '') } catch (error) { statusError = error } // 批次推进以平台页面的完成信号为准。后台状态接口偶发失败时, // 仍继续打开下一平台,避免一个回写请求把整个多平台批次卡住。 if (BATCH_ADVANCE_STATUSES.has(payload.status)) { await advanceBatch(context) } if (statusError) throw statusError } async function fillPlatformContent(payload, sender) { if (!sender.tab || !sender.tab.id || !payload || payload.platform !== 'wechat_channels' || !isExpectedPlatformUrl(sender.url, payload.platform)) { throw new Error('文案填写请求来源不可信') } const value = String(payload.value || '') if (!value || value.length > 10000) throw new Error('视频号发布文案为空或过长') const results = await chrome.scripting.executeScript({ target: { tabId: sender.tab.id, allFrames: true }, world: 'MAIN', func: fillWechatChannelsContent, args: [value] }) const frameResults = results.map(item => item.result).filter(Boolean) const hit = frameResults.find(item => item.ok) if (!hit) { const details = frameResults.map(item => (item.url || 'unknown') + ': ' + (item.error || '未找到编辑器')) throw new Error('视频号文案写入失败;' + details.join(' | ').slice(0, 800)) } return { frameUrl: hit.url } } function fillWechatChannelsContent(value) { const normalize = text => String(text || '').replace(/\s+/g, ' ').trim() const isVisible = element => { if (!element || !element.isConnected) return false const rect = element.getBoundingClientRect() const style = getComputedStyle(element) return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden' } const queryAllDeep = (root, selector) => { const matches = Array.from(root.querySelectorAll(selector)) for (const element of root.querySelectorAll('*')) { if (element.shadowRoot) matches.push(...queryAllDeep(element.shadowRoot, selector)) } return matches } const selectors = [ 'div.input-editor[contenteditable][data-placeholder="添加描述"]', '[contenteditable][data-placeholder*="描述"]', '.post-desc-box [contenteditable]' ] let editor = null for (const selector of selectors) { const candidates = queryAllDeep(document, selector).filter(element => isVisible(element.closest && element.closest('.post-desc-box') || element) ) if (candidates.length === 1) { editor = candidates[0] break } } if (!editor) { return { ok: false, url: location.href, error: '可见描述编辑器数量不为 1' } } editor.focus() const selection = getSelection() const range = document.createRange() range.selectNodeContents(editor) selection.removeAllRanges() selection.addRange(range) let inserted = false try { inserted = document.execCommand('insertText', false, value) if (!inserted) inserted = document.execCommand('insertHTML', false, value) } catch (error) { return { ok: false, url: location.href, error: error.message } } editor.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: value })) editor.dispatchEvent(new Event('change', { bubbles: true })) editor.blur() const current = normalize(editor.textContent) return { ok: inserted && current === normalize(value), url: location.href, error: inserted ? '写入后内容校验不一致' : '富文本写入命令未生效' } } async function updateStatus(context, status, message, error) { const url = bridgeUrl(context, '/status') const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status, message, error }) }) const body = await readJson(response) if (!response.ok || body.code !== 200) { throw new Error(body.msg || '发布状态回写失败') } context.status = status context.statusMessage = message || '' context.lastError = error || '' if (context.platformTabId) { await chrome.storage.local.set({ [taskKey(context.taskId)]: context }) } await broadcastStatus(context, status, message, error) } async function broadcastStatus(context, status, message, error) { const event = { type: 'ADMIN_PUBLISH_STATUS', payload: { taskId: context.taskId, platform: context.platform, status, message, error } } if (context.adminTabId) { try { await chrome.tabs.sendMessage(context.adminTabId, event) return } catch (ignored) {} } const tabs = await chrome.tabs.query({}) await Promise.all(tabs.filter(tab => isAllowedAdmin(tab.url)).map(tab => chrome.tabs.sendMessage(tab.id, event).catch(() => {}) )) } async function fetchBridgeTask(apiBaseUrl, taskId, token) { const url = apiBaseUrl + '/office/publish/bridge/task/' + encodeURIComponent(taskId) + '?token=' + encodeURIComponent(token) const response = await fetch(url, { method: 'GET', cache: 'no-store' }) const body = await readJson(response) if (!response.ok || body.code !== 200 || !body.data) { throw new Error(body.msg || '读取发布任务失败') } return body.data } async function readJson(response) { try { return await response.json() } catch (error) { return { code: response.status, msg: '服务返回了无法识别的内容' } } } async function getContextForTab(tabId) { const tabEntry = await chrome.storage.local.get(tabKey(tabId)) const taskId = tabEntry[tabKey(tabId)] if (!taskId) return null const taskEntry = await chrome.storage.local.get(taskKey(taskId)) return taskEntry[taskKey(taskId)] || null } function bridgeUrl(context, suffix) { return context.apiBaseUrl + '/office/publish/bridge/task/' + encodeURIComponent(context.taskId) + suffix + '?token=' + encodeURIComponent(context.token) } function validatePayload(payload) { if (!payload || typeof payload.taskId !== 'string' || typeof payload.token !== 'string' || typeof payload.platform !== 'string' || typeof payload.apiBaseUrl !== 'string') { throw new Error('发布任务参数不完整') } if (!PLATFORM_URLS[payload.platform]) throw new Error('不支持的发布平台') if (!/^publish_[a-f0-9]{32}$/.test(payload.taskId)) throw new Error('任务编号格式不正确') if (!/^[a-f0-9]{32}$/.test(payload.token)) throw new Error('任务凭证格式不正确') } function normalizeApiBase(value, senderUrl) { const api = new URL(value) const sender = new URL(senderUrl) if (api.origin !== sender.origin) throw new Error('任务接口必须与后台页面同源') return api.origin + api.pathname.replace(/\/$/, '') } function isAllowedAdmin(value) { if (!value) return false try { const url = new URL(value) const allowedHost = url.hostname === 'feast.yidaima.cn' || url.hostname === 'localhost' || url.hostname === '127.0.0.1' return allowedHost && url.pathname.indexOf('/office/publish') >= 0 } catch (error) { return false } } function isExpectedPlatformUrl(value, platform) { if (!value || !PLATFORM_HOSTS[platform]) return false try { const hostname = new URL(value).hostname return hostname === PLATFORM_HOSTS[platform] || (platform === 'xianyu' && hostname === 'www.goofish.pro') } catch (error) { return false } } function platformName(platform) { return { xiaohongshu: '小红书', douyin: '抖音', wechat_channels: '视频号', bilibili: 'B站', xianyu: '闲鱼' }[platform] || platform } function taskKey(taskId) { return 'publisher_task_' + taskId } function tabKey(tabId) { return 'publisher_tab_' + tabId } function batchKey(batchId) { return 'publisher_batch_' + batchId } function batchWatchdogName(batchId) { return BATCH_WATCHDOG_PREFIX + batchId } function readableError(error) { return error && error.message ? error.message : String(error || '未知错误') } function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)) }