Files
RuoYi-Vue/browser-extension/multi-platform-publisher-1.1.17/content/platform-common.js

958 lines
37 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 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 sourceName = parseFileName(disposition) || fallbackName
const mimeType = normalizedAssetMimeType(blob.type, sourceName)
validateAssetMimeType(kind, mimeType)
const fileName = fileNameForMimeType(sourceName, mimeType)
return new File([blob], fileName, { type: mimeType, 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 normalizedAssetMimeType(responseType, fileName) {
const normalized = String(responseType || '').split(';')[0].trim().toLowerCase()
return normalized && normalized !== 'application/octet-stream'
? normalized
: mimeForName(fileName)
}
function validateAssetMimeType(kind, mimeType) {
if ((kind === 'image' || kind === 'cover')
&& !['image/png', 'image/jpeg', 'image/webp'].includes(mimeType)) {
throw new Error(assetName(kind) + '返回的不是支持的图片格式(' + mimeType + ')')
}
if (kind === 'video' && ![
'video/mp4', 'video/quicktime', 'video/x-m4v', 'video/webm'
].includes(mimeType)) {
throw new Error('视频返回的不是支持的视频格式(' + mimeType + ')')
}
}
function fileNameForMimeType(fileName, mimeType) {
const expectedExtension = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/webp': 'webp',
'video/mp4': 'mp4',
'video/quicktime': 'mov',
'video/x-m4v': 'm4v',
'video/webm': 'webm'
}[mimeType]
const normalizedName = String(fileName || 'publish-asset').trim() || 'publish-asset'
if (!expectedExtension) return normalizedName
const currentMimeType = mimeForName(normalizedName)
if (currentMimeType === mimeType) return normalizedName
const lastSlash = Math.max(normalizedName.lastIndexOf('/'), normalizedName.lastIndexOf('\\'))
const lastDot = normalizedName.lastIndexOf('.')
const baseName = lastDot > lastSlash ? normalizedName.slice(0, lastDot) : normalizedName
return baseName + '.' + expectedExtension
}
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 || '未知错误') }
const publisherApi = { run, watch }
if (globalThis.__YIDAIMA_PUBLISHER_TEST__) {
publisherApi.test = Object.freeze({
normalizedAssetMimeType,
validateAssetMimeType,
fileNameForMimeType
})
}
globalThis.YidaimaPublisher = Object.freeze(publisherApi)
})()