@@ -0,0 +1,957 @@
( ( ) => {
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 ) % / . t e s t ( t e x t ) & & ! / 1 0 0 % / . t e s t ( t e x t ) )
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 )
} ) ( )