import fs from 'node:fs'; import { mkdir, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { CdpConnection, copyHtmlToClipboard, copyImageToClipboard, findChromeExecutable, findExistingChromeDebugPort, getDefaultProfileDir, launchChrome, pasteFromClipboard, sleep, waitForChromeDebugPort, } from './weibo-utils.js'; import { parseMarkdown } from './md-to-html.js'; const WEIBO_ARTICLE_URL = 'https://card.weibo.com/article/v3/editor'; const TITLE_MAX_LENGTH = 32; const SUMMARY_MAX_LENGTH = 44; interface ArticleOptions { markdownPath: string; coverImage?: string; title?: string; summary?: string; profileDir?: string; chromePath?: string; } export async function publishArticle(options: ArticleOptions): Promise { const { markdownPath, profileDir = getDefaultProfileDir() } = options; console.log('[weibo-article] Parsing markdown...'); const parsed = await parseMarkdown(markdownPath, { title: options.title, coverImage: options.coverImage, }); let title = parsed.title; if (title.length > TITLE_MAX_LENGTH) { console.warn(`[weibo-article] Title exceeds ${TITLE_MAX_LENGTH} chars (${title.length}), truncating at word boundary...`); const truncated = title.slice(0, TITLE_MAX_LENGTH); const breakChars = [':', ',', '、', '。', ' ', '—', '→', '|', '|', '-']; let lastBreak = -1; for (const ch of breakChars) { const idx = truncated.lastIndexOf(ch); if (idx > lastBreak) lastBreak = idx; } title = lastBreak > TITLE_MAX_LENGTH * 0.4 ? truncated.slice(0, lastBreak).replace(/[\s→—\-||:,]+$/, '') : truncated; } let summary = options.summary || parsed.summary || ''; if (summary.length > SUMMARY_MAX_LENGTH) { console.warn(`[weibo-article] Summary exceeds ${SUMMARY_MAX_LENGTH} chars (${summary.length}), regenerating from content...`); summary = parsed.shortSummary || summary.slice(0, SUMMARY_MAX_LENGTH - 1) + '\u2026'; } console.log(`[weibo-article] Title (${title.length}/${TITLE_MAX_LENGTH}): ${title}`); console.log(`[weibo-article] Summary (${summary.length}/${SUMMARY_MAX_LENGTH}): ${summary}`); console.log(`[weibo-article] Cover: ${parsed.coverImage ?? 'none'}`); console.log(`[weibo-article] Content images: ${parsed.contentImages.length}`); const htmlPath = path.join(os.tmpdir(), 'weibo-article-content.html'); await writeFile(htmlPath, parsed.html, 'utf-8'); console.log(`[weibo-article] HTML saved to: ${htmlPath}`); await mkdir(profileDir, { recursive: true }); // Try reusing an existing Chrome instance with the same profile const existingPort = await findExistingChromeDebugPort(profileDir); let port: number; if (existingPort) { console.log(`[weibo-article] Found existing Chrome on port ${existingPort}, reusing...`); port = existingPort; } else { const chromePath = findChromeExecutable(options.chromePath); if (!chromePath) throw new Error('Chrome not found. Set WEIBO_BROWSER_CHROME_PATH env var.'); port = await launchChrome(WEIBO_ARTICLE_URL, profileDir, chromePath); } let cdp: CdpConnection | null = null; try { const wsUrl = await waitForChromeDebugPort(port, 30_000); cdp = await CdpConnection.connect(wsUrl, 30_000, { defaultTimeoutMs: 60_000 }); const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets'); // Always create a fresh tab for the article editor const { targetId } = await cdp.send<{ targetId: string }>('Target.createTarget', { url: WEIBO_ARTICLE_URL }); const pageTarget = { targetId, url: WEIBO_ARTICLE_URL, type: 'page' }; console.log('[weibo-article] Opened article editor in new tab'); const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId: pageTarget.targetId, flatten: true }); await cdp.send('Page.enable', {}, { sessionId }); await cdp.send('Runtime.enable', {}, { sessionId }); await cdp.send('DOM.enable', {}, { sessionId }); console.log('[weibo-article] Waiting for article editor page...'); await sleep(3000); const waitForElement = async (expression: string, timeoutMs = 60_000): Promise => { const start = Date.now(); while (Date.now() - start < timeoutMs) { const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', { expression, returnByValue: true, }, { sessionId }); if (result.result.value) return true; await sleep(500); } return false; }; // Step 1: Find and click "写文章" button console.log('[weibo-article] Looking for "写文章" button...'); const writeButtonFound = await waitForElement(` !!Array.from(document.querySelectorAll('button, a, div[role="button"]')).find(el => el.textContent?.trim() === '写文章') `, 15_000); if (writeButtonFound) { console.log('[weibo-article] Clicking "写文章" button...'); await cdp.send('Runtime.evaluate', { expression: ` const btn = Array.from(document.querySelectorAll('button, a, div[role="button"]')).find(el => el.textContent?.trim() === '写文章'); if (btn) btn.click(); `, }, { sessionId }); await sleep(1000); // Wait for title input to become editable (not readonly) console.log('[weibo-article] Waiting for editor to become editable...'); const editable = await waitForElement(` (() => { const el = document.querySelector('textarea[placeholder="请输入标题"]'); return el && !el.readOnly && !el.disabled; })() `, 15_000); if (!editable) { console.warn('[weibo-article] Title input still readonly after waiting. Proceeding anyway...'); } } else { // Maybe we're already on the editor page console.log('[weibo-article] "写文章" button not found, checking if editor is already loaded...'); const editorExists = await waitForElement(` !!document.querySelector('textarea[placeholder="请输入标题"]') `, 10_000); if (!editorExists) { throw new Error('Weibo article editor not found. Please ensure you are logged in.'); } } // Step 2: Fill title if (title) { console.log('[weibo-article] Filling title...'); // Check if title input exists const titleExists = await cdp.send<{ result: { value: boolean } }>('Runtime.evaluate', { expression: `!!document.querySelector('textarea[placeholder="请输入标题"]')`, returnByValue: true, }, { sessionId }); if (!titleExists.result.value) { console.error('[weibo-article] Title input NOT found: textarea[placeholder="请输入标题"]'); } else { console.log('[weibo-article] Title input found'); // Focus and use Input.insertText via CDP (more reliable for React/Vue controlled inputs) await cdp.send('Runtime.evaluate', { expression: `(() => { const el = document.querySelector('textarea[placeholder="请输入标题"]'); if (el) { el.focus(); el.value = ''; } })()`, }, { sessionId }); await sleep(200); await cdp.send('Input.insertText', { text: title }, { sessionId }); await sleep(500); // Verify title was entered const titleCheck = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', { expression: `document.querySelector('textarea[placeholder="请输入标题"]')?.value || ''`, returnByValue: true, }, { sessionId }); if (titleCheck.result.value === title) { console.log(`[weibo-article] Title verified: "${titleCheck.result.value}"`); } else if (titleCheck.result.value.length > 0) { console.warn(`[weibo-article] Title partially entered: "${titleCheck.result.value}" (expected: "${title}")`); } else { console.warn('[weibo-article] Title input appears empty after insertion, trying execCommand fallback...'); await cdp.send('Runtime.evaluate', { expression: `(() => { const el = document.querySelector('textarea[placeholder="请输入标题"]'); if (el) { el.focus(); document.execCommand('insertText', false, ${JSON.stringify(title)}); } })()`, }, { sessionId }); await sleep(300); const titleRecheck = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', { expression: `document.querySelector('textarea[placeholder="请输入标题"]')?.value || ''`, returnByValue: true, }, { sessionId }); console.log(`[weibo-article] Title after fallback: "${titleRecheck.result.value}"`); } } } // Step 3: Fill summary (导语) if (summary) { console.log('[weibo-article] Filling summary...'); const summaryExists = await cdp.send<{ result: { value: boolean } }>('Runtime.evaluate', { expression: `!!document.querySelector('textarea[placeholder="导语(选填)"]')`, returnByValue: true, }, { sessionId }); if (!summaryExists.result.value) { console.error('[weibo-article] Summary input NOT found: textarea[placeholder="导语(选填)"]'); } else { console.log('[weibo-article] Summary input found'); await cdp.send('Runtime.evaluate', { expression: `(() => { const el = document.querySelector('textarea[placeholder="导语(选填)"]'); if (el) { el.focus(); el.value = ''; } })()`, }, { sessionId }); await sleep(200); await cdp.send('Input.insertText', { text: summary }, { sessionId }); await sleep(500); // Verify summary was entered const summaryCheck = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', { expression: `document.querySelector('textarea[placeholder="导语(选填)"]')?.value || ''`, returnByValue: true, }, { sessionId }); if (summaryCheck.result.value === summary) { console.log(`[weibo-article] Summary verified: "${summaryCheck.result.value}"`); } else if (summaryCheck.result.value.length > 0) { console.warn(`[weibo-article] Summary partially entered: "${summaryCheck.result.value}"`); } else { console.warn('[weibo-article] Summary input appears empty, trying execCommand fallback...'); await cdp.send('Runtime.evaluate', { expression: `(() => { const el = document.querySelector('textarea[placeholder="导语(选填)"]'); if (el) { el.focus(); document.execCommand('insertText', false, ${JSON.stringify(summary)}); } })()`, }, { sessionId }); await sleep(300); const summaryRecheck = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', { expression: `document.querySelector('textarea[placeholder="导语(选填)"]')?.value || ''`, returnByValue: true, }, { sessionId }); console.log(`[weibo-article] Summary after fallback: "${summaryRecheck.result.value}"`); } } } // Step 4: Insert HTML content into ProseMirror editor console.log('[weibo-article] Inserting content...'); const htmlContent = fs.readFileSync(htmlPath, 'utf-8'); // Check if ProseMirror editor exists const editorExists2 = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', { expression: `(() => { const el = document.querySelector('div[contenteditable="true"]'); if (!el) return 'NOT_FOUND'; return 'class=' + el.className; })()`, returnByValue: true, }, { sessionId }); if (editorExists2.result.value === 'NOT_FOUND') { console.error('[weibo-article] ProseMirror editor NOT found: div[contenteditable="true"]'); } else { console.log(`[weibo-article] Editor found (${editorExists2.result.value})`); } // Focus ProseMirror editor await cdp.send('Runtime.evaluate', { expression: `(() => { const editor = document.querySelector('div[contenteditable="true"]'); if (editor) { editor.focus(); editor.click(); } })()`, }, { sessionId }); await sleep(300); // Method 1: Copy HTML to system clipboard, then real paste keystroke console.log('[weibo-article] Copying HTML to clipboard and pasting...'); copyHtmlToClipboard(htmlPath); await sleep(500); // Focus editor again before paste await cdp.send('Runtime.evaluate', { expression: `document.querySelector('div[contenteditable="true"]')?.focus()`, }, { sessionId }); await sleep(200); pasteFromClipboard('Google Chrome', 5, 500); await sleep(2000); // Check if content was inserted const contentCheck = await cdp.send<{ result: { value: number } }>('Runtime.evaluate', { expression: `document.querySelector('div[contenteditable="true"]')?.innerText?.length || 0`, returnByValue: true, }, { sessionId }); if (contentCheck.result.value > 50) { console.log(`[weibo-article] Content inserted via clipboard paste (${contentCheck.result.value} chars)`); } else { console.log(`[weibo-article] Clipboard paste got ${contentCheck.result.value} chars, trying DataTransfer paste event...`); // Method 2: Simulate paste event with HTML data await cdp.send('Runtime.evaluate', { expression: `(() => { const editor = document.querySelector('div[contenteditable="true"]'); if (!editor) return false; editor.focus(); const html = ${JSON.stringify(htmlContent)}; const dt = new DataTransfer(); dt.setData('text/html', html); dt.setData('text/plain', html.replace(/<[^>]*>/g, '')); const pasteEvent = new ClipboardEvent('paste', { bubbles: true, cancelable: true, clipboardData: dt }); editor.dispatchEvent(pasteEvent); return true; })()`, returnByValue: true, }, { sessionId }); await sleep(1000); const check2 = await cdp.send<{ result: { value: number } }>('Runtime.evaluate', { expression: `document.querySelector('div[contenteditable="true"]')?.innerText?.length || 0`, returnByValue: true, }, { sessionId }); if (check2.result.value > 50) { console.log(`[weibo-article] Content inserted via DataTransfer (${check2.result.value} chars)`); } else { console.log(`[weibo-article] DataTransfer got ${check2.result.value} chars, trying insertHTML...`); // Method 3: execCommand insertHTML await cdp.send('Runtime.evaluate', { expression: `(() => { const editor = document.querySelector('div[contenteditable="true"]'); if (!editor) return false; editor.focus(); document.execCommand('insertHTML', false, ${JSON.stringify(htmlContent)}); return true; })()`, }, { sessionId }); await sleep(1000); const check3 = await cdp.send<{ result: { value: number } }>('Runtime.evaluate', { expression: `document.querySelector('div[contenteditable="true"]')?.innerText?.length || 0`, returnByValue: true, }, { sessionId }); if (check3.result.value > 50) { console.log(`[weibo-article] Content inserted via execCommand (${check3.result.value} chars)`); } else { console.error('[weibo-article] All auto-insert methods failed. HTML is on clipboard - please paste manually (Cmd+V)'); console.log('[weibo-article] Waiting 30s for manual paste...'); await sleep(30_000); } } } // Step 5: Insert content images if (parsed.contentImages.length > 0) { console.log('[weibo-article] Inserting content images...'); const editorContent = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', { expression: `document.querySelector('div[contenteditable="true"]')?.innerText || ''`, returnByValue: true, }, { sessionId }); console.log('[weibo-article] Checking for placeholders in content...'); let placeholderCount = 0; for (const img of parsed.contentImages) { const regex = new RegExp(img.placeholder + '(?!\\d)'); if (regex.test(editorContent.result.value)) { console.log(`[weibo-article] Found: ${img.placeholder}`); placeholderCount++; } else { console.log(`[weibo-article] NOT found: ${img.placeholder}`); } } console.log(`[weibo-article] ${placeholderCount}/${parsed.contentImages.length} placeholders found in editor`); const getPlaceholderIndex = (placeholder: string): number => { const match = placeholder.match(/WBIMGPH_(\d+)/); return match ? Number(match[1]) : Number.POSITIVE_INFINITY; }; const sortedImages = [...parsed.contentImages].sort( (a, b) => getPlaceholderIndex(a.placeholder) - getPlaceholderIndex(b.placeholder), ); for (let i = 0; i < sortedImages.length; i++) { const img = sortedImages[i]!; console.log(`[weibo-article] [${i + 1}/${sortedImages.length}] Inserting image at placeholder: ${img.placeholder}`); const selectPlaceholder = async (maxRetries = 3): Promise => { for (let attempt = 1; attempt <= maxRetries; attempt++) { await cdp!.send('Runtime.evaluate', { expression: `(() => { const editor = document.querySelector('div[contenteditable="true"]'); if (!editor) return false; const placeholder = ${JSON.stringify(img.placeholder)}; const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT, null, false); let node; while ((node = walker.nextNode())) { const text = node.textContent || ''; let searchStart = 0; let idx; while ((idx = text.indexOf(placeholder, searchStart)) !== -1) { const afterIdx = idx + placeholder.length; const charAfter = text[afterIdx]; if (charAfter === undefined || !/\\d/.test(charAfter)) { const parentElement = node.parentElement; if (parentElement) { parentElement.scrollIntoView({ behavior: 'instant', block: 'center' }); } const range = document.createRange(); range.setStart(node, idx); range.setEnd(node, idx + placeholder.length); const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); return true; } searchStart = afterIdx; } } return false; })()`, }, { sessionId }); await sleep(800); const selectionCheck = await cdp!.send<{ result: { value: string } }>('Runtime.evaluate', { expression: `window.getSelection()?.toString() || ''`, returnByValue: true, }, { sessionId }); const selectedText = selectionCheck.result.value.trim(); if (selectedText === img.placeholder) { console.log(`[weibo-article] Selection verified: "${selectedText}"`); return true; } if (attempt < maxRetries) { console.log(`[weibo-article] Selection attempt ${attempt} got "${selectedText}", retrying...`); await sleep(500); } else { console.warn(`[weibo-article] Selection failed after ${maxRetries} attempts, got: "${selectedText}"`); } } return false; }; // Step A: Copy image to clipboard first (slow due to Swift compilation) console.log(`[weibo-article] Copying image to clipboard: ${path.basename(img.localPath)}`); if (!copyImageToClipboard(img.localPath)) { console.warn(`[weibo-article] Failed to copy image to clipboard`); continue; } await sleep(500); // Step B: Select placeholder text (paste will replace the selection) const selected = await selectPlaceholder(3); if (!selected) { console.warn(`[weibo-article] Skipping image - could not select placeholder: ${img.placeholder}`); continue; } // Step C: Delete selected placeholder via Backspace (ProseMirror-compatible) console.log(`[weibo-article] Deleting placeholder via Backspace...`); await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Backspace', code: 'Backspace', windowsVirtualKeyCode: 8 }, { sessionId }); await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Backspace', code: 'Backspace', windowsVirtualKeyCode: 8 }, { sessionId }); await sleep(500); // Verify placeholder was deleted const placeholderGone = await cdp.send<{ result: { value: boolean } }>('Runtime.evaluate', { expression: `(() => { const editor = document.querySelector('div[contenteditable="true"]'); if (!editor) return true; const placeholder = ${JSON.stringify(img.placeholder)}; const regex = new RegExp(placeholder + '(?!\\\\d)'); return !regex.test(editor.innerText); })()`, returnByValue: true, }, { sessionId }); if (placeholderGone.result.value) { console.log(`[weibo-article] Placeholder deleted`); } else { console.warn(`[weibo-article] Placeholder may still exist, trying execCommand delete...`); // Re-select and delete via execCommand await selectPlaceholder(1); await cdp.send('Runtime.evaluate', { expression: `document.execCommand('delete')`, }, { sessionId }); await sleep(300); } // Step D: Focus editor and paste image await cdp.send('Runtime.evaluate', { expression: `document.querySelector('div[contenteditable="true"]')?.focus()`, }, { sessionId }); await sleep(200); // Count images before paste const imgCountBefore = await cdp.send<{ result: { value: number } }>('Runtime.evaluate', { expression: `document.querySelectorAll('div[contenteditable="true"] img').length`, returnByValue: true, }, { sessionId }); // Paste image at cursor position (where placeholder was) console.log(`[weibo-article] Pasting image...`); if (pasteFromClipboard('Google Chrome', 5, 1000)) { console.log(`[weibo-article] Paste keystroke sent for: ${path.basename(img.localPath)}`); } else { console.warn(`[weibo-article] Failed to paste image after retries`); } // Verify image appeared in editor console.log(`[weibo-article] Verifying image insertion...`); const expectedImgCount = imgCountBefore.result.value + 1; let imgInserted = false; const imgWaitStart = Date.now(); while (Date.now() - imgWaitStart < 15_000) { const r = await cdp!.send<{ result: { value: number } }>('Runtime.evaluate', { expression: `document.querySelectorAll('div[contenteditable="true"] img').length`, returnByValue: true, }, { sessionId }); if (r.result.value >= expectedImgCount) { imgInserted = true; break; } await sleep(1000); } if (imgInserted) { console.log(`[weibo-article] Image insertion verified (${expectedImgCount} image(s) in editor)`); await sleep(1000); // Clean up extra empty

before the image (Tiptap invisible chars +
) console.log(`[weibo-article] Cleaning up empty lines around image...`); await cdp!.send('Runtime.evaluate', { expression: `(() => { const editor = document.querySelector('div[contenteditable="true"]'); if (!editor) return; const imageViews = editor.querySelectorAll('.image-view__body'); const lastView = imageViews[imageViews.length - 1]; const imgBlock = lastView?.closest('div[data-type], .ProseMirror > *') || lastView?.parentElement; if (!imgBlock) return; let prev = imgBlock.previousElementSibling; let removed = 0; while (prev) { const tag = prev.tagName?.toLowerCase(); const text = prev.textContent?.replace(/\\u200b/g, '').trim(); const hasOnlyBreaks = prev.querySelectorAll('br, .Tiptap-invisible-character').length > 0; if ((tag === 'p' || tag === 'div') && (!text || text === '') && hasOnlyBreaks) { const toRemove = prev; prev = prev.previousElementSibling; toRemove.remove(); removed++; if (removed >= 2) break; } else { break; } } })()`, }, { sessionId }); // Fill image caption if alt text exists const altText = img.alt?.trim(); if (altText) { console.log(`[weibo-article] Setting image caption: "${altText}"`); const captionResult = await cdp!.send<{ result: { value: string } }>('Runtime.evaluate', { expression: `(() => { const editor = document.querySelector('div[contenteditable="true"]'); if (!editor) return 'no_editor'; const views = editor.querySelectorAll('.image-view__body'); const lastView = views[views.length - 1]; if (!lastView) return 'no_view'; const captionSpan = lastView.querySelector('.image-view__caption span[data-node-view-content]'); if (!captionSpan) return 'no_caption_span'; captionSpan.focus(); captionSpan.textContent = ${JSON.stringify(altText)}; captionSpan.dispatchEvent(new Event('input', { bubbles: true })); return 'set'; })()`, returnByValue: true, }, { sessionId }); console.log(`[weibo-article] Caption result: ${captionResult.result.value}`); await sleep(300); } } else { console.warn(`[weibo-article] Image insertion not detected after 15s`); if (i === 0) { console.error('[weibo-article] First image paste failed. Check Accessibility permissions for your terminal app.'); } } // Wait for editor to stabilize await sleep(2000); } console.log('[weibo-article] All images processed.'); // Clean up extra empty

before images (Tiptap invisible chars +
) console.log('[weibo-article] Cleaning up extra line breaks before images...'); const cleanupResult = await cdp.send<{ result: { value: number } }>('Runtime.evaluate', { expression: `(() => { const editor = document.querySelector('div[contenteditable="true"]'); if (!editor) return 0; let removed = 0; const imageViews = editor.querySelectorAll('.image-view__body'); for (const view of imageViews) { const imgBlock = view.closest('div[data-type], .ProseMirror > *') || view.parentElement; if (!imgBlock) continue; let prev = imgBlock.previousElementSibling; while (prev) { const tag = prev.tagName?.toLowerCase(); const text = prev.textContent?.replace(/\\u200b/g, '').trim(); const hasOnlyBreaks = prev.querySelectorAll('br, .Tiptap-invisible-character').length > 0; if ((tag === 'p' || tag === 'div') && (!text || text === '') && hasOnlyBreaks) { const toRemove = prev; prev = toRemove.previousElementSibling; toRemove.remove(); removed++; } else { break; } } } return removed; })()`, returnByValue: true, }, { sessionId }); if (cleanupResult.result.value > 0) { console.log(`[weibo-article] Removed ${cleanupResult.result.value} extra line break(s) before images.`); } await sleep(500); // Final verification console.log('[weibo-article] Running post-composition verification...'); const finalEditorContent = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', { expression: `document.querySelector('div[contenteditable="true"]')?.innerText || ''`, returnByValue: true, }, { sessionId }); const remainingPlaceholders: string[] = []; for (const img of parsed.contentImages) { const regex = new RegExp(img.placeholder + '(?!\\d)'); if (regex.test(finalEditorContent.result.value)) { remainingPlaceholders.push(img.placeholder); } } const finalImgCount = await cdp.send<{ result: { value: number } }>('Runtime.evaluate', { expression: `document.querySelectorAll('div[contenteditable="true"] img').length`, returnByValue: true, }, { sessionId }); const expectedCount = parsed.contentImages.length; const actualCount = finalImgCount.result.value; if (remainingPlaceholders.length > 0 || actualCount < expectedCount) { console.warn('[weibo-article] POST-COMPOSITION CHECK FAILED:'); if (remainingPlaceholders.length > 0) { console.warn(`[weibo-article] Remaining placeholders: ${remainingPlaceholders.join(', ')}`); } if (actualCount < expectedCount) { console.warn(`[weibo-article] Image count: expected ${expectedCount}, found ${actualCount}`); } console.warn('[weibo-article] Please check the article before publishing.'); } else { console.log(`[weibo-article] Verification passed: ${actualCount} image(s), no remaining placeholders.`); } } // Step 6: Set cover image const coverImagePath = parsed.coverImage; if (coverImagePath && fs.existsSync(coverImagePath)) { console.log(`[weibo-article] Setting cover image: ${path.basename(coverImagePath)}`); // Scroll to top first await cdp.send('Runtime.evaluate', { expression: `window.scrollTo(0, 0)`, }, { sessionId }); await sleep(500); // 1. Click cover area to open dialog (cover-empty or cover-preview) // First scroll element into view await cdp.send('Runtime.evaluate', { expression: `(() => { const el = document.querySelector('.cover-empty') || document.querySelector('.cover-preview'); if (el) { el.scrollIntoView({ block: 'center' }); return true; } return false; })()`, returnByValue: true, }, { sessionId }); await sleep(1000); // Then get coordinates after scroll settles const coverBtnPos = await cdp.send<{ result: { value: { x: number; y: number } | null } }>('Runtime.evaluate', { expression: `(() => { const el = document.querySelector('.cover-empty') || document.querySelector('.cover-preview'); if (el) { const rect = el.getBoundingClientRect(); return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }; } return null; })()`, returnByValue: true, }, { sessionId }); if (coverBtnPos.result.value) { const { x, y } = coverBtnPos.result.value; console.log(`[weibo-article] "设置文章封面" at (${x}, ${y}), clicking...`); await cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 }, { sessionId }); await sleep(100); await cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 }, { sessionId }); } else { console.warn('[weibo-article] "设置文章封面" (.cover-empty) not found'); } await sleep(2000); // Wait for dialog to appear const dialogReady = await waitForElement(`!!document.querySelector('.n-dialog')`, 10_000); console.log(`[weibo-article] Dialog appeared: ${dialogReady}`); // 2. Click "图片库" tab const tabClicked = await cdp.send<{ result: { value: boolean } }>('Runtime.evaluate', { expression: `(() => { const tabs = document.querySelectorAll('.n-tabs-tab'); for (const t of tabs) { if (t.querySelector('.n-tabs-tab__label span')?.textContent?.trim() === '图片库') { t.click(); return true; } } return false; })()`, returnByValue: true, }, { sessionId }); console.log(`[weibo-article] "图片库" tab clicked: ${tabClicked.result.value}`); await sleep(1000); // 3. Count existing items before upload const itemCountBefore = await cdp.send<{ result: { value: number } }>('Runtime.evaluate', { expression: `document.querySelectorAll('.image-list .image-item').length`, returnByValue: true, }, { sessionId }); console.log(`[weibo-article] Items before upload: ${itemCountBefore.result.value}`); // 4. Upload via hidden file input console.log('[weibo-article] Uploading cover image via file input...'); const absPath = path.resolve(coverImagePath); // Get DOM document root first, then find file input via DOM.querySelector const docRoot = await cdp.send<{ root: { nodeId: number } }>('DOM.getDocument', { depth: -1 }, { sessionId }); const fileInputNodes = await cdp.send<{ nodeIds: number[] }>('DOM.querySelectorAll', { nodeId: docRoot.root.nodeId, selector: 'input[type="file"]', }, { sessionId }); const fileInputNodeId = fileInputNodes.nodeIds?.[0]; if (!fileInputNodeId) { console.warn('[weibo-article] File input not found, skipping cover image'); } else { await cdp.send('DOM.setFileInputFiles', { nodeId: fileInputNodeId, files: [absPath], }, { sessionId }); console.log('[weibo-article] File set on input, waiting for upload...'); // 5. Wait for a new item to appear (item count increases) let uploadSuccess = false; const uploadStart = Date.now(); while (Date.now() - uploadStart < 30_000) { const state = await cdp.send<{ result: { value: { count: number; firstSrc: string } } }>('Runtime.evaluate', { expression: `(() => { const items = document.querySelectorAll('.image-list .image-item'); const first = items[0]; const img = first?.querySelector('img'); return { count: items.length, firstSrc: img?.src || '' }; })()`, returnByValue: true, }, { sessionId }); const { count, firstSrc } = state.result.value; if (count > itemCountBefore.result.value && firstSrc.startsWith('https://')) { console.log(`[weibo-article] New image uploaded (${count} items, src: https://...)`); uploadSuccess = true; break; } if (firstSrc.startsWith('blob:')) { console.log('[weibo-article] Cover image uploading (blob detected)...'); } await sleep(1000); } if (!uploadSuccess) { // Fallback: check if first item has https (maybe count didn't change but image was replaced) const fallback = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', { expression: `document.querySelector('.image-list .image-item img')?.src || ''`, returnByValue: true, }, { sessionId }); if (fallback.result.value.startsWith('https://')) { console.log('[weibo-article] Cover image ready (fallback check)'); uploadSuccess = true; } else { console.warn('[weibo-article] Cover image upload timed out after 30s'); } } if (uploadSuccess) { // 6. Click first item to select it const clickResult = await cdp.send<{ result: { value: boolean } }>('Runtime.evaluate', { expression: `(() => { const item = document.querySelector('.image-list .image-item'); if (item) { item.click(); return true; } return false; })()`, returnByValue: true, }, { sessionId }); console.log(`[weibo-article] First item clicked: ${clickResult.result.value}`); await sleep(500); // Verify selection const selected = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', { expression: `(() => { const items = document.querySelectorAll('.image-list .image-item'); const selectedIdx = Array.from(items).findIndex(i => i.classList.contains('is-selected')); return 'selected_index=' + selectedIdx + ' total=' + items.length; })()`, returnByValue: true, }, { sessionId }); console.log(`[weibo-article] Selection: ${selected.result.value}`); // 7. Click "下一步" in dialog (image selection → crop) const nextResult = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', { expression: `(() => { const dialog = document.querySelector('.n-dialog'); if (!dialog) return 'no_dialog'; const buttons = dialog.querySelectorAll('.n-button'); for (const b of buttons) { const text = b.querySelector('.n-button__content')?.textContent?.trim() || ''; if (text === '下一步') { b.click(); return 'clicked'; } } return 'not_found'; })()`, returnByValue: true, }, { sessionId }); console.log(`[weibo-article] "下一步" (select→crop): ${nextResult.result.value}`); await sleep(3000); // 8. Click "确定" in crop dialog // First check button state and dispatch full pointer event sequence const confirmInfo = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', { expression: `(() => { const dialog = document.querySelector('.n-dialog'); if (!dialog) return 'no_dialog'; const buttons = dialog.querySelectorAll('.n-button'); for (const b of buttons) { const text = b.querySelector('.n-button__content')?.textContent?.trim() || ''; if (text === '确定' || text === '确认') { const disabled = b.disabled || b.classList.contains('n-button--disabled'); const rect = b.getBoundingClientRect(); return 'found:' + text + ':disabled=' + disabled + ':y=' + rect.y + ':h=' + rect.height; } } const allTexts = Array.from(buttons).map(b => b.querySelector('.n-button__content')?.textContent?.trim() || '').join(','); return 'not_found:' + allTexts; })()`, returnByValue: true, }, { sessionId }); console.log(`[weibo-article] Confirm button info: ${confirmInfo.result.value}`); // Use full pointer event simulation via JS (not CDP Input.dispatchMouseEvent) const confirmClickResult = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', { expression: `(() => { const dialog = document.querySelector('.n-dialog'); if (!dialog) return 'no_dialog'; const buttons = dialog.querySelectorAll('.n-button'); for (const b of buttons) { const text = b.querySelector('.n-button__content')?.textContent?.trim() || ''; if (text === '确定' || text === '确认') { b.scrollIntoView({ block: 'center' }); const rect = b.getBoundingClientRect(); const cx = rect.x + rect.width / 2; const cy = rect.y + rect.height / 2; const opts = { bubbles: true, cancelable: true, clientX: cx, clientY: cy, button: 0 }; b.dispatchEvent(new PointerEvent('pointerdown', opts)); b.dispatchEvent(new MouseEvent('mousedown', opts)); b.dispatchEvent(new PointerEvent('pointerup', opts)); b.dispatchEvent(new MouseEvent('mouseup', opts)); b.dispatchEvent(new MouseEvent('click', opts)); return 'dispatched:' + text; } } return 'not_found'; })()`, returnByValue: true, }, { sessionId }); console.log(`[weibo-article] Confirm click: ${confirmClickResult.result.value}`); await sleep(2000); // Check dialog state const afterConfirm = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', { expression: `(() => { const dialog = document.querySelector('.n-dialog'); if (!dialog) return 'closed'; const buttons = dialog.querySelectorAll('.n-button'); return 'open:' + Array.from(buttons).map(b => b.querySelector('.n-button__content')?.textContent?.trim() || '').join(','); })()`, returnByValue: true, }, { sessionId }); console.log(`[weibo-article] After confirm: ${afterConfirm.result.value}`); // If still open, try focusing the button and pressing Enter if (afterConfirm.result.value !== 'closed') { console.log('[weibo-article] Dialog still open, trying focus + Enter...'); await cdp!.send('Runtime.evaluate', { expression: `(() => { const dialog = document.querySelector('.n-dialog'); if (!dialog) return; const buttons = dialog.querySelectorAll('.n-button'); for (const b of buttons) { const text = b.querySelector('.n-button__content')?.textContent?.trim() || ''; if (text === '确定' || text === '确认') { b.focus(); return; } } })()`, }, { sessionId }); await sleep(200); await cdp!.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 }, { sessionId }); await cdp!.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 }, { sessionId }); await sleep(2000); const afterEnter = await cdp!.send<{ result: { value: string } }>('Runtime.evaluate', { expression: `!document.querySelector('.n-dialog') ? 'closed' : 'still_open'`, returnByValue: true, }, { sessionId }); console.log(`[weibo-article] After Enter: ${afterEnter.result.value}`); } await sleep(1000); // Verify cover was set (cover-preview with img should exist) const coverSet = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', { expression: `(() => { const preview = document.querySelector('.cover-preview .cover-img'); if (preview) return 'cover_set'; const empty = document.querySelector('.cover-empty'); if (empty) return 'cover_empty_still_exists'; return 'cover_unknown'; })()`, returnByValue: true, }, { sessionId }); console.log(`[weibo-article] Cover result: ${coverSet.result.value}`); } } } else if (coverImagePath) { console.warn(`[weibo-article] Cover image not found: ${coverImagePath}`); } else { console.log('[weibo-article] No cover image specified'); } console.log('[weibo-article] Article composed. Please review and publish manually.'); console.log('[weibo-article] Browser remains open for manual review.'); } finally { if (cdp) { cdp.close(); } } } function printUsage(): never { console.log(`Publish Markdown article to Weibo Headline Articles Usage: npx -y bun weibo-article.ts [options] Options: --title Override title (max 32 chars) --summary <text> Override summary (max 44 chars) --cover <image> Override cover image --profile <dir> Chrome profile directory --help Show this help Markdown frontmatter: --- title: My Article Title summary: Brief description cover_image: /path/to/cover.jpg --- Example: npx -y bun weibo-article.ts article.md npx -y bun weibo-article.ts article.md --cover ./hero.png npx -y bun weibo-article.ts article.md --title "Custom Title" `); process.exit(0); } async function main(): Promise<void> { const args = process.argv.slice(2); if (args.length === 0 || args.includes('--help') || args.includes('-h')) { printUsage(); } let markdownPath: string | undefined; let title: string | undefined; let summary: string | undefined; let coverImage: string | undefined; let profileDir: string | undefined; for (let i = 0; i < args.length; i++) { const arg = args[i]!; if (arg === '--title' && args[i + 1]) { title = args[++i]; } else if (arg === '--summary' && args[i + 1]) { summary = args[++i]; } else if (arg === '--cover' && args[i + 1]) { const raw = args[++i]!; coverImage = path.isAbsolute(raw) ? raw : path.resolve(process.cwd(), raw); } else if (arg === '--profile' && args[i + 1]) { profileDir = args[++i]; } else if (!arg.startsWith('-')) { markdownPath = arg; } } if (!markdownPath) { console.error('Error: Markdown file path required'); process.exit(1); } if (!fs.existsSync(markdownPath)) { console.error(`Error: File not found: ${markdownPath}`); process.exit(1); } await publishArticle({ markdownPath, title, summary, coverImage, profileDir }); } await main().catch((err) => { console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); process.exit(1); });