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

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