Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Post text, images, videos, and long-form articles to Weibo (微博) via Chrome browser automation.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/weibo-article.ts
1import fs from 'node:fs';2import { mkdir, writeFile } from 'node:fs/promises';3import os from 'node:os';4import path from 'node:path';5import {6CdpConnection,7copyHtmlToClipboard,8copyImageToClipboard,9findChromeExecutable,10findExistingChromeDebugPort,11getDefaultProfileDir,12launchChrome,13pasteFromClipboard,14sleep,15waitForChromeDebugPort,16} from './weibo-utils.js';17import { parseMarkdown } from './md-to-html.js';1819const WEIBO_ARTICLE_URL = 'https://card.weibo.com/article/v3/editor';2021const TITLE_MAX_LENGTH = 32;22const SUMMARY_MAX_LENGTH = 44;2324interface ArticleOptions {25markdownPath: string;26coverImage?: string;27title?: string;28summary?: string;29profileDir?: string;30chromePath?: string;31}3233export async function publishArticle(options: ArticleOptions): Promise<void> {34const { markdownPath, profileDir = getDefaultProfileDir() } = options;3536console.log('[weibo-article] Parsing markdown...');37const parsed = await parseMarkdown(markdownPath, {38title: options.title,39coverImage: options.coverImage,40});4142let title = parsed.title;43if (title.length > TITLE_MAX_LENGTH) {44console.warn(`[weibo-article] Title exceeds ${TITLE_MAX_LENGTH} chars (${title.length}), truncating at word boundary...`);45const truncated = title.slice(0, TITLE_MAX_LENGTH);46const breakChars = [':', ',', '、', '。', ' ', '—', '→', '|', '|', '-'];47let lastBreak = -1;48for (const ch of breakChars) {49const idx = truncated.lastIndexOf(ch);50if (idx > lastBreak) lastBreak = idx;51}52title = lastBreak > TITLE_MAX_LENGTH * 0.453? truncated.slice(0, lastBreak).replace(/[\s→—\-||:,]+$/, '')54: truncated;55}5657let summary = options.summary || parsed.summary || '';58if (summary.length > SUMMARY_MAX_LENGTH) {59console.warn(`[weibo-article] Summary exceeds ${SUMMARY_MAX_LENGTH} chars (${summary.length}), regenerating from content...`);60summary = parsed.shortSummary || summary.slice(0, SUMMARY_MAX_LENGTH - 1) + '\u2026';61}6263console.log(`[weibo-article] Title (${title.length}/${TITLE_MAX_LENGTH}): ${title}`);64console.log(`[weibo-article] Summary (${summary.length}/${SUMMARY_MAX_LENGTH}): ${summary}`);65console.log(`[weibo-article] Cover: ${parsed.coverImage ?? 'none'}`);66console.log(`[weibo-article] Content images: ${parsed.contentImages.length}`);6768const htmlPath = path.join(os.tmpdir(), 'weibo-article-content.html');69await writeFile(htmlPath, parsed.html, 'utf-8');70console.log(`[weibo-article] HTML saved to: ${htmlPath}`);7172await mkdir(profileDir, { recursive: true });7374// Try reusing an existing Chrome instance with the same profile75const existingPort = await findExistingChromeDebugPort(profileDir);76let port: number;7778if (existingPort) {79console.log(`[weibo-article] Found existing Chrome on port ${existingPort}, reusing...`);80port = existingPort;81} else {82const chromePath = findChromeExecutable(options.chromePath);83if (!chromePath) throw new Error('Chrome not found. Set WEIBO_BROWSER_CHROME_PATH env var.');8485port = await launchChrome(WEIBO_ARTICLE_URL, profileDir, chromePath);86}8788let cdp: CdpConnection | null = null;8990try {91const wsUrl = await waitForChromeDebugPort(port, 30_000);92cdp = await CdpConnection.connect(wsUrl, 30_000, { defaultTimeoutMs: 60_000 });9394const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets');95// Always create a fresh tab for the article editor96const { targetId } = await cdp.send<{ targetId: string }>('Target.createTarget', { url: WEIBO_ARTICLE_URL });97const pageTarget = { targetId, url: WEIBO_ARTICLE_URL, type: 'page' };98console.log('[weibo-article] Opened article editor in new tab');99100const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId: pageTarget.targetId, flatten: true });101102await cdp.send('Page.enable', {}, { sessionId });103await cdp.send('Runtime.enable', {}, { sessionId });104await cdp.send('DOM.enable', {}, { sessionId });105106console.log('[weibo-article] Waiting for article editor page...');107await sleep(3000);108109const waitForElement = async (expression: string, timeoutMs = 60_000): Promise<boolean> => {110const start = Date.now();111while (Date.now() - start < timeoutMs) {112const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', {113expression,114returnByValue: true,115}, { sessionId });116if (result.result.value) return true;117await sleep(500);118}119return false;120};121122// Step 1: Find and click "写文章" button123console.log('[weibo-article] Looking for "写文章" button...');124const writeButtonFound = await waitForElement(`125!!Array.from(document.querySelectorAll('button, a, div[role="button"]')).find(el => el.textContent?.trim() === '写文章')126`, 15_000);127128if (writeButtonFound) {129console.log('[weibo-article] Clicking "写文章" button...');130await cdp.send('Runtime.evaluate', {131expression: `132const btn = Array.from(document.querySelectorAll('button, a, div[role="button"]')).find(el => el.textContent?.trim() === '写文章');133if (btn) btn.click();134`,135}, { sessionId });136await sleep(1000);137138// Wait for title input to become editable (not readonly)139console.log('[weibo-article] Waiting for editor to become editable...');140const editable = await waitForElement(`141(() => {142const el = document.querySelector('textarea[placeholder="请输入标题"]');143return el && !el.readOnly && !el.disabled;144})()145`, 15_000);146147if (!editable) {148console.warn('[weibo-article] Title input still readonly after waiting. Proceeding anyway...');149}150} else {151// Maybe we're already on the editor page152console.log('[weibo-article] "写文章" button not found, checking if editor is already loaded...');153const editorExists = await waitForElement(`154!!document.querySelector('textarea[placeholder="请输入标题"]')155`, 10_000);156if (!editorExists) {157throw new Error('Weibo article editor not found. Please ensure you are logged in.');158}159}160161// Step 2: Fill title162if (title) {163console.log('[weibo-article] Filling title...');164165// Check if title input exists166const titleExists = await cdp.send<{ result: { value: boolean } }>('Runtime.evaluate', {167expression: `!!document.querySelector('textarea[placeholder="请输入标题"]')`,168returnByValue: true,169}, { sessionId });170171if (!titleExists.result.value) {172console.error('[weibo-article] Title input NOT found: textarea[placeholder="请输入标题"]');173} else {174console.log('[weibo-article] Title input found');175176// Focus and use Input.insertText via CDP (more reliable for React/Vue controlled inputs)177await cdp.send('Runtime.evaluate', {178expression: `(() => {179const el = document.querySelector('textarea[placeholder="请输入标题"]');180if (el) { el.focus(); el.value = ''; }181})()`,182}, { sessionId });183await sleep(200);184185await cdp.send('Input.insertText', { text: title }, { sessionId });186await sleep(500);187188// Verify title was entered189const titleCheck = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', {190expression: `document.querySelector('textarea[placeholder="请输入标题"]')?.value || ''`,191returnByValue: true,192}, { sessionId });193194if (titleCheck.result.value === title) {195console.log(`[weibo-article] Title verified: "${titleCheck.result.value}"`);196} else if (titleCheck.result.value.length > 0) {197console.warn(`[weibo-article] Title partially entered: "${titleCheck.result.value}" (expected: "${title}")`);198} else {199console.warn('[weibo-article] Title input appears empty after insertion, trying execCommand fallback...');200await cdp.send('Runtime.evaluate', {201expression: `(() => {202const el = document.querySelector('textarea[placeholder="请输入标题"]');203if (el) { el.focus(); document.execCommand('insertText', false, ${JSON.stringify(title)}); }204})()`,205}, { sessionId });206await sleep(300);207208const titleRecheck = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', {209expression: `document.querySelector('textarea[placeholder="请输入标题"]')?.value || ''`,210returnByValue: true,211}, { sessionId });212console.log(`[weibo-article] Title after fallback: "${titleRecheck.result.value}"`);213}214}215}216217// Step 3: Fill summary (导语)218if (summary) {219console.log('[weibo-article] Filling summary...');220221const summaryExists = await cdp.send<{ result: { value: boolean } }>('Runtime.evaluate', {222expression: `!!document.querySelector('textarea[placeholder="导语(选填)"]')`,223returnByValue: true,224}, { sessionId });225226if (!summaryExists.result.value) {227console.error('[weibo-article] Summary input NOT found: textarea[placeholder="导语(选填)"]');228} else {229console.log('[weibo-article] Summary input found');230231await cdp.send('Runtime.evaluate', {232expression: `(() => {233const el = document.querySelector('textarea[placeholder="导语(选填)"]');234if (el) { el.focus(); el.value = ''; }235})()`,236}, { sessionId });237await sleep(200);238239await cdp.send('Input.insertText', { text: summary }, { sessionId });240await sleep(500);241242// Verify summary was entered243const summaryCheck = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', {244expression: `document.querySelector('textarea[placeholder="导语(选填)"]')?.value || ''`,245returnByValue: true,246}, { sessionId });247248if (summaryCheck.result.value === summary) {249console.log(`[weibo-article] Summary verified: "${summaryCheck.result.value}"`);250} else if (summaryCheck.result.value.length > 0) {251console.warn(`[weibo-article] Summary partially entered: "${summaryCheck.result.value}"`);252} else {253console.warn('[weibo-article] Summary input appears empty, trying execCommand fallback...');254await cdp.send('Runtime.evaluate', {255expression: `(() => {256const el = document.querySelector('textarea[placeholder="导语(选填)"]');257if (el) { el.focus(); document.execCommand('insertText', false, ${JSON.stringify(summary)}); }258})()`,259}, { sessionId });260await sleep(300);261262const summaryRecheck = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', {263expression: `document.querySelector('textarea[placeholder="导语(选填)"]')?.value || ''`,264returnByValue: true,265}, { sessionId });266console.log(`[weibo-article] Summary after fallback: "${summaryRecheck.result.value}"`);267}268}269}270271// Step 4: Insert HTML content into ProseMirror editor272console.log('[weibo-article] Inserting content...');273274const htmlContent = fs.readFileSync(htmlPath, 'utf-8');275276// Check if ProseMirror editor exists277const editorExists2 = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', {278expression: `(() => {279const el = document.querySelector('div[contenteditable="true"]');280if (!el) return 'NOT_FOUND';281return 'class=' + el.className;282})()`,283returnByValue: true,284}, { sessionId });285286if (editorExists2.result.value === 'NOT_FOUND') {287console.error('[weibo-article] ProseMirror editor NOT found: div[contenteditable="true"]');288} else {289console.log(`[weibo-article] Editor found (${editorExists2.result.value})`);290}291292// Focus ProseMirror editor293await cdp.send('Runtime.evaluate', {294expression: `(() => {295const editor = document.querySelector('div[contenteditable="true"]');296if (editor) { editor.focus(); editor.click(); }297})()`,298}, { sessionId });299await sleep(300);300301// Method 1: Copy HTML to system clipboard, then real paste keystroke302console.log('[weibo-article] Copying HTML to clipboard and pasting...');303copyHtmlToClipboard(htmlPath);304await sleep(500);305306// Focus editor again before paste307await cdp.send('Runtime.evaluate', {308expression: `document.querySelector('div[contenteditable="true"]')?.focus()`,309}, { sessionId });310await sleep(200);311312pasteFromClipboard('Google Chrome', 5, 500);313await sleep(2000);314315// Check if content was inserted316const contentCheck = await cdp.send<{ result: { value: number } }>('Runtime.evaluate', {317expression: `document.querySelector('div[contenteditable="true"]')?.innerText?.length || 0`,318returnByValue: true,319}, { sessionId });320321if (contentCheck.result.value > 50) {322console.log(`[weibo-article] Content inserted via clipboard paste (${contentCheck.result.value} chars)`);323} else {324console.log(`[weibo-article] Clipboard paste got ${contentCheck.result.value} chars, trying DataTransfer paste event...`);325326// Method 2: Simulate paste event with HTML data327await cdp.send('Runtime.evaluate', {328expression: `(() => {329const editor = document.querySelector('div[contenteditable="true"]');330if (!editor) return false;331editor.focus();332333const html = ${JSON.stringify(htmlContent)};334const dt = new DataTransfer();335dt.setData('text/html', html);336dt.setData('text/plain', html.replace(/<[^>]*>/g, ''));337338const pasteEvent = new ClipboardEvent('paste', {339bubbles: true, cancelable: true, clipboardData: dt340});341editor.dispatchEvent(pasteEvent);342return true;343})()`,344returnByValue: true,345}, { sessionId });346await sleep(1000);347348const check2 = await cdp.send<{ result: { value: number } }>('Runtime.evaluate', {349expression: `document.querySelector('div[contenteditable="true"]')?.innerText?.length || 0`,350returnByValue: true,351}, { sessionId });352353if (check2.result.value > 50) {354console.log(`[weibo-article] Content inserted via DataTransfer (${check2.result.value} chars)`);355} else {356console.log(`[weibo-article] DataTransfer got ${check2.result.value} chars, trying insertHTML...`);357358// Method 3: execCommand insertHTML359await cdp.send('Runtime.evaluate', {360expression: `(() => {361const editor = document.querySelector('div[contenteditable="true"]');362if (!editor) return false;363editor.focus();364document.execCommand('insertHTML', false, ${JSON.stringify(htmlContent)});365return true;366})()`,367}, { sessionId });368await sleep(1000);369370const check3 = await cdp.send<{ result: { value: number } }>('Runtime.evaluate', {371expression: `document.querySelector('div[contenteditable="true"]')?.innerText?.length || 0`,372returnByValue: true,373}, { sessionId });374375if (check3.result.value > 50) {376console.log(`[weibo-article] Content inserted via execCommand (${check3.result.value} chars)`);377} else {378console.error('[weibo-article] All auto-insert methods failed. HTML is on clipboard - please paste manually (Cmd+V)');379console.log('[weibo-article] Waiting 30s for manual paste...');380await sleep(30_000);381}382}383}384385// Step 5: Insert content images386if (parsed.contentImages.length > 0) {387console.log('[weibo-article] Inserting content images...');388389const editorContent = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', {390expression: `document.querySelector('div[contenteditable="true"]')?.innerText || ''`,391returnByValue: true,392}, { sessionId });393394console.log('[weibo-article] Checking for placeholders in content...');395let placeholderCount = 0;396for (const img of parsed.contentImages) {397const regex = new RegExp(img.placeholder + '(?!\\d)');398if (regex.test(editorContent.result.value)) {399console.log(`[weibo-article] Found: ${img.placeholder}`);400placeholderCount++;401} else {402console.log(`[weibo-article] NOT found: ${img.placeholder}`);403}404}405console.log(`[weibo-article] ${placeholderCount}/${parsed.contentImages.length} placeholders found in editor`);406407const getPlaceholderIndex = (placeholder: string): number => {408const match = placeholder.match(/WBIMGPH_(\d+)/);409return match ? Number(match[1]) : Number.POSITIVE_INFINITY;410};411const sortedImages = [...parsed.contentImages].sort(412(a, b) => getPlaceholderIndex(a.placeholder) - getPlaceholderIndex(b.placeholder),413);414415for (let i = 0; i < sortedImages.length; i++) {416const img = sortedImages[i]!;417console.log(`[weibo-article] [${i + 1}/${sortedImages.length}] Inserting image at placeholder: ${img.placeholder}`);418419const selectPlaceholder = async (maxRetries = 3): Promise<boolean> => {420for (let attempt = 1; attempt <= maxRetries; attempt++) {421await cdp!.send('Runtime.evaluate', {422expression: `(() => {423const editor = document.querySelector('div[contenteditable="true"]');424if (!editor) return false;425426const placeholder = ${JSON.stringify(img.placeholder)};427428const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT, null, false);429let node;430431while ((node = walker.nextNode())) {432const text = node.textContent || '';433let searchStart = 0;434let idx;435while ((idx = text.indexOf(placeholder, searchStart)) !== -1) {436const afterIdx = idx + placeholder.length;437const charAfter = text[afterIdx];438if (charAfter === undefined || !/\\d/.test(charAfter)) {439const parentElement = node.parentElement;440if (parentElement) {441parentElement.scrollIntoView({ behavior: 'instant', block: 'center' });442}443444const range = document.createRange();445range.setStart(node, idx);446range.setEnd(node, idx + placeholder.length);447const sel = window.getSelection();448sel.removeAllRanges();449sel.addRange(range);450return true;451}452searchStart = afterIdx;453}454}455return false;456})()`,457}, { sessionId });458459await sleep(800);460461const selectionCheck = await cdp!.send<{ result: { value: string } }>('Runtime.evaluate', {462expression: `window.getSelection()?.toString() || ''`,463returnByValue: true,464}, { sessionId });465466const selectedText = selectionCheck.result.value.trim();467if (selectedText === img.placeholder) {468console.log(`[weibo-article] Selection verified: "${selectedText}"`);469return true;470}471472if (attempt < maxRetries) {473console.log(`[weibo-article] Selection attempt ${attempt} got "${selectedText}", retrying...`);474await sleep(500);475} else {476console.warn(`[weibo-article] Selection failed after ${maxRetries} attempts, got: "${selectedText}"`);477}478}479return false;480};481482// Step A: Copy image to clipboard first (slow due to Swift compilation)483console.log(`[weibo-article] Copying image to clipboard: ${path.basename(img.localPath)}`);484if (!copyImageToClipboard(img.localPath)) {485console.warn(`[weibo-article] Failed to copy image to clipboard`);486continue;487}488await sleep(500);489490// Step B: Select placeholder text (paste will replace the selection)491const selected = await selectPlaceholder(3);492if (!selected) {493console.warn(`[weibo-article] Skipping image - could not select placeholder: ${img.placeholder}`);494continue;495}496497// Step C: Delete selected placeholder via Backspace (ProseMirror-compatible)498console.log(`[weibo-article] Deleting placeholder via Backspace...`);499await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Backspace', code: 'Backspace', windowsVirtualKeyCode: 8 }, { sessionId });500await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Backspace', code: 'Backspace', windowsVirtualKeyCode: 8 }, { sessionId });501await sleep(500);502503// Verify placeholder was deleted504const placeholderGone = await cdp.send<{ result: { value: boolean } }>('Runtime.evaluate', {505expression: `(() => {506const editor = document.querySelector('div[contenteditable="true"]');507if (!editor) return true;508const placeholder = ${JSON.stringify(img.placeholder)};509const regex = new RegExp(placeholder + '(?!\\\\d)');510return !regex.test(editor.innerText);511})()`,512returnByValue: true,513}, { sessionId });514515if (placeholderGone.result.value) {516console.log(`[weibo-article] Placeholder deleted`);517} else {518console.warn(`[weibo-article] Placeholder may still exist, trying execCommand delete...`);519// Re-select and delete via execCommand520await selectPlaceholder(1);521await cdp.send('Runtime.evaluate', {522expression: `document.execCommand('delete')`,523}, { sessionId });524await sleep(300);525}526527// Step D: Focus editor and paste image528await cdp.send('Runtime.evaluate', {529expression: `document.querySelector('div[contenteditable="true"]')?.focus()`,530}, { sessionId });531await sleep(200);532533// Count images before paste534const imgCountBefore = await cdp.send<{ result: { value: number } }>('Runtime.evaluate', {535expression: `document.querySelectorAll('div[contenteditable="true"] img').length`,536returnByValue: true,537}, { sessionId });538539// Paste image at cursor position (where placeholder was)540console.log(`[weibo-article] Pasting image...`);541if (pasteFromClipboard('Google Chrome', 5, 1000)) {542console.log(`[weibo-article] Paste keystroke sent for: ${path.basename(img.localPath)}`);543} else {544console.warn(`[weibo-article] Failed to paste image after retries`);545}546547// Verify image appeared in editor548console.log(`[weibo-article] Verifying image insertion...`);549const expectedImgCount = imgCountBefore.result.value + 1;550let imgInserted = false;551const imgWaitStart = Date.now();552while (Date.now() - imgWaitStart < 15_000) {553const r = await cdp!.send<{ result: { value: number } }>('Runtime.evaluate', {554expression: `document.querySelectorAll('div[contenteditable="true"] img').length`,555returnByValue: true,556}, { sessionId });557if (r.result.value >= expectedImgCount) {558imgInserted = true;559break;560}561await sleep(1000);562}563564if (imgInserted) {565console.log(`[weibo-article] Image insertion verified (${expectedImgCount} image(s) in editor)`);566567await sleep(1000);568569// Clean up extra empty <p> before the image (Tiptap invisible chars + <br>)570console.log(`[weibo-article] Cleaning up empty lines around image...`);571await cdp!.send('Runtime.evaluate', {572expression: `(() => {573const editor = document.querySelector('div[contenteditable="true"]');574if (!editor) return;575const imageViews = editor.querySelectorAll('.image-view__body');576const lastView = imageViews[imageViews.length - 1];577const imgBlock = lastView?.closest('div[data-type], .ProseMirror > *') || lastView?.parentElement;578if (!imgBlock) return;579let prev = imgBlock.previousElementSibling;580let removed = 0;581while (prev) {582const tag = prev.tagName?.toLowerCase();583const text = prev.textContent?.replace(/\\u200b/g, '').trim();584const hasOnlyBreaks = prev.querySelectorAll('br, .Tiptap-invisible-character').length > 0;585if ((tag === 'p' || tag === 'div') && (!text || text === '') && hasOnlyBreaks) {586const toRemove = prev;587prev = prev.previousElementSibling;588toRemove.remove();589removed++;590if (removed >= 2) break;591} else {592break;593}594}595})()`,596}, { sessionId });597598// Fill image caption if alt text exists599const altText = img.alt?.trim();600if (altText) {601console.log(`[weibo-article] Setting image caption: "${altText}"`);602const captionResult = await cdp!.send<{ result: { value: string } }>('Runtime.evaluate', {603expression: `(() => {604const editor = document.querySelector('div[contenteditable="true"]');605if (!editor) return 'no_editor';606const views = editor.querySelectorAll('.image-view__body');607const lastView = views[views.length - 1];608if (!lastView) return 'no_view';609const captionSpan = lastView.querySelector('.image-view__caption span[data-node-view-content]');610if (!captionSpan) return 'no_caption_span';611captionSpan.focus();612captionSpan.textContent = ${JSON.stringify(altText)};613captionSpan.dispatchEvent(new Event('input', { bubbles: true }));614return 'set';615})()`,616returnByValue: true,617}, { sessionId });618console.log(`[weibo-article] Caption result: ${captionResult.result.value}`);619await sleep(300);620}621} else {622console.warn(`[weibo-article] Image insertion not detected after 15s`);623if (i === 0) {624console.error('[weibo-article] First image paste failed. Check Accessibility permissions for your terminal app.');625}626}627628// Wait for editor to stabilize629await sleep(2000);630}631632console.log('[weibo-article] All images processed.');633634// Clean up extra empty <p> before images (Tiptap invisible chars + <br>)635console.log('[weibo-article] Cleaning up extra line breaks before images...');636const cleanupResult = await cdp.send<{ result: { value: number } }>('Runtime.evaluate', {637expression: `(() => {638const editor = document.querySelector('div[contenteditable="true"]');639if (!editor) return 0;640let removed = 0;641const imageViews = editor.querySelectorAll('.image-view__body');642for (const view of imageViews) {643const imgBlock = view.closest('div[data-type], .ProseMirror > *') || view.parentElement;644if (!imgBlock) continue;645let prev = imgBlock.previousElementSibling;646while (prev) {647const tag = prev.tagName?.toLowerCase();648const text = prev.textContent?.replace(/\\u200b/g, '').trim();649const hasOnlyBreaks = prev.querySelectorAll('br, .Tiptap-invisible-character').length > 0;650if ((tag === 'p' || tag === 'div') && (!text || text === '') && hasOnlyBreaks) {651const toRemove = prev;652prev = toRemove.previousElementSibling;653toRemove.remove();654removed++;655} else {656break;657}658}659}660return removed;661})()`,662returnByValue: true,663}, { sessionId });664if (cleanupResult.result.value > 0) {665console.log(`[weibo-article] Removed ${cleanupResult.result.value} extra line break(s) before images.`);666}667await sleep(500);668669// Final verification670console.log('[weibo-article] Running post-composition verification...');671const finalEditorContent = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', {672expression: `document.querySelector('div[contenteditable="true"]')?.innerText || ''`,673returnByValue: true,674}, { sessionId });675676const remainingPlaceholders: string[] = [];677for (const img of parsed.contentImages) {678const regex = new RegExp(img.placeholder + '(?!\\d)');679if (regex.test(finalEditorContent.result.value)) {680remainingPlaceholders.push(img.placeholder);681}682}683684const finalImgCount = await cdp.send<{ result: { value: number } }>('Runtime.evaluate', {685expression: `document.querySelectorAll('div[contenteditable="true"] img').length`,686returnByValue: true,687}, { sessionId });688689const expectedCount = parsed.contentImages.length;690const actualCount = finalImgCount.result.value;691692if (remainingPlaceholders.length > 0 || actualCount < expectedCount) {693console.warn('[weibo-article] POST-COMPOSITION CHECK FAILED:');694if (remainingPlaceholders.length > 0) {695console.warn(`[weibo-article] Remaining placeholders: ${remainingPlaceholders.join(', ')}`);696}697if (actualCount < expectedCount) {698console.warn(`[weibo-article] Image count: expected ${expectedCount}, found ${actualCount}`);699}700console.warn('[weibo-article] Please check the article before publishing.');701} else {702console.log(`[weibo-article] Verification passed: ${actualCount} image(s), no remaining placeholders.`);703}704}705706// Step 6: Set cover image707const coverImagePath = parsed.coverImage;708if (coverImagePath && fs.existsSync(coverImagePath)) {709console.log(`[weibo-article] Setting cover image: ${path.basename(coverImagePath)}`);710711// Scroll to top first712await cdp.send('Runtime.evaluate', {713expression: `window.scrollTo(0, 0)`,714}, { sessionId });715await sleep(500);716717// 1. Click cover area to open dialog (cover-empty or cover-preview)718// First scroll element into view719await cdp.send('Runtime.evaluate', {720expression: `(() => {721const el = document.querySelector('.cover-empty') || document.querySelector('.cover-preview');722if (el) { el.scrollIntoView({ block: 'center' }); return true; }723return false;724})()`,725returnByValue: true,726}, { sessionId });727await sleep(1000);728729// Then get coordinates after scroll settles730const coverBtnPos = await cdp.send<{ result: { value: { x: number; y: number } | null } }>('Runtime.evaluate', {731expression: `(() => {732const el = document.querySelector('.cover-empty') || document.querySelector('.cover-preview');733if (el) {734const rect = el.getBoundingClientRect();735return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };736}737return null;738})()`,739returnByValue: true,740}, { sessionId });741742if (coverBtnPos.result.value) {743const { x, y } = coverBtnPos.result.value;744console.log(`[weibo-article] "设置文章封面" at (${x}, ${y}), clicking...`);745await cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 }, { sessionId });746await sleep(100);747await cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 }, { sessionId });748} else {749console.warn('[weibo-article] "设置文章封面" (.cover-empty) not found');750}751await sleep(2000);752753// Wait for dialog to appear754const dialogReady = await waitForElement(`!!document.querySelector('.n-dialog')`, 10_000);755console.log(`[weibo-article] Dialog appeared: ${dialogReady}`);756757// 2. Click "图片库" tab758const tabClicked = await cdp.send<{ result: { value: boolean } }>('Runtime.evaluate', {759expression: `(() => {760const tabs = document.querySelectorAll('.n-tabs-tab');761for (const t of tabs) {762if (t.querySelector('.n-tabs-tab__label span')?.textContent?.trim() === '图片库') { t.click(); return true; }763}764return false;765})()`,766returnByValue: true,767}, { sessionId });768console.log(`[weibo-article] "图片库" tab clicked: ${tabClicked.result.value}`);769await sleep(1000);770771// 3. Count existing items before upload772const itemCountBefore = await cdp.send<{ result: { value: number } }>('Runtime.evaluate', {773expression: `document.querySelectorAll('.image-list .image-item').length`,774returnByValue: true,775}, { sessionId });776console.log(`[weibo-article] Items before upload: ${itemCountBefore.result.value}`);777778// 4. Upload via hidden file input779console.log('[weibo-article] Uploading cover image via file input...');780const absPath = path.resolve(coverImagePath);781782// Get DOM document root first, then find file input via DOM.querySelector783const docRoot = await cdp.send<{ root: { nodeId: number } }>('DOM.getDocument', { depth: -1 }, { sessionId });784const fileInputNodes = await cdp.send<{ nodeIds: number[] }>('DOM.querySelectorAll', {785nodeId: docRoot.root.nodeId,786selector: 'input[type="file"]',787}, { sessionId });788789const fileInputNodeId = fileInputNodes.nodeIds?.[0];790if (!fileInputNodeId) {791console.warn('[weibo-article] File input not found, skipping cover image');792} else {793await cdp.send('DOM.setFileInputFiles', {794nodeId: fileInputNodeId,795files: [absPath],796}, { sessionId });797console.log('[weibo-article] File set on input, waiting for upload...');798799// 5. Wait for a new item to appear (item count increases)800let uploadSuccess = false;801const uploadStart = Date.now();802while (Date.now() - uploadStart < 30_000) {803const state = await cdp.send<{ result: { value: { count: number; firstSrc: string } } }>('Runtime.evaluate', {804expression: `(() => {805const items = document.querySelectorAll('.image-list .image-item');806const first = items[0];807const img = first?.querySelector('img');808return { count: items.length, firstSrc: img?.src || '' };809})()`,810returnByValue: true,811}, { sessionId });812const { count, firstSrc } = state.result.value;813if (count > itemCountBefore.result.value && firstSrc.startsWith('https://')) {814console.log(`[weibo-article] New image uploaded (${count} items, src: https://...)`);815uploadSuccess = true;816break;817}818if (firstSrc.startsWith('blob:')) {819console.log('[weibo-article] Cover image uploading (blob detected)...');820}821await sleep(1000);822}823824if (!uploadSuccess) {825// Fallback: check if first item has https (maybe count didn't change but image was replaced)826const fallback = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', {827expression: `document.querySelector('.image-list .image-item img')?.src || ''`,828returnByValue: true,829}, { sessionId });830if (fallback.result.value.startsWith('https://')) {831console.log('[weibo-article] Cover image ready (fallback check)');832uploadSuccess = true;833} else {834console.warn('[weibo-article] Cover image upload timed out after 30s');835}836}837838if (uploadSuccess) {839// 6. Click first item to select it840const clickResult = await cdp.send<{ result: { value: boolean } }>('Runtime.evaluate', {841expression: `(() => {842const item = document.querySelector('.image-list .image-item');843if (item) { item.click(); return true; }844return false;845})()`,846returnByValue: true,847}, { sessionId });848console.log(`[weibo-article] First item clicked: ${clickResult.result.value}`);849await sleep(500);850851// Verify selection852const selected = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', {853expression: `(() => {854const items = document.querySelectorAll('.image-list .image-item');855const selectedIdx = Array.from(items).findIndex(i => i.classList.contains('is-selected'));856return 'selected_index=' + selectedIdx + ' total=' + items.length;857})()`,858returnByValue: true,859}, { sessionId });860console.log(`[weibo-article] Selection: ${selected.result.value}`);861862// 7. Click "下一步" in dialog (image selection → crop)863const nextResult = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', {864expression: `(() => {865const dialog = document.querySelector('.n-dialog');866if (!dialog) return 'no_dialog';867const buttons = dialog.querySelectorAll('.n-button');868for (const b of buttons) {869const text = b.querySelector('.n-button__content')?.textContent?.trim() || '';870if (text === '下一步') { b.click(); return 'clicked'; }871}872return 'not_found';873})()`,874returnByValue: true,875}, { sessionId });876console.log(`[weibo-article] "下一步" (select→crop): ${nextResult.result.value}`);877await sleep(3000);878879// 8. Click "确定" in crop dialog880// First check button state and dispatch full pointer event sequence881const confirmInfo = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', {882expression: `(() => {883const dialog = document.querySelector('.n-dialog');884if (!dialog) return 'no_dialog';885const buttons = dialog.querySelectorAll('.n-button');886for (const b of buttons) {887const text = b.querySelector('.n-button__content')?.textContent?.trim() || '';888if (text === '确定' || text === '确认') {889const disabled = b.disabled || b.classList.contains('n-button--disabled');890const rect = b.getBoundingClientRect();891return 'found:' + text + ':disabled=' + disabled + ':y=' + rect.y + ':h=' + rect.height;892}893}894const allTexts = Array.from(buttons).map(b => b.querySelector('.n-button__content')?.textContent?.trim() || '').join(',');895return 'not_found:' + allTexts;896})()`,897returnByValue: true,898}, { sessionId });899console.log(`[weibo-article] Confirm button info: ${confirmInfo.result.value}`);900901// Use full pointer event simulation via JS (not CDP Input.dispatchMouseEvent)902const confirmClickResult = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', {903expression: `(() => {904const dialog = document.querySelector('.n-dialog');905if (!dialog) return 'no_dialog';906const buttons = dialog.querySelectorAll('.n-button');907for (const b of buttons) {908const text = b.querySelector('.n-button__content')?.textContent?.trim() || '';909if (text === '确定' || text === '确认') {910b.scrollIntoView({ block: 'center' });911const rect = b.getBoundingClientRect();912const cx = rect.x + rect.width / 2;913const cy = rect.y + rect.height / 2;914const opts = { bubbles: true, cancelable: true, clientX: cx, clientY: cy, button: 0 };915b.dispatchEvent(new PointerEvent('pointerdown', opts));916b.dispatchEvent(new MouseEvent('mousedown', opts));917b.dispatchEvent(new PointerEvent('pointerup', opts));918b.dispatchEvent(new MouseEvent('mouseup', opts));919b.dispatchEvent(new MouseEvent('click', opts));920return 'dispatched:' + text;921}922}923return 'not_found';924})()`,925returnByValue: true,926}, { sessionId });927console.log(`[weibo-article] Confirm click: ${confirmClickResult.result.value}`);928await sleep(2000);929930// Check dialog state931const afterConfirm = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', {932expression: `(() => {933const dialog = document.querySelector('.n-dialog');934if (!dialog) return 'closed';935const buttons = dialog.querySelectorAll('.n-button');936return 'open:' + Array.from(buttons).map(b => b.querySelector('.n-button__content')?.textContent?.trim() || '').join(',');937})()`,938returnByValue: true,939}, { sessionId });940console.log(`[weibo-article] After confirm: ${afterConfirm.result.value}`);941942// If still open, try focusing the button and pressing Enter943if (afterConfirm.result.value !== 'closed') {944console.log('[weibo-article] Dialog still open, trying focus + Enter...');945await cdp!.send('Runtime.evaluate', {946expression: `(() => {947const dialog = document.querySelector('.n-dialog');948if (!dialog) return;949const buttons = dialog.querySelectorAll('.n-button');950for (const b of buttons) {951const text = b.querySelector('.n-button__content')?.textContent?.trim() || '';952if (text === '确定' || text === '确认') { b.focus(); return; }953}954})()`,955}, { sessionId });956await sleep(200);957await cdp!.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 }, { sessionId });958await cdp!.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 }, { sessionId });959await sleep(2000);960961const afterEnter = await cdp!.send<{ result: { value: string } }>('Runtime.evaluate', {962expression: `!document.querySelector('.n-dialog') ? 'closed' : 'still_open'`,963returnByValue: true,964}, { sessionId });965console.log(`[weibo-article] After Enter: ${afterEnter.result.value}`);966}967968await sleep(1000);969970// Verify cover was set (cover-preview with img should exist)971const coverSet = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', {972expression: `(() => {973const preview = document.querySelector('.cover-preview .cover-img');974if (preview) return 'cover_set';975const empty = document.querySelector('.cover-empty');976if (empty) return 'cover_empty_still_exists';977return 'cover_unknown';978})()`,979returnByValue: true,980}, { sessionId });981console.log(`[weibo-article] Cover result: ${coverSet.result.value}`);982}983}984} else if (coverImagePath) {985console.warn(`[weibo-article] Cover image not found: ${coverImagePath}`);986} else {987console.log('[weibo-article] No cover image specified');988}989990console.log('[weibo-article] Article composed. Please review and publish manually.');991console.log('[weibo-article] Browser remains open for manual review.');992993} finally {994if (cdp) {995cdp.close();996}997}998}9991000function printUsage(): never {1001console.log(`Publish Markdown article to Weibo Headline Articles10021003Usage:1004npx -y bun weibo-article.ts <markdown_file> [options]10051006Options:1007--title <title> Override title (max 32 chars)1008--summary <text> Override summary (max 44 chars)1009--cover <image> Override cover image1010--profile <dir> Chrome profile directory1011--help Show this help10121013Markdown frontmatter:1014---1015title: My Article Title1016summary: Brief description1017cover_image: /path/to/cover.jpg1018---10191020Example:1021npx -y bun weibo-article.ts article.md1022npx -y bun weibo-article.ts article.md --cover ./hero.png1023npx -y bun weibo-article.ts article.md --title "Custom Title"1024`);1025process.exit(0);1026}10271028async function main(): Promise<void> {1029const args = process.argv.slice(2);1030if (args.length === 0 || args.includes('--help') || args.includes('-h')) {1031printUsage();1032}10331034let markdownPath: string | undefined;1035let title: string | undefined;1036let summary: string | undefined;1037let coverImage: string | undefined;1038let profileDir: string | undefined;10391040for (let i = 0; i < args.length; i++) {1041const arg = args[i]!;1042if (arg === '--title' && args[i + 1]) {1043title = args[++i];1044} else if (arg === '--summary' && args[i + 1]) {1045summary = args[++i];1046} else if (arg === '--cover' && args[i + 1]) {1047const raw = args[++i]!;1048coverImage = path.isAbsolute(raw) ? raw : path.resolve(process.cwd(), raw);1049} else if (arg === '--profile' && args[i + 1]) {1050profileDir = args[++i];1051} else if (!arg.startsWith('-')) {1052markdownPath = arg;1053}1054}10551056if (!markdownPath) {1057console.error('Error: Markdown file path required');1058process.exit(1);1059}10601061if (!fs.existsSync(markdownPath)) {1062console.error(`Error: File not found: ${markdownPath}`);1063process.exit(1);1064}10651066await publishArticle({ markdownPath, title, summary, coverImage, profileDir });1067}10681069await main().catch((err) => {1070console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);1071process.exit(1);1072});1073