Add multi-platform publishing workflow
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
(() => {
|
||||
const SOURCE_ADMIN = 'yidaima-admin'
|
||||
const SOURCE_EXTENSION = 'yidaima-publisher-extension'
|
||||
const VERSION = chrome.runtime.getManifest().version
|
||||
|
||||
function isPublishPage() {
|
||||
return location.pathname.indexOf('/office/publish') >= 0
|
||||
}
|
||||
|
||||
function announceReady() {
|
||||
if (!isPublishPage()) return
|
||||
window.dispatchEvent(new CustomEvent('YIDAIMA_EXTENSION_READY', {
|
||||
detail: { source: SOURCE_EXTENSION, version: VERSION }
|
||||
}))
|
||||
}
|
||||
|
||||
window.addEventListener('YIDAIMA_EXTENSION_PING', event => {
|
||||
if (!isPublishPage() || !event.detail || event.detail.source !== SOURCE_ADMIN) return
|
||||
chrome.runtime.sendMessage({ type: 'ADMIN_PING' }, response => {
|
||||
if (chrome.runtime.lastError || !response || !response.ok) return
|
||||
announceReady()
|
||||
})
|
||||
})
|
||||
|
||||
window.addEventListener('YIDAIMA_START_PUBLISH', event => {
|
||||
const detail = event.detail
|
||||
if (!isPublishPage() || !detail || detail.source !== SOURCE_ADMIN) return
|
||||
const payload = {
|
||||
taskId: detail.taskId,
|
||||
token: detail.token,
|
||||
platform: detail.platform,
|
||||
apiBaseUrl: detail.apiBaseUrl
|
||||
}
|
||||
chrome.runtime.sendMessage({ type: 'ADMIN_START_PUBLISH', payload }, response => {
|
||||
const error = chrome.runtime.lastError
|
||||
window.dispatchEvent(new CustomEvent('YIDAIMA_PUBLISH_ACCEPTED', {
|
||||
detail: {
|
||||
source: SOURCE_EXTENSION,
|
||||
taskId: payload.taskId,
|
||||
ok: !error && Boolean(response && response.ok),
|
||||
message: error ? error.message : (response && response.message)
|
||||
}
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
window.addEventListener('YIDAIMA_START_PUBLISH_BATCH', event => {
|
||||
const detail = event.detail
|
||||
if (!isPublishPage() || !detail || detail.source !== SOURCE_ADMIN || !Array.isArray(detail.tasks)) return
|
||||
const payload = {
|
||||
batchId: detail.batchId,
|
||||
apiBaseUrl: detail.apiBaseUrl,
|
||||
tasks: detail.tasks.map(task => ({
|
||||
taskId: task.taskId,
|
||||
token: task.token,
|
||||
platform: task.platform
|
||||
}))
|
||||
}
|
||||
chrome.runtime.sendMessage({ type: 'ADMIN_START_PUBLISH_BATCH', payload }, response => {
|
||||
const error = chrome.runtime.lastError
|
||||
window.dispatchEvent(new CustomEvent('YIDAIMA_PUBLISH_ACCEPTED', {
|
||||
detail: {
|
||||
source: SOURCE_EXTENSION,
|
||||
batchId: payload.batchId,
|
||||
ok: !error && Boolean(response && response.ok),
|
||||
message: error ? error.message : (response && response.message)
|
||||
}
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
chrome.runtime.onMessage.addListener(message => {
|
||||
if (!message || message.type !== 'ADMIN_PUBLISH_STATUS' || !isPublishPage()) return
|
||||
window.dispatchEvent(new CustomEvent('YIDAIMA_PUBLISH_STATUS', {
|
||||
detail: { source: SOURCE_EXTENSION, ...message.payload }
|
||||
}))
|
||||
})
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', announceReady, { once: true })
|
||||
} else {
|
||||
announceReady()
|
||||
}
|
||||
})()
|
||||
@@ -0,0 +1,69 @@
|
||||
(() => {
|
||||
const config = {
|
||||
platform: 'bilibili',
|
||||
platformName: 'B站',
|
||||
fillAfterUploadSettled: true,
|
||||
uploadSettleTimeout: 30 * 60 * 1000,
|
||||
fillDelay: 1000,
|
||||
videoModeLabels: [],
|
||||
imageModeLabels: [],
|
||||
uploadSelectors: [
|
||||
'.upload-btn input[type="file"]',
|
||||
'.upload-wrapper input[type="file"]',
|
||||
'input[type="file"][accept*="video"]',
|
||||
'input[type="file"][accept*="mp4"]',
|
||||
'input[type="file"]'
|
||||
],
|
||||
uploadDropZoneSelectors: [
|
||||
'.upload-btn',
|
||||
'.upload-wrapper',
|
||||
'[class*="upload"] [class*="drag"]'
|
||||
],
|
||||
videoInputSelectors: [
|
||||
'.upload-btn input[type="file"]',
|
||||
'.upload-wrapper input[type="file"]',
|
||||
'input[type="file"][accept*="video"]',
|
||||
'input[type="file"][accept*="mp4"]'
|
||||
],
|
||||
imageInputSelectors: [],
|
||||
coverInputSelectors: [
|
||||
'[class*="cover"] input[type="file"][accept*="image"]',
|
||||
'[class*="cover"] input[type="file"]',
|
||||
'[class*="poster"] input[type="file"][accept*="image"]'
|
||||
],
|
||||
titleSelectors: [
|
||||
'input[placeholder*="稿件标题"]',
|
||||
'input[placeholder*="视频标题"]',
|
||||
'input[placeholder*="标题"]',
|
||||
'input[maxlength="80"]',
|
||||
'[class*="title"] input[type="text"]'
|
||||
],
|
||||
contentSelectors: [
|
||||
'textarea[placeholder*="视频简介"]',
|
||||
'textarea[placeholder*="稿件简介"]',
|
||||
'textarea[placeholder*="简介"]',
|
||||
'[contenteditable="true"][data-placeholder*="简介"]',
|
||||
'[contenteditable="true"][data-placeholder*="描述"]',
|
||||
'.ql-editor[contenteditable="true"]',
|
||||
'[class*="desc"] [contenteditable="true"]',
|
||||
'[class*="desc"] textarea'
|
||||
],
|
||||
progressSelectors: [
|
||||
'.upload-progress',
|
||||
'.bcc-progress',
|
||||
'[class*="upload"] [class*="progress"]',
|
||||
'[role="progressbar"]'
|
||||
],
|
||||
successTexts: [/^投稿成功[!!。]?$/, /^发布成功[!!。]?$/, /^稿件投递成功[!!。]?$/],
|
||||
failureTexts: [/^投稿失败/, /^发布失败/, /^稿件投递失败/]
|
||||
}
|
||||
|
||||
chrome.runtime.onMessage.addListener(message => {
|
||||
if (message && message.type === 'RUN_PUBLISH') {
|
||||
globalThis.YidaimaPublisher.run(config, message.payload)
|
||||
} else if (message && message.type === 'WATCH_PUBLISH_OUTCOME') {
|
||||
globalThis.YidaimaPublisher.watch(config, message.payload)
|
||||
}
|
||||
})
|
||||
chrome.runtime.sendMessage({ type: 'PLATFORM_READY', platform: config.platform })
|
||||
})()
|
||||
@@ -0,0 +1,45 @@
|
||||
(() => {
|
||||
const config = {
|
||||
platform: 'douyin',
|
||||
platformName: '抖音',
|
||||
preserveContentLineBreaks: true,
|
||||
useAtomicHtmlLineBreaks: true,
|
||||
looseContentVerification: true,
|
||||
videoModeLabels: [],
|
||||
imageModeLabels: [],
|
||||
uploadSelectors: [
|
||||
'input[type="file"][accept*="video"]',
|
||||
'input[type="file"]'
|
||||
],
|
||||
videoInputSelectors: ['input[type="file"][accept*="video"]'],
|
||||
imageInputSelectors: [],
|
||||
coverInputSelectors: [
|
||||
'[class*="cover"] input[type="file"][accept*="image"]',
|
||||
'[class*="poster"] input[type="file"][accept*="image"]'
|
||||
],
|
||||
titleSelectors: [
|
||||
'input[placeholder*="标题"]',
|
||||
'textarea[placeholder*="标题"]',
|
||||
'[class*="title"] input'
|
||||
],
|
||||
contentSelectors: [
|
||||
'[contenteditable="true"][data-placeholder*="作品描述"]',
|
||||
'[contenteditable="true"][data-placeholder*="描述"]',
|
||||
'textarea[placeholder*="作品描述"]',
|
||||
'textarea[placeholder*="描述"]',
|
||||
'[class*="editor"] [contenteditable="true"]'
|
||||
],
|
||||
progressSelectors: ['[class*="progress"]', '[role="progressbar"]'],
|
||||
successTexts: [/^发布成功[!!。]?$/, /^作品发布成功[!!。]?$/],
|
||||
failureTexts: [/^发布失败/, /^作品发布失败/]
|
||||
}
|
||||
|
||||
chrome.runtime.onMessage.addListener(message => {
|
||||
if (message && message.type === 'RUN_PUBLISH') {
|
||||
globalThis.YidaimaPublisher.run(config, message.payload)
|
||||
} else if (message && message.type === 'WATCH_PUBLISH_OUTCOME') {
|
||||
globalThis.YidaimaPublisher.watch(config, message.payload)
|
||||
}
|
||||
})
|
||||
chrome.runtime.sendMessage({ type: 'PLATFORM_READY', platform: config.platform })
|
||||
})()
|
||||
@@ -0,0 +1,907 @@
|
||||
(() => {
|
||||
const runningTasks = new Set()
|
||||
|
||||
async function run(config, payload) {
|
||||
const { task, taskId, token, apiBaseUrl } = payload || {}
|
||||
if (!task || !taskId || !token || !apiBaseUrl || runningTasks.has(taskId)) return
|
||||
runningTasks.add(taskId)
|
||||
const context = { config, task, taskId, token, apiBaseUrl, batchId: payload.batchId || '' }
|
||||
|
||||
try {
|
||||
await report(context, 'DOWNLOADING', '正在准备发布素材')
|
||||
await activatePublishMode(config, task)
|
||||
const preparationWarnings = []
|
||||
const preparationWarning = await prepareForm(config)
|
||||
if (preparationWarning) preparationWarnings.push(preparationWarning)
|
||||
if (config.fillBeforeUpload) {
|
||||
await fillPublishText(context)
|
||||
}
|
||||
const uploadTarget = await waitForUploadTarget(config, 60000)
|
||||
const uploadInputs = uploadTarget.inputs
|
||||
const uploadDropZone = uploadTarget.dropZone
|
||||
if (!uploadInputs.length && !uploadDropZone) {
|
||||
await report(
|
||||
context,
|
||||
'LOGIN_REQUIRED',
|
||||
'未找到发布表单,请登录平台后进入内容发布页面',
|
||||
createPageDiagnostics()
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const uploaded = await uploadAssets(context, uploadInputs, uploadDropZone)
|
||||
if (!uploaded && !config.allowNoAssets) throw new Error('没有找到与发布素材匹配的上传控件')
|
||||
|
||||
if (uploaded && config.fillAfterUploadSettled) {
|
||||
await waitForUploadSettled(config, config.uploadSettleTimeout || 10 * 60 * 1000)
|
||||
}
|
||||
|
||||
if (!config.fillBeforeUpload || config.refillAfterUpload) {
|
||||
await fillPublishText(context)
|
||||
}
|
||||
|
||||
await tryUploadCover(context)
|
||||
if (uploaded && !config.fillAfterUploadSettled) {
|
||||
try {
|
||||
const settleTimeout = context.batchId
|
||||
? (config.batchUploadSettleTimeout || 60000)
|
||||
: (config.uploadSettleTimeout || 10 * 60 * 1000)
|
||||
await waitForUploadSettled(config, settleTimeout)
|
||||
} catch (error) {
|
||||
if (!context.batchId) throw error
|
||||
preparationWarnings.push('平台仍显示上传处理中,批次已继续,请稍后检查本页面')
|
||||
}
|
||||
}
|
||||
const combinedWarning = preparationWarnings.join(';')
|
||||
const readyMessage = combinedWarning
|
||||
? '标题、文案和素材已处理;部分商品选项未能自动确认,请检查后再发布'
|
||||
: '资料已填写完成,请检查后手动点击发布按钮'
|
||||
await report(context, 'READY_TO_PUBLISH', readyMessage, combinedWarning)
|
||||
showReadyNotice(config.platformName, combinedWarning)
|
||||
watch(config, payload)
|
||||
} catch (error) {
|
||||
await report(context, 'FAILED', '发布资料填写失败', readableError(error))
|
||||
} finally {
|
||||
runningTasks.delete(taskId)
|
||||
}
|
||||
}
|
||||
|
||||
function watch(config, payload) {
|
||||
const { task, taskId, token, apiBaseUrl } = payload || {}
|
||||
if (!task || !taskId || !token || !apiBaseUrl) return
|
||||
const watcherKey = 'watch:' + taskId
|
||||
if (runningTasks.has(watcherKey)) return
|
||||
runningTasks.add(watcherKey)
|
||||
const context = { config, task, taskId, token, apiBaseUrl }
|
||||
const observer = new MutationObserver(mutations => {
|
||||
for (const mutation of mutations) {
|
||||
for (const node of mutation.addedNodes) {
|
||||
const text = String(node.innerText || node.textContent || '').trim()
|
||||
if (!text || text.length > 500) continue
|
||||
if (config.successTexts.some(pattern => pattern.test(text))) {
|
||||
observer.disconnect()
|
||||
runningTasks.delete(watcherKey)
|
||||
report(context, 'SUCCEEDED', '检测到用户已完成平台发布').catch(() => {})
|
||||
return
|
||||
}
|
||||
if (config.failureTexts.some(pattern => pattern.test(text))) {
|
||||
observer.disconnect()
|
||||
runningTasks.delete(watcherKey)
|
||||
report(context, 'FAILED', '平台提示发布失败', text.slice(0, 300)).catch(() => {})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true })
|
||||
setTimeout(() => {
|
||||
observer.disconnect()
|
||||
runningTasks.delete(watcherKey)
|
||||
}, 30 * 60 * 1000)
|
||||
}
|
||||
|
||||
async function uploadAssets(context, initialInputs, uploadDropZone) {
|
||||
const { task, config } = context
|
||||
let uploaded = false
|
||||
|
||||
if (task.hasVideo) {
|
||||
await report(context, 'DOWNLOADING', '正在读取发布视频')
|
||||
const video = await fetchAssetFile(context, 'video', null, 'publish-video.mp4')
|
||||
const input = findInput(initialInputs, 'video', config.videoInputSelectors)
|
||||
if (input) {
|
||||
setInputFiles(input, [video])
|
||||
} else if (uploadDropZone) {
|
||||
dropFiles(uploadDropZone, [video])
|
||||
} else {
|
||||
throw new Error('未找到视频上传控件')
|
||||
}
|
||||
uploaded = true
|
||||
await report(context, 'UPLOADING', '视频已交给平台上传')
|
||||
} else if (task.imageCount > 0) {
|
||||
await report(context, 'DOWNLOADING', '正在读取发布图片')
|
||||
const images = []
|
||||
for (let index = 0; index < task.imageCount; index++) {
|
||||
images.push(await fetchAssetFile(context, 'image', index, 'publish-image-' + (index + 1) + '.jpg'))
|
||||
}
|
||||
const latestInputs = queryVisibleInputs(context.config.uploadSelectors)
|
||||
const input = findInput(latestInputs.length ? latestInputs : initialInputs, 'image', config.imageInputSelectors)
|
||||
if (!input) throw new Error('未找到图片上传控件')
|
||||
setInputFiles(input, input.multiple ? images : [images[0]])
|
||||
uploaded = true
|
||||
await report(context, 'UPLOADING', '图片已交给平台上传')
|
||||
}
|
||||
return uploaded
|
||||
}
|
||||
|
||||
async function tryUploadCover(context) {
|
||||
if (!context.task.hasCover || !context.config.coverInputSelectors.length) return
|
||||
const coverInput = queryFirstVisible(context.config.coverInputSelectors)
|
||||
if (!coverInput) return
|
||||
const cover = await fetchAssetFile(context, 'cover', null, 'publish-cover.jpg')
|
||||
setInputFiles(coverInput, [cover])
|
||||
}
|
||||
|
||||
async function fetchAssetFile(context, kind, index, fallbackName) {
|
||||
const suffix = index === null ? '' : '/' + index
|
||||
const url = context.apiBaseUrl + '/office/publish/bridge/task/'
|
||||
+ encodeURIComponent(context.taskId) + '/asset/' + kind + suffix
|
||||
+ '?token=' + encodeURIComponent(context.token)
|
||||
const response = await fetch(url, { method: 'GET', cache: 'no-store' })
|
||||
if (!response.ok) throw new Error('读取' + assetName(kind) + '失败,HTTP ' + response.status)
|
||||
const blob = await response.blob()
|
||||
if (!blob.size) throw new Error(assetName(kind) + '文件为空')
|
||||
const disposition = response.headers.get('Content-Disposition') || ''
|
||||
const fileName = parseFileName(disposition) || fallbackName
|
||||
return new File([blob], fileName, { type: blob.type || mimeForName(fileName), lastModified: Date.now() })
|
||||
}
|
||||
|
||||
async function activatePublishMode(config, task) {
|
||||
const labels = task.hasVideo ? config.videoModeLabels : config.imageModeLabels
|
||||
for (const label of labels) {
|
||||
const element = findClickableByText(label)
|
||||
if (element) {
|
||||
element.click()
|
||||
await sleep(1000)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareForm(config) {
|
||||
await dismissBlockingNotice(config.blockingNotice)
|
||||
const warnings = []
|
||||
try {
|
||||
await prepareCategory(config.category)
|
||||
} catch (error) {
|
||||
if (!config.continueAfterCategoryFailure) throw error
|
||||
warnings.push(readableError(error))
|
||||
}
|
||||
for (const choice of config.prepareTextChoices || []) {
|
||||
try {
|
||||
await prepareTextChoice(choice)
|
||||
} catch (error) {
|
||||
if (!config.continueAfterCategoryFailure) throw error
|
||||
warnings.push(readableError(error))
|
||||
}
|
||||
}
|
||||
for (const step of config.prepareClickSteps || []) {
|
||||
const element = queryFirstVisible(step.selectors || [])
|
||||
if (!element) continue
|
||||
element.click()
|
||||
await sleep(step.wait || 600)
|
||||
}
|
||||
return warnings.join(';')
|
||||
}
|
||||
|
||||
async function fillPublishText(context) {
|
||||
const { config, task } = context
|
||||
await report(context, 'FILLING', '正在填写标题、文案和标签')
|
||||
await sleep(config.fillDelay || 500)
|
||||
const titleElement = config.skipTitle
|
||||
? null
|
||||
: await waitForAny(
|
||||
config.titleSelectors,
|
||||
config.titleOptional ? (config.optionalTitleTimeout || 3000) : 180000
|
||||
)
|
||||
if (!config.skipTitle && !titleElement && !config.titleOptional) {
|
||||
throw new Error('未找到标题输入框,平台页面可能已经更新')
|
||||
}
|
||||
if (titleElement) {
|
||||
const title = transformTitle(config, task.title || '')
|
||||
await fillAndVerify(config.titleSelectors, title, '标题')
|
||||
}
|
||||
|
||||
let content = !titleElement && config.mergeTitleIntoContent
|
||||
? mergeTitleAndContent(task.title, task.content)
|
||||
: task.content
|
||||
if (!content && config.fallbackContentToTitle) content = task.title
|
||||
const fullContent = buildContent(content, task.tags)
|
||||
if (!fullContent) return
|
||||
const contentElement = await waitForAny(config.contentSelectors, 180000)
|
||||
if (!contentElement) throw new Error('未找到文案输入框,平台页面可能已经更新')
|
||||
if (config.useMainWorldContent) {
|
||||
const fillResult = await chrome.runtime.sendMessage({
|
||||
type: 'PLATFORM_FILL_CONTENT',
|
||||
payload: { platform: config.platform, value: fullContent }
|
||||
})
|
||||
if (!fillResult || !fillResult.ok) {
|
||||
throw new Error(fillResult && fillResult.message ? fillResult.message : '平台文案写入失败')
|
||||
}
|
||||
} else {
|
||||
await fillAndVerify(config.contentSelectors, fullContent, '文案', {
|
||||
preserveLineBreaks: Boolean(config.preserveContentLineBreaks),
|
||||
useAtomicHtmlLineBreaks: Boolean(config.useAtomicHtmlLineBreaks),
|
||||
looseVerification: Boolean(config.looseContentVerification)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareTextChoice(choice) {
|
||||
const label = typeof choice === 'string' ? choice : choice.label
|
||||
const timeout = typeof choice === 'string' ? 5000 : (choice.timeout || 5000)
|
||||
const started = Date.now()
|
||||
let element = null
|
||||
while (Date.now() - started < timeout) {
|
||||
element = findExactCategoryText(['button, label, [role="button"], body *'], label)
|
||||
if (element) break
|
||||
await sleep(250)
|
||||
}
|
||||
if (!element) throw new Error('未找到商品选项:' + label)
|
||||
if (!isTextChoiceSelected(element)) {
|
||||
dispatchPointerClick(element)
|
||||
await sleep(typeof choice === 'string' ? 350 : (choice.wait || 350))
|
||||
element = findExactCategoryText(['button, label, [role="button"], body *'], label) || element
|
||||
if (!isTextChoiceSelected(element) && element.parentElement) {
|
||||
dispatchPointerClick(element.parentElement)
|
||||
await sleep(250)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isTextChoiceSelected(element) {
|
||||
let current = element
|
||||
for (let depth = 0; current && depth < 4; depth++, current = current.parentElement) {
|
||||
const className = String(current.className || '')
|
||||
if (/(^|[-_\s])(active|selected|checked|current)([-_\s]|$)/i.test(className)) return true
|
||||
if (current.getAttribute('aria-selected') === 'true' || current.getAttribute('aria-checked') === 'true') return true
|
||||
if ('checked' in current && current.checked) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function dismissBlockingNotice(notice) {
|
||||
if (!notice) return
|
||||
for (const currentDocument of collectAccessibleDocuments()) {
|
||||
const dialogs = queryAllDeep(currentDocument, '[role="dialog"], .el-dialog__wrapper, .el-message-box__wrapper')
|
||||
.filter(isVisible)
|
||||
for (const dialog of dialogs) {
|
||||
const text = String(dialog.textContent || '').replace(/\s+/g, '')
|
||||
if (!text.includes(notice.messageText)) continue
|
||||
const dismiss = queryAllDeep(dialog, 'button, [role="button"]').find(element =>
|
||||
isVisible(element) && String(element.textContent || '').trim() === notice.dismissText
|
||||
)
|
||||
if (dismiss) {
|
||||
dismiss.click()
|
||||
await sleep(500)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareCategory(category) {
|
||||
if (!category) return
|
||||
const input = await waitForAny(category.inputSelectors || [], 60000)
|
||||
if (!input) throw new Error('未找到商品分类选择框,闲鱼页面可能已经更新')
|
||||
const targetPath = category.path || []
|
||||
const targetLeaf = targetPath[targetPath.length - 1] || ''
|
||||
const targetFullPath = category.resultText || targetPath.join('/')
|
||||
if (isCategorySelected(input, targetFullPath, targetLeaf)) return
|
||||
|
||||
const history = queryFirstVisible(category.historySelectors || [])
|
||||
if (history) {
|
||||
history.click()
|
||||
await sleep(800)
|
||||
if (!targetLeaf || isCategorySelected(input, targetFullPath, targetLeaf)) return
|
||||
}
|
||||
|
||||
input.click()
|
||||
await sleep(500)
|
||||
for (const segment of targetPath) {
|
||||
const option = await waitForCategoryOption(category, segment, category.optionTimeout || 1800)
|
||||
if (!option) break
|
||||
clickCategoryResult(option)
|
||||
await sleep(500)
|
||||
if (isCategorySelected(input, targetFullPath, targetLeaf)) return
|
||||
}
|
||||
|
||||
// 分级菜单未命中时,使用分类框的关键词搜索固定末级分类。
|
||||
input.click()
|
||||
setElementValue(input, targetLeaf, true)
|
||||
await sleep(800)
|
||||
const searchResult = await waitForCategorySearchResult(category, targetFullPath, targetLeaf, 6000)
|
||||
if (!searchResult) throw new Error('分类搜索结果已打开,但未找到可点击的完整分类:' + targetFullPath)
|
||||
clickCategoryResult(searchResult)
|
||||
let selected = await waitForCategorySelection(input, category, targetFullPath, targetLeaf, 2500)
|
||||
if (!selected) {
|
||||
await selectCategoryWithKeyboard(input)
|
||||
selected = await waitForCategorySelection(input, category, targetFullPath, targetLeaf, 2500)
|
||||
}
|
||||
if (!selected) throw new Error('分类搜索结果未能选中:' + targetFullPath)
|
||||
}
|
||||
|
||||
async function waitForCategorySearchResult(category, fullPath, leaf, timeout) {
|
||||
const selectors = (category.searchResultSelectors || [])
|
||||
.concat(category.optionSelectors || [])
|
||||
.concat(['[role="option"]', '[class*="suggest"]', '[class*="dropdown"] li'])
|
||||
const started = Date.now()
|
||||
while (Date.now() - started < timeout) {
|
||||
const exact = findExactCategoryText(selectors, fullPath)
|
||||
if (exact) return exact
|
||||
|
||||
// 闲管家会把搜索结果渲染成普通 div/span,不一定带下拉选项的固定类名。
|
||||
const fallback = findExactCategoryText(['body *'], fullPath)
|
||||
if (fallback) return fallback
|
||||
|
||||
const leafOption = await waitForCategoryOption(category, leaf, 300)
|
||||
if (leafOption) return leafOption
|
||||
await sleep(250)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function findExactCategoryText(selectors, expectedText) {
|
||||
const normalizedExpected = normalizeCategoryText(expectedText)
|
||||
if (!normalizedExpected) return null
|
||||
const candidates = []
|
||||
for (const selector of selectors) {
|
||||
for (const element of queryAllAccessible(selector)) {
|
||||
if (!isVisible(element)) continue
|
||||
if (normalizeCategoryText(element.textContent) !== normalizedExpected) continue
|
||||
if (!candidates.includes(element)) candidates.push(element)
|
||||
}
|
||||
}
|
||||
return smallestVisible(candidates)
|
||||
}
|
||||
|
||||
function clickCategoryResult(element) {
|
||||
element.scrollIntoView({ block: 'center', inline: 'nearest' })
|
||||
const ancestor = element.closest(
|
||||
'[role="option"], [role="menuitem"], li, button, [class*="suggest"], [class*="option"], [class*="item"]'
|
||||
) || element
|
||||
const rect = element.getBoundingClientRect()
|
||||
const documentAtPoint = element.ownerDocument
|
||||
const hitTarget = documentAtPoint.elementFromPoint(
|
||||
Math.max(0, rect.left + Math.min(rect.width / 2, 24)),
|
||||
Math.max(0, rect.top + rect.height / 2)
|
||||
)
|
||||
const clickable = hitTarget && element.contains(hitTarget) ? hitTarget : ancestor
|
||||
dispatchPointerClick(clickable)
|
||||
if (clickable !== ancestor) ancestor.click()
|
||||
}
|
||||
|
||||
function dispatchPointerClick(element) {
|
||||
const view = element.ownerDocument.defaultView || window
|
||||
const common = { bubbles: true, cancelable: true, composed: true, view }
|
||||
for (const type of ['pointerover', 'mouseover', 'pointermove', 'mousemove']) {
|
||||
const EventConstructor = type.startsWith('pointer') && view.PointerEvent
|
||||
? view.PointerEvent
|
||||
: view.MouseEvent
|
||||
element.dispatchEvent(new EventConstructor(type, common))
|
||||
}
|
||||
if (view.PointerEvent) element.dispatchEvent(new view.PointerEvent('pointerdown', common))
|
||||
element.dispatchEvent(new view.MouseEvent('mousedown', common))
|
||||
if (view.PointerEvent) element.dispatchEvent(new view.PointerEvent('pointerup', common))
|
||||
element.dispatchEvent(new view.MouseEvent('mouseup', common))
|
||||
element.click()
|
||||
}
|
||||
|
||||
async function selectCategoryWithKeyboard(input) {
|
||||
input.focus()
|
||||
dispatchKeyboardEvent(input, 'keydown', 'ArrowDown', 40)
|
||||
dispatchKeyboardEvent(input, 'keyup', 'ArrowDown', 40)
|
||||
await sleep(150)
|
||||
dispatchKeyboardEvent(input, 'keydown', 'Enter', 13)
|
||||
dispatchKeyboardEvent(input, 'keyup', 'Enter', 13)
|
||||
}
|
||||
|
||||
function dispatchKeyboardEvent(element, type, key, keyCode) {
|
||||
const view = element.ownerDocument.defaultView || window
|
||||
element.dispatchEvent(new view.KeyboardEvent(type, {
|
||||
key,
|
||||
code: key === 'Enter' ? 'Enter' : 'ArrowDown',
|
||||
keyCode,
|
||||
which: keyCode,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true
|
||||
}))
|
||||
}
|
||||
|
||||
async function waitForCategorySelection(input, category, fullPath, leaf, timeout) {
|
||||
const started = Date.now()
|
||||
while (Date.now() - started < timeout) {
|
||||
if (isCategorySelected(input, fullPath, leaf)) return true
|
||||
const resultStillOpen = findExactCategoryText(
|
||||
(category.searchResultSelectors || [])
|
||||
.concat(category.optionSelectors || [])
|
||||
.concat(['[role="option"]', 'body *']),
|
||||
fullPath
|
||||
)
|
||||
const inputValue = normalizeCategoryText(input.value)
|
||||
if (!resultStillOpen && inputValue === normalizeCategoryText(leaf)) return true
|
||||
await sleep(250)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function isCategorySelected(input, fullPath, leaf) {
|
||||
const value = normalizeCategoryText(input && input.value)
|
||||
const normalizedPath = normalizeCategoryText(fullPath)
|
||||
const normalizedLeaf = normalizeCategoryText(leaf)
|
||||
return Boolean(value && (
|
||||
value === normalizedPath
|
||||
|| value.endsWith('/' + normalizedLeaf)
|
||||
|| value.endsWith('>' + normalizedLeaf)
|
||||
))
|
||||
}
|
||||
|
||||
function normalizeCategoryText(value) {
|
||||
return String(value || '')
|
||||
.replace(/[>>]/g, '/')
|
||||
.replace(/\s+/g, '')
|
||||
.replace(/\/{2,}/g, '/')
|
||||
.replace(/^\/|\/$/g, '')
|
||||
}
|
||||
|
||||
async function waitForCategoryOption(category, label, timeout) {
|
||||
const started = Date.now()
|
||||
while (Date.now() - started < timeout) {
|
||||
for (const selector of category.optionSelectors || []) {
|
||||
const candidates = queryAllAccessible(selector).filter(element => {
|
||||
if (!isVisible(element)) return false
|
||||
const text = String(element.textContent || '').replace(/\s+/g, '')
|
||||
return text === label || text.endsWith('/' + label) || text.endsWith(label)
|
||||
})
|
||||
if (candidates.length) {
|
||||
const candidate = smallestVisible(candidates)
|
||||
return candidate.closest('li, [role="menuitem"], button') || candidate
|
||||
}
|
||||
}
|
||||
if (category.allowGlobalTextOption) {
|
||||
const candidate = findExactCategoryText(['body *'], label)
|
||||
if (candidate) return candidate
|
||||
}
|
||||
await sleep(250)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function findClickableByText(label) {
|
||||
// 这里只允许切换发布类型标签,不扫描或点击普通按钮,避免误触最终发布。
|
||||
const candidates = document.querySelectorAll('[role="tab"], [class*="tab-item"], [class*="tabItem"]')
|
||||
for (const candidate of candidates) {
|
||||
if (!isVisible(candidate)) continue
|
||||
const text = (candidate.innerText || candidate.textContent || '').trim()
|
||||
if (text === label || text.indexOf(label) >= 0) return candidate
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function waitForUploadTarget(config, timeout) {
|
||||
const started = Date.now()
|
||||
while (Date.now() - started < timeout) {
|
||||
const inputs = queryVisibleInputs(config.uploadSelectors)
|
||||
if (inputs.length) return { inputs, dropZone: null }
|
||||
const dropZone = findUploadDropZone(config)
|
||||
if (dropZone) return { inputs: [], dropZone }
|
||||
await sleep(500)
|
||||
}
|
||||
return { inputs: [], dropZone: null }
|
||||
}
|
||||
|
||||
async function waitForAny(selectors, timeout) {
|
||||
const started = Date.now()
|
||||
while (Date.now() - started < timeout) {
|
||||
const element = queryFirstVisible(selectors)
|
||||
if (element) return element
|
||||
await sleep(400)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function fillAndVerify(selectors, value, label, options = {}) {
|
||||
const normalize = options.preserveLineBreaks ? normalizeMultilineValue : normalizeFieldValue
|
||||
const expected = normalize(value)
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const element = await waitForAny(selectors, 10000)
|
||||
if (!element) break
|
||||
setElementValue(element, value, false, options)
|
||||
await sleep(500 + attempt * 500)
|
||||
const current = queryFirstVisible(selectors)
|
||||
if (current && valuesMatch(readElementValue(current), expected, normalize, options)) return
|
||||
}
|
||||
throw new Error(label + '写入后校验失败,平台页面可能已经更新')
|
||||
}
|
||||
|
||||
function valuesMatch(actualValue, expected, normalize, options) {
|
||||
const actual = normalize(actualValue)
|
||||
if (actual === expected) return true
|
||||
if (!options.looseVerification) return false
|
||||
return normalizeLooseEditorValue(actual) === normalizeLooseEditorValue(expected)
|
||||
}
|
||||
|
||||
function readElementValue(element) {
|
||||
if (element.isContentEditable || element.getAttribute('contenteditable') === 'true') {
|
||||
return element.innerText || element.textContent || ''
|
||||
}
|
||||
return element.value || ''
|
||||
}
|
||||
|
||||
function normalizeFieldValue(value) {
|
||||
return String(value || '').replace(/\r\n/g, '\n').trim()
|
||||
}
|
||||
|
||||
function normalizeMultilineValue(value) {
|
||||
return String(value || '')
|
||||
.replace(/\r\n?/g, '\n')
|
||||
.replace(/\u00a0/g, ' ')
|
||||
.replace(/[\u200b-\u200d\ufeff]/g, '')
|
||||
.split('\n')
|
||||
.map(line => line.replace(/[ \t]+$/g, ''))
|
||||
.join('\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function normalizeLooseEditorValue(value) {
|
||||
return String(value || '')
|
||||
.replace(/\u00a0/g, ' ')
|
||||
.replace(/[\u200b-\u200d\ufeff]/g, '')
|
||||
.replace(/\s+/g, '')
|
||||
}
|
||||
|
||||
function transformTitle(config, value) {
|
||||
let title = String(value || '')
|
||||
if (typeof config.titleTransform === 'function') title = config.titleTransform(title)
|
||||
if (config.titleMaxLength) title = Array.from(title).slice(0, config.titleMaxLength).join('')
|
||||
return title.trim()
|
||||
}
|
||||
|
||||
async function waitForUploadSettled(config, timeout) {
|
||||
const started = Date.now()
|
||||
let calmChecks = 0
|
||||
await sleep(5000)
|
||||
while (Date.now() - started < timeout) {
|
||||
const pageText = collectAccessibleDocuments()
|
||||
.map(doc => (doc.body && doc.body.innerText) || '')
|
||||
.join('\n')
|
||||
if (/上传失败|文件损坏|格式不支持|重新上传/.test(pageText)) {
|
||||
throw new Error('平台提示素材上传失败或格式不支持')
|
||||
}
|
||||
const progressTexts = queryAllAccessible(config.progressSelectors.join(','))
|
||||
.filter(isVisible)
|
||||
.map(element => (element.innerText || element.textContent || '').trim())
|
||||
const uploading = progressTexts.some(text => /(^|\D)([1-9]|[1-9]\d)%/.test(text) && !/100%/.test(text))
|
||||
if (uploading) {
|
||||
calmChecks = 0
|
||||
} else {
|
||||
calmChecks++
|
||||
if (calmChecks >= 3) return
|
||||
}
|
||||
await sleep(1500)
|
||||
}
|
||||
throw new Error('等待平台上传完成超时')
|
||||
}
|
||||
|
||||
function findInput(inputs, kind, preferredSelectors) {
|
||||
const preferred = queryFirstVisible(preferredSelectors)
|
||||
if (preferred) return preferred
|
||||
const matcher = kind === 'video' ? /video/i : /image|png|jpe?g|webp/i
|
||||
return inputs.find(input => matcher.test(input.accept || '')) || inputs[0] || null
|
||||
}
|
||||
|
||||
function queryVisibleInputs(selectors) {
|
||||
const result = []
|
||||
for (const selector of selectors) {
|
||||
queryAllAccessible(selector).forEach(element => {
|
||||
if (isFileInput(element) && !result.includes(element)) {
|
||||
result.push(element)
|
||||
}
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function queryFirstVisible(selectors) {
|
||||
for (const selector of selectors) {
|
||||
const elements = queryAllAccessible(selector)
|
||||
for (const element of elements) {
|
||||
if (isVisible(element) || isFileInput(element)) return element
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function setInputFiles(input, files) {
|
||||
const view = input.ownerDocument.defaultView || window
|
||||
const transfer = new view.DataTransfer()
|
||||
files.forEach(file => {
|
||||
const transferable = file instanceof view.File
|
||||
? file
|
||||
: new view.File([file], file.name, { type: file.type, lastModified: file.lastModified })
|
||||
transfer.items.add(transferable)
|
||||
})
|
||||
input.files = transfer.files
|
||||
input.dispatchEvent(new view.Event('input', { bubbles: true, composed: true }))
|
||||
input.dispatchEvent(new view.Event('change', { bubbles: true, composed: true }))
|
||||
if (!input.files || input.files.length === 0) throw new Error('浏览器未能把素材交给上传控件')
|
||||
}
|
||||
|
||||
function dropFiles(target, files) {
|
||||
const view = target.ownerDocument.defaultView || window
|
||||
const transfer = new view.DataTransfer()
|
||||
files.forEach(file => {
|
||||
const transferable = file instanceof view.File
|
||||
? file
|
||||
: new view.File([file], file.name, { type: file.type, lastModified: file.lastModified })
|
||||
transfer.items.add(transferable)
|
||||
})
|
||||
for (const type of ['dragenter', 'dragover', 'drop']) {
|
||||
target.dispatchEvent(new view.DragEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
dataTransfer: transfer
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
function setElementValue(element, value, keepFocus, options = {}) {
|
||||
const view = element.ownerDocument.defaultView || window
|
||||
element.focus()
|
||||
if (element.isContentEditable || element.getAttribute('contenteditable') === 'true') {
|
||||
let nativeInputDispatched = false
|
||||
if (options.preserveLineBreaks) {
|
||||
nativeInputDispatched = options.useAtomicHtmlLineBreaks
|
||||
? setRichTextWithAtomicLineBreaks(element, value)
|
||||
: setRichTextWithLineBreaks(element, value)
|
||||
} else {
|
||||
element.innerHTML = ''
|
||||
element.textContent = value
|
||||
}
|
||||
if (!nativeInputDispatched) {
|
||||
element.dispatchEvent(new view.InputEvent('input', {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
inputType: 'insertReplacementText',
|
||||
data: null
|
||||
}))
|
||||
}
|
||||
} else {
|
||||
const prototype = element instanceof view.HTMLTextAreaElement
|
||||
? view.HTMLTextAreaElement.prototype
|
||||
: view.HTMLInputElement.prototype
|
||||
const setter = Object.getOwnPropertyDescriptor(prototype, 'value').set
|
||||
setter.call(element, value)
|
||||
const inputEvent = view.InputEvent
|
||||
? new view.InputEvent('input', {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
inputType: 'insertText',
|
||||
data: value
|
||||
})
|
||||
: new view.Event('input', { bubbles: true, composed: true })
|
||||
element.dispatchEvent(inputEvent)
|
||||
element.dispatchEvent(new view.Event('change', { bubbles: true, composed: true }))
|
||||
}
|
||||
if (!keepFocus) element.blur()
|
||||
}
|
||||
|
||||
function setRichTextWithLineBreaks(element, value) {
|
||||
const document = element.ownerDocument
|
||||
const selection = document.getSelection ? document.getSelection() : null
|
||||
let inserted = false
|
||||
if (selection) {
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(element)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
try {
|
||||
inserted = document.execCommand('insertText', false, String(value || ''))
|
||||
} catch (ignored) {}
|
||||
}
|
||||
// execCommand 已经触发编辑器自身的输入事件。成功后不要再派发携带全文的
|
||||
// input 事件,否则部分受控富文本编辑器会把同一份内容再次追加。
|
||||
if (inserted) return true
|
||||
|
||||
element.innerHTML = ''
|
||||
const lines = String(value || '').replace(/\r\n?/g, '\n').split('\n')
|
||||
lines.forEach(line => {
|
||||
const block = document.createElement('div')
|
||||
if (line) {
|
||||
block.appendChild(document.createTextNode(line))
|
||||
} else {
|
||||
block.appendChild(document.createElement('br'))
|
||||
}
|
||||
element.appendChild(block)
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
function setRichTextWithAtomicLineBreaks(element, value) {
|
||||
const document = element.ownerDocument
|
||||
const selection = document.getSelection ? document.getSelection() : null
|
||||
if (!selection) return false
|
||||
|
||||
const container = document.createElement('div')
|
||||
const lines = String(value || '').replace(/\r\n?/g, '\n').split('\n')
|
||||
lines.forEach((line, index) => {
|
||||
if (index > 0) container.appendChild(document.createElement('br'))
|
||||
container.appendChild(document.createTextNode(line))
|
||||
})
|
||||
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(element)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
|
||||
try {
|
||||
// 一次命令同时替换全文和换行,避免受控编辑器在逐行写入期间重绘,
|
||||
// 导致光标复位并反复插入文案开头。
|
||||
if (document.execCommand('insertHTML', false, container.innerHTML)) return true
|
||||
} catch (ignored) {}
|
||||
|
||||
element.replaceChildren(...Array.from(container.childNodes).map(node => node.cloneNode(true)))
|
||||
return false
|
||||
}
|
||||
|
||||
async function report(context, status, message, error) {
|
||||
await chrome.runtime.sendMessage({
|
||||
type: 'PUBLISH_STATUS',
|
||||
payload: { taskId: context.taskId, status, message, error: error || '' }
|
||||
})
|
||||
}
|
||||
|
||||
function showReadyNotice(platformName, preparationWarning) {
|
||||
const existing = document.getElementById('yidaima-publisher-ready-notice')
|
||||
if (existing) existing.remove()
|
||||
const notice = document.createElement('div')
|
||||
notice.id = 'yidaima-publisher-ready-notice'
|
||||
notice.textContent = preparationWarning
|
||||
? platformName + '资料已填写;部分商品选项请手动检查后发布'
|
||||
: platformName + '资料已填写完成,请检查后手动点击发布按钮'
|
||||
Object.assign(notice.style, {
|
||||
position: 'fixed', top: '20px', left: '50%', transform: 'translateX(-50%)',
|
||||
zIndex: '2147483647', padding: '14px 22px', color: '#fff', background: '#1677ff',
|
||||
borderRadius: '8px', boxShadow: '0 8px 28px rgba(0,0,0,.24)', fontSize: '15px'
|
||||
})
|
||||
document.documentElement.appendChild(notice)
|
||||
setTimeout(() => notice.remove(), 12000)
|
||||
}
|
||||
|
||||
function buildContent(content, tags) {
|
||||
const tagText = (tags || []).map(tag => '#' + String(tag).replace(/^#/, '')).join(' ')
|
||||
return [content || '', tagText].filter(Boolean).join('\n\n')
|
||||
}
|
||||
|
||||
function mergeTitleAndContent(title, content) {
|
||||
const normalizedTitle = String(title || '').trim()
|
||||
const normalizedContent = String(content || '').trim()
|
||||
if (!normalizedTitle || normalizedContent.indexOf(normalizedTitle) >= 0) return normalizedContent
|
||||
return [normalizedTitle, normalizedContent].filter(Boolean).join('\n\n')
|
||||
}
|
||||
|
||||
function parseFileName(disposition) {
|
||||
const utf = disposition.match(/filename\*=UTF-8''([^;]+)/i)
|
||||
if (utf) {
|
||||
try { return decodeURIComponent(utf[1]) } catch (ignored) {}
|
||||
}
|
||||
const plain = disposition.match(/filename="?([^";]+)"?/i)
|
||||
return plain ? plain[1] : ''
|
||||
}
|
||||
|
||||
function mimeForName(name) {
|
||||
const extension = String(name).toLowerCase().split('.').pop()
|
||||
return {
|
||||
mp4: 'video/mp4', mov: 'video/quicktime', m4v: 'video/x-m4v', webm: 'video/webm',
|
||||
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp'
|
||||
}[extension] || 'application/octet-stream'
|
||||
}
|
||||
|
||||
function assetName(kind) {
|
||||
return { video: '视频', image: '图片', cover: '封面' }[kind] || '素材'
|
||||
}
|
||||
|
||||
function isVisible(element) {
|
||||
if (!element || !element.isConnected) return false
|
||||
const view = element.ownerDocument.defaultView || window
|
||||
const style = view.getComputedStyle(element)
|
||||
const rect = element.getBoundingClientRect()
|
||||
return rect.width > 0 && rect.height > 0
|
||||
&& style.display !== 'none' && style.visibility !== 'hidden'
|
||||
}
|
||||
|
||||
function isFileInput(element) {
|
||||
if (!element || !element.ownerDocument) return false
|
||||
const view = element.ownerDocument.defaultView || window
|
||||
return element instanceof view.HTMLInputElement && element.type === 'file'
|
||||
}
|
||||
|
||||
function 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
|
||||
}
|
||||
|
||||
function collectAccessibleDocuments(rootWindow = window) {
|
||||
const documents = []
|
||||
const seen = new Set()
|
||||
const visit = currentWindow => {
|
||||
try {
|
||||
const currentDocument = currentWindow.document
|
||||
if (!currentDocument || seen.has(currentDocument)) return
|
||||
seen.add(currentDocument)
|
||||
documents.push(currentDocument)
|
||||
for (const frame of queryAllDeep(currentDocument, 'iframe, frame')) {
|
||||
if (frame.contentWindow) visit(frame.contentWindow)
|
||||
}
|
||||
} catch (ignored) {}
|
||||
}
|
||||
visit(rootWindow)
|
||||
return documents
|
||||
}
|
||||
|
||||
function queryAllAccessible(selector) {
|
||||
if (!selector) return []
|
||||
const matches = []
|
||||
const seen = new Set()
|
||||
for (const currentDocument of collectAccessibleDocuments()) {
|
||||
for (const element of queryAllDeep(currentDocument, selector)) {
|
||||
if (!seen.has(element)) {
|
||||
seen.add(element)
|
||||
matches.push(element)
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
function findUploadDropZone(config) {
|
||||
for (const selector of config.uploadDropZoneSelectors || []) {
|
||||
const candidates = queryAllAccessible(selector).filter(isVisible)
|
||||
if (candidates.length) return smallestVisible(candidates)
|
||||
}
|
||||
const candidates = queryAllAccessible('div, label, section').filter(element => {
|
||||
if (!isVisible(element)) return false
|
||||
const text = String(element.textContent || '').replace(/\s+/g, ' ').trim()
|
||||
return text.indexOf('上传时长') >= 0
|
||||
&& (text.indexOf('20GB') >= 0 || text.indexOf('MP4/H.264') >= 0)
|
||||
})
|
||||
return smallestVisible(candidates)
|
||||
}
|
||||
|
||||
function smallestVisible(elements) {
|
||||
return elements.slice().sort((left, right) => {
|
||||
const leftRect = left.getBoundingClientRect()
|
||||
const rightRect = right.getBoundingClientRect()
|
||||
return leftRect.width * leftRect.height - rightRect.width * rightRect.height
|
||||
})[0] || null
|
||||
}
|
||||
|
||||
function createPageDiagnostics() {
|
||||
const documents = collectAccessibleDocuments().map(currentDocument => ({
|
||||
url: currentDocument.defaultView && currentDocument.defaultView.location.href,
|
||||
fileInputs: queryAllDeep(currentDocument, 'input[type="file"]').length,
|
||||
editables: queryAllDeep(currentDocument, '[contenteditable]').length,
|
||||
hasUploadText: String(currentDocument.body && currentDocument.body.innerText || '').indexOf('上传时长') >= 0
|
||||
}))
|
||||
return '页面结构诊断:' + JSON.stringify(documents).slice(0, 900)
|
||||
}
|
||||
|
||||
function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)) }
|
||||
function readableError(error) { return error && error.message ? error.message : String(error || '未知错误') }
|
||||
|
||||
globalThis.YidaimaPublisher = Object.freeze({ run, watch })
|
||||
})()
|
||||
@@ -0,0 +1,67 @@
|
||||
(() => {
|
||||
const config = {
|
||||
platform: 'wechat_channels',
|
||||
platformName: '视频号',
|
||||
skipTitle: true,
|
||||
mergeTitleIntoContent: false,
|
||||
useMainWorldContent: true,
|
||||
titleMaxLength: 30,
|
||||
titleTransform: value => String(value || '')
|
||||
.replace(/[【】\[\]()(){}<>]/g, ' ')
|
||||
.replace(/[^\u3400-\u9fffA-Za-z0-9《》“”‘’"'::++??%%℃,,、。!!\s]/g, ' ')
|
||||
.replace(/\s+/g, ' '),
|
||||
batchUploadSettleTimeout: 30000,
|
||||
videoModeLabels: [],
|
||||
imageModeLabels: [],
|
||||
uploadSelectors: [
|
||||
'input[type="file"][accept*="video"]',
|
||||
'input[type="file"]'
|
||||
],
|
||||
uploadDropZoneSelectors: [
|
||||
'div.upload-content',
|
||||
'[class*="upload-content"]',
|
||||
'[class*="upload-wrapper"]'
|
||||
],
|
||||
videoInputSelectors: ['input[type="file"][accept*="video"]'],
|
||||
imageInputSelectors: [],
|
||||
coverInputSelectors: [
|
||||
'[class*="cover"] input[type="file"][accept*="image"]',
|
||||
'[class*="poster"] input[type="file"][accept*="image"]'
|
||||
],
|
||||
titleSelectors: [
|
||||
'input[type="text"][placeholder="填写短标题有机会获得更多流量"]',
|
||||
'input[type="text"][placeholder*="短标题"]',
|
||||
'input[placeholder*="标题"]',
|
||||
'textarea[placeholder*="标题"]',
|
||||
'[class*="title"] input'
|
||||
],
|
||||
contentSelectors: [
|
||||
'div.input-editor[contenteditable][data-placeholder="添加描述"]',
|
||||
'[contenteditable="true"][data-placeholder*="添加描述"]',
|
||||
'[contenteditable="true"][data-placeholder*="描述"]',
|
||||
'[contenteditable="true"][placeholder*="描述"]',
|
||||
'[contenteditable="true"][aria-label*="描述"]',
|
||||
'textarea[placeholder*="描述"]',
|
||||
'textarea[placeholder*="文案"]',
|
||||
'[class*="post-desc"] [contenteditable="true"]',
|
||||
'[class*="post_desc"] [contenteditable="true"]',
|
||||
'[class*="desc"] [contenteditable="true"]',
|
||||
'.post-desc-box [contenteditable]',
|
||||
'[class*="editor"][contenteditable="true"]',
|
||||
'[class*="editor"] [contenteditable="true"]',
|
||||
'.ProseMirror[contenteditable="true"]'
|
||||
],
|
||||
progressSelectors: ['[class*="progress"]', '[role="progressbar"]'],
|
||||
successTexts: [/^发表成功[!!。]?$/, /^发布成功[!!。]?$/, /^动态发表成功[!!。]?$/],
|
||||
failureTexts: [/^发表失败/, /^发布失败/]
|
||||
}
|
||||
|
||||
chrome.runtime.onMessage.addListener(message => {
|
||||
if (message && message.type === 'RUN_PUBLISH') {
|
||||
globalThis.YidaimaPublisher.run(config, message.payload)
|
||||
} else if (message && message.type === 'WATCH_PUBLISH_OUTCOME') {
|
||||
globalThis.YidaimaPublisher.watch(config, message.payload)
|
||||
}
|
||||
})
|
||||
chrome.runtime.sendMessage({ type: 'PLATFORM_READY', platform: config.platform })
|
||||
})()
|
||||
@@ -0,0 +1,80 @@
|
||||
(() => {
|
||||
const config = {
|
||||
platform: 'xianyu',
|
||||
platformName: '闲鱼',
|
||||
fillAfterUploadSettled: true,
|
||||
fillBeforeUpload: true,
|
||||
refillAfterUpload: true,
|
||||
fillDelay: 500,
|
||||
uploadSettleTimeout: 30000,
|
||||
fallbackContentToTitle: true,
|
||||
allowNoAssets: true,
|
||||
continueAfterCategoryFailure: true,
|
||||
videoModeLabels: [],
|
||||
imageModeLabels: [],
|
||||
prepareClickSteps: [],
|
||||
prepareTextChoices: ['Bug修改次数', '1-5天', '元/次'],
|
||||
blockingNotice: {
|
||||
messageText: '高级版已过期',
|
||||
dismissText: '再想想'
|
||||
},
|
||||
category: {
|
||||
historySelectors: ['.release-history'],
|
||||
inputSelectors: [
|
||||
'.channelCat input[placeholder="请输入关键词"]',
|
||||
'.channelCat input',
|
||||
'input[placeholder="请输入关键词"]'
|
||||
],
|
||||
optionSelectors: [
|
||||
'.el-cascader-node__label',
|
||||
'.el-popper [role="menuitem"]',
|
||||
'.el-popper li',
|
||||
'.custom-cascader [class*="item"]'
|
||||
],
|
||||
searchResultSelectors: [
|
||||
'[role="option"]',
|
||||
'.el-select-dropdown__item',
|
||||
'.el-cascader__suggestion-item',
|
||||
'[class*="cascader"] [class*="suggest"]',
|
||||
'[class*="category"] [class*="item"]'
|
||||
],
|
||||
resultText: '家居/服务/IT技术/软件/程序/网站开发',
|
||||
allowGlobalTextOption: true,
|
||||
optionTimeout: 1800,
|
||||
path: ['家居', '服务', 'IT技术', '软件', '程序', '网站开发']
|
||||
},
|
||||
uploadSelectors: [
|
||||
'input[type="file"][accept*="image"]',
|
||||
'input[type="file"][multiple]',
|
||||
'input[type="file"]'
|
||||
],
|
||||
videoInputSelectors: [],
|
||||
imageInputSelectors: [
|
||||
'input[type="file"][accept*="image"]',
|
||||
'input[type="file"][multiple]'
|
||||
],
|
||||
coverInputSelectors: [],
|
||||
titleSelectors: [
|
||||
'input[placeholder="请输入商品标题,最多允许输入30个汉字"]',
|
||||
'input[placeholder*="商品标题"]',
|
||||
'input[maxlength="30"]'
|
||||
],
|
||||
contentSelectors: [
|
||||
'textarea[placeholder="请输入商品描述"]',
|
||||
'textarea[placeholder*="商品描述"]',
|
||||
'[contenteditable="true"][data-placeholder*="商品描述"]'
|
||||
],
|
||||
progressSelectors: ['[class*="progress"]', '.el-progress', '[role="progressbar"]'],
|
||||
successTexts: [/^发布成功[!!。]?$/, /^商品发布成功[!!。]?$/, /^提交成功[!!。]?$/],
|
||||
failureTexts: [/^发布失败/, /^提交失败/, /^商品发布失败/]
|
||||
}
|
||||
|
||||
chrome.runtime.onMessage.addListener(message => {
|
||||
if (message && message.type === 'RUN_PUBLISH') {
|
||||
globalThis.YidaimaPublisher.run(config, message.payload)
|
||||
} else if (message && message.type === 'WATCH_PUBLISH_OUTCOME') {
|
||||
globalThis.YidaimaPublisher.watch(config, message.payload)
|
||||
}
|
||||
})
|
||||
chrome.runtime.sendMessage({ type: 'PLATFORM_READY', platform: config.platform })
|
||||
})()
|
||||
@@ -0,0 +1,44 @@
|
||||
(() => {
|
||||
const config = {
|
||||
platform: 'xiaohongshu',
|
||||
platformName: '小红书',
|
||||
preserveContentLineBreaks: true,
|
||||
videoModeLabels: ['上传视频', '视频笔记'],
|
||||
imageModeLabels: ['上传图文', '图文笔记'],
|
||||
uploadSelectors: [
|
||||
'input[type="file"][accept*="video"]',
|
||||
'input[type="file"][accept*="image"]',
|
||||
'input[type="file"]'
|
||||
],
|
||||
videoInputSelectors: ['input[type="file"][accept*="video"]'],
|
||||
imageInputSelectors: ['input[type="file"][accept*="image"]'],
|
||||
coverInputSelectors: [
|
||||
'[class*="cover"] input[type="file"][accept*="image"]',
|
||||
'[class*="poster"] input[type="file"][accept*="image"]'
|
||||
],
|
||||
titleSelectors: [
|
||||
'input[placeholder*="标题"]',
|
||||
'textarea[placeholder*="标题"]',
|
||||
'[class*="title"] input'
|
||||
],
|
||||
contentSelectors: [
|
||||
'[contenteditable="true"][data-placeholder*="正文"]',
|
||||
'[contenteditable="true"][data-placeholder*="描述"]',
|
||||
'textarea[placeholder*="正文"]',
|
||||
'textarea[placeholder*="描述"]',
|
||||
'[class*="editor"] [contenteditable="true"]'
|
||||
],
|
||||
progressSelectors: ['[class*="progress"]', '[role="progressbar"]'],
|
||||
successTexts: [/^发布成功[!!。]?$/, /^笔记发布成功[!!。]?$/],
|
||||
failureTexts: [/^发布失败/, /^笔记发布失败/]
|
||||
}
|
||||
|
||||
chrome.runtime.onMessage.addListener(message => {
|
||||
if (message && message.type === 'RUN_PUBLISH') {
|
||||
globalThis.YidaimaPublisher.run(config, message.payload)
|
||||
} else if (message && message.type === 'WATCH_PUBLISH_OUTCOME') {
|
||||
globalThis.YidaimaPublisher.watch(config, message.payload)
|
||||
}
|
||||
})
|
||||
chrome.runtime.sendMessage({ type: 'PLATFORM_READY', platform: config.platform })
|
||||
})()
|
||||
Reference in New Issue
Block a user