From 5fc697166daa78c2625ef885b08918adf952b21b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jim=20Liu=20=E5=AE=9D=E7=8E=89?= Date: Fri, 6 Mar 2026 14:56:28 -0600 Subject: [PATCH] feat(baoyu-post-to-weibo): add Weibo posting skill with text, images, and headline articles --- .claude-plugin/marketplace.json | 1 + skills/baoyu-post-to-weibo/SKILL.md | 149 +++ .../scripts/copy-to-clipboard.ts | 380 ++++++ .../baoyu-post-to-weibo/scripts/md-to-html.ts | 352 +++++ .../scripts/paste-from-clipboard.ts | 194 +++ .../scripts/weibo-article.ts | 1149 +++++++++++++++++ .../baoyu-post-to-weibo/scripts/weibo-post.ts | 262 ++++ .../scripts/weibo-utils.ts | 213 +++ 8 files changed, 2700 insertions(+) create mode 100644 skills/baoyu-post-to-weibo/SKILL.md create mode 100644 skills/baoyu-post-to-weibo/scripts/copy-to-clipboard.ts create mode 100644 skills/baoyu-post-to-weibo/scripts/md-to-html.ts create mode 100644 skills/baoyu-post-to-weibo/scripts/paste-from-clipboard.ts create mode 100644 skills/baoyu-post-to-weibo/scripts/weibo-article.ts create mode 100644 skills/baoyu-post-to-weibo/scripts/weibo-post.ts create mode 100644 skills/baoyu-post-to-weibo/scripts/weibo-utils.ts diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 5812ab4..d4baf75 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -18,6 +18,7 @@ "./skills/baoyu-xhs-images", "./skills/baoyu-post-to-x", "./skills/baoyu-post-to-wechat", + "./skills/baoyu-post-to-weibo", "./skills/baoyu-article-illustrator", "./skills/baoyu-cover-image", "./skills/baoyu-slide-deck", diff --git a/skills/baoyu-post-to-weibo/SKILL.md b/skills/baoyu-post-to-weibo/SKILL.md new file mode 100644 index 0000000..271cf04 --- /dev/null +++ b/skills/baoyu-post-to-weibo/SKILL.md @@ -0,0 +1,149 @@ +--- +name: baoyu-post-to-weibo +description: Posts content to Weibo (微博). Supports regular posts with text and images, and headline articles (头条文章) with Markdown input via Chrome CDP. Use when user asks to "post to Weibo", "发微博", "发布微博", "publish to Weibo", "share on Weibo", "写微博", or "微博头条文章". +--- + +# Post to Weibo + +Posts text, images, and long-form articles to Weibo via real Chrome browser (bypasses anti-bot detection). + +## Script Directory + +**Important**: All scripts are located in the `scripts/` subdirectory of this skill. + +**Agent Execution Instructions**: +1. Determine this SKILL.md file's directory path as `SKILL_DIR` +2. Script path = `${SKILL_DIR}/scripts/.ts` +3. Replace all `${SKILL_DIR}` in this document with the actual path +4. Resolve `${BUN_X}` runtime: if `bun` installed → `bun`; if `npx` available → `npx -y bun`; else suggest installing bun + +**Script Reference**: +| Script | Purpose | +|--------|---------| +| `scripts/weibo-post.ts` | Regular posts (text + images) | +| `scripts/weibo-article.ts` | Headline article publishing (Markdown) | +| `scripts/copy-to-clipboard.ts` | Copy content to clipboard | +| `scripts/paste-from-clipboard.ts` | Send real paste keystroke | + +## Preferences (EXTEND.md) + +Check EXTEND.md existence (priority order): + +```bash +# macOS, Linux, WSL, Git Bash +test -f .baoyu-skills/baoyu-post-to-weibo/EXTEND.md && echo "project" +test -f "$HOME/.baoyu-skills/baoyu-post-to-weibo/EXTEND.md" && echo "user" +``` + +```powershell +# PowerShell (Windows) +if (Test-Path .baoyu-skills/baoyu-post-to-weibo/EXTEND.md) { "project" } +if (Test-Path "$HOME/.baoyu-skills/baoyu-post-to-weibo/EXTEND.md") { "user" } +``` + +┌──────────────────────────────────────────────────┬───────────────────┐ +│ Path │ Location │ +├──────────────────────────────────────────────────┼───────────────────┤ +│ .baoyu-skills/baoyu-post-to-weibo/EXTEND.md │ Project directory │ +├──────────────────────────────────────────────────┼───────────────────┤ +│ $HOME/.baoyu-skills/baoyu-post-to-weibo/EXTEND.md│ User home │ +└──────────────────────────────────────────────────┴───────────────────┘ + +┌───────────┬───────────────────────────────────────────────────────────────────────────┐ +│ Result │ Action │ +├───────────┼───────────────────────────────────────────────────────────────────────────┤ +│ Found │ Read, parse, apply settings │ +├───────────┼───────────────────────────────────────────────────────────────────────────┤ +│ Not found │ Use defaults │ +└───────────┴───────────────────────────────────────────────────────────────────────────┘ + +**EXTEND.md Supports**: Default Chrome profile + +## Prerequisites + +- Google Chrome or Chromium +- `bun` runtime +- First run: log in to Weibo manually (session saved) + +--- + +## Regular Posts + +Text + up to 9 images. Posted on Weibo homepage. + +```bash +${BUN_X} ${SKILL_DIR}/scripts/weibo-post.ts "Hello Weibo!" --image ./photo.png +``` + +**Parameters**: +| Parameter | Description | +|-----------|-------------| +| `` | Post content (positional) | +| `--image ` | Image file (repeatable, max 9) | +| `--profile ` | Custom Chrome profile | + +**Note**: Script opens browser with content filled in. User reviews and publishes manually. + +--- + +## Headline Articles (头条文章) + +Long-form Markdown articles published at `https://card.weibo.com/article/v3/editor`. + +```bash +${BUN_X} ${SKILL_DIR}/scripts/weibo-article.ts article.md +${BUN_X} ${SKILL_DIR}/scripts/weibo-article.ts article.md --cover ./cover.jpg +``` + +**Parameters**: +| Parameter | Description | +|-----------|-------------| +| `` | Markdown file (positional) | +| `--cover ` | Cover image | +| `--title ` | Override title (max 32 chars, truncated if longer) | +| `--summary ` | Override summary (max 44 chars, auto-regenerated if longer) | +| `--profile ` | Custom Chrome profile | + +**Frontmatter**: `title`, `summary`, `cover_image` supported in YAML front matter. + +**Character Limits**: +- Title: 32 characters max (truncated with warning if longer) +- Summary/导语: 44 characters max (auto-regenerated from content if longer) + +**Article Workflow**: +1. Opens `https://card.weibo.com/article/v3/editor` +2. Clicks "写文章" button, waits for editor to become editable +3. Fills title (validated for 32-char limit) +4. Fills summary/导语 (validated for 44-char limit) +5. Inserts HTML content into ProseMirror editor via paste +6. Replaces image placeholders one by one (copy image → select placeholder → paste) + +**Post-Composition Check**: The script automatically verifies after all images are inserted: +- Remaining `WBIMGPH_` placeholders in editor content +- Expected vs actual image count + +If the check fails (warnings in output), alert the user with the specific issues before they publish. + +--- + +## Troubleshooting + +### Chrome debug port not ready + +If a script fails with `Chrome debug port not ready` or `Unable to connect`, kill existing Chrome CDP instances first, then retry: + +```bash +pkill -f "Chrome.*remote-debugging-port" 2>/dev/null; pkill -f "Chromium.*remote-debugging-port" 2>/dev/null; sleep 2 +``` + +**Important**: This should be done automatically -- when encountering this error, kill Chrome CDP instances and retry the command without asking the user. + +## Notes + +- First run: manual login required (session persists) +- All scripts only fill content into the browser, user must review and publish manually +- Cross-platform: macOS, Linux, Windows + +## Extension Support + +Custom configurations via EXTEND.md. See **Preferences** section for paths and supported options. diff --git a/skills/baoyu-post-to-weibo/scripts/copy-to-clipboard.ts b/skills/baoyu-post-to-weibo/scripts/copy-to-clipboard.ts new file mode 100644 index 0000000..e990f03 --- /dev/null +++ b/skills/baoyu-post-to-weibo/scripts/copy-to-clipboard.ts @@ -0,0 +1,380 @@ +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import process from 'node:process'; + +const SUPPORTED_IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']); + +function printUsage(exitCode = 0): never { + console.log(`Copy image or HTML to system clipboard + +Supports: + - Image files (jpg, png, gif, webp) - copies as image data + - HTML content - copies as rich text for paste + +Usage: + # Copy image to clipboard + npx -y bun copy-to-clipboard.ts image /path/to/image.jpg + + # Copy HTML to clipboard + npx -y bun copy-to-clipboard.ts html "

Hello

" + + # Copy HTML from file + npx -y bun copy-to-clipboard.ts html --file /path/to/content.html +`); + process.exit(exitCode); +} + +function resolvePath(filePath: string): string { + return path.isAbsolute(filePath) ? filePath : path.resolve(process.cwd(), filePath); +} + +function inferImageMimeType(imagePath: string): string { + const ext = path.extname(imagePath).toLowerCase(); + switch (ext) { + case '.jpg': + case '.jpeg': + return 'image/jpeg'; + case '.png': + return 'image/png'; + case '.gif': + return 'image/gif'; + case '.webp': + return 'image/webp'; + default: + return 'application/octet-stream'; + } +} + +type RunResult = { stdout: string; stderr: string; exitCode: number }; + +async function runCommand( + command: string, + args: string[], + options?: { input?: string | Buffer; allowNonZeroExit?: boolean }, +): Promise { + return await new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'] }); + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + + child.stdout.on('data', (chunk) => stdoutChunks.push(Buffer.from(chunk))); + child.stderr.on('data', (chunk) => stderrChunks.push(Buffer.from(chunk))); + child.on('error', reject); + child.on('close', (code) => { + resolve({ + stdout: Buffer.concat(stdoutChunks).toString('utf8'), + stderr: Buffer.concat(stderrChunks).toString('utf8'), + exitCode: code ?? 0, + }); + }); + + if (options?.input != null) child.stdin.write(options.input); + child.stdin.end(); + }).then((result) => { + if (!options?.allowNonZeroExit && result.exitCode !== 0) { + const details = result.stderr.trim() || result.stdout.trim(); + throw new Error(`Command failed (${command}): exit ${result.exitCode}${details ? `\n${details}` : ''}`); + } + return result; + }); +} + +async function commandExists(command: string): Promise { + if (process.platform === 'win32') { + const result = await runCommand('where', [command], { allowNonZeroExit: true }); + return result.exitCode === 0 && result.stdout.trim().length > 0; + } + const result = await runCommand('which', [command], { allowNonZeroExit: true }); + return result.exitCode === 0 && result.stdout.trim().length > 0; +} + +async function runCommandWithFileStdin(command: string, args: string[], filePath: string): Promise { + await new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'] }); + const stderrChunks: Buffer[] = []; + const stdoutChunks: Buffer[] = []; + + child.stdout.on('data', (chunk) => stdoutChunks.push(Buffer.from(chunk))); + child.stderr.on('data', (chunk) => stderrChunks.push(Buffer.from(chunk))); + child.on('error', reject); + child.on('close', (code) => { + const exitCode = code ?? 0; + if (exitCode !== 0) { + const details = Buffer.concat(stderrChunks).toString('utf8').trim() || Buffer.concat(stdoutChunks).toString('utf8').trim(); + reject( + new Error(`Command failed (${command}): exit ${exitCode}${details ? `\n${details}` : ''}`), + ); + return; + } + resolve(); + }); + + fs.createReadStream(filePath).on('error', reject).pipe(child.stdin); + }); +} + +async function withTempDir(prefix: string, fn: (tempDir: string) => Promise): Promise { + const tempDir = await mkdtemp(path.join(os.tmpdir(), prefix)); + try { + return await fn(tempDir); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +} + +function getMacSwiftClipboardSource(): string { + return `import AppKit +import Foundation + +func die(_ message: String, _ code: Int32 = 1) -> Never { + FileHandle.standardError.write(message.data(using: .utf8)!) + exit(code) +} + +if CommandLine.arguments.count < 3 { + die("Usage: clipboard.swift \\n") +} + +let mode = CommandLine.arguments[1] +let inputPath = CommandLine.arguments[2] +let pasteboard = NSPasteboard.general +pasteboard.clearContents() + +switch mode { +case "image": + guard let image = NSImage(contentsOfFile: inputPath) else { + die("Failed to load image: \\(inputPath)\\n") + } + if !pasteboard.writeObjects([image]) { + die("Failed to write image to clipboard\\n") + } + +case "html": + let url = URL(fileURLWithPath: inputPath) + let data: Data + do { + data = try Data(contentsOf: url) + } catch { + die("Failed to read HTML file: \\(inputPath)\\n") + } + + _ = pasteboard.setData(data, forType: .html) + + let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [ + .documentType: NSAttributedString.DocumentType.html, + .characterEncoding: String.Encoding.utf8.rawValue + ] + + if let attr = try? NSAttributedString(data: data, options: options, documentAttributes: nil) { + pasteboard.setString(attr.string, forType: .string) + if let rtf = try? attr.data( + from: NSRange(location: 0, length: attr.length), + documentAttributes: [.documentType: NSAttributedString.DocumentType.rtf] + ) { + _ = pasteboard.setData(rtf, forType: .rtf) + } + } else if let html = String(data: data, encoding: .utf8) { + pasteboard.setString(html, forType: .string) + } + +default: + die("Unknown mode: \\(mode)\\n") +} +`; +} + +async function copyImageMac(imagePath: string): Promise { + await withTempDir('copy-to-clipboard-', async (tempDir) => { + const swiftPath = path.join(tempDir, 'clipboard.swift'); + await writeFile(swiftPath, getMacSwiftClipboardSource(), 'utf8'); + await runCommand('swift', [swiftPath, 'image', imagePath]); + }); +} + +async function copyHtmlMac(htmlFilePath: string): Promise { + await withTempDir('copy-to-clipboard-', async (tempDir) => { + const swiftPath = path.join(tempDir, 'clipboard.swift'); + await writeFile(swiftPath, getMacSwiftClipboardSource(), 'utf8'); + await runCommand('swift', [swiftPath, 'html', htmlFilePath]); + }); +} + +async function copyImageLinux(imagePath: string): Promise { + const mime = inferImageMimeType(imagePath); + if (await commandExists('wl-copy')) { + await runCommandWithFileStdin('wl-copy', ['--type', mime], imagePath); + return; + } + if (await commandExists('xclip')) { + await runCommand('xclip', ['-selection', 'clipboard', '-t', mime, '-i', imagePath]); + return; + } + throw new Error('No clipboard tool found. Install `wl-clipboard` (wl-copy) or `xclip`.'); +} + +async function copyHtmlLinux(htmlFilePath: string): Promise { + if (await commandExists('wl-copy')) { + await runCommandWithFileStdin('wl-copy', ['--type', 'text/html'], htmlFilePath); + return; + } + if (await commandExists('xclip')) { + await runCommand('xclip', ['-selection', 'clipboard', '-t', 'text/html', '-i', htmlFilePath]); + return; + } + throw new Error('No clipboard tool found. Install `wl-clipboard` (wl-copy) or `xclip`.'); +} + +async function copyImageWindows(imagePath: string): Promise { + const escaped = imagePath.replace(/'/g, "''"); + const ps = [ + 'Add-Type -AssemblyName System.Windows.Forms', + 'Add-Type -AssemblyName System.Drawing', + `$img = [System.Drawing.Image]::FromFile('${escaped}')`, + '[System.Windows.Forms.Clipboard]::SetImage($img)', + '$img.Dispose()', + ].join('; '); + await runCommand('powershell.exe', ['-NoProfile', '-Sta', '-Command', ps]); +} + +async function copyHtmlWindows(htmlFilePath: string): Promise { + const escaped = htmlFilePath.replace(/'/g, "''"); + const ps = [ + 'Add-Type -AssemblyName System.Windows.Forms', + `$html = Get-Content -Raw -LiteralPath '${escaped}'`, + '[System.Windows.Forms.Clipboard]::SetText($html, [System.Windows.Forms.TextDataFormat]::Html)', + ].join('; '); + await runCommand('powershell.exe', ['-NoProfile', '-Sta', '-Command', ps]); +} + +async function copyImageToClipboard(imagePathInput: string): Promise { + const imagePath = resolvePath(imagePathInput); + const ext = path.extname(imagePath).toLowerCase(); + if (!SUPPORTED_IMAGE_EXTS.has(ext)) { + throw new Error( + `Unsupported image type: ${ext || '(none)'} (supported: ${Array.from(SUPPORTED_IMAGE_EXTS).join(', ')})`, + ); + } + if (!fs.existsSync(imagePath)) throw new Error(`File not found: ${imagePath}`); + + switch (process.platform) { + case 'darwin': + await copyImageMac(imagePath); + return; + case 'linux': + await copyImageLinux(imagePath); + return; + case 'win32': + await copyImageWindows(imagePath); + return; + default: + throw new Error(`Unsupported platform: ${process.platform}`); + } +} + +async function copyHtmlFileToClipboard(htmlFilePathInput: string): Promise { + const htmlFilePath = resolvePath(htmlFilePathInput); + if (!fs.existsSync(htmlFilePath)) throw new Error(`File not found: ${htmlFilePath}`); + + switch (process.platform) { + case 'darwin': + await copyHtmlMac(htmlFilePath); + return; + case 'linux': + await copyHtmlLinux(htmlFilePath); + return; + case 'win32': + await copyHtmlWindows(htmlFilePath); + return; + default: + throw new Error(`Unsupported platform: ${process.platform}`); + } +} + +async function readStdinText(): Promise { + if (process.stdin.isTTY) return null; + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + const text = Buffer.concat(chunks).toString('utf8'); + return text.length > 0 ? text : null; +} + +async function copyHtmlToClipboard(args: string[]): Promise { + let htmlFile: string | undefined; + const positional: string[] = []; + + for (let i = 0; i < args.length; i += 1) { + const arg = args[i] ?? ''; + if (arg === '--help' || arg === '-h') printUsage(0); + if (arg === '--file') { + htmlFile = args[i + 1]; + i += 1; + continue; + } + if (arg.startsWith('--file=')) { + htmlFile = arg.slice('--file='.length); + continue; + } + if (arg === '--') { + positional.push(...args.slice(i + 1)); + break; + } + if (arg.startsWith('-')) { + throw new Error(`Unknown option: ${arg}`); + } + positional.push(arg); + } + + if (htmlFile && positional.length > 0) { + throw new Error('Do not pass HTML text when using --file.'); + } + + if (htmlFile) { + await copyHtmlFileToClipboard(htmlFile); + return; + } + + const htmlFromArgs = positional.join(' ').trim(); + const htmlFromStdin = (await readStdinText())?.trim() ?? ''; + const html = htmlFromArgs || htmlFromStdin; + if (!html) throw new Error('Missing HTML input. Provide a string or use --file.'); + + await withTempDir('copy-to-clipboard-', async (tempDir) => { + const htmlPath = path.join(tempDir, 'input.html'); + await writeFile(htmlPath, html, 'utf8'); + await copyHtmlFileToClipboard(htmlPath); + }); +} + +async function main(): Promise { + const argv = process.argv.slice(2); + if (argv.length === 0) printUsage(1); + + const command = argv[0]; + if (command === '--help' || command === '-h') printUsage(0); + + if (command === 'image') { + const imagePath = argv[1]; + if (!imagePath) throw new Error('Missing image path.'); + await copyImageToClipboard(imagePath); + return; + } + + if (command === 'html') { + await copyHtmlToClipboard(argv.slice(1)); + return; + } + + throw new Error(`Unknown command: ${command}`); +} + +await main().catch((err) => { + const message = err instanceof Error ? err.message : String(err); + console.error(`Error: ${message}`); + process.exit(1); +}); + diff --git a/skills/baoyu-post-to-weibo/scripts/md-to-html.ts b/skills/baoyu-post-to-weibo/scripts/md-to-html.ts new file mode 100644 index 0000000..5a1ac2b --- /dev/null +++ b/skills/baoyu-post-to-weibo/scripts/md-to-html.ts @@ -0,0 +1,352 @@ +import fs from 'node:fs'; +import { mkdir, writeFile } from 'node:fs/promises'; +import https from 'node:https'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import process from 'node:process'; +import { createHash } from 'node:crypto'; + +interface ImageInfo { + placeholder: string; + localPath: string; + originalPath: string; + alt: string; + blockIndex: number; +} + +interface ParsedMarkdown { + title: string; + summary: string; + shortSummary: string; + coverImage: string | null; + contentImages: ImageInfo[]; + html: string; + totalBlocks: number; +} + +type FrontmatterFields = Record; + +function parseFrontmatter(content: string): { frontmatter: FrontmatterFields; body: string } { + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/); + if (!match) return { frontmatter: {}, body: content }; + + const fields: FrontmatterFields = {}; + for (const line of match[1]!.split('\n')) { + const kv = line.match(/^(\w[\w_]*)\s*:\s*(.+)$/); + if (kv) { + let val = kv[2]!.trim(); + if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { + val = val.slice(1, -1); + } + fields[kv[1]!] = val; + } + } + + return { frontmatter: fields, body: match[2]! }; +} + +function pickFirstString(fm: FrontmatterFields, keys: string[]): string | undefined { + for (const key of keys) { + const v = fm[key]; + if (typeof v === 'string' && v.trim()) return v.trim(); + } + return undefined; +} + +function extractTitleFromBody(body: string): string { + const match = body.match(/^#\s+(.+)$/m); + return match ? match[1]!.trim() : ''; +} + +function extractSummaryFromBody(body: string, maxLen: number): string { + const lines = body.split('\n').filter(l => l.trim() && !l.startsWith('#') && !l.startsWith('!') && !l.startsWith('```')); + const firstParagraph = lines[0]?.replace(/[*_`\[\]()]/g, '').trim() || ''; + if (firstParagraph.length <= maxLen) return firstParagraph; + return firstParagraph.slice(0, maxLen - 1) + '\u2026'; +} + +function downloadFile(url: string, destPath: string): Promise { + return new Promise((resolve, reject) => { + const protocol = url.startsWith('https') ? https : http; + const file = fs.createWriteStream(destPath); + + const request = protocol.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (response) => { + if (response.statusCode === 301 || response.statusCode === 302) { + const redirectUrl = response.headers.location; + if (redirectUrl) { + file.close(); + fs.unlinkSync(destPath); + downloadFile(redirectUrl, destPath).then(resolve).catch(reject); + return; + } + } + if (response.statusCode !== 200) { + file.close(); + fs.unlinkSync(destPath); + reject(new Error(`Failed to download: ${response.statusCode}`)); + return; + } + response.pipe(file); + file.on('finish', () => { file.close(); resolve(); }); + }); + + request.on('error', (err) => { file.close(); fs.unlink(destPath, () => {}); reject(err); }); + request.setTimeout(30000, () => { request.destroy(); reject(new Error('Download timeout')); }); + }); +} + +function getImageExtension(urlOrPath: string): string { + const match = urlOrPath.match(/\.(jpg|jpeg|png|gif|webp)(\?|$)/i); + return match ? match[1]!.toLowerCase() : 'png'; +} + +function resolveLocalWithFallback(resolved: string): string { + if (fs.existsSync(resolved)) return resolved; + const ext = path.extname(resolved); + const base = resolved.slice(0, -ext.length); + const alternatives = [ + base + '.webp', + base + '.jpg', + base + '.jpeg', + base + '.png', + base + '.gif', + base + '_original.png', + base + '_original.jpg', + ].filter((p) => p !== resolved); + for (const alt of alternatives) { + if (fs.existsSync(alt)) { + console.error(`[md-to-html] Image fallback: ${path.basename(resolved)} → ${path.basename(alt)}`); + return alt; + } + } + return resolved; +} + +async function resolveImagePath(imagePath: string, baseDir: string, tempDir: string): Promise { + if (imagePath.startsWith('http://') || imagePath.startsWith('https://')) { + const hash = createHash('md5').update(imagePath).digest('hex').slice(0, 8); + const ext = getImageExtension(imagePath); + const localPath = path.join(tempDir, `remote_${hash}.${ext}`); + if (!fs.existsSync(localPath)) { + console.error(`[md-to-html] Downloading: ${imagePath}`); + await downloadFile(imagePath, localPath); + } + return localPath; + } + const resolved = path.isAbsolute(imagePath) ? imagePath : path.resolve(baseDir, imagePath); + return resolveLocalWithFallback(resolved); +} + +function escapeHtml(text: string): string { + return text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +function markdownToHtml(body: string, imageCallback: (src: string, alt: string) => string): { html: string; totalBlocks: number } { + const lines = body.split('\n'); + const htmlParts: string[] = []; + let blockCount = 0; + let inCodeBlock = false; + let codeLines: string[] = []; + let codeLang = ''; + + for (const line of lines) { + if (line.startsWith('```')) { + if (!inCodeBlock) { + inCodeBlock = true; + codeLang = line.slice(3).trim(); + codeLines = []; + } else { + inCodeBlock = false; + htmlParts.push(`
${escapeHtml(codeLines.join('\n'))}
`); + blockCount++; + } + continue; + } + + if (inCodeBlock) { + codeLines.push(line); + continue; + } + + // H1 (skip, used as title) + if (line.match(/^#\s+/)) continue; + + // H2-H6 + const headingMatch = line.match(/^(#{2,6})\s+(.+)$/); + if (headingMatch) { + const level = headingMatch[1]!.length; + htmlParts.push(`${processInline(headingMatch[2]!)}`); + blockCount++; + continue; + } + + // Image + const imgMatch = line.match(/^!\[([^\]]*)\]\(([^)]+)\)\s*$/); + if (imgMatch) { + htmlParts.push(imageCallback(imgMatch[2]!, imgMatch[1]!)); + blockCount++; + continue; + } + + // Blockquote + if (line.startsWith('> ')) { + htmlParts.push(`

${processInline(line.slice(2))}

`); + blockCount++; + continue; + } + + // Unordered list + if (line.match(/^[-*]\s+/)) { + htmlParts.push(`
  • ${processInline(line.replace(/^[-*]\s+/, ''))}
  • `); + blockCount++; + continue; + } + + // Ordered list + if (line.match(/^\d+\.\s+/)) { + htmlParts.push(`
  • ${processInline(line.replace(/^\d+\.\s+/, ''))}
  • `); + blockCount++; + continue; + } + + // Horizontal rule + if (line.match(/^[-*]{3,}$/)) { + htmlParts.push('
    '); + continue; + } + + // Empty line + if (!line.trim()) continue; + + // Paragraph + htmlParts.push(`

    ${processInline(line)}

    `); + blockCount++; + } + + return { html: htmlParts.join('\n'), totalBlocks: blockCount }; +} + +function processInline(text: string): string { + return text + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/\*(.+?)\*/g, '$1') + .replace(/`(.+?)`/g, '$1') + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); +} + +export async function parseMarkdown( + markdownPath: string, + options?: { coverImage?: string; title?: string; tempDir?: string }, +): Promise { + const content = fs.readFileSync(markdownPath, 'utf-8'); + const baseDir = path.dirname(markdownPath); + const tempDir = options?.tempDir ?? path.join(os.tmpdir(), 'weibo-article-images'); + + await mkdir(tempDir, { recursive: true }); + + const { frontmatter, body } = parseFrontmatter(content); + + let title = options?.title?.trim() || pickFirstString(frontmatter, ['title']) || ''; + if (!title) title = extractTitleFromBody(body); + if (!title) title = path.basename(markdownPath, path.extname(markdownPath)); + + let summary = pickFirstString(frontmatter, ['summary', 'description', 'excerpt']) || ''; + if (!summary) summary = extractSummaryFromBody(body, 44); + const shortSummary = extractSummaryFromBody(body, 44); + + let coverImagePath = options?.coverImage?.trim() || pickFirstString(frontmatter, [ + 'featureImage', 'cover_image', 'coverImage', 'cover', 'image', + ]) || null; + + const images: Array<{ src: string; alt: string }> = []; + let imageCounter = 0; + + const { html, totalBlocks } = markdownToHtml(body, (src, alt) => { + const placeholder = `WBIMGPH_${++imageCounter}`; + images.push({ src, alt }); + return placeholder; + }); + + const contentImages: ImageInfo[] = []; + for (let i = 0; i < images.length; i++) { + const img = images[i]!; + const localPath = await resolveImagePath(img.src, baseDir, tempDir); + contentImages.push({ + placeholder: `WBIMGPH_${i + 1}`, + localPath, + originalPath: img.src, + alt: img.alt, + blockIndex: i, + }); + } + + let resolvedCoverImage: string | null = null; + if (coverImagePath) { + resolvedCoverImage = await resolveImagePath(coverImagePath, baseDir, tempDir); + } + + return { + title, + summary, + shortSummary, + coverImage: resolvedCoverImage, + contentImages, + html: html.replace(/\n{3,}/g, '\n\n').trim(), + totalBlocks, + }; +} + +async function main(): Promise { + const args = process.argv.slice(2); + if (args.length === 0 || args.includes('--help') || args.includes('-h')) { + console.log(`Convert Markdown to HTML for Weibo article publishing + +Usage: + npx -y bun md-to-html.ts [options] + +Options: + --title Override title + --cover <image> Override cover image + --output <json|html> Output format (default: json) + --help Show this help +`); + process.exit(0); + } + + let markdownPath: string | undefined; + let title: string | undefined; + let coverImage: string | undefined; + let outputFormat: 'json' | 'html' = 'json'; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]!; + if (arg === '--title' && args[i + 1]) { + title = args[++i]; + } else if (arg === '--cover' && args[i + 1]) { + coverImage = args[++i]; + } else if (arg === '--output' && args[i + 1]) { + outputFormat = args[++i] as 'json' | 'html'; + } else if (!arg.startsWith('-')) { + markdownPath = arg; + } + } + + if (!markdownPath || !fs.existsSync(markdownPath)) { + console.error('Error: Valid markdown file path required'); + process.exit(1); + } + + const result = await parseMarkdown(markdownPath, { title, coverImage }); + + if (outputFormat === 'html') { + console.log(result.html); + } else { + console.log(JSON.stringify(result, null, 2)); + } +} + +await main().catch((err) => { + console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); +}); diff --git a/skills/baoyu-post-to-weibo/scripts/paste-from-clipboard.ts b/skills/baoyu-post-to-weibo/scripts/paste-from-clipboard.ts new file mode 100644 index 0000000..8466c08 --- /dev/null +++ b/skills/baoyu-post-to-weibo/scripts/paste-from-clipboard.ts @@ -0,0 +1,194 @@ +import { spawnSync } from 'node:child_process'; +import process from 'node:process'; + +function printUsage(exitCode = 0): never { + console.log(`Send real paste keystroke (Cmd+V / Ctrl+V) to the frontmost application + +This bypasses CDP's synthetic events which websites can detect and ignore. + +Usage: + npx -y bun paste-from-clipboard.ts [options] + +Options: + --retries <n> Number of retry attempts (default: 3) + --delay <ms> Delay between retries in ms (default: 500) + --app <name> Target application to activate first (macOS only) + --help Show this help + +Examples: + # Simple paste + npx -y bun paste-from-clipboard.ts + + # Paste to Chrome with retries + npx -y bun paste-from-clipboard.ts --app "Google Chrome" --retries 5 + + # Quick paste with shorter delay + npx -y bun paste-from-clipboard.ts --delay 200 +`); + process.exit(exitCode); +} + +function sleepSync(ms: number): void { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +function activateApp(appName: string): boolean { + if (process.platform !== 'darwin') return false; + + // Activate and wait for app to be frontmost + const script = ` + tell application "${appName}" + activate + delay 0.5 + end tell + + -- Verify app is frontmost + tell application "System Events" + set frontApp to name of first application process whose frontmost is true + if frontApp is not "${appName}" then + tell application "${appName}" to activate + delay 0.3 + end if + end tell + `; + const result = spawnSync('osascript', ['-e', script], { stdio: 'pipe' }); + return result.status === 0; +} + +function pasteMac(retries: number, delayMs: number, targetApp?: string): boolean { + for (let i = 0; i < retries; i++) { + // Build script that activates app (if specified) and sends keystroke in one atomic operation + const script = targetApp + ? ` + tell application "${targetApp}" + activate + end tell + delay 0.3 + tell application "System Events" + keystroke "v" using command down + end tell + ` + : ` + tell application "System Events" + keystroke "v" using command down + end tell + `; + + const result = spawnSync('osascript', ['-e', script], { stdio: 'pipe' }); + if (result.status === 0) { + return true; + } + + const stderr = result.stderr?.toString().trim(); + if (stderr) { + console.error(`[paste] osascript error: ${stderr}`); + } + + if (i < retries - 1) { + console.error(`[paste] Attempt ${i + 1}/${retries} failed, retrying in ${delayMs}ms...`); + sleepSync(delayMs); + } + } + return false; +} + +function pasteLinux(retries: number, delayMs: number): boolean { + // Try xdotool first (X11), then ydotool (Wayland) + const tools = [ + { cmd: 'xdotool', args: ['key', 'ctrl+v'] }, + { cmd: 'ydotool', args: ['key', '29:1', '47:1', '47:0', '29:0'] }, // Ctrl down, V down, V up, Ctrl up + ]; + + for (const tool of tools) { + const which = spawnSync('which', [tool.cmd], { stdio: 'pipe' }); + if (which.status !== 0) continue; + + for (let i = 0; i < retries; i++) { + const result = spawnSync(tool.cmd, tool.args, { stdio: 'pipe' }); + if (result.status === 0) { + return true; + } + if (i < retries - 1) { + console.error(`[paste] Attempt ${i + 1}/${retries} failed, retrying in ${delayMs}ms...`); + sleepSync(delayMs); + } + } + return false; + } + + console.error('[paste] No supported tool found. Install xdotool (X11) or ydotool (Wayland).'); + return false; +} + +function pasteWindows(retries: number, delayMs: number): boolean { + const ps = ` + Add-Type -AssemblyName System.Windows.Forms + [System.Windows.Forms.SendKeys]::SendWait("^v") + `; + + for (let i = 0; i < retries; i++) { + const result = spawnSync('powershell.exe', ['-NoProfile', '-Command', ps], { stdio: 'pipe' }); + if (result.status === 0) { + return true; + } + if (i < retries - 1) { + console.error(`[paste] Attempt ${i + 1}/${retries} failed, retrying in ${delayMs}ms...`); + sleepSync(delayMs); + } + } + return false; +} + +function paste(retries: number, delayMs: number, targetApp?: string): boolean { + switch (process.platform) { + case 'darwin': + return pasteMac(retries, delayMs, targetApp); + case 'linux': + return pasteLinux(retries, delayMs); + case 'win32': + return pasteWindows(retries, delayMs); + default: + console.error(`[paste] Unsupported platform: ${process.platform}`); + return false; + } +} + +async function main(): Promise<void> { + const args = process.argv.slice(2); + + let retries = 3; + let delayMs = 500; + let targetApp: string | undefined; + + for (let i = 0; i < args.length; i++) { + const arg = args[i] ?? ''; + if (arg === '--help' || arg === '-h') { + printUsage(0); + } + if (arg === '--retries' && args[i + 1]) { + retries = parseInt(args[++i]!, 10) || 3; + } else if (arg === '--delay' && args[i + 1]) { + delayMs = parseInt(args[++i]!, 10) || 500; + } else if (arg === '--app' && args[i + 1]) { + targetApp = args[++i]; + } else if (arg.startsWith('-')) { + console.error(`Unknown option: ${arg}`); + printUsage(1); + } + } + + if (targetApp) { + console.log(`[paste] Target app: ${targetApp}`); + } + console.log(`[paste] Sending paste keystroke (retries=${retries}, delay=${delayMs}ms)...`); + const success = paste(retries, delayMs, targetApp); + + if (success) { + console.log('[paste] Paste keystroke sent successfully'); + } else { + console.error('[paste] Failed to send paste keystroke'); + process.exit(1); + } +} + +await main(); diff --git a/skills/baoyu-post-to-weibo/scripts/weibo-article.ts b/skills/baoyu-post-to-weibo/scripts/weibo-article.ts new file mode 100644 index 0000000..b0cc9e3 --- /dev/null +++ b/skills/baoyu-post-to-weibo/scripts/weibo-article.ts @@ -0,0 +1,1149 @@ +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import { mkdir, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import process from 'node:process'; +import { + CdpConnection, + copyHtmlToClipboard, + copyImageToClipboard, + findChromeExecutable, + findExistingChromeDebugPort, + getDefaultProfileDir, + getFreePort, + 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<void> { + 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 = findExistingChromeDebugPort(profileDir); + let port: number; + let launched = false; + + if (existingPort) { + console.log(`[weibo-article] Found existing Chrome on port ${existingPort}, reusing...`); + port = existingPort; + } else { + const chromePath = options.chromePath ?? findChromeExecutable(); + if (!chromePath) throw new Error('Chrome not found. Set WEIBO_BROWSER_CHROME_PATH env var.'); + + port = await getFreePort(); + console.log(`[weibo-article] Launching Chrome...`); + const chromeArgs = [ + `--remote-debugging-port=${port}`, + `--user-data-dir=${profileDir}`, + '--no-first-run', + '--no-default-browser-check', + '--disable-blink-features=AutomationControlled', + '--start-maximized', + WEIBO_ARTICLE_URL, + ]; + + if (process.platform === 'darwin') { + const appPath = chromePath.replace(/\/Contents\/MacOS\/Google Chrome$/, ''); + spawn('open', ['-na', appPath, '--args', ...chromeArgs], { stdio: 'ignore' }); + } else { + spawn(chromePath, chromeArgs, { stdio: 'ignore' }); + } + launched = true; + } + + 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<boolean> => { + 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<boolean> => { + 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: 'smooth', 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; + }; + + const selected = await selectPlaceholder(3); + if (!selected) { + console.warn(`[weibo-article] Skipping image - could not select placeholder: ${img.placeholder}`); + continue; + } + + console.log(`[weibo-article] Copying image: ${path.basename(img.localPath)}`); + + if (!copyImageToClipboard(img.localPath)) { + console.warn(`[weibo-article] Failed to copy image to clipboard`); + continue; + } + + await sleep(1000); + + // Delete placeholder by replacing selection with empty text + console.log(`[weibo-article] Deleting placeholder...`); + await cdp.send('Runtime.evaluate', { + expression: `(() => { + const sel = window.getSelection(); + if (!sel || sel.isCollapsed) return false; + const range = sel.getRangeAt(0); + range.deleteContents(); + sel.collapseToStart(); + return true; + })()`, + returnByValue: true, + }, { sessionId }); + + await sleep(300); + + // Fallback: send Backspace key if placeholder still exists + const stillExists = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', { + expression: `(() => { + const editor = document.querySelector('div[contenteditable="true"]'); + if (!editor) return false; + const placeholder = ${JSON.stringify(img.placeholder)}; + const regex = new RegExp(placeholder + '(?!\\\\d)'); + return regex.test(editor.innerText); + })()`, + returnByValue: true, + }, { sessionId }); + + if (stillExists.result.value) { + console.log('[weibo-article] Placeholder survived deleteContents, trying Input.insertText replacement...'); + // Re-select and replace with empty via Input.insertText + 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 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(200); + await cdp!.send('Input.insertText', { text: '' }, { sessionId }); + await sleep(300); + } + + await sleep(200); + + // Verify placeholder was deleted + const afterDelete = await cdp.send<{ result: { value: boolean } }>('Runtime.evaluate', { + expression: `(() => { + const editor = document.querySelector('div[contenteditable="true"]'); + if (!editor) return true; + const text = editor.innerText; + const placeholder = ${JSON.stringify(img.placeholder)}; + const regex = new RegExp(placeholder + '(?!\\\\d)'); + return !regex.test(text); + })()`, + returnByValue: true, + }, { sessionId }); + + if (afterDelete.result.value) { + console.log(`[weibo-article] Placeholder deleted`); + } else { + console.warn(`[weibo-article] Placeholder may still exist after delete`); + } + + // Focus editor for paste + await cdp.send('Runtime.evaluate', { + expression: `document.querySelector('div[contenteditable="true"]')?.focus()`, + }, { sessionId }); + await sleep(300); + + // 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 + 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 <p> before the image (Tiptap invisible chars + <br>) + 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 <p> before images (Tiptap invisible chars + <br>) + 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 <markdown_file> [options] + +Options: + --title <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); +}); diff --git a/skills/baoyu-post-to-weibo/scripts/weibo-post.ts b/skills/baoyu-post-to-weibo/scripts/weibo-post.ts new file mode 100644 index 0000000..666ef71 --- /dev/null +++ b/skills/baoyu-post-to-weibo/scripts/weibo-post.ts @@ -0,0 +1,262 @@ +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import { mkdir } from 'node:fs/promises'; +import process from 'node:process'; +import { + CdpConnection, + copyImageToClipboard, + findChromeExecutable, + findExistingChromeDebugPort, + getDefaultProfileDir, + getFreePort, + pasteFromClipboard, + sleep, + waitForChromeDebugPort, +} from './weibo-utils.js'; + +const WEIBO_HOME_URL = 'https://weibo.com/'; + +interface WeiboPostOptions { + text?: string; + images?: string[]; + timeoutMs?: number; + profileDir?: string; + chromePath?: string; +} + +export async function postToWeibo(options: WeiboPostOptions): Promise<void> { + const { text, images = [], timeoutMs = 120_000, profileDir = getDefaultProfileDir() } = options; + + await mkdir(profileDir, { recursive: true }); + + const existingPort = findExistingChromeDebugPort(profileDir); + let port: number; + + if (existingPort) { + console.log(`[weibo-post] Found existing Chrome on port ${existingPort}, reusing...`); + port = existingPort; + } else { + const chromePath = options.chromePath ?? findChromeExecutable(); + if (!chromePath) throw new Error('Chrome not found. Set WEIBO_BROWSER_CHROME_PATH env var.'); + + port = await getFreePort(); + console.log(`[weibo-post] Launching Chrome (profile: ${profileDir})`); + + const chromeArgs = [ + `--remote-debugging-port=${port}`, + `--user-data-dir=${profileDir}`, + '--no-first-run', + '--no-default-browser-check', + '--disable-blink-features=AutomationControlled', + '--start-maximized', + WEIBO_HOME_URL, + ]; + + if (process.platform === 'darwin') { + const appPath = chromePath.replace(/\/Contents\/MacOS\/Google Chrome$/, ''); + spawn('open', ['-na', appPath, '--args', ...chromeArgs], { stdio: 'ignore' }); + } else { + spawn(chromePath, chromeArgs, { stdio: 'ignore' }); + } + } + + let cdp: CdpConnection | null = null; + + try { + const wsUrl = await waitForChromeDebugPort(port, 30_000); + cdp = await CdpConnection.connect(wsUrl, 30_000, { defaultTimeoutMs: 15_000 }); + + const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets'); + let pageTarget = targets.targetInfos.find((t) => t.type === 'page' && t.url.includes('weibo.com')); + + if (!pageTarget) { + const { targetId } = await cdp.send<{ targetId: string }>('Target.createTarget', { url: WEIBO_HOME_URL }); + pageTarget = { targetId, url: WEIBO_HOME_URL, type: 'page' }; + } + + 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('Input.setIgnoreInputEvents', { ignore: false }, { sessionId }); + + console.log('[weibo-post] Waiting for Weibo editor...'); + await sleep(3000); + + const waitForEditor = async (): Promise<boolean> => { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', { + expression: `!!document.querySelector('#homeWrap textarea')`, + returnByValue: true, + }, { sessionId }); + if (result.result.value) return true; + await sleep(1000); + } + return false; + }; + + const editorFound = await waitForEditor(); + if (!editorFound) { + console.log('[weibo-post] Editor not found. Please log in to Weibo in the browser window.'); + console.log('[weibo-post] Waiting for login...'); + const loggedIn = await waitForEditor(); + if (!loggedIn) throw new Error('Timed out waiting for Weibo editor. Please log in first.'); + } + + if (text) { + console.log('[weibo-post] Typing text...'); + + // Focus and use Input.insertText via CDP + await cdp.send('Runtime.evaluate', { + expression: `(() => { + const editor = document.querySelector('#homeWrap textarea'); + if (editor) { editor.focus(); editor.value = ''; } + })()`, + }, { sessionId }); + await sleep(200); + + await cdp.send('Input.insertText', { text }, { sessionId }); + await sleep(500); + + // Verify text was entered + const textCheck = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', { + expression: `document.querySelector('#homeWrap textarea')?.value || ''`, + returnByValue: true, + }, { sessionId }); + + if (textCheck.result.value.length > 0) { + console.log(`[weibo-post] Text verified (${textCheck.result.value.length} chars)`); + } else { + console.warn('[weibo-post] Text input appears empty, trying execCommand fallback...'); + await cdp.send('Runtime.evaluate', { + expression: `(() => { + const editor = document.querySelector('#homeWrap textarea'); + if (editor) { editor.focus(); document.execCommand('insertText', false, ${JSON.stringify(text)}); } + })()`, + }, { sessionId }); + await sleep(300); + + const textRecheck = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', { + expression: `document.querySelector('#homeWrap textarea')?.value || ''`, + returnByValue: true, + }, { sessionId }); + console.log(`[weibo-post] Text after fallback: ${textRecheck.result.value.length} chars`); + } + } + + for (const imagePath of images) { + if (!fs.existsSync(imagePath)) { + console.warn(`[weibo-post] Image not found: ${imagePath}`); + continue; + } + + console.log(`[weibo-post] Pasting image: ${imagePath}`); + + if (!copyImageToClipboard(imagePath)) { + console.warn(`[weibo-post] Failed to copy image to clipboard: ${imagePath}`); + continue; + } + + await sleep(500); + + await cdp.send('Runtime.evaluate', { + expression: `document.querySelector('#homeWrap textarea')?.focus()`, + }, { sessionId }); + await sleep(200); + + // Count images before paste + const imgCountBefore = await cdp.send<{ result: { value: number } }>('Runtime.evaluate', { + expression: `document.querySelectorAll('#homeWrap img[src^="blob:"], #homeWrap img[src^="data:"]').length`, + returnByValue: true, + }, { sessionId }); + + console.log('[weibo-post] Pasting from clipboard...'); + pasteFromClipboard('Google Chrome', 5, 500); + + // Verify image appeared + console.log('[weibo-post] Verifying image upload...'); + const expectedImgCount = imgCountBefore.result.value + 1; + let imgUploadOk = false; + const imgWaitStart = Date.now(); + while (Date.now() - imgWaitStart < 15_000) { + const r = await cdp!.send<{ result: { value: number } }>('Runtime.evaluate', { + expression: `document.querySelectorAll('#homeWrap img[src^="blob:"], #homeWrap img[src^="data:"]').length`, + returnByValue: true, + }, { sessionId }); + if (r.result.value >= expectedImgCount) { + imgUploadOk = true; + break; + } + await sleep(1000); + } + + if (imgUploadOk) { + console.log('[weibo-post] Image upload verified'); + } else { + console.warn('[weibo-post] Image upload not detected after 15s. Check Accessibility permissions.'); + } + } + + console.log('[weibo-post] Post composed. Please review and click the publish button in the browser.'); + console.log('[weibo-post] Browser remains open for manual review.'); + + } finally { + if (cdp) { + cdp.close(); + } + } +} + +function printUsage(): never { + console.log(`Post to Weibo using real Chrome browser + +Usage: + npx -y bun weibo-post.ts [options] [text] + +Options: + --image <path> Add image (can be repeated, max 9) + --profile <dir> Chrome profile directory + --help Show this help + +Examples: + npx -y bun weibo-post.ts "Hello from CLI!" + npx -y bun weibo-post.ts "Check this out" --image ./screenshot.png + npx -y bun weibo-post.ts "Post it!" --image a.png --image b.png +`); + process.exit(0); +} + +async function main(): Promise<void> { + const args = process.argv.slice(2); + if (args.includes('--help') || args.includes('-h')) printUsage(); + + const images: string[] = []; + let profileDir: string | undefined; + const textParts: string[] = []; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]!; + if (arg === '--image' && args[i + 1]) { + images.push(args[++i]!); + } else if (arg === '--profile' && args[i + 1]) { + profileDir = args[++i]; + } else if (!arg.startsWith('-')) { + textParts.push(arg); + } + } + + const text = textParts.join(' ').trim() || undefined; + + if (!text && images.length === 0) { + console.error('Error: Provide text or at least one image.'); + process.exit(1); + } + + await postToWeibo({ text, images, profileDir }); +} + +await main().catch((err) => { + console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); +}); diff --git a/skills/baoyu-post-to-weibo/scripts/weibo-utils.ts b/skills/baoyu-post-to-weibo/scripts/weibo-utils.ts new file mode 100644 index 0000000..60c8b33 --- /dev/null +++ b/skills/baoyu-post-to-weibo/scripts/weibo-utils.ts @@ -0,0 +1,213 @@ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +export const CHROME_CANDIDATES = { + darwin: [ + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', + '/Applications/Chromium.app/Contents/MacOS/Chromium', + ], + win32: [ + 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', + 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', + ], + default: [ + '/usr/bin/google-chrome', + '/usr/bin/chromium', + '/usr/bin/chromium-browser', + ], +}; + +export function findChromeExecutable(): string | undefined { + const override = process.env.WEIBO_BROWSER_CHROME_PATH?.trim(); + if (override && fs.existsSync(override)) return override; + + const candidates = process.platform === 'darwin' + ? CHROME_CANDIDATES.darwin + : process.platform === 'win32' + ? CHROME_CANDIDATES.win32 + : CHROME_CANDIDATES.default; + + for (const candidate of candidates) { + if (fs.existsSync(candidate)) return candidate; + } + return undefined; +} + +export function findExistingChromeDebugPort(profileDir: string): number | null { + try { + const result = spawnSync('ps', ['aux'], { encoding: 'utf-8', timeout: 5000 }); + if (result.status !== 0 || !result.stdout) return null; + const lines = result.stdout.split('\n'); + for (const line of lines) { + if (!line.includes('--remote-debugging-port=') || !line.includes(profileDir)) continue; + const portMatch = line.match(/--remote-debugging-port=(\d+)/); + if (portMatch) return Number(portMatch[1]); + } + } catch {} + return null; +} + +export function getDefaultProfileDir(): string { + const override = process.env.WEIBO_BROWSER_PROFILE_DIR?.trim(); + if (override) return path.resolve(override); + const base = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'); + return path.join(base, 'x-browser-profile'); +} + +export function sleep(ms: number): Promise<void> { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function getFreePort(): Promise<number> { + const fixed = parseInt(process.env.WEIBO_BROWSER_DEBUG_PORT || '', 10); + if (fixed > 0) return fixed; + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.on('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(() => reject(new Error('Unable to allocate a free TCP port.'))); + return; + } + const port = address.port; + server.close((err) => { + if (err) reject(err); + else resolve(port); + }); + }); + }); +} + +async function fetchJson<T = unknown>(url: string): Promise<T> { + const res = await fetch(url, { redirect: 'follow' }); + if (!res.ok) throw new Error(`Request failed: ${res.status} ${res.statusText}`); + return (await res.json()) as T; +} + +export async function waitForChromeDebugPort(port: number, timeoutMs: number): Promise<string> { + const start = Date.now(); + let lastError: unknown = null; + + while (Date.now() - start < timeoutMs) { + try { + const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`); + if (version.webSocketDebuggerUrl) return version.webSocketDebuggerUrl; + lastError = new Error('Missing webSocketDebuggerUrl'); + } catch (error) { + lastError = error; + } + await sleep(200); + } + + throw new Error(`Chrome debug port not ready: ${lastError instanceof Error ? lastError.message : String(lastError)}`); +} + +type PendingRequest = { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + timer: ReturnType<typeof setTimeout> | null; +}; + +export class CdpConnection { + private ws: WebSocket; + private nextId = 0; + private pending = new Map<number, PendingRequest>(); + private defaultTimeoutMs: number; + + private constructor(ws: WebSocket, options?: { defaultTimeoutMs?: number }) { + this.ws = ws; + this.defaultTimeoutMs = options?.defaultTimeoutMs ?? 15_000; + + this.ws.addEventListener('message', (event) => { + try { + const data = typeof event.data === 'string' ? event.data : new TextDecoder().decode(event.data as ArrayBuffer); + const msg = JSON.parse(data) as { id?: number; method?: string; params?: unknown; result?: unknown; error?: { message?: string } }; + + if (msg.id) { + const pending = this.pending.get(msg.id); + if (pending) { + this.pending.delete(msg.id); + if (pending.timer) clearTimeout(pending.timer); + if (msg.error?.message) pending.reject(new Error(msg.error.message)); + else pending.resolve(msg.result); + } + } + } catch {} + }); + + this.ws.addEventListener('close', () => { + for (const [id, pending] of this.pending.entries()) { + this.pending.delete(id); + if (pending.timer) clearTimeout(pending.timer); + pending.reject(new Error('CDP connection closed.')); + } + }); + } + + static async connect(url: string, timeoutMs: number, options?: { defaultTimeoutMs?: number }): Promise<CdpConnection> { + const ws = new WebSocket(url); + await new Promise<void>((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('CDP connection timeout.')), timeoutMs); + ws.addEventListener('open', () => { clearTimeout(timer); resolve(); }); + ws.addEventListener('error', () => { clearTimeout(timer); reject(new Error('CDP connection failed.')); }); + }); + return new CdpConnection(ws, options); + } + + async send<T = unknown>(method: string, params?: Record<string, unknown>, options?: { sessionId?: string; timeoutMs?: number }): Promise<T> { + const id = ++this.nextId; + const message: Record<string, unknown> = { id, method }; + if (params) message.params = params; + if (options?.sessionId) message.sessionId = options.sessionId; + + const timeoutMs = options?.timeoutMs ?? this.defaultTimeoutMs; + + const result = await new Promise<unknown>((resolve, reject) => { + const timer = timeoutMs > 0 + ? setTimeout(() => { this.pending.delete(id); reject(new Error(`CDP timeout: ${method}`)); }, timeoutMs) + : null; + this.pending.set(id, { resolve, reject, timer }); + this.ws.send(JSON.stringify(message)); + }); + + return result as T; + } + + close(): void { + try { this.ws.close(); } catch {} + } +} + +export function getScriptDir(): string { + return path.dirname(fileURLToPath(import.meta.url)); +} + +function runBunScript(scriptPath: string, args: string[]): boolean { + const result = spawnSync('npx', ['-y', 'bun', scriptPath, ...args], { stdio: 'inherit' }); + return result.status === 0; +} + +export function copyImageToClipboard(imagePath: string): boolean { + const copyScript = path.join(getScriptDir(), 'copy-to-clipboard.ts'); + return runBunScript(copyScript, ['image', imagePath]); +} + +export function copyHtmlToClipboard(htmlPath: string): boolean { + const copyScript = path.join(getScriptDir(), 'copy-to-clipboard.ts'); + return runBunScript(copyScript, ['html', '--file', htmlPath]); +} + +export function pasteFromClipboard(targetApp?: string, retries = 3, delayMs = 500): boolean { + const pasteScript = path.join(getScriptDir(), 'paste-from-clipboard.ts'); + const args = ['--retries', String(retries), '--delay', String(delayMs)]; + if (targetApp) args.push('--app', targetApp); + return runBunScript(pasteScript, args); +}