(() => { 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 }) })()