diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index ddfae72..7805876 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,7 +6,7 @@ }, "metadata": { "description": "Skills shared by Baoyu for improving daily work efficiency", - "version": "0.2.0" + "version": "0.3.0" }, "plugins": [ { @@ -18,6 +18,7 @@ "./skills/gemini-web", "./skills/xhs-images", "./skills/post-to-x", + "./skills/post-to-wechat", "./skills/article-illustrator", "./skills/cover-image", "./skills/slide-deck" diff --git a/README.md b/README.md index 2d2a1be..f2a3f94 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,28 @@ Generate professional slide deck images from content. Creates comprehensive outl Available styles: `editorial` (default), `corporate`, `technical`, `playful`, `minimal`, `storytelling`, `warm`, `retro-flat` +### post-to-wechat + +Post content to WeChat Official Account (微信公众号). Two modes available: + +**Image-Text (图文)** - Multiple images with short title/content: + +```bash +/post-to-wechat 图文 --markdown article.md --images ./photos/ +/post-to-wechat 图文 --markdown article.md --image img1.png --image img2.png --image img3.png +/post-to-wechat 图文 --title "标题" --content "内容" --image img1.png --submit +``` + +**Article (文章)** - Full markdown/HTML with rich formatting: + +```bash +/post-to-wechat 文章 --markdown article.md +/post-to-wechat 文章 --markdown article.md --theme grace +/post-to-wechat 文章 --html article.html +``` + +Prerequisites: Google Chrome installed. First run requires QR code login (session preserved). + ## Disclaimer ### gemini-web diff --git a/README.zh.md b/README.zh.md index 91bd538..9e0ae28 100644 --- a/README.zh.md +++ b/README.zh.md @@ -105,6 +105,28 @@ 可用风格:`editorial`(默认)、`corporate`、`technical`、`playful`、`minimal`、`storytelling`、`warm`、`retro-flat` +### post-to-wechat + +发布内容到微信公众号,支持两种模式: + +**图文模式** - 多图配短标题和正文: + +```bash +/post-to-wechat 图文 --markdown article.md --images ./photos/ +/post-to-wechat 图文 --markdown article.md --image img1.png --image img2.png --image img3.png +/post-to-wechat 图文 --title "标题" --content "内容" --image img1.png --submit +``` + +**文章模式** - 完整 markdown/HTML 富文本格式: + +```bash +/post-to-wechat 文章 --markdown article.md +/post-to-wechat 文章 --markdown article.md --theme grace +/post-to-wechat 文章 --html article.html +``` + +前置要求:已安装 Google Chrome,首次运行需扫码登录(登录状态会保存) + ## 免责声明 ### gemini-web diff --git a/skills/post-to-wechat/SKILL.md b/skills/post-to-wechat/SKILL.md new file mode 100644 index 0000000..164ded1 --- /dev/null +++ b/skills/post-to-wechat/SKILL.md @@ -0,0 +1,54 @@ +--- +name: post-to-wechat +description: Post content to WeChat Official Account (微信公众号). Supports both article posting (文章) and image-text posting (图文). +--- + +# Post to WeChat Official Account (微信公众号) + +Post content to WeChat Official Account using Chrome CDP automation. + +## Quick Usage + +### Image-Text (图文) - Multiple images with title/content + +```bash +# From markdown file and image directory +npx -y bun ./scripts/wechat-browser.ts --markdown article.md --images ./images/ + +# With explicit parameters +npx -y bun ./scripts/wechat-browser.ts --title "标题" --content "内容" --image img1.png --image img2.png --submit +``` + +### Article (文章) - Full markdown with formatting + +```bash +# Post markdown article +npx -y bun ./scripts/wechat-article.ts --markdown article.md --theme grace +``` + +## References + +- **Image-Text Posting**: See `references/image-text-posting.md` for detailed image-text posting guide +- **Article Posting**: See `references/article-posting.md` for detailed article posting guide + +## Prerequisites + +- Google Chrome installed +- `bun` runtime (via `npx -y bun`) +- First run: log in to WeChat Official Account in the opened browser window + +## Features + +| Feature | Image-Text | Article | +|---------|------------|---------| +| Multiple images | ✓ (up to 9) | ✓ (inline) | +| Markdown support | Title/content extraction | Full formatting | +| Auto title compression | ✓ (to 20 chars) | ✗ | +| Content compression | ✓ (to 1000 chars) | ✗ | +| Themes | ✗ | ✓ (default, grace, simple) | + +## Troubleshooting + +- **Not logged in**: First run opens browser - scan QR code to log in, session is preserved +- **Chrome not found**: Set `WECHAT_BROWSER_CHROME_PATH` environment variable +- **Paste fails**: Check system clipboard permissions diff --git a/skills/post-to-wechat/references/article-posting.md b/skills/post-to-wechat/references/article-posting.md new file mode 100644 index 0000000..7555ec3 --- /dev/null +++ b/skills/post-to-wechat/references/article-posting.md @@ -0,0 +1,89 @@ +# Article Posting (文章发表) + +Post markdown articles to WeChat Official Account with full formatting support. + +## Usage + +```bash +# Post markdown article +npx -y bun ./scripts/wechat-article.ts --markdown article.md + +# With theme +npx -y bun ./scripts/wechat-article.ts --markdown article.md --theme grace + +# With explicit options +npx -y bun ./scripts/wechat-article.ts --markdown article.md --author "作者名" --summary "摘要" +``` + +## Parameters + +| Parameter | Description | +|-----------|-------------| +| `--markdown ` | Markdown file to convert and post | +| `--theme ` | Theme: default, grace, or simple | +| `--title ` | Override title (auto-extracted from markdown) | +| `--author ` | Author name (default: 宝玉) | +| `--summary ` | Article summary | +| `--html ` | Pre-rendered HTML file (alternative to markdown) | +| `--profile ` | Chrome profile directory | + +## Markdown Format + +```markdown +--- +title: Article Title +author: Author Name +--- + +# Title (becomes article title) + +Regular paragraph with **bold** and *italic*. + +## Section Header + +![Image description](./image.png) + +- List item 1 +- List item 2 + +> Blockquote text + +[Link text](https://example.com) +``` + +## Image Handling + +1. **Parse**: Images in markdown are replaced with `[[IMAGE_PLACEHOLDER_N]]` +2. **Render**: HTML is generated with placeholders in text +3. **Paste**: HTML content is pasted into WeChat editor +4. **Replace**: For each placeholder: + - Find and select the placeholder text + - Scroll into view + - Press Backspace to delete the placeholder + - Paste the image from clipboard + +## Scripts + +| Script | Purpose | +|--------|---------| +| `wechat-article.ts` | Main article publishing script | +| `md-to-wechat.ts` | Markdown to HTML with placeholders | +| `md/render.ts` | Markdown rendering with themes | + +## Example Session + +``` +User: /post-to-wechat --markdown ./article.md + +Claude: +1. Parses markdown, finds 5 images +2. Generates HTML with placeholders +3. Opens Chrome, navigates to WeChat editor +4. Pastes HTML content +5. For each image: + - Selects [[IMAGE_PLACEHOLDER_1]] + - Scrolls into view + - Presses Backspace to delete + - Pastes image +6. Reports: "Article composed with 5 images." +``` diff --git a/skills/post-to-wechat/references/image-text-posting.md b/skills/post-to-wechat/references/image-text-posting.md new file mode 100644 index 0000000..6ee3a94 --- /dev/null +++ b/skills/post-to-wechat/references/image-text-posting.md @@ -0,0 +1,92 @@ +# Image-Text Posting (图文发表) + +Post image-text messages with multiple images to WeChat Official Account. + +## Usage + +```bash +# Post with images and markdown file (title/content extracted automatically) +npx -y bun ./scripts/wechat-browser.ts --markdown source.md --images ./images/ + +# Post with explicit title and content +npx -y bun ./scripts/wechat-browser.ts --title "标题" --content "内容" --image img1.png --image img2.png + +# Save as draft +npx -y bun ./scripts/wechat-browser.ts --markdown source.md --images ./images/ --submit +``` + +## Parameters + +| Parameter | Description | +|-----------|-------------| +| `--markdown ` | Markdown file for title/content extraction | +| `--images ` | Directory containing images (sorted by name) | +| `--title ` | Article title (max 20 chars, auto-compressed if too long) | +| `--content ` | Article content (max 1000 chars, auto-compressed if too long) | +| `--image ` | Single image file (can be repeated) | +| `--submit` | Save as draft (default: preview only) | +| `--profile ` | Chrome profile directory | + +## Auto Title/Content from Markdown + +When using `--markdown`, the script: + +1. **Parses frontmatter** for title and author: + ```yaml + --- + title: 文章标题 + author: 作者名 + --- + ``` + +2. **Falls back to H1** if no frontmatter title: + ```markdown + # 这将成为标题 + ``` + +3. **Compresses title** to 20 characters if too long: + - Original: "如何在一天内彻底重塑你的人生" + - Compressed: "一天彻底重塑你的人生" + +4. **Extracts first paragraphs** as content (max 1000 chars) + +## Image Directory Mode + +When using `--images `: + +- All PNG/JPG files in directory are uploaded +- Files are sorted alphabetically by name +- Naming convention: `01-cover.png`, `02-content.png`, etc. + +## Constraints + +| Field | Max Length | Notes | +|-------|------------|-------| +| Title | 20 chars | Auto-compressed if longer | +| Content | 1000 chars | Auto-compressed if longer | +| Images | 9 max | WeChat limit | + +## Example Session + +``` +User: /post-to-wechat --markdown ./article.md --images ./xhs-images/ + +Claude: +1. Parses markdown meta: + - Title: "如何在一天内彻底重塑你的人生" → "一天内重塑你的人生" + - Author: from frontmatter or default +2. Extracts content from first paragraphs +3. Finds 7 images in xhs-images/ +4. Opens Chrome, navigates to WeChat "图文" editor +5. Uploads all images +6. Fills title and content +7. Reports: "Image-text posted with 7 images." +``` + +## Scripts + +| Script | Purpose | +|--------|---------| +| `wechat-browser.ts` | Main image-text posting script | +| `cdp.ts` | Chrome DevTools Protocol utilities | +| `copy-to-clipboard.ts` | Clipboard operations | diff --git a/skills/post-to-wechat/scripts/cdp.ts b/skills/post-to-wechat/scripts/cdp.ts new file mode 100644 index 0000000..77d2020 --- /dev/null +++ b/skills/post-to-wechat/scripts/cdp.ts @@ -0,0 +1,274 @@ +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import { mkdir } from 'node:fs/promises'; +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import process from 'node:process'; + +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function getFreePort(): Promise { + 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); + }); + }); + }); +} + +export function findChromeExecutable(): string | undefined { + const override = process.env.WECHAT_BROWSER_CHROME_PATH?.trim(); + if (override && fs.existsSync(override)) return override; + + const candidates: string[] = []; + switch (process.platform) { + case 'darwin': + candidates.push( + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', + '/Applications/Chromium.app/Contents/MacOS/Chromium', + ); + break; + case 'win32': + candidates.push( + 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', + 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', + ); + break; + default: + candidates.push('/usr/bin/google-chrome', '/usr/bin/chromium'); + break; + } + + for (const p of candidates) { + if (fs.existsSync(p)) return p; + } + return undefined; +} + +export function getDefaultProfileDir(): string { + const base = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'); + return path.join(base, 'wechat-browser-profile'); +} + +async function fetchJson(url: string): Promise { + 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; +} + +async function waitForChromeDebugPort(port: number, timeoutMs: number): Promise { + 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)}`); +} + +export class CdpConnection { + private ws: WebSocket; + private nextId = 0; + private pending = new Map void; reject: (e: Error) => void; timer: ReturnType | null }>(); + private eventHandlers = new Map void>>(); + + private constructor(ws: WebSocket) { + this.ws = ws; + 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.method) { + const handlers = this.eventHandlers.get(msg.method); + if (handlers) handlers.forEach((h) => h(msg.params)); + } + + 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): Promise { + const ws = new WebSocket(url); + await new Promise((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); + } + + on(method: string, handler: (params: unknown) => void): void { + if (!this.eventHandlers.has(method)) this.eventHandlers.set(method, new Set()); + this.eventHandlers.get(method)!.add(handler); + } + + async send(method: string, params?: Record, options?: { sessionId?: string; timeoutMs?: number }): Promise { + const id = ++this.nextId; + const message: Record = { id, method }; + if (params) message.params = params; + if (options?.sessionId) message.sessionId = options.sessionId; + + const timeoutMs = options?.timeoutMs ?? 15_000; + + const result = await new Promise((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 interface ChromeSession { + cdp: CdpConnection; + sessionId: string; + targetId: string; +} + +export async function launchChrome(url: string, profileDir?: string): Promise<{ cdp: CdpConnection; chrome: ReturnType }> { + const chromePath = findChromeExecutable(); + if (!chromePath) throw new Error('Chrome not found. Set WECHAT_BROWSER_CHROME_PATH env var.'); + + const profile = profileDir ?? getDefaultProfileDir(); + await mkdir(profile, { recursive: true }); + + const port = await getFreePort(); + console.log(`[cdp] Launching Chrome (profile: ${profile})`); + + const chrome = spawn(chromePath, [ + `--remote-debugging-port=${port}`, + `--user-data-dir=${profile}`, + '--no-first-run', + '--no-default-browser-check', + '--disable-blink-features=AutomationControlled', + '--start-maximized', + url, + ], { stdio: 'ignore' }); + + const wsUrl = await waitForChromeDebugPort(port, 30_000); + const cdp = await CdpConnection.connect(wsUrl, 30_000); + + return { cdp, chrome }; +} + +export async function getPageSession(cdp: CdpConnection, urlPattern: string): Promise { + 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(urlPattern)); + + if (!pageTarget) throw new Error(`Page not found: ${urlPattern}`); + + 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 }); + + return { cdp, sessionId, targetId: pageTarget.targetId }; +} + +export async function waitForNewTab(cdp: CdpConnection, initialIds: Set, urlPattern: string, timeoutMs = 30_000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets'); + const newTab = targets.targetInfos.find(t => t.type === 'page' && !initialIds.has(t.targetId) && t.url.includes(urlPattern)); + if (newTab) return newTab.targetId; + await sleep(500); + } + throw new Error(`New tab not found: ${urlPattern}`); +} + +export async function clickElement(session: ChromeSession, selector: string): Promise { + const posResult = await session.cdp.send<{ result: { value: string } }>('Runtime.evaluate', { + expression: ` + (function() { + const el = document.querySelector('${selector}'); + if (!el) return 'null'; + el.scrollIntoView({ block: 'center' }); + const rect = el.getBoundingClientRect(); + return JSON.stringify({ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }); + })() + `, + returnByValue: true, + }, { sessionId: session.sessionId }); + + if (posResult.result.value === 'null') throw new Error(`Element not found: ${selector}`); + const pos = JSON.parse(posResult.result.value); + + await session.cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: pos.x, y: pos.y, button: 'left', clickCount: 1 }, { sessionId: session.sessionId }); + await sleep(50); + await session.cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: pos.x, y: pos.y, button: 'left', clickCount: 1 }, { sessionId: session.sessionId }); +} + +export async function typeText(session: ChromeSession, text: string): Promise { + const lines = text.split('\n'); + for (let i = 0; i < lines.length; i++) { + if (lines[i].length > 0) { + await session.cdp.send('Input.insertText', { text: lines[i] }, { sessionId: session.sessionId }); + } + if (i < lines.length - 1) { + await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 }, { sessionId: session.sessionId }); + await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 }, { sessionId: session.sessionId }); + } + await sleep(30); + } +} + +export async function pasteFromClipboard(session: ChromeSession): Promise { + const modifiers = process.platform === 'darwin' ? 4 : 2; + await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'v', code: 'KeyV', modifiers, windowsVirtualKeyCode: 86 }, { sessionId: session.sessionId }); + await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'v', code: 'KeyV', modifiers, windowsVirtualKeyCode: 86 }, { sessionId: session.sessionId }); +} + +export async function evaluate(session: ChromeSession, expression: string): Promise { + const result = await session.cdp.send<{ result: { value: T } }>('Runtime.evaluate', { + expression, + returnByValue: true, + }, { sessionId: session.sessionId }); + return result.result.value; +} diff --git a/skills/post-to-wechat/scripts/copy-to-clipboard.ts b/skills/post-to-wechat/scripts/copy-to-clipboard.ts new file mode 100644 index 0000000..d27e955 --- /dev/null +++ b/skills/post-to-wechat/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 ps = [ + 'param([string]$Path)', + 'Add-Type -AssemblyName System.Windows.Forms', + 'Add-Type -AssemblyName System.Drawing', + '$img = [System.Drawing.Image]::FromFile($Path)', + '[System.Windows.Forms.Clipboard]::SetImage($img)', + '$img.Dispose()', + ].join('; '); + await runCommand('powershell.exe', ['-NoProfile', '-Sta', '-Command', ps, '-Path', imagePath]); +} + +async function copyHtmlWindows(htmlFilePath: string): Promise { + const ps = [ + 'param([string]$Path)', + 'Add-Type -AssemblyName System.Windows.Forms', + '$html = Get-Content -Raw -LiteralPath $Path', + '[System.Windows.Forms.Clipboard]::SetText($html, [System.Windows.Forms.TextDataFormat]::Html)', + ].join('; '); + await runCommand('powershell.exe', ['-NoProfile', '-Sta', '-Command', ps, '-Path', htmlFilePath]); +} + +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/post-to-wechat/scripts/md-to-wechat.ts b/skills/post-to-wechat/scripts/md-to-wechat.ts new file mode 100644 index 0000000..b4ecdd2 --- /dev/null +++ b/skills/post-to-wechat/scripts/md-to-wechat.ts @@ -0,0 +1,281 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { mkdir, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import { createHash } from 'node:crypto'; +import https from 'node:https'; +import http from 'node:http'; +import { spawnSync } from 'node:child_process'; +import process from 'node:process'; + +interface ImageInfo { + placeholder: string; + localPath: string; + originalPath: string; +} + +interface ParsedResult { + title: string; + author: string; + summary: string; + htmlPath: string; + contentImages: ImageInfo[]; +} + +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'; +} + +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-wechat] Downloading: ${imagePath}`); + await downloadFile(imagePath, localPath); + } + return localPath; + } + + if (path.isAbsolute(imagePath)) { + return imagePath; + } + + return path.resolve(baseDir, imagePath); +} + +function parseFrontmatter(content: string): { frontmatter: Record; body: string } { + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/); + if (!match) return { frontmatter: {}, body: content }; + + const frontmatter: Record = {}; + const lines = match[1]!.split('\n'); + for (const line of lines) { + const colonIdx = line.indexOf(':'); + if (colonIdx > 0) { + const key = line.slice(0, colonIdx).trim(); + let value = line.slice(colonIdx + 1).trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + frontmatter[key] = value; + } + } + + return { frontmatter, body: match[2]! }; +} + +async function parseMarkdownForWechat( + markdownPath: string, + options?: { title?: string; theme?: string; tempDir?: string }, +): Promise { + const content = fs.readFileSync(markdownPath, 'utf-8'); + const baseDir = path.dirname(markdownPath); + const tempDir = options?.tempDir ?? path.join(os.tmpdir(), 'wechat-article-images'); + const theme = options?.theme ?? 'default'; + + await mkdir(tempDir, { recursive: true }); + + const { frontmatter, body } = parseFrontmatter(content); + + let title = options?.title ?? frontmatter.title ?? ''; + if (!title) { + const h1Match = body.match(/^#\s+(.+)$/m); + if (h1Match) title = h1Match[1]!; + } + + const author = frontmatter.author ?? ''; + let summary = frontmatter.summary ?? frontmatter.description ?? ''; + + if (!summary) { + const lines = body.split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + if (trimmed.startsWith('#')) continue; + if (trimmed.startsWith('![')) continue; + if (trimmed.startsWith('>')) continue; + if (trimmed.startsWith('-') || trimmed.startsWith('*')) continue; + if (/^\d+\./.test(trimmed)) continue; + + const cleanText = trimmed + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/\*(.+?)\*/g, '$1') + .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') + .replace(/`([^`]+)`/g, '$1'); + + if (cleanText.length > 20) { + summary = cleanText.length > 120 ? cleanText.slice(0, 117) + '...' : cleanText; + break; + } + } + } + + const images: Array<{ src: string; placeholder: string }> = []; + let imageCounter = 0; + + const modifiedBody = body.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => { + const placeholder = `[[IMAGE_PLACEHOLDER_${++imageCounter}]]`; + images.push({ src, placeholder }); + return placeholder; + }); + + const modifiedMarkdown = `---\n${Object.entries(frontmatter).map(([k, v]) => `${k}: ${v}`).join('\n')}\n---\n${modifiedBody}`; + + const tempMdPath = path.join(tempDir, 'temp-article.md'); + await writeFile(tempMdPath, modifiedMarkdown, 'utf-8'); + + const scriptDir = path.dirname(new URL(import.meta.url).pathname); + const renderScript = path.join(scriptDir, 'md', 'render.ts'); + + console.error(`[md-to-wechat] Rendering markdown with theme: ${theme}`); + const result = spawnSync('npx', ['-y', 'bun', renderScript, tempMdPath, '--theme', theme], { + stdio: ['inherit', 'pipe', 'pipe'], + cwd: baseDir, + }); + + if (result.status !== 0) { + const stderr = result.stderr?.toString() || ''; + throw new Error(`Render failed: ${stderr}`); + } + + const htmlPath = tempMdPath.replace(/\.md$/i, '.html'); + if (!fs.existsSync(htmlPath)) { + throw new Error(`HTML file not generated: ${htmlPath}`); + } + + const contentImages: ImageInfo[] = []; + for (const img of images) { + const localPath = await resolveImagePath(img.src, baseDir, tempDir); + contentImages.push({ + placeholder: img.placeholder, + localPath, + originalPath: img.src, + }); + } + + return { + title, + author, + summary, + htmlPath, + contentImages, + }; +} + +function printUsage(): never { + console.log(`Convert Markdown to WeChat-ready HTML with image placeholders + +Usage: + npx -y bun md-to-wechat.ts [options] + +Options: + --title Override title + --theme <name> Theme name (default, grace, simple) + --help Show this help + +Output JSON format: +{ + "title": "Article Title", + "htmlPath": "/tmp/wechat-article-images/temp-article.html", + "contentImages": [ + { + "placeholder": "[[IMAGE_PLACEHOLDER_1]]", + "localPath": "/tmp/wechat-article-images/img.png", + "originalPath": "imgs/image.png" + } + ] +} + +Example: + npx -y bun md-to-wechat.ts article.md + npx -y bun md-to-wechat.ts article.md --theme grace +`); + 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 theme: 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 === '--theme' && args[i + 1]) { + theme = 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); + } + + const result = await parseMarkdownForWechat(markdownPath, { title, theme }); + 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/post-to-wechat/scripts/md/LICENSE b/skills/post-to-wechat/scripts/md/LICENSE new file mode 100644 index 0000000..245b7fe --- /dev/null +++ b/skills/post-to-wechat/scripts/md/LICENSE @@ -0,0 +1,18 @@ +This directory contains code adapted from the doocs/md project. + +Original project: https://github.com/doocs/md +License: WTFPL (Do What The Fuck You Want To Public License) + + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + Version 2, December 2004 + + Copyright (C) 2025 Doocs <admin@doocs.org> + + Everyone is permitted to copy and distribute verbatim or modified + copies of this license document, and changing it is allowed as long + as the name is changed. + + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. You just DO WHAT THE FUCK YOU WANT TO. diff --git a/skills/post-to-wechat/scripts/md/extensions/alert.ts b/skills/post-to-wechat/scripts/md/extensions/alert.ts new file mode 100644 index 0000000..39785c4 --- /dev/null +++ b/skills/post-to-wechat/scripts/md/extensions/alert.ts @@ -0,0 +1,284 @@ +import type { MarkedExtension, Tokens } from 'marked' + +export interface AlertOptions { + className?: string + variants?: AlertVariantItem[] + withoutStyle?: boolean +} + +export interface AlertVariantItem { + type: string + icon: string + title?: string + titleClassName?: string +} + +function ucfirst(str: string) { + return str.slice(0, 1).toUpperCase() + str.slice(1).toLowerCase() +} + +/** + * https://github.com/bent10/marked-extensions/tree/main/packages/alert + * To support theme, we need to modify the source code. + * A [marked](https://marked.js.org/) extension to support [GFM alerts](https://github.com/orgs/community/discussions/16925). + */ +export function markedAlert(options: AlertOptions = {}): MarkedExtension { + const { className = `markdown-alert`, variants = [], withoutStyle = false } = options + const resolvedVariants = resolveVariants(variants) + + // 提取公共的元数据构建逻辑 + function buildMeta(variantType: string, matchedVariant: AlertVariantItem, fromContainer = false) { + return { + className, + variant: variantType, + icon: matchedVariant.icon, + title: matchedVariant.title ?? ucfirst(variantType), + titleClassName: `${className}-title`, + fromContainer, + } + } + + // 提取公共的渲染逻辑 + function renderAlert(token: any) { + const { meta, tokens = [] } = token + // @ts-expect-error marked renderer context has parser property + const text = this.parser.parse(tokens) + // 新主题系统:使用 CSS 选择器而非内联样式 + let tmpl = `<blockquote class="${meta.className} ${meta.className}-${meta.variant}">\n` + tmpl += `<p class="${meta.titleClassName} alert-title-${meta.variant}">` + if (!withoutStyle) { + // 给 SVG 添加 class,通过 CSS 控制颜色 + tmpl += meta.icon.replace( + `<svg`, + `<svg class="alert-icon-${meta.variant}"`, + ) + } + tmpl += meta.title + tmpl += `</p>\n` + tmpl += text + tmpl += `</blockquote>\n` + + return tmpl + } + + return { + walkTokens(token) { + if (token.type !== `blockquote`) + return + + const matchedVariant = resolvedVariants.find(({ type }) => + new RegExp(createSyntaxPattern(type), `i`).test(token.text), + ) + + if (matchedVariant) { + const { type: variantType } = matchedVariant + const typeRegexp = new RegExp(createSyntaxPattern(variantType), `i`) + + Object.assign(token, { + type: `alert`, + meta: buildMeta(variantType, matchedVariant), + }) + + const firstLine = token.tokens?.[0] as Tokens.Paragraph + const firstLineText = firstLine.raw?.replace(typeRegexp, ``).trim() + + if (firstLineText) { + const patternToken = firstLine.tokens[0] as Tokens.Text + + Object.assign(patternToken, { + raw: patternToken.raw.replace(typeRegexp, ``), + text: patternToken.text.replace(typeRegexp, ``), + }) + + if (firstLine.tokens[1]?.type === `br`) { + firstLine.tokens.splice(1, 1) + } + } + else { + token.tokens?.shift() + } + } + }, + extensions: [ + { + name: `alert`, + level: `block`, + renderer: renderAlert, + }, + { + name: `alertContainer`, + level: `block`, + start(src) { + return src.match(/^:::/)?.index + }, + tokenizer(src, _tokens) { + // eslint-disable-next-line regexp/no-super-linear-backtracking + const match = /^:::\s*(\w+)\s*\n([\s\S]*?)\n:::/.exec(src) + + if (match) { + const [raw, variant, content] = match + const matchedVariant = resolvedVariants.find(v => v.type === variant) + if (!matchedVariant) + return + + return { + type: `alert`, + raw, + text: content.trim(), + tokens: this.lexer.blockTokens(content.trim()), + meta: buildMeta(variant, matchedVariant, true), + } + } + }, + renderer: renderAlert, + }, + ], + } +} + +/** + * The default configuration for alert variants. + */ +const defaultAlertVariant: AlertVariantItem[] = [ + { + type: `note`, + icon: `<svg class="octicon octicon-info" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>`, + }, + { + type: `info`, + icon: `<svg class="octicon octicon-info" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>`, + }, + { + type: `tip`, + icon: `<svg class="octicon octicon-light-bulb" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M8 1.5c-2.363 0-4 1.69-4 3.75 0 .984.424 1.625.984 2.304l.214.253c.223.264.47.556.673.848.284.411.537.896.621 1.49a.75.75 0 0 1-1.484.211c-.04-.282-.163-.547-.37-.847a8.456 8.456 0 0 0-.542-.68c-.084-.1-.173-.205-.268-.32C3.201 7.75 2.5 6.766 2.5 5.25 2.5 2.31 4.863 0 8 0s5.5 2.31 5.5 5.25c0 1.516-.701 2.5-1.328 3.259-.095.115-.184.22-.268.319-.207.245-.383.453-.541.681-.208.3-.33.565-.37.847a.751.751 0 0 1-1.485-.212c.084-.593.337-1.078.621-1.489.203-.292.45-.584.673-.848.075-.088.147-.173.213-.253.561-.679.985-1.32.985-2.304 0-2.06-1.637-3.75-4-3.75ZM5.75 12h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1 0-1.5ZM6 15.25a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Z"></path></svg>`, + }, + { + type: `important`, + icon: `<svg class="octicon octicon-report" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v9.5A1.75 1.75 0 0 1 14.25 13H8.06l-2.573 2.573A1.458 1.458 0 0 1 3 14.543V13H1.75A1.75 1.75 0 0 1 0 11.25Zm1.75-.25a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h6.5a.25.25 0 0 0 .25-.25v-9.5a.25.25 0 0 0-.25-.25Zm7 2.25v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 9a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path></svg>`, + }, + { + type: `warning`, + icon: `<svg class="octicon octicon-alert" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Zm1.763.707a.25.25 0 0 0-.44 0L1.698 13.132a.25.25 0 0 0 .22.368h12.164a.25.25 0 0 0 .22-.368Zm.53 3.996v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path></svg>`, + }, + { + type: `caution`, + icon: `<svg class="octicon octicon-stop" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M4.47.22A.749.749 0 0 1 5 0h6c.199 0 .389.079.53.22l4.25 4.25c.141.14.22.331.22.53v6a.749.749 0 0 1-.22.53l-4.25 4.25A.749.749 0 0 1 11 16H5a.749.749 0 0 1-.53-.22L.22 11.53A.749.749 0 0 1 0 11V5c0-.199.079-.389.22-.53Zm.84 1.28L1.5 5.31v5.38l3.81 3.81h5.38l3.81-3.81V5.31L10.69 1.5ZM8 4a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 8 4Zm0 8a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>`, + }, + // Obsidian-style callouts + { + type: `abstract`, + title: `Abstract`, + icon: `<svg class="octicon octicon-clipboard" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M5.75 1a.75.75 0 0 0-.75.75v.5c0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75v-.5a.75.75 0 0 0-.75-.75Zm4.5-1.5a2.25 2.25 0 0 1 2.122 1.5H13a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h.628A2.25 2.25 0 0 1 5.75-.5ZM3.5 3v10a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5V3a.5.5 0 0 0-.5-.5h-8a.5.5 0 0 0-.5.5Z"></path></svg>`, + }, + { + type: `summary`, + title: `Summary`, + icon: `<svg class="octicon octicon-clipboard" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M5.75 1a.75.75 0 0 0-.75.75v.5c0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75v-.5a.75.75 0 0 0-.75-.75Zm4.5-1.5a2.25 2.25 0 0 1 2.122 1.5H13a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h.628A2.25 2.25 0 0 1 5.75-.5ZM3.5 3v10a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5V3a.5.5 0 0 0-.5-.5h-8a.5.5 0 0 0-.5.5Z"></path></svg>`, + }, + { + type: `tldr`, + title: `TL;DR`, + icon: `<svg class="octicon octicon-clipboard" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M5.75 1a.75.75 0 0 0-.75.75v.5c0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75v-.5a.75.75 0 0 0-.75-.75Zm4.5-1.5a2.25 2.25 0 0 1 2.122 1.5H13a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h.628A2.25 2.25 0 0 1 5.75-.5ZM3.5 3v10a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5V3a.5.5 0 0 0-.5-.5h-8a.5.5 0 0 0-.5.5Z"></path></svg>`, + }, + { + type: `todo`, + title: `Todo`, + icon: `<svg class="octicon octicon-checklist" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M2.5 1.75v11.5c0 .138.112.25.25.25h3.17a.75.75 0 0 1 0 1.5H2.75A1.75 1.75 0 0 1 1 13.25V1.75C1 .784 1.784 0 2.75 0h8.5C12.216 0 13 .784 13 1.75v7.736a.75.75 0 0 1-1.5 0V1.75a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25Zm10.97 8.72a.75.75 0 0 1 1.06 0l2 2a.75.75 0 0 1-1.06 1.06l-1.22-1.22v4.94a.75.75 0 0 1-1.5 0v-4.94l-1.22 1.22a.75.75 0 0 1-1.06-1.06Z"></path></svg>`, + }, + { + type: `success`, + title: `Success`, + icon: `<svg class="octicon octicon-check-circle" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M8 16A8 8 0 1 1 8 0a8 8 0 0 1 0 16Zm3.78-9.72a.751.751 0 0 0-.018-1.042.751.751 0 0 0-1.042-.018L6.75 9.19 5.28 7.72a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042l2 2a.75.75 0 0 0 1.06 0Z"></path></svg>`, + }, + { + type: `done`, + title: `Done`, + icon: `<svg class="octicon octicon-check-circle" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M8 16A8 8 0 1 1 8 0a8 8 0 0 1 0 16Zm3.78-9.72a.751.751 0 0 0-.018-1.042.751.751 0 0 0-1.042-.018L6.75 9.19 5.28 7.72a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042l2 2a.75.75 0 0 0 1.06 0Z"></path></svg>`, + }, + { + type: `question`, + title: `Question`, + icon: `<svg class="octicon octicon-question" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.92 6.085h.001a.749.749 0 1 1-1.342-.67c.169-.339.436-.701.849-.977C6.845 4.16 7.369 4 8 4a2.756 2.756 0 0 1 1.637.525c.503.377.863.965.863 1.725 0 .448-.115.83-.329 1.15-.205.307-.47.513-.692.662-.109.072-.22.138-.313.195l-.006.004a6.24 6.24 0 0 0-.26.16.952.952 0 0 0-.276.245.75.75 0 0 1-1.248-.832c.184-.264.42-.489.692-.661.103-.067.207-.132.313-.195l.007-.004c.1-.061.182-.11.258-.161a.969.969 0 0 0 .277-.245C8.96 6.514 9 6.427 9 6.25a.612.612 0 0 0-.262-.525A1.27 1.27 0 0 0 8 5.5c-.369 0-.595.09-.74.187a1.01 1.01 0 0 0-.34.398ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path></svg>`, + }, + { + type: `help`, + title: `Help`, + icon: `<svg class="octicon octicon-question" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.92 6.085h.001a.749.749 0 1 1-1.342-.67c.169-.339.436-.701.849-.977C6.845 4.16 7.369 4 8 4a2.756 2.756 0 0 1 1.637.525c.503.377.863.965.863 1.725 0 .448-.115.83-.329 1.15-.205.307-.47.513-.692.662-.109.072-.22.138-.313.195l-.006.004a6.24 6.24 0 0 0-.26.16.952.952 0 0 0-.276.245.75.75 0 0 1-1.248-.832c.184-.264.42-.489.692-.661.103-.067.207-.132.313-.195l.007-.004c.1-.061.182-.11.258-.161a.969.969 0 0 0 .277-.245C8.96 6.514 9 6.427 9 6.25a.612.612 0 0 0-.262-.525A1.27 1.27 0 0 0 8 5.5c-.369 0-.595.09-.74.187a1.01 1.01 0 0 0-.34.398ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path></svg>`, + }, + { + type: `faq`, + title: `FAQ`, + icon: `<svg class="octicon octicon-question" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.92 6.085h.001a.749.749 0 1 1-1.342-.67c.169-.339.436-.701.849-.977C6.845 4.16 7.369 4 8 4a2.756 2.756 0 0 1 1.637.525c.503.377.863.965.863 1.725 0 .448-.115.83-.329 1.15-.205.307-.47.513-.692.662-.109.072-.22.138-.313.195l-.006.004a6.24 6.24 0 0 0-.26.16.952.952 0 0 0-.276.245.75.75 0 0 1-1.248-.832c.184-.264.42-.489.692-.661.103-.067.207-.132.313-.195l.007-.004c.1-.061.182-.11.258-.161a.969.969 0 0 0 .277-.245C8.96 6.514 9 6.427 9 6.25a.612.612 0 0 0-.262-.525A1.27 1.27 0 0 0 8 5.5c-.369 0-.595.09-.74.187a1.01 1.01 0 0 0-.34.398ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path></svg>`, + }, + { + type: `failure`, + title: `Failure`, + icon: `<svg class="octicon octicon-x-circle" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M2.343 13.657A8 8 0 1 1 13.658 2.343 8 8 0 0 1 2.343 13.657ZM6.03 4.97a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042L6.94 8 4.97 9.97a.749.749 0 0 0 .326 1.275.749.749 0 0 0 .734-.215L8 9.06l1.97 1.97a.749.749 0 0 0 1.275-.326.749.749 0 0 0-.215-.734L9.06 8l1.97-1.97a.749.749 0 0 0-.326-1.275.749.749 0 0 0-.734.215L8 6.94Z"></path></svg>`, + }, + { + type: `fail`, + title: `Fail`, + icon: `<svg class="octicon octicon-x-circle" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M2.343 13.657A8 8 0 1 1 13.658 2.343 8 8 0 0 1 2.343 13.657ZM6.03 4.97a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042L6.94 8 4.97 9.97a.749.749 0 0 0 .326 1.275.749.749 0 0 0 .734-.215L8 9.06l1.97 1.97a.749.749 0 0 0 1.275-.326.749.749 0 0 0-.215-.734L9.06 8l1.97-1.97a.749.749 0 0 0-.326-1.275.749.749 0 0 0-.734.215L8 6.94Z"></path></svg>`, + }, + { + type: `missing`, + title: `Missing`, + icon: `<svg class="octicon octicon-x-circle" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M2.343 13.657A8 8 0 1 1 13.658 2.343 8 8 0 0 1 2.343 13.657ZM6.03 4.97a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042L6.94 8 4.97 9.97a.749.749 0 0 0 .326 1.275.749.749 0 0 0 .734-.215L8 9.06l1.97 1.97a.749.749 0 0 0 1.275-.326.749.749 0 0 0-.215-.734L9.06 8l1.97-1.97a.749.749 0 0 0-.326-1.275.749.749 0 0 0-.734.215L8 6.94Z"></path></svg>`, + }, + { + type: `danger`, + title: `Danger`, + icon: `<svg class="octicon octicon-zap" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M9.504.43a1.516 1.516 0 0 1 2.437 1.713L10.415 5.5h2.123c1.57 0 2.346 1.909 1.22 3.004l-7.34 7.142a1.249 1.249 0 0 1-.871.354h-.302a1.25 1.25 0 0 1-1.157-1.723L5.633 10.5H3.462c-1.57 0-2.346-1.909-1.22-3.004ZM9.98 1.873a.016.016 0 0 0-.016.006L2.252 9.021a.75.75 0 0 0 .488 1.212h3.838a.75.75 0 0 1 .694 1.034L5.545 15.02a.016.016 0 0 0 .003.017.017.017 0 0 0 .018.004h.302a.016.016 0 0 0 .012-.005l7.34-7.142a.75.75 0 0 0-.488-1.212h-3.838a.75.75 0 0 1-.694-1.034l1.527-3.628a.016.016 0 0 0-.003-.017.017.017 0 0 0-.018-.004h-.302Z"></path></svg>`, + }, + { + type: `error`, + title: `Error`, + icon: `<svg class="octicon octicon-zap" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M9.504.43a1.516 1.516 0 0 1 2.437 1.713L10.415 5.5h2.123c1.57 0 2.346 1.909 1.22 3.004l-7.34 7.142a1.249 1.249 0 0 1-.871.354h-.302a1.25 1.25 0 0 1-1.157-1.723L5.633 10.5H3.462c-1.57 0-2.346-1.909-1.22-3.004ZM9.98 1.873a.016.016 0 0 0-.016.006L2.252 9.021a.75.75 0 0 0 .488 1.212h3.838a.75.75 0 0 1 .694 1.034L5.545 15.02a.016.016 0 0 0 .003.017.017.017 0 0 0 .018.004h.302a.016.016 0 0 0 .012-.005l7.34-7.142a.75.75 0 0 0-.488-1.212h-3.838a.75.75 0 0 1-.694-1.034l1.527-3.628a.016.016 0 0 0-.003-.017.017.017 0 0 0-.018-.004h-.302Z"></path></svg>`, + }, + { + type: `bug`, + title: `Bug`, + icon: `<svg class="octicon octicon-bug" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M4.72.22a.75.75 0 0 1 1.06 0l1 .999a3.488 3.488 0 0 1 2.441 0l.999-1a.748.748 0 0 1 1.265.332.75.75 0 0 1-.205.729l-.775.776c.616.63.995 1.493.995 2.444v.327c0 .1-.009.197-.025.292l.727.726a.75.75 0 1 1-1.06 1.06l-.727-.727a2.17 2.17 0 0 1-.292.026H9.25V7.5a.75.75 0 0 1-1.5 0V6.125H6.875a2.17 2.17 0 0 1-.292-.026l-.727.727a.75.75 0 1 1-1.06-1.06l.727-.726a2.17 2.17 0 0 1-.025-.292V4.5c0-.951.379-1.814.995-2.444l-.775-.776a.75.75 0 0 1 0-1.06Zm6.437 6.003A.608.608 0 0 0 11 6.072v-.026a3.999 3.999 0 0 0-.11-.936 2.488 2.488 0 0 0-5.78 0 3.992 3.992 0 0 0-.11.936v.026c0 .05.008.098.02.147h4.937a.612.612 0 0 0 .2-.02ZM2.25 7.5a.75.75 0 0 0 0 1.5h.5v1.25a4.75 4.75 0 0 0 2.478 4.166l.247.137a.75.75 0 1 0 .722-1.313l-.246-.137A3.25 3.25 0 0 1 4.25 10.25V9h7.5v1.25a3.25 3.25 0 0 1-1.701 2.853l-.246.137a.75.75 0 1 0 .722 1.313l.247-.137A4.75 4.75 0 0 0 13.25 10.25V9h.5a.75.75 0 0 0 0-1.5Z"></path></svg>`, + }, + { + type: `example`, + title: `Example`, + icon: `<svg class="octicon octicon-list-unordered" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M5.75 2.5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5Zm0 5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5Zm0 5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5ZM2 14a1 1 0 1 1 0-2 1 1 0 0 1 0 2Zm1-6a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM2 4a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>`, + }, + { + type: `quote`, + title: `Quote`, + icon: `<svg class="octicon octicon-quote" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M1.75 2h12.5c.966 0 1.75.784 1.75 1.75v8.5A1.75 1.75 0 0 1 14.25 14H1.75A1.75 1.75 0 0 1 0 12.25v-8.5C0 2.784.784 2 1.75 2ZM1.5 12.25c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-8.5a.25.25 0 0 0-.25-.25H1.75a.25.25 0 0 0-.25.25ZM4 5.25a.75.75 0 0 1 .75-.75h6.5a.75.75 0 0 1 0 1.5h-6.5A.75.75 0 0 1 4 5.25Zm0 4a.75.75 0 0 1 .75-.75h6.5a.75.75 0 0 1 0 1.5h-6.5a.75.75 0 0 1-.75-.75Z"></path></svg>`, + }, + { + type: `cite`, + title: `Cite`, + icon: `<svg class="octicon octicon-quote" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M1.75 2h12.5c.966 0 1.75.784 1.75 1.75v8.5A1.75 1.75 0 0 1 14.25 14H1.75A1.75 1.75 0 0 1 0 12.25v-8.5C0 2.784.784 2 1.75 2ZM1.5 12.25c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-8.5a.25.25 0 0 0-.25-.25H1.75a.25.25 0 0 0-.25.25ZM4 5.25a.75.75 0 0 1 .75-.75h6.5a.75.75 0 0 1 0 1.5h-6.5A.75.75 0 0 1 4 5.25Zm0 4a.75.75 0 0 1 .75-.75h6.5a.75.75 0 0 1 0 1.5h-6.5a.75.75 0 0 1-.75-.75Z"></path></svg>`, + }, +] + +/** + * Resolves the variants configuration, combining the provided variants with + * the default variants. + */ +export function resolveVariants(variants: AlertVariantItem[]) { + if (!variants.length) + return defaultAlertVariant + + return Object.values( + [...defaultAlertVariant, ...variants].reduce( + (map, item) => { + map[item.type] = item + return map + }, + {} as { [key: string]: AlertVariantItem }, + ), + ) +} + +/** + * Returns regex pattern to match alert syntax. + */ +export function createSyntaxPattern(type: string) { + return `^(?:\\[!${type}])\\s*?\n*` +} diff --git a/skills/post-to-wechat/scripts/md/extensions/footnotes.ts b/skills/post-to-wechat/scripts/md/extensions/footnotes.ts new file mode 100644 index 0000000..5660b64 --- /dev/null +++ b/skills/post-to-wechat/scripts/md/extensions/footnotes.ts @@ -0,0 +1,89 @@ +import type { MarkedExtension, Tokens } from 'marked' +/** + * A marked extension to support footnotes syntax. + * Syntax: + * This is a footnote reference[^1][^2]. + * + * [^1]: ..... + * [^2]: ..... + */ + +interface MapContent { + index: number + text: string +} +const fnMap = new Map<string, MapContent>() + +export function markedFootnotes(): MarkedExtension { + return { + extensions: [ + { + name: `footnoteDef`, + level: `block`, + start(src: string) { + fnMap.clear() + return src.match(/^\[\^/)?.index + }, + tokenizer(src: string) { + const match = src.match(/^\[\^(.*)\]:(.*)/) + if (match) { + const [raw, fnId, text] = match + const index = fnMap.size + 1 + fnMap.set(fnId, { index, text }) + return { + type: `footnoteDef`, + raw, + fnId, + index, + text, + } + } + return undefined + }, + renderer(token: Tokens.Generic) { + const { index, text, fnId } = token + const fnInner = ` + <code>${index}.</code> + <span>${text}</span> + <a id="fnDef-${fnId}" href="#fnRef-${fnId}" style="color: var(--md-primary-color);">\u21A9\uFE0E</a> + <br>` + if (index === 1) { + return ` + <p style="font-size: 80%;margin: 0.5em 8px;word-break:break-all;">${fnInner}` + } + if (index === fnMap.size) { + return `${fnInner}</p>` + } + return fnInner + }, + }, + { + name: `footnoteRef`, + level: `inline`, + start(src: string) { + return src.match(/\[\^/)?.index + }, + tokenizer(src: string) { + const match = src.match(/^\[\^(.*?)\]/) + if (match) { + const [raw, fnId] = match + if (fnMap.has(fnId)) { + return { + type: `footnoteRef`, + raw, + fnId, + } + } + } + }, + renderer(token: Tokens.Generic) { + const { fnId } = token + const { index } = fnMap.get(fnId) as MapContent + return `<sup style="color: var(--md-primary-color);"> + <a href="#fnDef-${fnId}" id="fnRef-${fnId}">\[${index}\]</a> + </sup>` + }, + }, + ], + } +} diff --git a/skills/post-to-wechat/scripts/md/extensions/index.ts b/skills/post-to-wechat/scripts/md/extensions/index.ts new file mode 100644 index 0000000..dcf6c21 --- /dev/null +++ b/skills/post-to-wechat/scripts/md/extensions/index.ts @@ -0,0 +1,10 @@ +// Markdown 扩展导出 +export * from './alert.js' +export * from './footnotes.js' +export * from './infographic.js' +export * from './katex.js' +export * from './markup.js' +export * from './plantuml.js' +export * from './ruby.js' +export * from './slider.js' +export * from './toc.js' diff --git a/skills/post-to-wechat/scripts/md/extensions/infographic.ts b/skills/post-to-wechat/scripts/md/extensions/infographic.ts new file mode 100644 index 0000000..47feedf --- /dev/null +++ b/skills/post-to-wechat/scripts/md/extensions/infographic.ts @@ -0,0 +1,119 @@ +import type { MarkedExtension } from 'marked' + +interface InfographicOptions { + themeMode?: 'dark' | 'light' +} + +async function renderInfographic(containerId: string, code: string, options?: InfographicOptions) { + if (typeof window === 'undefined') + return + + try { + const { Infographic, setDefaultFont, setFontExtendFactor, exportToSVG } = await import('@antv/infographic') + + setFontExtendFactor(1.1) + setDefaultFont('-apple-system-font, "system-ui", "Helvetica Neue", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", Arial, sans-serif') + + const findContainer = (retries = 5, delay = 100) => { + const container = document.getElementById(containerId) + if (container) { + const isDark = options?.themeMode === 'dark' + + // 从 CSS 变量中读取主题颜色 + const root = document.documentElement + const computedStyle = getComputedStyle(root) + const primaryColor = computedStyle.getPropertyValue('--md-primary-color').trim() + const backgroundColor = computedStyle.getPropertyValue('--background').trim() + + // 转换 HSL 格式 + const toHSLString = (variant: string) => { + const vars = variant.split(' ') + if (vars.length === 3) + return `hsl(${vars.join(', ')})` + if (vars.length === 4) + return `hsla(${vars.join(', ')})` + return '' + } + + const instance = new Infographic({ + container, + svg: { + style: { + width: '100%', + height: '100%', + background: isDark ? '#000' : 'transparent', + }, + background: false, + }, + theme: isDark ? 'dark' : 'default', + themeConfig: { + colorPrimary: primaryColor || undefined, + colorBg: toHSLString(backgroundColor) || undefined, + }, + }) + + instance.on('loaded', ({ node }) => { + exportToSVG(node, { removeIds: true }).then((svg) => { + container.replaceChildren(svg) + }) + }) + + instance.render(code) + + return + } + + if (retries > 0) { + setTimeout(() => findContainer(retries - 1, delay), delay) + } + } + + findContainer() + } + catch (error) { + console.error('Failed to render Infographic:', error) + const container = document.getElementById(containerId) + if (container) { + container.innerHTML = `<div style="color: red; padding: 10px; border: 1px solid red;">Infographic 渲染失败: ${error instanceof Error ? error.message : String(error)}</div>` + } + } +} + +export function markedInfographic(options?: InfographicOptions): MarkedExtension { + const className = 'infographic-diagram' + + return { + extensions: [ + { + name: 'infographic', + level: 'block', + start(src: string) { + return src.match(/^```infographic/m)?.index + }, + tokenizer(src: string) { + const match = /^```infographic\r?\n([\s\S]*?)\r?\n```/.exec(src) + if (match) { + return { + type: 'infographic', + raw: match[0], + text: match[1].trim(), + } + } + }, + renderer(token: any) { + const id = `infographic-${Math.random().toString(36).slice(2, 11)}` + const code = token.text + + renderInfographic(id, code, options) + + return `<div id="${id}" class="${className}" style="width: 100%;">正在加载 Infographic...</div>` + }, + }, + ], + walkTokens(token: any) { + if (token.type === 'code' && token.lang === 'infographic') { + token.type = 'infographic' + } + }, + } +} diff --git a/skills/post-to-wechat/scripts/md/extensions/katex.ts b/skills/post-to-wechat/scripts/md/extensions/katex.ts new file mode 100644 index 0000000..be62590 --- /dev/null +++ b/skills/post-to-wechat/scripts/md/extensions/katex.ts @@ -0,0 +1,162 @@ +import type { MarkedExtension } from 'marked' + +export interface MarkedKatexOptions { + nonStandard?: boolean +} + +const inlineRule = /^(\${1,2})(?!\$)((?:\\.|[^\\\n])*?(?:\\.|[^\\\n$]))\1(?=[\s?!.,:?!。,:]|$)/ +const inlineRuleNonStandard = /^(\${1,2})(?!\$)((?:\\.|[^\\\n])*?(?:\\.|[^\\\n$]))\1/ // Non-standard, even if there are no spaces before and after $ or $$, try to parse + +const blockRule = /^\s{0,3}(\${1,2})[ \t]*\n([\s\S]+?)\n\s{0,3}\1[ \t]*(?:\n|$)/ + +// LaTeX style rules for \( ... \) and \[ ... \] +const inlineLatexRule = /^\\\(([^\\]*(?:\\.[^\\]*)*?)\\\)/ +const blockLatexRule = /^\\\[([^\\]*(?:\\.[^\\]*)*?)\\\]/ + +function createRenderer(display: boolean, withStyle: boolean = true) { + return (token: any) => { + // @ts-expect-error MathJax is a global variable + window.MathJax.texReset() + // @ts-expect-error MathJax is a global variable + const mjxContainer = window.MathJax.tex2svg(token.text, { display }) + const svg = mjxContainer.firstChild + const width = svg.style[`min-width`] || svg.getAttribute(`width`) + svg.removeAttribute(`width`) + + // 行内公式对齐 https://groups.google.com/g/mathjax-users/c/zThKffrrCvE?pli=1 + // 直接覆盖 style 会覆盖 MathJax 的样式,需要手动设置 + // svg.style = `max-width: 300vw !important; display: initial; flex-shrink: 0;` + + if (withStyle) { + svg.style.display = `initial` + svg.style.setProperty(`max-width`, `300vw`, `important`) + svg.style.flexShrink = `0` + svg.style.width = width + } + + if (!display) { + // 新主题系统:使用 class 而非内联样式 + return `<span class="katex-inline">${svg.outerHTML}</span>` + } + + return `<section class="katex-block">${svg.outerHTML}</section>` + } +} + +function inlineKatex(options: MarkedKatexOptions | undefined, renderer: any) { + const nonStandard = options && options.nonStandard + const ruleReg = nonStandard ? inlineRuleNonStandard : inlineRule + return { + name: `inlineKatex`, + level: `inline`, + start(src: string) { + let index + let indexSrc = src + + while (indexSrc) { + index = indexSrc.indexOf(`$`) + if (index === -1) { + return + } + const f = nonStandard ? index > -1 : index === 0 || indexSrc.charAt(index - 1) === ` ` + if (f) { + const possibleKatex = indexSrc.substring(index) + + if (possibleKatex.match(ruleReg)) { + return index + } + } + + indexSrc = indexSrc.substring(index + 1).replace(/^\$+/, ``) + } + }, + tokenizer(src: string) { + const match = src.match(ruleReg) + if (match) { + return { + type: `inlineKatex`, + raw: match[0], + text: match[2].trim(), + displayMode: match[1].length === 2, + } + } + }, + renderer, + } +} + +function blockKatex(_options: MarkedKatexOptions | undefined, renderer: any) { + return { + name: `blockKatex`, + level: `block`, + tokenizer(src: string) { + const match = src.match(blockRule) + if (match) { + return { + type: `blockKatex`, + raw: match[0], + text: match[2].trim(), + displayMode: match[1].length === 2, + } + } + }, + renderer, + } +} + +function inlineLatexKatex(_options: MarkedKatexOptions | undefined, renderer: any) { + return { + name: `inlineLatexKatex`, + level: `inline`, + start(src: string) { + const index = src.indexOf(`\\(`) + return index !== -1 ? index : undefined + }, + tokenizer(src: string) { + const match = src.match(inlineLatexRule) + if (match) { + return { + type: `inlineLatexKatex`, + raw: match[0], + text: match[1].trim(), + displayMode: false, + } + } + }, + renderer, + } +} + +function blockLatexKatex(_options: MarkedKatexOptions | undefined, renderer: any) { + return { + name: `blockLatexKatex`, + level: `block`, + start(src: string) { + const index = src.indexOf(`\\[`) + return index !== -1 ? index : undefined + }, + tokenizer(src: string) { + const match = src.match(blockLatexRule) + if (match) { + return { + type: `blockLatexKatex`, + raw: match[0], + text: match[1].trim(), + displayMode: true, + } + } + }, + renderer, + } +} + +export function MDKatex(options: MarkedKatexOptions | undefined, withStyle: boolean = true): MarkedExtension { + return { + extensions: [ + inlineKatex(options, createRenderer(false, withStyle)), + blockKatex(options, createRenderer(true, withStyle)), + inlineLatexKatex(options, createRenderer(false, withStyle)), + blockLatexKatex(options, createRenderer(true, withStyle)), + ], + } +} diff --git a/skills/post-to-wechat/scripts/md/extensions/markup.ts b/skills/post-to-wechat/scripts/md/extensions/markup.ts new file mode 100644 index 0000000..0aeac9b --- /dev/null +++ b/skills/post-to-wechat/scripts/md/extensions/markup.ts @@ -0,0 +1,87 @@ +import type { MarkedExtension } from 'marked' + +/** + * 扩展标记语法: + * - 高亮: ==文本== + * - 下划线: ++文本++ + * - 波浪线: ~文本~ + */ +export function markedMarkup(): MarkedExtension { + return { + extensions: [ + // 高亮语法 ==文本== + { + name: `markup_highlight`, + level: `inline`, + start(src: string) { + return src.match(/==(?!=)/)?.index + }, + tokenizer(src: string) { + const rule = /^==((?:[^=]|=(?!=))+)==/ + const match = rule.exec(src) + if (match) { + return { + type: `markup_highlight`, + raw: match[0], + text: match[1], + } + } + }, + renderer(token: any) { + // 新主题系统:使用 class 而非内联样式 + return `<span class="markup-highlight">${token.text}</span>` + }, + }, + + // 下划线语法 ++文本++ + { + name: `markup_underline`, + level: `inline`, + start(src: string) { + return src.match(/\+\+(?!\+)/)?.index + }, + tokenizer(src: string) { + const rule = /^\+\+((?:[^+]|\+(?!\+))+)\+\+/ + const match = rule.exec(src) + if (match) { + return { + type: `markup_underline`, + raw: match[0], + text: match[1], + } + } + }, + renderer(token: any) { + // 新主题系统:使用 class 而非内联样式 + return `<span class="markup-underline">${token.text}</span>` + }, + }, + + // 波浪线语法 ~文本~ + { + name: `markup_wavyline`, + level: `inline`, + start(src: string) { + // 查找单个 ~ 但不是连续的 ~~ + return src.match(/~(?!~)/)?.index + }, + tokenizer(src: string) { + // 匹配 ~文本~ 但确保不是 ~~文本~~ + const rule = /^~([^~\n]+)~(?!~)/ + const match = rule.exec(src) + if (match) { + return { + type: `markup_wavyline`, + raw: match[0], + text: match[1], + } + } + }, + renderer(token: any) { + // 新主题系统:使用 class 而非内联样式 + return `<span class="markup-wavyline">${token.text}</span>` + }, + }, + ], + } +} diff --git a/skills/post-to-wechat/scripts/md/extensions/plantuml.ts b/skills/post-to-wechat/scripts/md/extensions/plantuml.ts new file mode 100644 index 0000000..6b402f3 --- /dev/null +++ b/skills/post-to-wechat/scripts/md/extensions/plantuml.ts @@ -0,0 +1,289 @@ +import type { MarkedExtension, Tokens } from 'marked' +import { deflateSync } from 'fflate' + +export interface PlantUMLOptions { + /** + * PlantUML 服务器地址 + * @default 'https://www.plantuml.com/plantuml' + */ + serverUrl?: string + /** + * 渲染格式 + * @default 'svg' + */ + format?: `svg` | `png` + /** + * CSS 类名 + * @default 'plantuml-diagram' + */ + className?: string + /** + * 是否内嵌SVG内容(用于微信公众号等不支持外链图片的环境) + * @default false + */ + inlineSvg?: boolean + /** + * 自定义样式 + */ + styles?: { + container?: Record<string, string | number> + } +} + +/** + * PlantUML 专用的 6-bit 编码函数 + * 基于官方文档 https://plantuml.com/text-encoding + */ +function encode6bit(b: number): string { + if (b < 10) { + return String.fromCharCode(48 + b) + } + b -= 10 + if (b < 26) { + return String.fromCharCode(65 + b) + } + b -= 26 + if (b < 26) { + return String.fromCharCode(97 + b) + } + b -= 26 + if (b === 0) { + return `-` + } + if (b === 1) { + return `_` + } + return `?` +} + +/** + * 将 3 个字节附加到编码字符串中 + * 基于官方文档 https://plantuml.com/text-encoding + */ +function append3bytes(b1: number, b2: number, b3: number): string { + const c1 = b1 >> 2 + const c2 = ((b1 & 0x3) << 4) | (b2 >> 4) + const c3 = ((b2 & 0xF) << 2) | (b3 >> 6) + const c4 = b3 & 0x3F + let r = `` + r += encode6bit(c1 & 0x3F) + r += encode6bit(c2 & 0x3F) + r += encode6bit(c3 & 0x3F) + r += encode6bit(c4 & 0x3F) + return r +} + +/** + * PlantUML 专用的 base64 编码函数 + * 基于官方文档 https://plantuml.com/text-encoding + */ +function encode64(data: string): string { + let r = `` + for (let i = 0; i < data.length; i += 3) { + if (i + 2 === data.length) { + r += append3bytes(data.charCodeAt(i), data.charCodeAt(i + 1), 0) + } + else if (i + 1 === data.length) { + r += append3bytes(data.charCodeAt(i), 0, 0) + } + else { + r += append3bytes(data.charCodeAt(i), data.charCodeAt(i + 1), data.charCodeAt(i + 2)) + } + } + return r +} + +/** + * 使用 fflate 库进行 Deflate 压缩 + * 按照官方规范进行压缩 + */ +function performDeflate(input: string): string { + try { + // 将字符串转换为字节数组 + const inputBytes = new TextEncoder().encode(input) + + // 使用 fflate 进行 deflate 压缩(最高压缩级别 9) + const compressed = deflateSync(inputBytes, { level: 9 }) + + // 将压缩后的字节数组转换为二进制字符串 + return String.fromCharCode(...compressed) + } + catch (error) { + console.warn(`Deflate compression failed:`, error) + // 如果压缩失败,返回原始输入 + return input + } +} + +/** + * 编码 PlantUML 代码为服务器可识别的格式 + * 按照官方规范:UTF-8 编码 -> Deflate 压缩 -> PlantUML Base64 编码 + */ +function encodePlantUML(plantumlCode: string): string { + try { + // 步骤 1 & 2: UTF-8 编码 + Deflate 压缩 + const deflated = performDeflate(plantumlCode) + + // 步骤 3: PlantUML 专用的 base64 编码 + return encode64(deflated) + } + catch (error) { + // 如果编码失败,回退到简单方案 + console.warn(`PlantUML encoding failed, using fallback:`, error) + const utf8Bytes = new TextEncoder().encode(plantumlCode) + const base64 = btoa(String.fromCharCode(...utf8Bytes)) + return `~1${base64.replace(/\+/g, `-`).replace(/\//g, `_`).replace(/=/g, ``)}` + } +} + +/** + * 生成 PlantUML 图片 URL + */ +function generatePlantUMLUrl(code: string, options: Required<PlantUMLOptions>): string { + const encoded = encodePlantUML(code) + const formatPath = options.format === `svg` ? `svg` : `png` + return `${options.serverUrl}/${formatPath}/${encoded}` +} + +/** + * 渲染 PlantUML 图表 + */ +function renderPlantUMLDiagram(token: Tokens.Code, options: Required<PlantUMLOptions>): string { + const { text: code } = token + + // 检查代码是否包含 PlantUML 标记 + const finalCode = (!code.trim().includes(`@start`) || !code.trim().includes(`@end`)) + ? `@startuml\n${code.trim()}\n@enduml` + : code + + const imageUrl = generatePlantUMLUrl(finalCode, options) + + // 如果启用了内嵌SVG且格式是SVG + if (options.inlineSvg && options.format === `svg`) { + // 由于marked是同步的,我们需要返回一个占位符,然后异步替换 + const placeholder = `plantuml-placeholder-${Math.random().toString(36).slice(2, 11)}` + + // 异步获取SVG内容并替换 + fetchSvgContent(imageUrl).then((svgContent) => { + const placeholderElement = document.querySelector(`[data-placeholder="${placeholder}"]`) + if (placeholderElement) { + placeholderElement.outerHTML = createPlantUMLHTML(imageUrl, options, svgContent) + } + }) + + const containerStyles = options.styles.container + ? Object.entries(options.styles.container) + .map(([key, value]) => `${key.replace(/([A-Z])/g, `-$1`).toLowerCase()}: ${value}`) + .join(`; `) + : `` + + return `<div class="${options.className}" style="${containerStyles}" data-placeholder="${placeholder}"> + <div style="color: #666; font-style: italic;">正在加载PlantUML图表...</div> + </div>` + } + + return createPlantUMLHTML(imageUrl, options) +} + +/** + * 获取SVG内容 + */ +async function fetchSvgContent(svgUrl: string): Promise<string> { + try { + const response = await fetch(svgUrl) + if (!response.ok) { + throw new Error(`HTTP ${response.status}`) + } + const svgContent = await response.text() + // 移除SVG根元素的固定尺寸,使其响应式 + return svgContent + // 移除width和height属性 + .replace(/(<svg[^>]*)\swidth="[^"]*"/g, `$1`) + .replace(/(<svg[^>]*)\sheight="[^"]*"/g, `$1`) + // 移除style中的width和height + .replace(/(<svg[^>]*style="[^"]*?)width:[^;]*;?/g, `$1`) + .replace(/(<svg[^>]*style="[^"]*?)height:[^;]*;?/g, `$1`) + } + catch (error) { + console.warn(`Failed to fetch SVG content from ${svgUrl}:`, error) + return `<div style="color: #666; font-style: italic;">PlantUML图表加载失败</div>` + } +} + +/** + * 创建 PlantUML HTML 元素 + */ +function createPlantUMLHTML(imageUrl: string, options: Required<PlantUMLOptions>, svgContent?: string): string { + const containerStyles = options.styles.container + ? Object.entries(options.styles.container) + .map(([key, value]) => `${key.replace(/([A-Z])/g, `-$1`).toLowerCase()}: ${value}`) + .join(`; `) + : `` + + // 如果有SVG内容,直接嵌入 + if (svgContent) { + return `<div class="${options.className}" style="${containerStyles}"> + ${svgContent} + </div>` + } + + // 否则使用图片链接 + return `<div class="${options.className}" style="${containerStyles}"> + <img src="${imageUrl}" alt="PlantUML Diagram" style="max-width: 100%; height: auto;" /> + </div>` +} + +/** + * PlantUML marked 扩展 + */ +export function markedPlantUML(options: PlantUMLOptions = {}): MarkedExtension { + const resolvedOptions: Required<PlantUMLOptions> = { + serverUrl: options.serverUrl || `https://www.plantuml.com/plantuml`, + format: options.format || `svg`, + className: options.className || `plantuml-diagram`, + inlineSvg: options.inlineSvg || false, + styles: { + container: { + textAlign: `center`, + margin: `16px 8px`, + overflowX: `auto`, + ...options.styles?.container, + }, + }, + } + + return { + extensions: [ + { + name: `plantuml`, + level: `block`, + start(src: string) { + // 匹配 ```plantuml 代码块 + return src.match(/^```plantuml/m)?.index + }, + tokenizer(src: string) { + // 匹配完整的 plantuml 代码块 + const match = /^```plantuml\r?\n([\s\S]*?)\r?\n```/.exec(src) + + if (match) { + const [raw, code] = match + return { + type: `plantuml`, + raw, + text: code.trim(), + } + } + }, + renderer(token: any) { + return renderPlantUMLDiagram(token, resolvedOptions) + }, + }, + ], + walkTokens(token: any) { + // 处理现有的代码块,如果语言是 plantuml 就转换类型 + if (token.type === `code` && token.lang === `plantuml`) { + token.type = `plantuml` + } + }, + } +} diff --git a/skills/post-to-wechat/scripts/md/extensions/ruby.ts b/skills/post-to-wechat/scripts/md/extensions/ruby.ts new file mode 100644 index 0000000..a064b0a --- /dev/null +++ b/skills/post-to-wechat/scripts/md/extensions/ruby.ts @@ -0,0 +1,125 @@ +import type { MarkedExtension } from 'marked' + +/** + * 注音/拼音标注扩展 + * https://talk.commonmark.org/t/proper-ruby-text-rb-syntax-support-in-markdown/2279 + * https://www.w3.org/TR/ruby/ + * + * 支持的格式: + * 1. [文字]{注音} + * 2. [文字]^(注音) + * + * 分隔符: + * - `・` (中点) + * - `.` (全角句点) + * - `。` (中文句号) + * - `-` (英文减号) + */ +export function markedRuby(): MarkedExtension { + return { + extensions: [ + { + name: `ruby`, + level: `inline`, + start(src: string) { + // 匹配以 [ 开头的格式 + return src.match(/\[/)?.index + }, + tokenizer(src: string) { + // 1. [文字]{注音} + const rule1 = /^\[([^\]]+)\]\{([^}]+)\}/ + let match = rule1.exec(src) + if (match) { + return { + type: `ruby`, + raw: match[0], + text: match[1].trim(), + ruby: match[2].trim(), + format: `basic`, + } + } + + // 2. [文字]^(注音) + const rule2 = /^\[([^\]]+)\]\^\(([^)]+)\)/ + match = rule2.exec(src) + if (match) { + return { + type: `ruby`, + raw: match[0], + text: match[1].trim(), + ruby: match[2].trim(), + format: `basic-hat`, + } + } + + return undefined + }, + renderer(token: any) { + const { text, ruby, format } = token + + // 检查是否有分隔符 + const separatorRegex = /[・.。-]/g + const hasSeparators = separatorRegex.test(ruby) + + if (hasSeparators) { + // 分割注音部分 + const rubyParts = ruby.split(separatorRegex).filter((part: string) => part.trim() !== ``) + + const textChars = text.split(``) + const result = [] + + if (textChars.length >= rubyParts.length) { + // 文字字符数量 >= 注音部分数量 + // 按注音部分数量分割文字 + let currentIndex = 0 + + for (let i = 0; i < rubyParts.length; i++) { + const rubyPart = rubyParts[i] + const remainingChars = textChars.length - currentIndex + const remainingParts = rubyParts.length - i + + // 计算当前部分应该包含多少个字符,默认为 1 + let charCount = 1 + if (remainingParts === 1) { + // 最后一个部分,包含所有剩余字符 + charCount = remainingChars + } + + // 提取当前部分的文字 + const currentText = textChars.slice(currentIndex, currentIndex + charCount).join(``) + + result.push(`<ruby data-text="${currentText}" data-ruby="${rubyPart}" data-format="${format}">${currentText}<rp>(</rp><rt>${rubyPart}</rt><rp>)</rp></ruby>`) + + currentIndex += charCount + } + + // 处理剩余的字符 + if (currentIndex < textChars.length) { + result.push(textChars.slice(currentIndex).join(``)) + } + } + else { + // 文字字符数量 < 注音部分数量 + // 每个字符对应一个注音部分,多余的注音被忽略 + for (let i = 0; i < textChars.length; i++) { + const char = textChars[i] + const rubyPart = rubyParts[i] || `` + + if (rubyPart) { + result.push(`<ruby data-text="${char}" data-ruby="${rubyPart}" data-format="${format}">${char}<rp>(</rp><rt>${rubyPart}</rt><rp>)</rp></ruby>`) + } + else { + result.push(char) + } + } + } + + return result.join(``) + } + + return `<ruby data-text="${text}" data-ruby="${ruby}" data-format="${format}">${text}<rp>(</rp><rt>${ruby}</rt><rp>)</rp></ruby>` + }, + }, + ], + } +} diff --git a/skills/post-to-wechat/scripts/md/extensions/slider.ts b/skills/post-to-wechat/scripts/md/extensions/slider.ts new file mode 100644 index 0000000..f1f1a10 --- /dev/null +++ b/skills/post-to-wechat/scripts/md/extensions/slider.ts @@ -0,0 +1,73 @@ +import type { MarkedExtension, Tokens } from 'marked' + +/** + * A marked extension to support horizontal sliding images. + * Syntax: <![alt1](url1),![alt2](url2),![alt3](url3)> + */ +export function markedSlider(): MarkedExtension { + return { + extensions: [ + { + name: `horizontalSlider`, + level: `block`, + start(src: string) { + return src.match(/^<!\[/)?.index + }, + tokenizer(src: string) { + const rule = /^<(!\[.*?\]\(.*?\)(?:,!\[.*?\]\(.*?\))*)>/ + const match = src.match(rule) + if (match) { + return { + type: `horizontalSlider`, + raw: match[0], + text: match[1], + } + } + return undefined + }, + renderer(token: Tokens.Generic) { + const { text } = token + const imageMatches = text.match(/!\[(.*?)\]\((.*?)\)/g) || [] + + if (imageMatches.length === 0) { + return `` + } + + const images = imageMatches.map((img: string) => { + const altMatch = img.match(/!\[(.*?)\]/) || [] + const srcMatch = img.match(/\]\((.*?)\)/) || [] + const alt = altMatch[1] || `` + const src = srcMatch[1] || `` + + // 新主题系统:不再需要内联样式 + return { src, alt } + }) + + // 使用微信公众号兼容的滑动容器布局 + // 使用微信支持的section标签和特殊样式组合 + + return ` + <section style="box-sizing: border-box; font-size: 16px;"> + <section data-role="outer" style="font-family: 微软雅黑; font-size: 16px;"> + <section data-role="paragraph" style="margin: 0px auto; box-sizing: border-box; width: 100%;"> + <section style="margin: 0px auto; text-align: center;"> + <section style="display: inline-block; width: 100%;"> + <!-- 微信公众号支持的滑动图片容器 --> + <section style="overflow-x: scroll; -webkit-overflow-scrolling: touch; white-space: nowrap; width: 100%; text-align: center;"> + ${images.map((img: { src: string, alt: string }, _index: number) => `<section style="display: inline-block; width: 100%; margin-right: 0; vertical-align: top;"> + <img src="${img.src}" alt="${img.alt}" title="${img.alt}" style="width: 100%; height: auto; border-radius: 4px; vertical-align: top;"/> + <p style="margin-top: 5px; font-size: 14px; color: #666; text-align: center; white-space: normal;">${img.alt}</p> + </section>`).join(``)} + </section> + </section> + </section> + </section> + </section> + <p style="font-size: 14px; color: #999; text-align: center; margin-top: 5px;"><<< 左右滑动看更多 >>></p> + </section> + ` + }, + }, + ], + } +} diff --git a/skills/post-to-wechat/scripts/md/extensions/toc.ts b/skills/post-to-wechat/scripts/md/extensions/toc.ts new file mode 100644 index 0000000..85e44ff --- /dev/null +++ b/skills/post-to-wechat/scripts/md/extensions/toc.ts @@ -0,0 +1,74 @@ +import type { MarkedExtension } from 'marked' + +/** + * marked 插件:支持 [TOC] 语法,自动生成嵌套目录 + */ +export function markedToc(): MarkedExtension { + let headings: { text: string, depth: number, index: number }[] = [] + + let firstToken = true + + return { + walkTokens(token) { + if (firstToken) { + headings = [] + firstToken = false + } + if (token.type === `heading`) { + const text = token.text || `` + const depth = token.depth || 1 + const index = headings.length + headings.push({ text, depth, index }) + } + }, + extensions: [ + { + name: `toc`, + level: `block`, + start(src) { + // 只匹配独立一行的 [TOC],避免误伤 + const match = src.match(/^\s*\[TOC\]\s*$/m) + return match ? match.index : undefined + }, + tokenizer(src) { + const match = /^\[TOC\]/.exec(src) + if (match) { + return { + type: `toc`, + raw: match[0], + } + } + }, + renderer() { + if (!headings.length) + return `` + let html = `<nav class="markdown-toc"><ul class="toc-ul toc-level-1 pl-4 border-l ml-2">` + let lastDepth = 1 + headings.forEach(({ text, depth, index }) => { + if (depth > lastDepth) { + for (let i = lastDepth + 1; i <= depth; i++) { + html += `<ul class="toc-ul toc-level-${i} pl-4 border-l ml-2">` + } + } + else if (depth < lastDepth) { + for (let i = lastDepth; i > depth; i--) { + html += `</ul>` + } + } + html += `<li class="toc-li toc-level-${depth} mb-1"><a class="text-gray-700 hover:text-blue-600 underline transition-colors" href="#${index}">${text}</a></li>` + lastDepth = depth + }) + + for (let i = lastDepth; i > 1; i--) { + html += `</ul>` + } + + html += `</ul></nav>` + + firstToken = true + return html + }, + }, + ], + } +} diff --git a/skills/post-to-wechat/scripts/md/render.ts b/skills/post-to-wechat/scripts/md/render.ts new file mode 100644 index 0000000..08d173e --- /dev/null +++ b/skills/post-to-wechat/scripts/md/render.ts @@ -0,0 +1,654 @@ +#!/usr/bin/env npx tsx + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import frontMatter from "front-matter"; +import hljs from "highlight.js/lib/core"; +import { marked, type RendererObject, type Tokens } from "marked"; +import readingTime, { type ReadTimeResults } from "reading-time"; + +import { + markedAlert, + markedFootnotes, + markedInfographic, + markedMarkup, + markedPlantUML, + markedRuby, + markedSlider, + markedToc, + MDKatex, +} from "./extensions/index.js"; +import { + COMMON_LANGUAGES, + highlightAndFormatCode, +} from "./utils/languages.js"; + +type ThemeName = "default" | "grace" | "simple"; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const THEME_DIR = path.resolve(SCRIPT_DIR, "themes"); +const THEME_NAMES: ThemeName[] = ["default", "grace", "simple"]; + +const DEFAULT_STYLE = { + primaryColor: "#0F4C81", + fontFamily: + "-apple-system-font,BlinkMacSystemFont, Helvetica Neue, PingFang SC, Hiragino Sans GB , Microsoft YaHei UI , Microsoft YaHei ,Arial,sans-serif", + fontSize: "16px", + foreground: "0 0% 3.9%", + blockquoteBackground: "#f7f7f7", +}; + +Object.entries(COMMON_LANGUAGES).forEach(([name, lang]) => { + hljs.registerLanguage(name, lang); +}); + +export { hljs }; + +marked.setOptions({ + breaks: true, +}); +marked.use(markedSlider()); + +interface IOpts { + legend?: string; + citeStatus?: boolean; + countStatus?: boolean; + isMacCodeBlock?: boolean; + isShowLineNumber?: boolean; + themeMode?: "light" | "dark"; +} + +interface RendererAPI { + reset: (newOpts: Partial<IOpts>) => void; + setOptions: (newOpts: Partial<IOpts>) => void; + getOpts: () => IOpts; + parseFrontMatterAndContent: (markdown: string) => { + yamlData: Record<string, any>; + markdownContent: string; + readingTime: ReadTimeResults; + }; + buildReadingTime: (reading: ReadTimeResults) => string; + buildFootnotes: () => string; + buildAddition: () => string; + createContainer: (html: string) => string; +} + +interface ParseResult { + yamlData: Record<string, any>; + markdownContent: string; + readingTime: ReadTimeResults; +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") + .replace(/`/g, "`"); +} + +function buildAddition(): string { + return ` + <style> + .preview-wrapper pre::before { + position: absolute; + top: 0; + right: 0; + color: #ccc; + text-align: center; + font-size: 0.8em; + padding: 5px 10px 0; + line-height: 15px; + height: 15px; + font-weight: 600; + } + </style> + `; +} + +function buildFootnoteArray(footnotes: [number, string, string][]): string { + return footnotes + .map(([index, title, link]) => + link === title + ? `<code style="font-size: 90%; opacity: 0.6;">[${index}]</code>: <i style="word-break: break-all">${title}</i><br/>` + : `<code style="font-size: 90%; opacity: 0.6;">[${index}]</code> ${title}: <i style="word-break: break-all">${link}</i><br/>` + ) + .join("\n"); +} + +function transform(legend: string, text: string | null, title: string | null): string { + const options = legend.split("-"); + for (const option of options) { + if (option === "alt" && text) { + return text; + } + if (option === "title" && title) { + return title; + } + } + return ""; +} + +const macCodeSvg = ` + <svg xmlns="http://www.w3.org/2000/svg" version="1.1" x="0px" y="0px" width="45px" height="13px" viewBox="0 0 450 130"> + <ellipse cx="50" cy="65" rx="50" ry="52" stroke="rgb(220,60,54)" stroke-width="2" fill="rgb(237,108,96)" /> + <ellipse cx="225" cy="65" rx="50" ry="52" stroke="rgb(218,151,33)" stroke-width="2" fill="rgb(247,193,81)" /> + <ellipse cx="400" cy="65" rx="50" ry="52" stroke="rgb(27,161,37)" stroke-width="2" fill="rgb(100,200,86)" /> + </svg> +`.trim(); + +function parseFrontMatterAndContent(markdownText: string): ParseResult { + try { + const parsed = frontMatter(markdownText); + const yamlData = parsed.attributes; + const markdownContent = parsed.body; + + const readingTimeResult = readingTime(markdownContent); + + return { + yamlData: yamlData as Record<string, any>, + markdownContent, + readingTime: readingTimeResult, + }; + } catch (error) { + console.error("Error parsing front-matter:", error); + return { + yamlData: {}, + markdownContent: markdownText, + readingTime: readingTime(markdownText), + }; + } +} + +export function initRenderer(opts: IOpts = {}): RendererAPI { + const footnotes: [number, string, string][] = []; + let footnoteIndex = 0; + let codeIndex = 0; + const listOrderedStack: boolean[] = []; + const listCounters: number[] = []; + const isBrowser = typeof window !== "undefined"; + + function getOpts(): IOpts { + return opts; + } + + function styledContent(styleLabel: string, content: string, tagName?: string): string { + const tag = tagName ?? styleLabel; + const className = `${styleLabel.replace(/_/g, "-")}`; + const headingAttr = /^h\d$/.test(tag) ? " data-heading=\"true\"" : ""; + return `<${tag} class="${className}"${headingAttr}>${content}</${tag}>`; + } + + function addFootnote(title: string, link: string): number { + const existingFootnote = footnotes.find(([, , existingLink]) => existingLink === link); + if (existingFootnote) { + return existingFootnote[0]; + } + + footnotes.push([++footnoteIndex, title, link]); + return footnoteIndex; + } + + function reset(newOpts: Partial<IOpts>): void { + footnotes.length = 0; + footnoteIndex = 0; + setOptions(newOpts); + } + + function setOptions(newOpts: Partial<IOpts>): void { + opts = { ...opts, ...newOpts }; + marked.use(markedAlert()); + if (isBrowser) { + marked.use(MDKatex({ nonStandard: true }, true)); + } + marked.use(markedMarkup()); + marked.use(markedInfographic({ themeMode: opts.themeMode })); + } + + function buildReadingTime(readingTimeResult: ReadTimeResults): string { + if (!opts.countStatus) { + return ""; + } + if (!readingTimeResult.words) { + return ""; + } + return ` + <blockquote class="md-blockquote"> + <p class="md-blockquote-p">字数 ${readingTimeResult?.words},阅读大约需 ${Math.ceil(readingTimeResult?.minutes)} 分钟</p> + </blockquote> + `; + } + + const buildFootnotes = () => { + if (!footnotes.length) { + return ""; + } + + return ( + styledContent("h4", "引用链接") + + styledContent("footnotes", buildFootnoteArray(footnotes), "p") + ); + }; + + const renderer: RendererObject = { + heading({ tokens, depth }: Tokens.Heading) { + const text = this.parser.parseInline(tokens); + const tag = `h${depth}`; + return styledContent(tag, text); + }, + + paragraph({ tokens }: Tokens.Paragraph): string { + const text = this.parser.parseInline(tokens); + const isFigureImage = text.includes("<figure") && text.includes("<img"); + const isEmpty = text.trim() === ""; + if (isFigureImage || isEmpty) { + return text; + } + return styledContent("p", text); + }, + + blockquote({ tokens }: Tokens.Blockquote): string { + const text = this.parser.parse(tokens); + return styledContent("blockquote", text); + }, + + code({ text, lang = "" }: Tokens.Code): string { + if (lang.startsWith("mermaid")) { + if (isBrowser) { + clearTimeout(codeIndex as any); + codeIndex = setTimeout(async () => { + const windowRef = typeof window !== "undefined" ? (window as any) : undefined; + if (windowRef && windowRef.mermaid) { + const mermaid = windowRef.mermaid; + await mermaid.run(); + } else { + const mermaid = await import("mermaid"); + await mermaid.default.run(); + } + }, 0) as any as number; + } + return `<pre class="mermaid">${text}</pre>`; + } + const langText = lang.split(" ")[0]; + const isLanguageRegistered = hljs.getLanguage(langText); + const language = isLanguageRegistered ? langText : "plaintext"; + + const highlighted = highlightAndFormatCode( + text, + language, + hljs, + !!opts.isShowLineNumber + ); + + const span = `<span class="mac-sign" style="padding: 10px 14px 0;">${macCodeSvg}</span>`; + let pendingAttr = ""; + if (!isLanguageRegistered && langText !== "plaintext") { + const escapedText = text.replace(/"/g, """); + pendingAttr = ` data-language-pending="${langText}" data-raw-code="${escapedText}" data-show-line-number="${opts.isShowLineNumber}"`; + } + const code = `<code class="language-${lang}"${pendingAttr}>${highlighted}</code>`; + + return `<pre class="hljs code__pre">${span}${code}</pre>`; + }, + + codespan({ text }: Tokens.Codespan): string { + const escapedText = escapeHtml(text); + return styledContent("codespan", escapedText, "code"); + }, + + list({ ordered, items, start = 1 }: Tokens.List) { + listOrderedStack.push(ordered); + listCounters.push(Number(start)); + + const html = items.map((item) => this.listitem(item)).join(""); + + listOrderedStack.pop(); + listCounters.pop(); + + return styledContent(ordered ? "ol" : "ul", html); + }, + + listitem(token: Tokens.ListItem) { + const ordered = listOrderedStack[listOrderedStack.length - 1]; + const idx = listCounters[listCounters.length - 1]!; + + listCounters[listCounters.length - 1] = idx + 1; + + const prefix = ordered ? `${idx}. ` : "• "; + + let content: string; + try { + content = this.parser.parseInline(token.tokens); + } catch { + content = this.parser + .parse(token.tokens) + .replace(/^<p(?:\s[^>]*)?>([\s\S]*?)<\/p>/, "$1"); + } + + return styledContent("listitem", `${prefix}${content}`, "li"); + }, + + image({ href, title, text }: Tokens.Image): string { + const newText = opts.legend ? transform(opts.legend, text, title) : ""; + const subText = newText ? styledContent("figcaption", newText) : ""; + const titleAttr = title ? ` title="${title}"` : ""; + return `<figure><img src="${href}"${titleAttr} alt="${text}"/>${subText}</figure>`; + }, + + link({ href, title, text, tokens }: Tokens.Link): string { + const parsedText = this.parser.parseInline(tokens); + if (/^https?:\/\/mp\.weixin\.qq\.com/.test(href)) { + return `<a href="${href}" title="${title || text}">${parsedText}</a>`; + } + if (href === text) { + return parsedText; + } + if (opts.citeStatus) { + const ref = addFootnote(title || text, href); + return `<a href="${href}" title="${title || text}">${parsedText}<sup>[${ref}]</sup></a>`; + } + return `<a href="${href}" title="${title || text}">${parsedText}</a>`; + }, + + strong({ tokens }: Tokens.Strong): string { + return styledContent("strong", this.parser.parseInline(tokens)); + }, + + em({ tokens }: Tokens.Em): string { + return styledContent("em", this.parser.parseInline(tokens)); + }, + + table({ header, rows }: Tokens.Table): string { + const headerRow = header + .map((cell) => { + const text = this.parser.parseInline(cell.tokens); + return styledContent("th", text); + }) + .join(""); + const body = rows + .map((row) => { + const rowContent = row.map((cell) => this.tablecell(cell)).join(""); + return styledContent("tr", rowContent); + }) + .join(""); + return ` + <section style="max-width: 100%; overflow: auto"> + <table class="preview-table"> + <thead>${headerRow}</thead> + <tbody>${body}</tbody> + </table> + </section> + `; + }, + + tablecell(token: Tokens.TableCell): string { + const text = this.parser.parseInline(token.tokens); + return styledContent("td", text); + }, + + hr(_: Tokens.Hr): string { + return styledContent("hr", ""); + }, + }; + + marked.use({ renderer }); + marked.use(markedMarkup()); + marked.use(markedToc()); + marked.use(markedSlider()); + marked.use(markedAlert({})); + if (isBrowser) { + marked.use(MDKatex({ nonStandard: true }, true)); + } + marked.use(markedFootnotes()); + marked.use( + markedPlantUML({ + inlineSvg: isBrowser, + }) + ); + marked.use(markedInfographic()); + marked.use(markedRuby()); + + return { + buildAddition, + buildFootnotes, + setOptions, + reset, + parseFrontMatterAndContent, + buildReadingTime, + createContainer(content: string) { + return styledContent("container", content, "section"); + }, + getOpts, + }; +} + +function printUsage(): void { + console.error( + [ + "Usage:", + " npx tsx src/md/render.ts <markdown_file> [--theme <name>]", + "", + "Options:", + ` --theme Theme name (${THEME_NAMES.join(", ")})`, + ].join("\n") + ); +} + +function parseArgs(argv: string[]): CliOptions | null { + let inputPath = ""; + let theme: ThemeName = "default"; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (!arg.startsWith("--") && !inputPath) { + inputPath = arg; + continue; + } + + if (arg === "--theme") { + theme = (argv[i + 1] || "") as ThemeName; + i += 1; + continue; + } + + if (arg.startsWith("--theme=")) { + theme = arg.slice("--theme=".length) as ThemeName; + continue; + } + + if (arg === "--help" || arg === "-h") { + return null; + } + + console.error(`Unknown argument: ${arg}`); + return null; + } + + if (!inputPath) { + return null; + } + + if (!THEME_NAMES.includes(theme)) { + console.error(`Unknown theme: ${theme}`); + return null; + } + + return { + inputPath, + theme, + }; +} + +interface CliOptions { + inputPath: string; + theme: ThemeName; +} + +function renderMarkdown(raw: string, renderer: RendererAPI): { + html: string; + readingTime: ReadTimeResults; +} { + const { markdownContent, readingTime: readingTimeResult } = + renderer.parseFrontMatterAndContent(raw); + + const html = marked.parse(markdownContent) as string; + + return { html, readingTime: readingTimeResult }; +} + +function postProcessHtml( + baseHtml: string, + reading: ReadTimeResults, + renderer: RendererAPI +): string { + let html = baseHtml; + html = renderer.buildReadingTime(reading) + html; + html += renderer.buildFootnotes(); + html += renderer.buildAddition(); + html += ` + <style> + .hljs.code__pre > .mac-sign { + display: ${renderer.getOpts().isMacCodeBlock ? "flex" : "none"}; + } + </style> + `; + html += ` + <style> + h2 strong { + color: inherit !important; + } + </style> + `; + return renderer.createContainer(html); +} + +function formatTimestamp(date = new Date()): string { + const pad = (value: number) => String(value).padStart(2, "0"); + return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad( + date.getDate() + )}${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`; +} + +function ensureMarkdownPath(inputPath: string): void { + if (!inputPath.toLowerCase().endsWith(".md")) { + throw new Error("Input file must end with .md"); + } +} + +function loadThemeCss(theme: ThemeName): { + baseCss: string; + themeCss: string; +} { + const basePath = path.join(THEME_DIR, "base.css"); + const themePath = path.join(THEME_DIR, `${theme}.css`); + + if (!fs.existsSync(basePath)) { + throw new Error(`Missing base CSS: ${basePath}`); + } + + if (!fs.existsSync(themePath)) { + throw new Error(`Missing theme CSS: ${themePath}`); + } + + return { + baseCss: fs.readFileSync(basePath, "utf-8"), + themeCss: fs.readFileSync(themePath, "utf-8"), + }; +} + +function buildCss(baseCss: string, themeCss: string): string { + const variables = ` +:root { + --md-primary-color: ${DEFAULT_STYLE.primaryColor}; + --md-font-family: ${DEFAULT_STYLE.fontFamily}; + --md-font-size: ${DEFAULT_STYLE.fontSize}; + --foreground: ${DEFAULT_STYLE.foreground}; + --blockquote-background: ${DEFAULT_STYLE.blockquoteBackground}; +} + +body { + margin: 0; + padding: 24px; + background: #ffffff; +} + +#output { + max-width: 860px; + margin: 0 auto; +} +`.trim(); + + return [variables, baseCss, themeCss].join("\n\n"); +} + +function buildHtmlDocument(title: string, css: string, html: string): string { + return [ + "<!doctype html>", + "<html>", + "<head>", + ' <meta charset="utf-8" />', + ' <meta name="viewport" content="width=device-width, initial-scale=1" />', + ` <title>${title}`, + ` `, + "", + "", + '
', + html, + "
", + "", + "", + ].join("\n"); +} + +function main(): void { + const options = parseArgs(process.argv.slice(2)); + if (!options) { + printUsage(); + process.exit(1); + } + + const inputPath = path.resolve(process.cwd(), options.inputPath); + ensureMarkdownPath(inputPath); + + if (!fs.existsSync(inputPath)) { + console.error(`File not found: ${inputPath}`); + process.exit(1); + } + + const outputPath = path.resolve( + process.cwd(), + options.inputPath.replace(/\.md$/i, ".html") + ); + + const { baseCss, themeCss } = loadThemeCss(options.theme); + const css = buildCss(baseCss, themeCss); + const markdown = fs.readFileSync(inputPath, "utf-8"); + + const renderer = initRenderer({}); + const { html: baseHtml, readingTime: readingTimeResult } = renderMarkdown( + markdown, + renderer + ); + const content = postProcessHtml(baseHtml, readingTimeResult, renderer); + + const title = path.basename(outputPath, ".html"); + const html = buildHtmlDocument(title, css, content); + + let backupPath = ""; + if (fs.existsSync(outputPath)) { + backupPath = `${outputPath}.bak-${formatTimestamp()}`; + fs.renameSync(outputPath, backupPath); + } + + fs.writeFileSync(outputPath, html, "utf-8"); + + if (backupPath) { + console.log(`Backup created: ${backupPath}`); + } + console.log(`HTML written: ${outputPath}`); +} + +main(); diff --git a/skills/post-to-wechat/scripts/md/themes/base.css b/skills/post-to-wechat/scripts/md/themes/base.css new file mode 100644 index 0000000..7f8ee08 --- /dev/null +++ b/skills/post-to-wechat/scripts/md/themes/base.css @@ -0,0 +1,26 @@ +/** + * MD 基础主题样式 + * 包含所有元素的基础样式和 CSS 变量定义 + */ + +/* ==================== 容器样式 ==================== */ +section, +container { + font-family: var(--md-font-family); + font-size: var(--md-font-size); + line-height: 1.75; + text-align: left; +} + +/* 确保 #output 容器应用基础样式 */ +#output { + font-family: var(--md-font-family); + font-size: var(--md-font-size); + line-height: 1.75; + text-align: left; +} + +/* 去除第一个元素的 margin-top */ +#output section > :first-child { + margin-top: 0 !important; +} diff --git a/skills/post-to-wechat/scripts/md/themes/default.css b/skills/post-to-wechat/scripts/md/themes/default.css new file mode 100644 index 0000000..e30fcbf --- /dev/null +++ b/skills/post-to-wechat/scripts/md/themes/default.css @@ -0,0 +1,433 @@ +/** + * MD 默认主题(经典主题) + * 按 Alt/Option + Shift + F 可格式化 + * 如需使用主题色,请使用 var(--md-primary-color) 代替颜色值 + */ + +/* ==================== 一级标题 ==================== */ +h1 { + display: table; + padding: 0 1em; + border-bottom: 2px solid var(--md-primary-color); + margin: 2em auto 1em; + color: hsl(var(--foreground)); + font-size: calc(var(--md-font-size) * 1.2); + font-weight: bold; + text-align: center; +} + +/* ==================== 二级标题 ==================== */ +h2 { + display: table; + padding: 0 0.2em; + margin: 4em auto 2em; + color: #fff; + background: var(--md-primary-color); + font-size: calc(var(--md-font-size) * 1.2); + font-weight: bold; + text-align: center; +} + +/* ==================== 三级标题 ==================== */ +h3 { + padding-left: 8px; + border-left: 3px solid var(--md-primary-color); + margin: 2em 8px 0.75em 0; + color: hsl(var(--foreground)); + font-size: calc(var(--md-font-size) * 1.1); + font-weight: bold; + line-height: 1.2; +} + +/* ==================== 四级标题 ==================== */ +h4 { + margin: 2em 8px 0.5em; + color: var(--md-primary-color); + font-size: calc(var(--md-font-size) * 1); + font-weight: bold; +} + +/* ==================== 五级标题 ==================== */ +h5 { + margin: 1.5em 8px 0.5em; + color: var(--md-primary-color); + font-size: calc(var(--md-font-size) * 1); + font-weight: bold; +} + +/* ==================== 六级标题 ==================== */ +h6 { + margin: 1.5em 8px 0.5em; + font-size: calc(var(--md-font-size) * 1); + color: var(--md-primary-color); +} + +/* ==================== 段落 ==================== */ +p { + margin: 1.5em 8px; + letter-spacing: 0.1em; + color: hsl(var(--foreground)); +} + +/* ==================== 引用块 ==================== */ +blockquote { + font-style: normal; + padding: 1em; + border-left: 4px solid var(--md-primary-color); + border-radius: 6px; + color: hsl(var(--foreground)); + background: var(--blockquote-background); + margin-bottom: 1em; +} + +blockquote > p { + display: block; + font-size: 1em; + letter-spacing: 0.1em; + color: hsl(var(--foreground)); + margin: 0; +} + +/* ==================== GFM 警告块 ==================== */ +.alert-title-note, +.alert-title-tip, +.alert-title-info, +.alert-title-important, +.alert-title-warning, +.alert-title-caution, +.alert-title-abstract, +.alert-title-summary, +.alert-title-tldr, +.alert-title-todo, +.alert-title-success, +.alert-title-done, +.alert-title-question, +.alert-title-help, +.alert-title-faq, +.alert-title-failure, +.alert-title-fail, +.alert-title-missing, +.alert-title-danger, +.alert-title-error, +.alert-title-bug, +.alert-title-example, +.alert-title-quote, +.alert-title-cite { + display: flex; + align-items: center; + gap: 0.5em; + margin-bottom: 0.5em; +} + +.alert-title-note { + color: #478be6; +} + +.alert-title-tip { + color: #57ab5a; +} + +.alert-title-info { + color: #93c5fd; +} + +.alert-title-important { + color: #986ee2; +} + +.alert-title-warning { + color: #c69026; +} + +.alert-title-caution { + color: #e5534b; +} + +/* Obsidian-style callout colors */ +.alert-title-abstract, +.alert-title-summary, +.alert-title-tldr { + color: #00bfff; +} + +.alert-title-todo { + color: #478be6; +} + +.alert-title-success, +.alert-title-done { + color: #57ab5a; +} + +.alert-title-question, +.alert-title-help, +.alert-title-faq { + color: #c69026; +} + +.alert-title-failure, +.alert-title-fail, +.alert-title-missing { + color: #e5534b; +} + +.alert-title-danger, +.alert-title-error { + color: #e5534b; +} + +.alert-title-bug { + color: #e5534b; +} + +.alert-title-example { + color: #986ee2; +} + +.alert-title-quote, +.alert-title-cite { + color: #9ca3af; +} + +/* GFM Alert SVG 图标颜色 */ +.alert-icon-note { + fill: #478be6; +} + +.alert-icon-tip { + fill: #57ab5a; +} + +.alert-icon-info { + fill: #93c5fd; +} + +.alert-icon-important { + fill: #986ee2; +} + +.alert-icon-warning { + fill: #c69026; +} + +.alert-icon-caution { + fill: #e5534b; +} + +/* Obsidian-style callout icon colors */ +.alert-icon-abstract, +.alert-icon-summary, +.alert-icon-tldr { + fill: #00bfff; +} + +.alert-icon-todo { + fill: #478be6; +} + +.alert-icon-success, +.alert-icon-done { + fill: #57ab5a; +} + +.alert-icon-question, +.alert-icon-help, +.alert-icon-faq { + fill: #c69026; +} + +.alert-icon-failure, +.alert-icon-fail, +.alert-icon-missing { + fill: #e5534b; +} + +.alert-icon-danger, +.alert-icon-error { + fill: #e5534b; +} + +.alert-icon-bug { + fill: #e5534b; +} + +.alert-icon-example { + fill: #986ee2; +} + +.alert-icon-quote, +.alert-icon-cite { + fill: #9ca3af; +} + +/* ==================== 代码块 ==================== */ +pre.code__pre, +.hljs.code__pre { + font-size: 90%; + overflow-x: auto; + border-radius: 8px; + padding: 0 !important; + line-height: 1.5; + margin: 10px 8px; +} + +/* ==================== 图片 ==================== */ +img { + display: block; + max-width: 100%; + margin: 0.1em auto 0.5em; + border-radius: 4px; +} + +/* ==================== 列表 ==================== */ +ol { + padding-left: 1em; + margin-left: 0; + color: hsl(var(--foreground)); +} + +ul { + list-style: circle; + padding-left: 1em; + margin-left: 0; + color: hsl(var(--foreground)); +} + +li { + display: block; + margin: 0.2em 8px; + color: hsl(var(--foreground)); +} + +/* ==================== 脚注 ==================== */ +/* footnotes 在 buildFootnotes() 中渲染为

标签 */ +p.footnotes { + margin: 0.5em 8px; + font-size: 80%; + color: hsl(var(--foreground)); +} + +/* ==================== 图表 ==================== */ +figure { + margin: 1.5em 8px; + color: hsl(var(--foreground)); +} + +figcaption, +.md-figcaption { + text-align: center; + color: #888; + font-size: 0.8em; +} + +/* ==================== 分隔线 ==================== */ +hr { + border-style: solid; + border-width: 2px 0 0; + border-color: rgba(0, 0, 0, 0.1); + -webkit-transform-origin: 0 0; + -webkit-transform: scale(1, 0.5); + transform-origin: 0 0; + transform: scale(1, 0.5); + height: 0.4em; + margin: 1.5em 0; +} + +/* ==================== 行内代码 ==================== */ +code { + font-size: 90%; + color: #d14; + background: rgba(27, 31, 35, 0.05); + padding: 3px 5px; + border-radius: 4px; +} + +/* 代码块内的 code 标签需要特殊处理(覆盖行内 code 样式) */ +pre.code__pre > code, +.hljs.code__pre > code { + display: -webkit-box; + padding: 0.5em 1em 1em; + overflow-x: auto; + text-indent: 0; + color: inherit; + background: none; + white-space: nowrap; + margin: 0; +} + +/* ==================== 强调 ==================== */ +em { + font-style: italic; + font-size: inherit; +} + +/* ==================== 链接 ==================== */ +a { + color: #576b95; + text-decoration: none; +} + +/* ==================== 粗体 ==================== */ +strong { + color: var(--md-primary-color); + font-weight: bold; + font-size: inherit; +} + +/* ==================== 表格 ==================== */ +table { + color: hsl(var(--foreground)); +} + +thead { + font-weight: bold; + color: hsl(var(--foreground)); +} + +th { + border: 1px solid #dfdfdf; + padding: 0.25em 0.5em; + color: hsl(var(--foreground)); + word-break: keep-all; + background: rgba(0, 0, 0, 0.05); +} + +td { + border: 1px solid #dfdfdf; + padding: 0.25em 0.5em; + color: hsl(var(--foreground)); + word-break: keep-all; +} + +/* ==================== KaTeX 公式 ==================== */ +.katex-inline { + max-width: 100%; + overflow-x: auto; +} + +.katex-block { + max-width: 100%; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + padding: 0.5em 0; + text-align: center; +} + +/* ==================== 标记高亮 ==================== */ +.markup-highlight { + background-color: var(--md-primary-color); + padding: 2px 4px; + border-radius: 2px; + color: #fff; +} + +.markup-underline { + text-decoration: underline; + text-decoration-color: var(--md-primary-color); +} + +.markup-wavyline { + text-decoration: underline wavy; + text-decoration-color: var(--md-primary-color); + text-decoration-thickness: 2px; +} diff --git a/skills/post-to-wechat/scripts/md/themes/grace.css b/skills/post-to-wechat/scripts/md/themes/grace.css new file mode 100644 index 0000000..161e5f4 --- /dev/null +++ b/skills/post-to-wechat/scripts/md/themes/grace.css @@ -0,0 +1,136 @@ +/** + * MD 优雅主题 (@brzhang) + * 在默认主题基础上添加优雅的视觉效果 + */ + +/* ==================== 标题样式 ==================== */ +h1 { + padding: 0.5em 1em; + border-bottom: 2px solid var(--md-primary-color); + font-size: calc(var(--md-font-size) * 1.4); + text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1); +} + +h2 { + padding: 0.3em 1em; + border-radius: 8px; + font-size: calc(var(--md-font-size) * 1.3); + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +h3 { + padding-left: 12px; + font-size: calc(var(--md-font-size) * 1.2); + border-left: 4px solid var(--md-primary-color); + border-bottom: 1px dashed var(--md-primary-color); +} + +h4 { + font-size: calc(var(--md-font-size) * 1.1); +} + +h5 { + font-size: var(--md-font-size); +} + +h6 { + font-size: var(--md-font-size); +} + +/* ==================== 引用块 ==================== */ +blockquote { + font-style: italic; + padding: 1em 1em 1em 2em; + border-left: 4px solid var(--md-primary-color); + border-radius: 6px; + color: rgba(0, 0, 0, 0.6); + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05); + margin-bottom: 1em; +} + +.markdown-alert { + font-style: italic; +} + +/* ==================== 代码块 ==================== */ +pre.code__pre, +.hljs.code__pre { + box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.05); +} + +pre.code__pre > code, +.hljs.code__pre > code { + font-family: + 'Fira Code', + Menlo, + Operator Mono, + Consolas, + Monaco, + monospace; +} + +/* ==================== 图片 ==================== */ +img { + border-radius: 8px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} + +figcaption, +.md-figcaption { + text-align: center; + color: #888; + font-size: 0.8em; +} + +/* ==================== 列表 ==================== */ +ol { + padding-left: 1.5em; +} + +ul { + list-style: none; + padding-left: 1.5em; +} + +li { + margin: 0.5em 8px; +} + +/* ==================== 分隔线 ==================== */ +hr { + height: 1px; + border: none; + margin: 2em 0; + background: linear-gradient(to right, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0)); +} + +/* ==================== 表格 ==================== */ +table { + border-collapse: separate; + border-spacing: 0; + border-radius: 8px; + margin: 1em 8px; + color: hsl(var(--foreground)); + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + overflow: hidden; +} + +thead { + color: #fff; +} + +td { + padding: 0.5em 1em; +} + +/* ==================== 强调 ==================== */ +em { + font-style: italic; + font-size: inherit; +} + +/* ==================== 链接 ==================== */ +a { + color: #576b95; + text-decoration: none; +} diff --git a/skills/post-to-wechat/scripts/md/themes/simple.css b/skills/post-to-wechat/scripts/md/themes/simple.css new file mode 100644 index 0000000..a1db867 --- /dev/null +++ b/skills/post-to-wechat/scripts/md/themes/simple.css @@ -0,0 +1,129 @@ +/** + * MD 简洁主题 (@okooo5km) + * 简洁现代的设计风格 + */ + +/* ==================== 标题样式 ==================== */ +h1 { + padding: 0.5em 1em; + font-size: calc(var(--md-font-size) * 1.4); + text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.05); +} + +h2 { + padding: 0.3em 1.2em; + font-size: calc(var(--md-font-size) * 1.3); + border-radius: 8px 24px 8px 24px; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06); +} + +h3 { + padding-left: 12px; + font-size: calc(var(--md-font-size) * 1.2); + border-radius: 6px; + line-height: 2.4em; + border-left: 4px solid var(--md-primary-color); + border-right: 1px solid color-mix(in srgb, var(--md-primary-color) 10%, transparent); + border-bottom: 1px solid color-mix(in srgb, var(--md-primary-color) 10%, transparent); + border-top: 1px solid color-mix(in srgb, var(--md-primary-color) 10%, transparent); + background: color-mix(in srgb, var(--md-primary-color) 8%, transparent); +} + +h4 { + font-size: calc(var(--md-font-size) * 1.1); + border-radius: 6px; +} + +h5 { + font-size: var(--md-font-size); + border-radius: 6px; +} + +h6 { + font-size: var(--md-font-size); + border-radius: 6px; +} + +/* ==================== 引用块 ==================== */ +blockquote { + font-style: italic; + padding: 1em 1em 1em 2em; + color: rgba(0, 0, 0, 0.6); + border-bottom: 0.2px solid rgba(0, 0, 0, 0.04); + border-top: 0.2px solid rgba(0, 0, 0, 0.04); + border-right: 0.2px solid rgba(0, 0, 0, 0.04); +} + +/* GFM Alert 样式覆盖 */ +.markdown-alert-note, +.markdown-alert-tip, +.markdown-alert-info, +.markdown-alert-important, +.markdown-alert-warning, +.markdown-alert-caution { + font-style: italic; +} + +/* ==================== 代码块 ==================== */ +pre.code__pre, +.hljs.code__pre { + border: 1px solid rgba(0, 0, 0, 0.04); +} + +pre.code__pre > code, +.hljs.code__pre > code { + font-family: + 'Fira Code', + Menlo, + Operator Mono, + Consolas, + Monaco, + monospace; +} + +/* ==================== 图片 ==================== */ +img { + border-radius: 8px; + border: 1px solid rgba(0, 0, 0, 0.04); +} + +figcaption, +.md-figcaption { + text-align: center; + color: #888; + font-size: 0.8em; +} + +/* ==================== 列表 ==================== */ +ol { + padding-left: 1.5em; +} + +ul { + list-style: none; + padding-left: 1.5em; +} + +li { + margin: 0.5em 8px; +} + +/* ==================== 分隔线 ==================== */ +hr { + height: 1px; + border: none; + margin: 2em 0; + background: linear-gradient(to right, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0)); +} + +/* ==================== 强调 ==================== */ +em { + font-style: italic; + font-size: inherit; +} + +/* ==================== 链接 ==================== */ +a { + color: #576b95; + text-decoration: none; +} diff --git a/skills/post-to-wechat/scripts/md/utils/languages.ts b/skills/post-to-wechat/scripts/md/utils/languages.ts new file mode 100644 index 0000000..40905af --- /dev/null +++ b/skills/post-to-wechat/scripts/md/utils/languages.ts @@ -0,0 +1,238 @@ +import type { LanguageFn } from 'highlight.js' +import bash from 'highlight.js/lib/languages/bash' +import c from 'highlight.js/lib/languages/c' +import cpp from 'highlight.js/lib/languages/cpp' +import csharp from 'highlight.js/lib/languages/csharp' +import css from 'highlight.js/lib/languages/css' +import diff from 'highlight.js/lib/languages/diff' +import go from 'highlight.js/lib/languages/go' +import graphql from 'highlight.js/lib/languages/graphql' +import ini from 'highlight.js/lib/languages/ini' +import java from 'highlight.js/lib/languages/java' +import javascript from 'highlight.js/lib/languages/javascript' +import json from 'highlight.js/lib/languages/json' +import kotlin from 'highlight.js/lib/languages/kotlin' +import less from 'highlight.js/lib/languages/less' +import lua from 'highlight.js/lib/languages/lua' +import makefile from 'highlight.js/lib/languages/makefile' +import markdown from 'highlight.js/lib/languages/markdown' +import objectivec from 'highlight.js/lib/languages/objectivec' +import perl from 'highlight.js/lib/languages/perl' +import php from 'highlight.js/lib/languages/php' +import phpTemplate from 'highlight.js/lib/languages/php-template' +import plaintext from 'highlight.js/lib/languages/plaintext' +import python from 'highlight.js/lib/languages/python' +import pythonRepl from 'highlight.js/lib/languages/python-repl' +import r from 'highlight.js/lib/languages/r' +import ruby from 'highlight.js/lib/languages/ruby' +import rust from 'highlight.js/lib/languages/rust' +import scss from 'highlight.js/lib/languages/scss' +import shell from 'highlight.js/lib/languages/shell' +import sql from 'highlight.js/lib/languages/sql' +import swift from 'highlight.js/lib/languages/swift' +import typescript from 'highlight.js/lib/languages/typescript' +import vbnet from 'highlight.js/lib/languages/vbnet' +import wasm from 'highlight.js/lib/languages/wasm' +import xml from 'highlight.js/lib/languages/xml' +import yaml from 'highlight.js/lib/languages/yaml' + +export const COMMON_LANGUAGES: Record = { + bash, + c, + cpp, + csharp, + css, + diff, + go, + graphql, + ini, + java, + javascript, + json, + kotlin, + less, + lua, + makefile, + markdown, + objectivec, + perl, + php, + 'php-template': phpTemplate, + plaintext, + python, + 'python-repl': pythonRepl, + r, + ruby, + rust, + scss, + shell, + sql, + swift, + typescript, + vbnet, + wasm, + xml, + yaml, +} + +// highlight.js CDN 配置 +const HLJS_VERSION = `11.11.1` +const HLJS_CDN_BASE = `https://cdn-doocs.oss-cn-shenzhen.aliyuncs.com/npm/highlightjs/${HLJS_VERSION}` + +// 缓存正在加载的语言 +const loadingLanguages = new Map>() + +/** + * 生成语言包的 CDN URL + */ +function grammarUrlFor(language: string): string { + return `${HLJS_CDN_BASE}/es/languages/${language}.min.js` +} + +/** + * 动态加载并注册语言 + * @param language 语言名称 + * @param hljs highlight.js 实例 + */ +export async function loadAndRegisterLanguage(language: string, hljs: any): Promise { + // 如果已经注册,直接返回 + if (hljs.getLanguage(language)) { + return + } + + // 如果正在加载,等待加载完成 + if (loadingLanguages.has(language)) { + await loadingLanguages.get(language) + return + } + + // 开始加载 + const loadPromise = (async () => { + try { + const module = await import(/* @vite-ignore */ grammarUrlFor(language)) + hljs.registerLanguage(language, module.default) + } + catch (error) { + console.warn(`Failed to load language: ${language}`, error) + throw error + } + finally { + loadingLanguages.delete(language) + } + })() + + loadingLanguages.set(language, loadPromise) + await loadPromise +} + +/** + * 格式化高亮后的代码,处理空格和制表符 + */ +function formatHighlightedCode(html: string, preserveNewlines = false): string { + let formatted = html + // 将 span 之间的空格移到 span 内部 + formatted = formatted.replace(/(]*>[^<]*<\/span>)(\s+)(]*>[^<]*<\/span>)/g, (_: string, span1: string, spaces: string, span2: string) => span1 + span2.replace(/^(]*>)/, `$1${spaces}`)) + formatted = formatted.replace(/(\s+)(]*>)/g, (_: string, spaces: string, span: string) => span.replace(/^(]*>)/, `$1${spaces}`)) + // 替换制表符为4个空格 + formatted = formatted.replace(/\t/g, ` `) + + if (preserveNewlines) { + // 替换换行符为
,并将空格转换为   + formatted = formatted.replace(/\r\n/g, `
`).replace(/\n/g, `
`).replace(/(>[^<]+)|(^[^<]+)/g, (str: string) => str.replace(/\s/g, ` `)) + } + else { + // 只将空格转换为   + formatted = formatted.replace(/(>[^<]+)|(^[^<]+)/g, (str: string) => str.replace(/\s/g, ` `)) + } + + return formatted +} + +/** + * 高亮代码并格式化(支持行号) + * @param text 原始代码文本 + * @param language 语言名称 + * @param hljs highlight.js 实例 + * @param showLineNumber 是否显示行号 + * @returns 格式化后的 HTML + */ +export function highlightAndFormatCode(text: string, language: string, hljs: any, showLineNumber: boolean): string { + let highlighted = `` + + if (showLineNumber) { + const rawLines = text.replace(/\r\n/g, `\n`).split(`\n`) + + const highlightedLines = rawLines.map((lineRaw) => { + const lineHtml = hljs.highlight(lineRaw, { language }).value + const formatted = formatHighlightedCode(lineHtml, false) + return formatted === `` ? ` ` : formatted + }) + + const lineNumbersHtml = highlightedLines.map((_, idx) => `

${idx + 1}
`).join(``) + const codeInnerHtml = highlightedLines.join(`
`) + const codeLinesHtml = `
${codeInnerHtml}
` + const lineNumberColumnStyles = `text-align:right;padding:8px 0;border-right:1px solid rgba(0,0,0,0.04);user-select:none;background:var(--code-bg,transparent);` + + highlighted = ` +
+
${lineNumbersHtml}
+
${codeLinesHtml}
+
+ ` + } + else { + const rawHighlighted = hljs.highlight(text, { language }).value + highlighted = formatHighlightedCode(rawHighlighted, true) + } + + return highlighted +} + +export function highlightCodeBlock(codeBlock: Element, language: string, hljs: any): void { + const rawCode = codeBlock.getAttribute(`data-raw-code`) + const showLineNumber = codeBlock.getAttribute(`data-show-line-number`) === `true` + + if (!rawCode) + return + + const text = rawCode.replace(/"/g, `"`) + + const highlighted = highlightAndFormatCode(text, language, hljs, showLineNumber) + + codeBlock.innerHTML = highlighted + codeBlock.removeAttribute(`data-language-pending`) + codeBlock.removeAttribute(`data-raw-code`) + codeBlock.removeAttribute(`data-show-line-number`) +} + +/** + * 高亮 DOM 中待处理的代码块 + * 查找带有 data-language-pending 属性的代码块,动态加载语言后重新高亮 + * @param hljs highlight.js 实例 + * @param container 容器元素(可选,默认为 document) + */ +export function highlightPendingBlocks(hljs: any, container: Document | Element = document): void { + const pendingBlocks = container.querySelectorAll(`code[data-language-pending]`) + + pendingBlocks.forEach((codeBlock) => { + const language = codeBlock.getAttribute(`data-language-pending`) + if (!language) + return + + if (hljs.getLanguage(language)) { + // 语言已加载,直接高亮 + highlightCodeBlock(codeBlock, language, hljs) + } + else { + // 动态加载语言后重新高亮 + loadAndRegisterLanguage(language, hljs).then(() => { + highlightCodeBlock(codeBlock, language, hljs) + }).catch(() => { + // 加载失败,移除标记 + codeBlock.removeAttribute(`data-language-pending`) + codeBlock.removeAttribute(`data-raw-code`) + codeBlock.removeAttribute(`data-show-line-number`) + }) + } + }) +} diff --git a/skills/post-to-wechat/scripts/wechat-agent-browser.ts b/skills/post-to-wechat/scripts/wechat-agent-browser.ts new file mode 100644 index 0000000..08aa746 --- /dev/null +++ b/skills/post-to-wechat/scripts/wechat-agent-browser.ts @@ -0,0 +1,335 @@ +import { execSync, spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; + +const WECHAT_URL = 'https://mp.weixin.qq.com/'; +const SESSION = 'wechat-post'; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function ab(cmd: string, json = false): string { + const fullCmd = `agent-browser --session ${SESSION} ${cmd}${json ? ' --json' : ''}`; + console.log(`[ab] ${fullCmd}`); + try { + const result = execSync(fullCmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }); + return result.trim(); + } catch (e: unknown) { + const err = e as { stdout?: string; stderr?: string; message?: string }; + console.error(`[ab] Error: ${err.stderr || err.message}`); + return err.stdout || ''; + } +} + +function abRaw(args: string[]): { success: boolean; output: string } { + const result = spawnSync('agent-browser', ['--session', SESSION, ...args], { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'] + }); + return { + success: result.status === 0, + output: result.stdout || result.stderr || '' + }; +} + +interface SnapshotElement { + ref: string; + role: string; + name: string; +} + +function parseSnapshot(output: string): SnapshotElement[] { + const elements: SnapshotElement[] = []; + const refPattern = /\[ref=(@?\w+)\]/g; + const lines = output.split('\n'); + + for (const line of lines) { + const match = line.match(/\[ref=([@\w]+)\]/); + if (match) { + const ref = match[1].startsWith('@') ? match[1] : `@${match[1]}`; + const roleMatch = line.match(/^-\s+(\w+)/); + const nameMatch = line.match(/"([^"]+)"/); + elements.push({ + ref, + role: roleMatch?.[1] || 'unknown', + name: nameMatch?.[1] || '' + }); + } + } + return elements; +} + +function findElementByText(snapshot: string, text: string): string | null { + const lines = snapshot.split('\n'); + for (const line of lines) { + if (line.includes(`"${text}"`) || line.includes(text)) { + const match = line.match(/\[ref=([@\w]+)\]/); + if (match) { + return match[1].startsWith('@') ? match[1] : `@${match[1]}`; + } + } + } + return null; +} + +function findElementBySelector(snapshot: string, selector: string): string | null { + return null; +} + +interface WeChatOptions { + title: string; + content: string; + images: string[]; + submit?: boolean; + keepOpen?: boolean; +} + +async function postToWeChat(options: WeChatOptions): Promise { + const { title, content, images, submit = false, keepOpen = true } = options; + + if (title.length > 20) throw new Error(`Title too long: ${title.length} chars (max 20)`); + if (content.length > 1000) throw new Error(`Content too long: ${content.length} chars (max 1000)`); + if (images.length === 0) throw new Error('At least one image is required'); + + const absoluteImages = images.map(p => path.isAbsolute(p) ? p : path.resolve(process.cwd(), p)); + for (const img of absoluteImages) { + if (!fs.existsSync(img)) throw new Error(`Image not found: ${img}`); + } + + console.log('[wechat] Opening WeChat Official Account...'); + ab(`open ${WECHAT_URL} --headed`); + await sleep(5000); + + console.log('[wechat] Checking login status...'); + let url = ab('get url'); + console.log(`[wechat] Current URL: ${url}`); + + const waitForLogin = async (timeoutMs = 120_000): Promise => { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + url = ab('get url'); + if (url.includes('/cgi-bin/home')) return true; + console.log('[wechat] Waiting for login...'); + await sleep(3000); + } + return false; + }; + + if (!url.includes('/cgi-bin/home')) { + console.log('[wechat] Not logged in. Please scan QR code...'); + const loggedIn = await waitForLogin(); + if (!loggedIn) throw new Error('Login timeout'); + } + console.log('[wechat] Logged in.'); + await sleep(2000); + + console.log('[wechat] Getting page snapshot...'); + let snapshot = ab('snapshot'); + console.log(snapshot); + + console.log('[wechat] Looking for "图文" menu...'); + const tuWenRef = findElementByText(snapshot, '图文'); + + if (!tuWenRef) { + console.log('[wechat] Using eval to find and click menu...'); + ab(`eval "document.querySelectorAll('.new-creation__menu .new-creation__menu-item')[2].click()"`); + } else { + console.log(`[wechat] Clicking menu ref: ${tuWenRef}`); + ab(`click ${tuWenRef}`); + } + + await sleep(4000); + + console.log('[wechat] Checking for new tab...'); + const tabsOutput = ab('tab'); + console.log(`[wechat] Tabs: ${tabsOutput}`); + + const tabLines = tabsOutput.split('\n'); + const editorTabLine = tabLines.find(l => l.includes('appmsg') || (!l.includes('cgi-bin/home') && l.includes('mp.weixin.qq.com'))); + + if (tabLines.length > 1) { + const tabMatch = tabsOutput.match(/\[(\d+)\].*(?:appmsg|edit)/i); + if (tabMatch) { + console.log(`[wechat] Switching to editor tab ${tabMatch[1]}...`); + ab(`tab ${tabMatch[1]}`); + } else { + const lastTabMatch = tabsOutput.match(/\[(\d+)\]/g); + if (lastTabMatch && lastTabMatch.length > 1) { + const lastTab = lastTabMatch[lastTabMatch.length - 1].match(/\d+/)?.[0]; + if (lastTab) { + console.log(`[wechat] Switching to last tab ${lastTab}...`); + ab(`tab ${lastTab}`); + } + } + } + } + + await sleep(3000); + + url = ab('get url'); + console.log(`[wechat] Editor URL: ${url}`); + + console.log('[wechat] Getting editor snapshot...'); + snapshot = ab('snapshot'); + console.log(snapshot.substring(0, 2000)); + + console.log('[wechat] Uploading images...'); + const fileInputSelector = '.js_upload_btn_container input[type=file]'; + + ab(`eval "document.querySelector('${fileInputSelector}').style.display = 'block'"`); + await sleep(500); + + const uploadResult = abRaw(['upload', `"${fileInputSelector}"`, ...absoluteImages]); + console.log(`[wechat] Upload result: ${uploadResult.output}`); + + if (!uploadResult.success) { + console.log('[wechat] Using alternative upload method...'); + for (const img of absoluteImages) { + console.log(`[wechat] Uploading: ${img}`); + ab(`eval " + const input = document.querySelector('${fileInputSelector}'); + if (input) { + const dt = new DataTransfer(); + fetch('file://${img}').then(r => r.blob()).then(b => { + const file = new File([b], '${path.basename(img)}', { type: 'image/png' }); + dt.items.add(file); + input.files = dt.files; + input.dispatchEvent(new Event('change', { bubbles: true })); + }); + } + "`); + await sleep(2000); + } + } + + console.log('[wechat] Waiting for uploads to complete...'); + await sleep(10000); + + console.log('[wechat] Filling title...'); + snapshot = ab('snapshot -i'); + const titleRef = findElementByText(snapshot, 'title') || findElementByText(snapshot, '标题'); + + if (titleRef) { + ab(`fill ${titleRef} "${title.replace(/"/g, '\\"')}"`); + } else { + ab(`eval "const t = document.querySelector('#title'); if(t) { t.value = '${title.replace(/'/g, "\\'")}'; t.dispatchEvent(new Event('input', {bubbles: true})); }"`); + } + await sleep(500); + + console.log('[wechat] Clicking on content editor...'); + const editorRef = findElementByText(snapshot, 'js_pmEditorArea') || findElementByText(snapshot, 'textbox'); + + if (editorRef) { + ab(`click ${editorRef}`); + } else { + ab(`eval "document.querySelector('.js_pmEditorArea')?.click()"`); + } + await sleep(500); + + console.log('[wechat] Typing content...'); + const lines = content.split('\n'); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (line.length > 0) { + const escapedLine = line.replace(/"/g, '\\"').replace(/'/g, "\\'"); + ab(`eval "document.execCommand('insertText', false, '${escapedLine}')"`); + } + if (i < lines.length - 1) { + ab('press Enter'); + } + await sleep(100); + } + + console.log('[wechat] Content typed.'); + await sleep(1000); + + if (submit) { + console.log('[wechat] Saving as draft...'); + const submitRef = findElementByText(snapshot, 'js_submit') || findElementByText(snapshot, '保存'); + if (submitRef) { + ab(`click ${submitRef}`); + } else { + ab(`eval "document.querySelector('#js_submit')?.click()"`); + } + await sleep(3000); + console.log('[wechat] Draft saved!'); + } else { + console.log('[wechat] Article composed (preview mode). Add --submit to save as draft.'); + } + + if (!keepOpen) { + console.log('[wechat] Closing browser...'); + ab('close'); + } else { + console.log('[wechat] Done. Browser window left open.'); + } +} + +function printUsage(): never { + console.log(`Post to WeChat Official Account using agent-browser + +Usage: + npx -y bun wechat-agent-browser.ts [options] + +Options: + --title Article title (max 20 chars, required) + --content Article content (max 1000 chars, required) + --image Add image (can be repeated, 1+ images, required) + --submit Save as draft (default: preview only) + --close Close browser after operation (default: keep open) + --help Show this help + +Examples: + npx -y bun wechat-agent-browser.ts --title "测试" --content "内容" --image ./photo.png + npx -y bun wechat-agent-browser.ts --title "测试" --content "内容" --image a.png --image b.png --submit +`); + process.exit(0); +} + +async function main(): Promise { + const args = process.argv.slice(2); + if (args.includes('--help') || args.includes('-h')) printUsage(); + + const images: string[] = []; + let submit = false; + let keepOpen = true; + let title: string | undefined; + let content: string | undefined; + + 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 === '--title' && args[i + 1]) { + title = args[++i]; + } else if (arg === '--content' && args[i + 1]) { + content = args[++i]; + } else if (arg === '--submit') { + submit = true; + } else if (arg === '--close') { + keepOpen = false; + } + } + + if (!title) { + console.error('Error: --title is required'); + process.exit(1); + } + if (!content) { + console.error('Error: --content is required'); + process.exit(1); + } + if (images.length === 0) { + console.error('Error: At least one --image is required'); + process.exit(1); + } + + await postToWeChat({ title, content, images, submit, keepOpen }); +} + +await main().catch((err) => { + console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); +}); diff --git a/skills/post-to-wechat/scripts/wechat-article.ts b/skills/post-to-wechat/scripts/wechat-article.ts new file mode 100644 index 0000000..2c866d5 --- /dev/null +++ b/skills/post-to-wechat/scripts/wechat-article.ts @@ -0,0 +1,445 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import process from 'node:process'; +import { launchChrome, getPageSession, waitForNewTab, clickElement, typeText, evaluate, sleep, type ChromeSession, type CdpConnection } from './cdp.ts'; + +const WECHAT_URL = 'https://mp.weixin.qq.com/'; + +interface ImageInfo { + placeholder: string; + localPath: string; + originalPath: string; +} + +interface ArticleOptions { + title: string; + content?: string; + htmlFile?: string; + markdownFile?: string; + theme?: string; + author?: string; + summary?: string; + images?: string[]; + contentImages?: ImageInfo[]; + submit?: boolean; + profileDir?: string; +} + +async function waitForLogin(session: ChromeSession, timeoutMs = 120_000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const url = await evaluate(session, 'window.location.href'); + if (url.includes('/cgi-bin/home')) return true; + await sleep(2000); + } + return false; +} + +async function clickMenuByText(session: ChromeSession, text: string): Promise { + console.log(`[wechat] Clicking "${text}" menu...`); + const posResult = await session.cdp.send<{ result: { value: string } }>('Runtime.evaluate', { + expression: ` + (function() { + const items = document.querySelectorAll('.new-creation__menu .new-creation__menu-item'); + for (const item of items) { + const title = item.querySelector('.new-creation__menu-title'); + if (title && title.textContent?.trim() === '${text}') { + item.scrollIntoView({ block: 'center' }); + const rect = item.getBoundingClientRect(); + return JSON.stringify({ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }); + } + } + return 'null'; + })() + `, + returnByValue: true, + }, { sessionId: session.sessionId }); + + if (posResult.result.value === 'null') throw new Error(`Menu "${text}" not found`); + const pos = JSON.parse(posResult.result.value); + + await session.cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: pos.x, y: pos.y, button: 'left', clickCount: 1 }, { sessionId: session.sessionId }); + await sleep(100); + await session.cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: pos.x, y: pos.y, button: 'left', clickCount: 1 }, { sessionId: session.sessionId }); +} + +async function copyImageToClipboard(imagePath: string): Promise { + const scriptDir = path.dirname(new URL(import.meta.url).pathname); + const copyScript = path.join(scriptDir, './copy-to-clipboard.ts'); + const result = spawnSync('npx', ['-y', 'bun', copyScript, 'image', imagePath], { stdio: 'inherit' }); + if (result.status !== 0) throw new Error(`Failed to copy image: ${imagePath}`); +} + +async function pasteInEditor(session: ChromeSession): Promise { + const modifiers = process.platform === 'darwin' ? 4 : 2; + await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'v', code: 'KeyV', modifiers, windowsVirtualKeyCode: 86 }, { sessionId: session.sessionId }); + await sleep(50); + await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'v', code: 'KeyV', modifiers, windowsVirtualKeyCode: 86 }, { sessionId: session.sessionId }); +} + +async function copyHtmlFromBrowser(cdp: CdpConnection, htmlFilePath: string): Promise { + const absolutePath = path.isAbsolute(htmlFilePath) ? htmlFilePath : path.resolve(process.cwd(), htmlFilePath); + const fileUrl = `file://${absolutePath}`; + + console.log(`[wechat] Opening HTML file in new tab: ${fileUrl}`); + + const { targetId } = await cdp.send<{ targetId: string }>('Target.createTarget', { url: fileUrl }); + const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId, flatten: true }); + + await cdp.send('Page.enable', {}, { sessionId }); + await cdp.send('Runtime.enable', {}, { sessionId }); + await sleep(2000); + + console.log('[wechat] Selecting #output content...'); + await cdp.send<{ result: { value: unknown } }>('Runtime.evaluate', { + expression: ` + (function() { + const output = document.querySelector('#output') || document.body; + const range = document.createRange(); + range.selectNodeContents(output); + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + return true; + })() + `, + returnByValue: true, + }, { sessionId }); + await sleep(300); + + console.log('[wechat] Copying with system Cmd+C...'); + if (process.platform === 'darwin') { + spawnSync('osascript', ['-e', 'tell application "System Events" to keystroke "c" using command down']); + } else { + spawnSync('xdotool', ['key', 'ctrl+c']); + } + await sleep(1000); + + console.log('[wechat] Closing HTML tab...'); + await cdp.send('Target.closeTarget', { targetId }); +} + +async function pasteFromClipboardInEditor(): Promise { + if (process.platform === 'darwin') { + spawnSync('osascript', ['-e', 'tell application "System Events" to keystroke "v" using command down']); + } else { + spawnSync('xdotool', ['key', 'ctrl+v']); + } + await sleep(1000); +} + +async function parseMarkdownWithPlaceholders(markdownPath: string, theme?: string): Promise<{ title: string; author: string; summary: string; htmlPath: string; contentImages: ImageInfo[] }> { + const scriptDir = path.dirname(new URL(import.meta.url).pathname); + const mdToWechatScript = path.join(scriptDir, 'md-to-wechat.ts'); + const args = ['-y', 'bun', mdToWechatScript, markdownPath]; + if (theme) args.push('--theme', theme); + + const result = spawnSync('npx', args, { stdio: ['inherit', 'pipe', 'pipe'] }); + if (result.status !== 0) { + const stderr = result.stderr?.toString() || ''; + throw new Error(`Failed to parse markdown: ${stderr}`); + } + + const output = result.stdout.toString(); + return JSON.parse(output); +} + +function parseHtmlMeta(htmlPath: string): { title: string; author: string; summary: string } { + const content = fs.readFileSync(htmlPath, 'utf-8'); + + let title = ''; + const titleMatch = content.match(/([^<]+)<\/title>/i); + if (titleMatch) title = titleMatch[1]!; + + let author = ''; + const authorMatch = content.match(/<meta\s+name=["']author["']\s+content=["']([^"']+)["']/i); + if (authorMatch) author = authorMatch[1]!; + + let summary = ''; + const descMatch = content.match(/<meta\s+name=["']description["']\s+content=["']([^"']+)["']/i); + if (descMatch) summary = descMatch[1]!; + + if (!summary) { + const firstPMatch = content.match(/<p[^>]*>([^<]+)<\/p>/i); + if (firstPMatch) { + const text = firstPMatch[1]!.replace(/<[^>]+>/g, '').trim(); + if (text.length > 20) { + summary = text.length > 120 ? text.slice(0, 117) + '...' : text; + } + } + } + + return { title, author, summary }; +} + +async function selectAndReplacePlaceholder(session: ChromeSession, placeholder: string): Promise<boolean> { + const result = await session.cdp.send<{ result: { value: boolean } }>('Runtime.evaluate', { + expression: ` + (function() { + const editor = document.querySelector('.ProseMirror'); + if (!editor) return false; + + const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT, null, false); + let node; + + while ((node = walker.nextNode())) { + const text = node.textContent || ''; + const idx = text.indexOf(${JSON.stringify(placeholder)}); + if (idx !== -1) { + node.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; + } + } + return false; + })() + `, + returnByValue: true, + }, { sessionId: session.sessionId }); + + return result.result.value; +} + +async function pressDeleteKey(session: ChromeSession): Promise<void> { + await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Backspace', code: 'Backspace', windowsVirtualKeyCode: 8 }, { sessionId: session.sessionId }); + await sleep(50); + await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Backspace', code: 'Backspace', windowsVirtualKeyCode: 8 }, { sessionId: session.sessionId }); +} + +export async function postArticle(options: ArticleOptions): Promise<void> { + const { title, content, htmlFile, markdownFile, theme, author, summary, images = [], submit = false, profileDir } = options; + let { contentImages = [] } = options; + let effectiveTitle = title || ''; + let effectiveAuthor = author || ''; + let effectiveSummary = summary || ''; + let effectiveHtmlFile = htmlFile; + + if (markdownFile) { + console.log(`[wechat] Parsing markdown: ${markdownFile}`); + const parsed = await parseMarkdownWithPlaceholders(markdownFile, theme); + effectiveTitle = effectiveTitle || parsed.title; + effectiveAuthor = effectiveAuthor || parsed.author; + effectiveSummary = effectiveSummary || parsed.summary; + effectiveHtmlFile = parsed.htmlPath; + contentImages = parsed.contentImages; + console.log(`[wechat] Title: ${effectiveTitle || '(empty)'}`); + console.log(`[wechat] Author: ${effectiveAuthor || '(empty)'}`); + console.log(`[wechat] Summary: ${effectiveSummary || '(empty)'}`); + console.log(`[wechat] Found ${contentImages.length} images to insert`); + } else if (htmlFile && fs.existsSync(htmlFile)) { + console.log(`[wechat] Parsing HTML: ${htmlFile}`); + const meta = parseHtmlMeta(htmlFile); + effectiveTitle = effectiveTitle || meta.title; + effectiveAuthor = effectiveAuthor || meta.author; + effectiveSummary = effectiveSummary || meta.summary; + effectiveHtmlFile = htmlFile; + console.log(`[wechat] Title: ${effectiveTitle || '(empty)'}`); + console.log(`[wechat] Author: ${effectiveAuthor || '(empty)'}`); + console.log(`[wechat] Summary: ${effectiveSummary || '(empty)'}`); + } + + if (effectiveTitle && effectiveTitle.length > 64) throw new Error(`Title too long: ${effectiveTitle.length} chars (max 64)`); + if (!content && !effectiveHtmlFile) throw new Error('Either --content, --html, or --markdown is required'); + + const { cdp, chrome } = await launchChrome(WECHAT_URL, profileDir); + + try { + console.log('[wechat] Waiting for page load...'); + await sleep(3000); + + let session = await getPageSession(cdp, 'mp.weixin.qq.com'); + + const url = await evaluate<string>(session, 'window.location.href'); + if (!url.includes('/cgi-bin/home')) { + console.log('[wechat] Not logged in. Please scan QR code...'); + const loggedIn = await waitForLogin(session); + if (!loggedIn) throw new Error('Login timeout'); + } + console.log('[wechat] Logged in.'); + await sleep(2000); + + const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets'); + const initialIds = new Set(targets.targetInfos.map(t => t.targetId)); + + await clickMenuByText(session, '文章'); + await sleep(3000); + + const editorTargetId = await waitForNewTab(cdp, initialIds, 'mp.weixin.qq.com'); + console.log('[wechat] Editor tab opened.'); + + const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId: editorTargetId, flatten: true }); + session = { cdp, sessionId, targetId: editorTargetId }; + + await cdp.send('Page.enable', {}, { sessionId }); + await cdp.send('Runtime.enable', {}, { sessionId }); + await cdp.send('DOM.enable', {}, { sessionId }); + + await sleep(3000); + + if (effectiveTitle) { + console.log('[wechat] Filling title...'); + await evaluate(session, `document.querySelector('#title').value = ${JSON.stringify(effectiveTitle)}; document.querySelector('#title').dispatchEvent(new Event('input', { bubbles: true }));`); + } + + if (effectiveAuthor) { + console.log('[wechat] Filling author...'); + await evaluate(session, `document.querySelector('#author').value = ${JSON.stringify(effectiveAuthor)}; document.querySelector('#author').dispatchEvent(new Event('input', { bubbles: true }));`); + } + + console.log('[wechat] Clicking on editor...'); + await clickElement(session, '.ProseMirror'); + await sleep(500); + + if (effectiveHtmlFile && fs.existsSync(effectiveHtmlFile)) { + console.log(`[wechat] Copying HTML content from: ${effectiveHtmlFile}`); + await copyHtmlFromBrowser(cdp, effectiveHtmlFile); + await sleep(500); + console.log('[wechat] Pasting into editor...'); + await pasteFromClipboardInEditor(); + await sleep(3000); + + if (contentImages.length > 0) { + console.log(`[wechat] Inserting ${contentImages.length} images...`); + for (let i = 0; i < contentImages.length; i++) { + const img = contentImages[i]!; + console.log(`[wechat] [${i + 1}/${contentImages.length}] Processing: ${img.placeholder}`); + + const found = await selectAndReplacePlaceholder(session, img.placeholder); + if (!found) { + console.warn(`[wechat] Placeholder not found: ${img.placeholder}`); + continue; + } + + await sleep(500); + + console.log(`[wechat] Copying image: ${path.basename(img.localPath)}`); + await copyImageToClipboard(img.localPath); + await sleep(300); + + console.log('[wechat] Deleting placeholder with Backspace...'); + await pressDeleteKey(session); + await sleep(200); + + console.log('[wechat] Pasting image...'); + await pasteFromClipboardInEditor(); + await sleep(3000); + } + console.log('[wechat] All images inserted.'); + } + } else if (content) { + for (const img of images) { + if (fs.existsSync(img)) { + console.log(`[wechat] Pasting image: ${img}`); + await copyImageToClipboard(img); + await sleep(500); + await pasteInEditor(session); + await sleep(2000); + } + } + + console.log('[wechat] Typing content...'); + await typeText(session, content); + await sleep(1000); + } + + if (effectiveSummary) { + console.log(`[wechat] Filling summary: ${effectiveSummary}`); + await evaluate(session, `document.querySelector('#js_description').value = ${JSON.stringify(effectiveSummary)}; document.querySelector('#js_description').dispatchEvent(new Event('input', { bubbles: true }));`); + } + + console.log('[wechat] Saving as draft...'); + await evaluate(session, `document.querySelector('#js_submit button').click()`); + await sleep(3000); + + const saved = await evaluate<boolean>(session, `!!document.querySelector('.weui-desktop-toast')`); + if (saved) { + console.log('[wechat] Draft saved successfully!'); + } else { + console.log('[wechat] Waiting for save confirmation...'); + await sleep(5000); + } + + console.log('[wechat] Done. Browser window left open.'); + } finally { + cdp.close(); + } +} + +function printUsage(): never { + console.log(`Post article to WeChat Official Account + +Usage: + npx -y bun wechat-article.ts [options] + +Options: + --title <text> Article title (auto-extracted from markdown) + --content <text> Article content (use with --image) + --html <path> HTML file to paste (alternative to --content) + --markdown <path> Markdown file to convert and post (recommended) + --theme <name> Theme for markdown (default, grace, simple) + --author <name> Author name (default: 宝玉) + --summary <text> Article summary + --image <path> Content image, can repeat (only with --content) + --submit Save as draft + --profile <dir> Chrome profile directory + +Examples: + npx -y bun wechat-article.ts --markdown article.md + npx -y bun wechat-article.ts --markdown article.md --theme grace --submit + npx -y bun wechat-article.ts --title "标题" --content "内容" --image img.png + npx -y bun wechat-article.ts --title "标题" --html article.html --submit + +Markdown mode: + Images in markdown are converted to placeholders. After pasting HTML, + each placeholder is selected, scrolled into view, deleted, and replaced + with the actual image via paste. +`); + 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 title: string | undefined; + let content: string | undefined; + let htmlFile: string | undefined; + let markdownFile: string | undefined; + let theme: string | undefined; + let author: string | undefined; + let summary: string | undefined; + let submit = false; + 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 === '--content' && args[i + 1]) content = args[++i]; + else if (arg === '--html' && args[i + 1]) htmlFile = args[++i]; + else if (arg === '--markdown' && args[i + 1]) markdownFile = args[++i]; + else if (arg === '--theme' && args[i + 1]) theme = args[++i]; + else if (arg === '--author' && args[i + 1]) author = args[++i]; + else if (arg === '--summary' && args[i + 1]) summary = args[++i]; + else if (arg === '--image' && args[i + 1]) images.push(args[++i]!); + else if (arg === '--submit') submit = true; + else if (arg === '--profile' && args[i + 1]) profileDir = args[++i]; + } + + if (!markdownFile && !htmlFile && !title) { console.error('Error: --title is required (or use --markdown/--html)'); process.exit(1); } + if (!markdownFile && !htmlFile && !content) { console.error('Error: --content, --html, or --markdown is required'); process.exit(1); } + + await postArticle({ title: title || '', content, htmlFile, markdownFile, theme, author, summary, images, submit, profileDir }); +} + +await main().catch((err) => { + console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); +}); diff --git a/skills/post-to-wechat/scripts/wechat-browser.ts b/skills/post-to-wechat/scripts/wechat-browser.ts new file mode 100644 index 0000000..19de57d --- /dev/null +++ b/skills/post-to-wechat/scripts/wechat-browser.ts @@ -0,0 +1,717 @@ +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import { mkdir, readdir } from 'node:fs/promises'; +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import process from 'node:process'; + +const WECHAT_URL = 'https://mp.weixin.qq.com/'; + +interface MarkdownMeta { + title: string; + author: string; + content: string; +} + +function parseMarkdownFile(filePath: string): MarkdownMeta { + const text = fs.readFileSync(filePath, 'utf-8'); + let title = ''; + let author = ''; + let content = ''; + + const fmMatch = text.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (fmMatch) { + const fm = fmMatch[1]!; + const titleMatch = fm.match(/^title:\s*(.+)$/m); + if (titleMatch) title = titleMatch[1]!.trim().replace(/^["']|["']$/g, ''); + const authorMatch = fm.match(/^author:\s*(.+)$/m); + if (authorMatch) author = authorMatch[1]!.trim().replace(/^["']|["']$/g, ''); + } + + const bodyText = fmMatch ? text.slice(fmMatch[0].length) : text; + + if (!title) { + const h1Match = bodyText.match(/^#\s+(.+)$/m); + if (h1Match) title = h1Match[1]!.trim(); + } + + const lines = bodyText.split('\n'); + const paragraphs: string[] = []; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + if (trimmed.startsWith('#')) continue; + if (trimmed.startsWith('![')) continue; + if (trimmed.startsWith('---')) continue; + paragraphs.push(trimmed); + if (paragraphs.join('\n').length > 1200) break; + } + content = paragraphs.join('\n'); + + return { title, author, content }; +} + +function compressTitle(title: string, maxLen = 20): string { + if (title.length <= maxLen) return title; + + const prefixes = ['如何', '为什么', '什么是', '怎样', '怎么', '关于']; + let t = title; + for (const p of prefixes) { + if (t.startsWith(p) && t.length > maxLen) { + t = t.slice(p.length); + if (t.length <= maxLen) return t; + } + } + + const fillers = ['的', '了', '在', '是', '和', '与', '以及', '或者', '或', '还是', '而且', '并且', '但是', '但', '因为', '所以', '如果', '那么', '虽然', '不过', '然而', '——', '…']; + for (const f of fillers) { + if (t.length <= maxLen) break; + t = t.replace(new RegExp(f, 'g'), ''); + } + + if (t.length > maxLen) t = t.slice(0, maxLen); + + return t; +} + +function compressContent(content: string, maxLen = 1000): string { + if (content.length <= maxLen) return content; + + const lines = content.split('\n'); + const result: string[] = []; + let len = 0; + + for (const line of lines) { + if (len + line.length + 1 > maxLen) { + const remaining = maxLen - len - 1; + if (remaining > 20) result.push(line.slice(0, remaining - 3) + '...'); + break; + } + result.push(line); + len += line.length + 1; + } + + return result.join('\n'); +} + +async function loadImagesFromDir(dir: string): Promise<string[]> { + const entries = await readdir(dir); + const images = entries + .filter(f => /\.(png|jpg|jpeg|gif|webp)$/i.test(f)) + .sort() + .map(f => path.join(dir, f)); + return images; +} + +function sleep(ms: number): Promise<void> { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function getFreePort(): Promise<number> { + 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); + }); + }); + }); +} + +function findChromeExecutable(): string | undefined { + const override = process.env.WECHAT_BROWSER_CHROME_PATH?.trim(); + if (override && fs.existsSync(override)) return override; + + const candidates: string[] = []; + switch (process.platform) { + case 'darwin': + candidates.push( + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', + '/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta', + '/Applications/Chromium.app/Contents/MacOS/Chromium', + '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', + ); + break; + case 'win32': + candidates.push( + 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', + 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', + 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe', + 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe', + ); + break; + default: + candidates.push( + '/usr/bin/google-chrome', + '/usr/bin/google-chrome-stable', + '/usr/bin/chromium', + '/usr/bin/chromium-browser', + '/snap/bin/chromium', + '/usr/bin/microsoft-edge', + ); + break; + } + + for (const p of candidates) { + if (fs.existsSync(p)) return p; + } + return undefined; +} + +function getDefaultProfileDir(): string { + const base = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'); + return path.join(base, 'wechat-browser-profile'); +} + +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; +} + +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)}`); +} + +class CdpConnection { + private ws: WebSocket; + private nextId = 0; + private pending = new Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> | null }>(); + private eventHandlers = new Map<string, Set<(params: unknown) => void>>(); + + private constructor(ws: WebSocket) { + this.ws = ws; + 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.method) { + const handlers = this.eventHandlers.get(msg.method); + if (handlers) handlers.forEach((h) => h(msg.params)); + } + + 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): 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); + } + + on(method: string, handler: (params: unknown) => void): void { + if (!this.eventHandlers.has(method)) this.eventHandlers.set(method, new Set()); + this.eventHandlers.get(method)!.add(handler); + } + + 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 ?? 15_000; + + 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 {} + } +} + +interface WeChatBrowserOptions { + title?: string; + content?: string; + images?: string[]; + imagesDir?: string; + markdownFile?: string; + submit?: boolean; + timeoutMs?: number; + profileDir?: string; + chromePath?: string; +} + +export async function postToWeChat(options: WeChatBrowserOptions): Promise<void> { + const { submit = false, timeoutMs = 120_000, profileDir = getDefaultProfileDir() } = options; + + let title = options.title || ''; + let content = options.content || ''; + let images = options.images || []; + + if (options.markdownFile) { + const absPath = path.isAbsolute(options.markdownFile) ? options.markdownFile : path.resolve(process.cwd(), options.markdownFile); + if (!fs.existsSync(absPath)) throw new Error(`Markdown file not found: ${absPath}`); + const meta = parseMarkdownFile(absPath); + if (!title) title = meta.title; + if (!content) content = meta.content; + console.log(`[wechat-browser] Parsed markdown: title="${meta.title}", content=${meta.content.length} chars`); + } + + if (options.imagesDir) { + const absDir = path.isAbsolute(options.imagesDir) ? options.imagesDir : path.resolve(process.cwd(), options.imagesDir); + if (!fs.existsSync(absDir)) throw new Error(`Images directory not found: ${absDir}`); + images = await loadImagesFromDir(absDir); + console.log(`[wechat-browser] Found ${images.length} images in ${absDir}`); + } + + if (title.length > 20) { + const original = title; + title = compressTitle(title, 20); + console.log(`[wechat-browser] Title compressed: "${original}" → "${title}"`); + } + + if (content.length > 1000) { + const original = content.length; + content = compressContent(content, 1000); + console.log(`[wechat-browser] Content compressed: ${original} → ${content.length} chars`); + } + + if (!title) throw new Error('Title is required (use --title or --markdown)'); + if (!content) throw new Error('Content is required (use --content or --markdown)'); + if (images.length === 0) throw new Error('At least one image is required (use --image or --images)'); + + for (const img of images) { + if (!fs.existsSync(img)) throw new Error(`Image not found: ${img}`); + } + + const chromePath = options.chromePath ?? findChromeExecutable(); + if (!chromePath) throw new Error('Chrome not found. Set WECHAT_BROWSER_CHROME_PATH env var.'); + + await mkdir(profileDir, { recursive: true }); + + const port = await getFreePort(); + console.log(`[wechat-browser] Launching Chrome (profile: ${profileDir})`); + + const chrome = spawn(chromePath, [ + `--remote-debugging-port=${port}`, + `--user-data-dir=${profileDir}`, + '--no-first-run', + '--no-default-browser-check', + '--disable-blink-features=AutomationControlled', + '--start-maximized', + WECHAT_URL, + ], { stdio: 'ignore' }); + + let cdp: CdpConnection | null = null; + + try { + const wsUrl = await waitForChromeDebugPort(port, 30_000); + cdp = await CdpConnection.connect(wsUrl, 30_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('mp.weixin.qq.com')); + + if (!pageTarget) { + const { targetId } = await cdp.send<{ targetId: string }>('Target.createTarget', { url: WECHAT_URL }); + pageTarget = { targetId, url: WECHAT_URL, type: 'page' }; + } + + let { 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('[wechat-browser] Waiting for page load...'); + await sleep(3000); + + const checkLoginStatus = async (): Promise<boolean> => { + const result = await cdp!.send<{ result: { value: string } }>('Runtime.evaluate', { + expression: `window.location.href`, + returnByValue: true, + }, { sessionId }); + return result.result.value.includes('/cgi-bin/home'); + }; + + const waitForLogin = async (): Promise<boolean> => { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (await checkLoginStatus()) return true; + await sleep(2000); + } + return false; + }; + + let isLoggedIn = await checkLoginStatus(); + if (!isLoggedIn) { + console.log('[wechat-browser] Not logged in. Please scan QR code to log in...'); + isLoggedIn = await waitForLogin(); + if (!isLoggedIn) throw new Error('Timed out waiting for login. Please log in first.'); + } + console.log('[wechat-browser] Logged in.'); + + await sleep(2000); + + console.log('[wechat-browser] Looking for "图文" menu...'); + const menuResult = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', { + expression: ` + const menuItems = document.querySelectorAll('.new-creation__menu .new-creation__menu-item'); + const count = menuItems.length; + const texts = Array.from(menuItems).map(m => m.querySelector('.new-creation__menu-title')?.textContent?.trim() || m.textContent?.trim() || ''); + JSON.stringify({ count, texts }); + `, + returnByValue: true, + }, { sessionId }); + console.log(`[wechat-browser] Menu items: ${menuResult.result.value}`); + + const getTargets = async () => { + return await cdp!.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets'); + }; + + const initialTargets = await getTargets(); + const initialIds = new Set(initialTargets.targetInfos.map(t => t.targetId)); + console.log(`[wechat-browser] Initial targets count: ${initialTargets.targetInfos.length}`); + + console.log('[wechat-browser] Finding "图文" menu position...'); + const menuPos = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', { + expression: ` + (function() { + const menuItems = document.querySelectorAll('.new-creation__menu .new-creation__menu-item'); + console.log('Found menu items:', menuItems.length); + for (const item of menuItems) { + const title = item.querySelector('.new-creation__menu-title'); + const text = title?.textContent?.trim() || ''; + console.log('Menu item text:', text); + if (text === '图文') { + item.scrollIntoView({ block: 'center' }); + const rect = item.getBoundingClientRect(); + console.log('Found 图文,rect:', JSON.stringify(rect)); + return JSON.stringify({ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2, width: rect.width, height: rect.height }); + } + } + return 'null'; + })() + `, + returnByValue: true, + }, { sessionId }); + console.log(`[wechat-browser] Menu position: ${menuPos.result.value}`); + + const pos = menuPos.result.value !== 'null' ? JSON.parse(menuPos.result.value) : null; + if (!pos) throw new Error('图文 menu not found or not visible'); + + console.log('[wechat-browser] Clicking "图文" menu with mouse events...'); + await cdp.send('Input.dispatchMouseEvent', { + type: 'mousePressed', + x: pos.x, + y: pos.y, + button: 'left', + clickCount: 1, + }, { sessionId }); + await sleep(100); + await cdp.send('Input.dispatchMouseEvent', { + type: 'mouseReleased', + x: pos.x, + y: pos.y, + button: 'left', + clickCount: 1, + }, { sessionId }); + + console.log('[wechat-browser] Waiting for editor...'); + await sleep(3000); + + const waitForEditor = async (): Promise<{ targetId: string; isNewTab: boolean } | null> => { + const start = Date.now(); + + while (Date.now() - start < 30_000) { + const targets = await getTargets(); + const pageTargets = targets.targetInfos.filter(t => t.type === 'page'); + + for (const t of pageTargets) { + console.log(`[wechat-browser] Target: ${t.url}`); + } + + const newTab = pageTargets.find(t => !initialIds.has(t.targetId) && t.url.includes('mp.weixin.qq.com')); + if (newTab) { + console.log(`[wechat-browser] Found new tab: ${newTab.url}`); + return { targetId: newTab.targetId, isNewTab: true }; + } + + const editorTab = pageTargets.find(t => t.url.includes('appmsg')); + if (editorTab) { + console.log(`[wechat-browser] Found editor tab: ${editorTab.url}`); + return { targetId: editorTab.targetId, isNewTab: !initialIds.has(editorTab.targetId) }; + } + + const currentUrl = await cdp!.send<{ result: { value: string } }>('Runtime.evaluate', { + expression: `window.location.href`, + returnByValue: true, + }, { sessionId }); + console.log(`[wechat-browser] Current page URL: ${currentUrl.result.value}`); + + if (currentUrl.result.value.includes('appmsg')) { + console.log(`[wechat-browser] Current page navigated to editor`); + return { targetId: pageTarget!.targetId, isNewTab: false }; + } + + await sleep(1000); + } + return null; + }; + + const editorInfo = await waitForEditor(); + if (!editorInfo) { + const finalTargets = await getTargets(); + console.log(`[wechat-browser] Final targets: ${finalTargets.targetInfos.filter(t => t.type === 'page').map(t => t.url).join(', ')}`); + throw new Error('Editor not found.'); + } + + if (editorInfo.isNewTab) { + console.log('[wechat-browser] Switching to editor tab...'); + const editorSession = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId: editorInfo.targetId, flatten: true }); + sessionId = editorSession.sessionId; + + await cdp.send('Page.enable', {}, { sessionId }); + await cdp.send('Runtime.enable', {}, { sessionId }); + await cdp.send('DOM.enable', {}, { sessionId }); + } else { + console.log('[wechat-browser] Editor opened in current page'); + } + + await cdp.send('Page.enable', {}, { sessionId }); + await cdp.send('Runtime.enable', {}, { sessionId }); + await cdp.send('DOM.enable', {}, { sessionId }); + + await sleep(2000); + + console.log('[wechat-browser] Uploading all images at once...'); + const absolutePaths = images.map(p => path.isAbsolute(p) ? p : path.resolve(process.cwd(), p)); + console.log(`[wechat-browser] Images: ${absolutePaths.join(', ')}`); + + const { root } = await cdp.send<{ root: { nodeId: number } }>('DOM.getDocument', {}, { sessionId }); + const { nodeId } = await cdp.send<{ nodeId: number }>('DOM.querySelector', { + nodeId: root.nodeId, + selector: '.js_upload_btn_container input[type=file]', + }, { sessionId }); + + if (!nodeId) throw new Error('File input not found'); + + await cdp.send('DOM.setFileInputFiles', { + nodeId, + files: absolutePaths, + }, { sessionId }); + + await sleep(1000); + + console.log('[wechat-browser] Filling title...'); + await cdp.send('Runtime.evaluate', { + expression: ` + const titleInput = document.querySelector('#title'); + if (titleInput) { + titleInput.value = ${JSON.stringify(title)}; + titleInput.dispatchEvent(new Event('input', { bubbles: true })); + } else { + throw new Error('Title input not found'); + } + `, + }, { sessionId }); + await sleep(500); + + console.log('[wechat-browser] Clicking on content editor...'); + const editorPos = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', { + expression: ` + (function() { + const editor = document.querySelector('.js_pmEditorArea'); + if (editor) { + const rect = editor.getBoundingClientRect(); + return JSON.stringify({ x: rect.x + 50, y: rect.y + 20 }); + } + return 'null'; + })() + `, + returnByValue: true, + }, { sessionId }); + + if (editorPos.result.value === 'null') throw new Error('Content editor not found'); + const editorClickPos = JSON.parse(editorPos.result.value); + + await cdp.send('Input.dispatchMouseEvent', { + type: 'mousePressed', + x: editorClickPos.x, + y: editorClickPos.y, + button: 'left', + clickCount: 1, + }, { sessionId }); + await sleep(50); + await cdp.send('Input.dispatchMouseEvent', { + type: 'mouseReleased', + x: editorClickPos.x, + y: editorClickPos.y, + button: 'left', + clickCount: 1, + }, { sessionId }); + await sleep(300); + + console.log('[wechat-browser] Typing content with keyboard simulation...'); + const lines = content.split('\n'); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (line.length > 0) { + await cdp.send('Input.insertText', { text: line }, { sessionId }); + } + if (i < lines.length - 1) { + 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(50); + } + console.log('[wechat-browser] Content typed.'); + await sleep(500); + + if (submit) { + console.log('[wechat-browser] Saving as draft...'); + await cdp.send('Runtime.evaluate', { + expression: `document.querySelector('#js_submit')?.click()`, + }, { sessionId }); + await sleep(3000); + console.log('[wechat-browser] Draft saved!'); + } else { + console.log('[wechat-browser] Article composed (preview mode). Add --submit to save as draft.'); + } + } finally { + if (cdp) { + cdp.close(); + } + console.log('[wechat-browser] Done. Browser window left open.'); + } +} + +function printUsage(): never { + console.log(`Post image-text (图文) to WeChat Official Account + +Usage: + npx -y bun wechat-browser.ts [options] + +Options: + --markdown <path> Markdown file for title/content extraction + --images <dir> Directory containing images (PNG/JPG) + --title <text> Article title (max 20 chars, auto-compressed) + --content <text> Article content (max 1000 chars, auto-compressed) + --image <path> Add image (can be repeated) + --submit Save as draft (default: preview only) + --profile <dir> Chrome profile directory + --help Show this help + +Examples: + npx -y bun wechat-browser.ts --markdown article.md --images ./photos/ + npx -y bun wechat-browser.ts --title "测试" --content "内容" --image ./photo.png + npx -y bun wechat-browser.ts --markdown article.md --images ./photos/ --submit +`); + 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 submit = false; + let profileDir: string | undefined; + let title: string | undefined; + let content: string | undefined; + let markdownFile: string | undefined; + let imagesDir: string | undefined; + + 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 === '--images' && args[i + 1]) { + imagesDir = args[++i]; + } else if (arg === '--title' && args[i + 1]) { + title = args[++i]; + } else if (arg === '--content' && args[i + 1]) { + content = args[++i]; + } else if (arg === '--markdown' && args[i + 1]) { + markdownFile = args[++i]; + } else if (arg === '--submit') { + submit = true; + } else if (arg === '--profile' && args[i + 1]) { + profileDir = args[++i]; + } + } + + if (!markdownFile && !title) { + console.error('Error: --title or --markdown is required'); + process.exit(1); + } + if (!markdownFile && !content) { + console.error('Error: --content or --markdown is required'); + process.exit(1); + } + if (images.length === 0 && !imagesDir) { + console.error('Error: --image or --images is required'); + process.exit(1); + } + + await postToWeChat({ title, content, images: images.length > 0 ? images : undefined, imagesDir, markdownFile, submit, profileDir }); +} + +await main().catch((err) => { + console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); +});