Files
RuoYi-Vue/browser-extension/multi-platform-publisher-1.1.15/background/service-worker.js

557 lines
19 KiB
JavaScript
Raw Normal View History

2026-09-14 17:40:52 +08:00
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 })
recoverStoredBatches().catch(() => {})
})
chrome.runtime.onStartup.addListener(() => {
recoverStoredBatches().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
}
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)
}
await chrome.storage.local.remove([tabKey(tabId), taskKey(context.taskId)])
}).catch(() => {})
})
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
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)) }