x-article-publisher
Publish Markdown articles to X (Twitter) Articles editor with proper formatting. Use when user wants to publish a Markdown file/URL to X Articles, or mentions "publish to X", "post article to Twitter", "X article", or wants help with X Premium article publishing. Handles cover image upload and converts Markdown to rich text automatically.
1How It Works & Mechanism
Three-stage pipeline: A local Python script parses the Markdown file into structured data (JSON + HTML ready for pasting), the host-provided Playwright MCP browser tool injects the content into the X Articles editor, and the process stops strictly at drafting; separation of concerns is maintained between the script and browser layers, with the script making no network calls.
Markdown File ↓ Python parsing Structured Data (title, images with block_index, HTML) ↓ Playwright MCP X Articles Editor (browser automation) ↓ Draft Saved (never auto-publishes)
Block-level segmentation is the foundation for all positioning: split_into_blocks scans line-by-line, splitting code blocks (wrapped with ___CODE_BLOCK_START/END, including fallback for unclosed blocks), '---' horizontal dividers, '#' / '>' headings and blockquotes, and standalone images into independent blocks, while merging consecutive lines into paragraph blocks.
def split_into_blocks(markdown: str) -> list[str]: """Split markdown into logical blocks (paragraphs, headers, quotes, code blocks, etc.)."""
Images and dividers are stripped from the body text during the parsing stage: extract_images_and_dividers evaluates each block against ___DIVIDER___ or image regex, records block_index as the count of cleaned blocks collected so far, and takes the first 80 characters of the preceding block's last line as after_text; only the stripped body text proceeds to HTML conversion.
block_index = len(clean_blocks) after_text = "" if clean_blocks: prev_block = clean_blocks[-1].strip() lines = [l for l in prev_block.split('\n') if l.strip()] after_text = lines[-1][:80] if lines else ""
Title extraction follows clear priority and deduplication rules: extract_title checks sequentially for the first H1, the first H2, and the first non-empty, non-image line; if the title is derived from an H1, that line is removed from the Markdown to avoid duplicating the title in the body.
# Remove H1 title line from markdown to avoid duplication
Rich text conversion relies on order-sensitive regex substitutions rather than a Markdown library: code blocks are converted to <blockquote> (since X does not support <pre><code>), followed by H2/H3→h2/h3, **→strong, *→em, [text](url)→a, >→blockquote, and - / 1.→li wrapped in ul; finally, paragraphs are split by blank lines, internal newlines converted to <br>, and wrapped in <p>.
# Convert to blockquote format since X Articles doesn't support <pre><code>
Clipboard writing uses native OS clipboard APIs rather than generic third-party libraries: the macOS branch uses pyobjc's NSPasteboard, calling clearContents before writing the HTML type via setData_forType_ alongside a plain text fallback via setString_forType_, ensuring Cmd+V into the X editor retains H2, bold, links, and other rich formatting.
pasteboard.setData_forType_(ns_data, NSPasteboardTypeHTML) # Also set plain text version pasteboard.setString_forType_(html, NSPasteboardTypeString)
Images can optionally be compressed and packaged into platform-native formats before clipboard ingestion: compress_image opens the image with Pillow, converts to RGB if necessary, applies thumbnail constraints up to (2000,2000), and writes to BytesIO at the specified JPEG quality; the Windows branch further saves as BMP, strips the 14-byte BITMAPFILEHEADER, and inserts the raw DIB payload into CF_DIB.
data = output.getvalue()[14:] # Skip BITMAPFILEHEADER
Table-to-image rendering is fully self-drawn: table_to_image.py parses Markdown tables independently (recognizing :--- alignment markers), measures column widths via Pillow ImageDraw's textbbox, renders rounded borders, header backgrounds, and row dividers, and searches truetype fonts across macOS (sfns/pingfang/stheiti) → Linux (dejavu) → Windows (arial/msyh), falling back to bitmap fonts only if all fail.
for path in font_paths: try: return ImageFont.truetype(path, size) except (OSError, IOError): continue
2Core Capabilities
3External Dependencies
| Kind | Dependency / Endpoint |
|---|---|
| cli | python3(解释器,脚本 shebang 与文档命令均为 python3/python) |
| cli | mmdc(@mermaid-js/mermaid-cli,Mermaid→PNG,需 npm 全局安装) |
| cli | pkill(排障步骤里杀 Playwright MCP 相关进程) |
| package | Pillow(image 压缩、BMP/DIB 封装、表格自绘) |
| package | pyobjc-framework-Cocoa(macOS AppKit/Foundation,NSPasteboard) |
| package | pywin32(win32clipboard/CF_DIB)与 clip-util(Windows HTML 剪贴板) |
| network | Playwright MCP(宿主浏览器自动化工具集 browser_navigate/browser_click/browser_press_key 等;skill 不自带浏览器驱动) |
| network | X Articles 编辑器(被自动化访问的站点,读取用户已登录会话) |
| api | X (Twitter) Premium Plus 订阅(Articles 功能的准入前提,非 API 调用) |
4Recommended Governance & Evidence
- scripts/: Executes local script or command: python3 skills/x-article-publisher/scripts/parse_markdown.py <markdown_file
- 网络端点: Declares external network access: Script layer: None—none of the three scripts make requests/urllib.request/httpx/socket calls
- 本地工作目录: File read/write and artifact output: /tmp/article_html.html (SKILL.md instructs agent to write via shell redirection)
5Second-Pass Verification
- [ok] 全部 evidence.quote 逐字存在性(Python 子串校验,覆盖 SKILL.md/两个 README/GUIDE/三个脚本/plugin.json/LICENSE) — 所有引用均为原文精确子串,未发现 paraphrase 冒充 quote;含反斜杠的正则引用按源码原样保留。
- [ok] 网络调用反查(requests|urllib|httpx|socket|aiohttp|curl|wget|api.x.com|api.twitter) — 仅 parse_markdown.py 的 `import urllib.parse` 与 `urllib.parse.unquote(img_path)` 命中,用途是 URL 解码文件名;无任何 HTTP 客户端。
- [discrepancy] 凭证/密钥反查(token|api_key|environ|getenv|.env|cookie|oauth|Bearer) — 脚本层零命中,确认无凭证读取;但文档层 docs/GUIDE.md 指示 `cat ~/.claude/settings.json | grep playwright`,会读取可能含 API key 的 Claude Code 配置文件——第一遍未计入,已在 security.credential_reads 补记。
- [ok] 隐藏文件与额外可执行体反查(find 全清单 + .gitignore) — 仓库仅 13 个非 .git 条目:3 个 py 脚本、SKILL.md、GUIDE.md、两个 README、plugin.json、LICENSE、.gitignore、skills/、docs/、.claude-plugin/;无隐藏脚本、无二进制、无混淆内容。
- [ok] 外部依赖调用点复核(Pillow / pyobjc / pywin32 / clip-util / mmdc / pkill / Playwright MCP) — Pillow 在 copy_to_clipboard.py 与 table_to_image.py 的 import 行可定位;mmdc 与 pkill 只出现在 SKILL.md 文档指令中,仓库无脚本调用它们;Playwright MCP 只在 plugin.json 与 SKILL.md 工具名中出现,skill 不自带驱动。
- [discrepancy] 功能声明 vs 实际能力:frontmatter 宣称 'publish a Markdown file/URL to X Articles' — 夸大:parse_markdown.py 只有 `with open(filepath, 'r', encoding='utf-8') as f:` 与 `os.path.exists(args.file)`,仅接受本地文件,全仓库无 URL 抓取分支。若 agent 照字面接受 URL 输入,会自行引入不受控的网页内容。
- [discrepancy] 定位策略一致性:SKILL.md Step 6 推荐 after_text 文本搜索 vs Critical Rules 第 5 条与 README 声称的 block_index — SKILL.md 自身矛盾:Step 6 标题写 '(Text Search Positioning)' 且正文称『推荐方法: 使用 after_text 文字搜索定位,比 block_index 更直观可靠』,而同一文件 Critical Rules 写『5. Block index positioning - Use block_index for precise image/divider placement』,README 又称 v1.1 已用 block_index 替换文本匹配。agent 执行时选错策略会导致图片错位。
- [discrepancy] 版本一致性:plugin.json version 与 README/changelog 版本号、日期 — plugin.json 为 version 1.1.0,README 顶部却写 v1.2.0;changelog 条目为 v1.2.0 (2025-01)、v1.1.0 (2025-12)、v1.0.0 (2025-12),v1.2.0 的月份早于 v1.1.0,与 pin commit 日期 2026-01-25 也对不上,疑为文档笔误。
6Verdict & Scenarios
61755757ff05c8f7…