667 lines
24 KiB
JavaScript
667 lines
24 KiB
JavaScript
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)) }
|