Publish Markdown content to X (Twitter) Articles editor, preserving formatting with rich text conversion.
Prerequisites
- Playwright MCP for browser automation
- User logged into X with Premium Plus subscription
- Python 3.9+ with dependencies:
- macOS:
pip install Pillow pyobjc-framework-Cocoa - Windows:
pip install Pillow pywin32 clip-util
- macOS:
- For Mermaid diagrams:
npm install -g @mermaid-js/mermaid-cli
Scripts
Located in ~/.claude/skills/x-article-publisher/scripts/:
parse_markdown.py
Parse Markdown and extract structured data:
python parse_markdown.py <markdown_file> [--output json|html] [--html-only]
Returns JSON with: title, cover_image, content_images, dividers (with block_index for positioning), html, total_blocks
copy_to_clipboard.py
Copy image or HTML to system clipboard (cross-platform):
# Copy image (with optional compression)
python copy_to_clipboard.py image /path/to/image.jpg [--quality 80]
# Copy HTML for rich text paste
python copy_to_clipboard.py html --file /path/to/content.html
table_to_image.py
Convert Markdown table to PNG image:
python table_to_image.py <input.md> <output.png> [--scale 2]
Use when X Articles doesn't support native table rendering or for consistent styling.
Pre-Processing (Optional)
Before publishing, scan the Markdown for elements that need conversion:
Tables → PNG
# Extract table to temp file, then convert
python ~/.claude/skills/x-article-publisher/scripts/table_to_image.py /tmp/table.md /tmp/table.png
# Replace table in markdown with: 
Mermaid Diagrams → PNG
# Extract mermaid block to .mmd file, then convert
mmdc -i /tmp/diagram.mmd -o /tmp/diagram.png -b white -s 2
# Replace mermaid block with: 
Dividers (---)
Dividers are automatically detected by parse_markdown.py and output in the dividers array. They must be inserted via X Articles' Insert > Divider menu (HTML <hr> tags are ignored by X).
Workflow
Strategy: "Text First, Images Second, Dividers Last"
For articles with images and dividers, paste ALL text content first, then insert images and dividers at correct positions using block index.
- (Optional) Pre-process: Convert tables/mermaid to images
- Parse Markdown with Python script → get title, images, dividers with block_index, HTML
- Navigate to X Articles editor
- Upload cover image (first image)
- Fill title
- Copy HTML to clipboard (Python) → Paste with Cmd+V
- Insert content images at positions specified by block_index
- Insert dividers at positions specified by block_index (via Insert > Divider menu)
- Save as draft (NEVER auto-publish)
Efficiency Guidelines
Goal: Minimize waiting time between operations to achieve a smooth automated experience.
1. Avoid unnecessary browser_snapshot
Most browser operations (click, type, press_key, etc.) include the page state in their return result.Do notcall browser_snapshotseparately after each operation; just use the page state returned by the operation.
❌ 错误做法:
browser_click → browser_snapshot → 分析 → browser_click → browser_snapshot → ...
✅ 正确做法:
browser_click → 从返回结果中获取页面状态 → browser_click → ...
2. Avoid unnecessary browser_wait_for
Use browser_wait_for:
- only in the following cases: waiting for image upload to complete (
textGone="正在上传媒体") - waiting for initial page load (very rare cases)
Do notuse browser_wait_for to wait for buttons or input boxes to appear - they are immediately available after the page finishes loading.
3. Execute independent operations in parallel
When two operations have no dependency relationship, you can call multiple tools in parallel in the same message:
✅ 可以并行:
- 填写标题 (browser_type) + 复制HTML到剪贴板 (Bash)
- 解析Markdown生成JSON + 生成HTML文件
❌ 不能并行(有依赖):
- 必须先点击create才能上传封面图
- 必须先粘贴内容才能插入图片
4. Execute browser operations consecutively
The page state returned by each browser operation contains all the element references needed. Use these references directly for the next operation:
# 理想流程(每步直接执行,不额外等待):
browser_navigate → 从返回状态找create按钮 → browser_click(create)
→ 从返回状态找上传按钮 → browser_click(上传) → browser_file_upload
→ 从返回状态找应用按钮 → browser_click(应用)
→ 从返回状态找标题框 → browser_type(标题)
→ 点击编辑器 → browser_press_key(Meta+v)
→ ...
5. Do preparatory work in advance
Before starting browser operations, complete all preparatory work first:
- Parse Markdown to obtain JSON data
- Generate HTML files to /tmp/
- Record information such as title, cover_image, content_images, etc.
This way, the browser operation phase can be executed consecutively without stopping midway to process data.
Step 1: Parse Markdown (Python)
Use parse_markdown.py to extract all structured data:
python ~/.claude/skills/x-article-publisher/scripts/parse_markdown.py /path/to/article.md
Output JSON:
{
"title": "Article Title",
"cover_image": "/path/to/first-image.jpg",
"cover_exists": true,
"content_images": [
{"path": "/path/to/img2.jpg", "original_path": "/md/dir/assets/img2.jpg", "exists": true, "block_index": 5, "after_text": "context..."},
{"path": "/path/to/img3.jpg", "original_path": "/md/dir/assets/img3.jpg", "exists": true, "block_index": 12, "after_text": "another..."}
],
"html": "<p>Content...</p><h2>Section</h2>...",
"total_blocks": 45,
"missing_images": 0
}
Key fields:
block_index: The image should be inserted AFTER block element at this index (0-indexed)total_blocks: Total number of block elements in the HTMLafter_text: Kept for reference/debugging only, NOT for positioningexists: Whether the image file was found (if false, upload will fail)original_path: The path resolved from Markdown (before auto-search)path: The actual path to use (may differ from original_path if auto-searched)missing_images: Count of images not found anywhere
Save HTML to temp file for clipboard:
python parse_markdown.py article.md --html-only > /tmp/article_html.html
Step 2: Open X Articles Editor
Browser error handling
If you encounter Error: Browser is already in use error:
# 方案1:先关闭浏览器再重新打开
browser_close
browser_navigate: https://x.com/compose/articles
# 方案2:如果 browser_close 无效(锁定),提示用户手动关闭 Chrome
# 方案3:使用已有标签页,直接导航
browser_tabs action=list # 查看现有标签
browser_navigate: https://x.com/compose/articles # 在当前标签导航
Best practice: Before each start, first use browser_tabs action=list to check the status and avoid creating extra blank tabs.
Navigate to the editor
browser_navigate: https://x.com/compose/articles
Important: After the page loads, it will display the draft list, not the editor. You need to:
- Wait for the page to finish loading: Use
browser_snapshotto check the page status - Immediately click the "create" button: Do not wait for editor elements such as "Add title"; they only appear after clicking create
- Wait for the editor to load: After clicking create, wait for the editor elements to appear
# 1. 导航到页面
browser_navigate: https://x.com/compose/articles
# 2. 获取页面快照,找到 create 按钮
browser_snapshot
# 3. 点击 create 按钮(通常 ref 类似 "create" 或带有 create 标签)
browser_click: element="create button", ref=<create_button_ref>
# 4. 现在编辑器应该打开了,可以继续上传封面图等操作
Note: Do not use browser_wait_for text="添加标题" to wait for the page to load, because this text only appears after clicking create, which will cause a timeout.
If login needed, prompt user to log in manually.
Step 3: Upload Cover Image
- Click "Add photos or videos" button
- Use browser_file_upload with the cover image path (from JSON output)
- Verify image uploaded
Step 4: Fill Title
- Find textbox with "Add title" placeholder
- Use browser_type to input title (from JSON output)
Step 5: Paste Text Content (Python Clipboard)
Copy HTML to system clipboard using Python, then paste:
# Copy HTML to clipboard
python ~/.claude/skills/x-article-publisher/scripts/copy_to_clipboard.py html --file /tmp/article_html.html
Then in browser:
browser_click on editor textbox
browser_press_key: Meta+v
This preserves all rich text formatting (H2, bold, links, lists).
Step 6: Insert Content Images (Text Search Positioning)
Recommended method: Use after_text text search for positioning, which is more intuitive and reliable than block_index .
Positioning principle
Each image's after_text field records the ending text of the paragraph before it (up to 80 characters). Search the editor for the paragraph containing that text, click it, and then insert the image.
Operation steps
For each content image (from content_images array), In descending order of block_index:
# 1. Copy image to clipboard (with compression)
python ~/.claude/skills/x-article-publisher/scripts/copy_to_clipboard.py image /path/to/img.jpg --quality 85
# 2. 在 browser_snapshot 中搜索包含 after_text 的段落
# 找到该段落的 ref
# 3. Click the paragraph containing after_text
browser_click: element="paragraph with target text", ref=<paragraph_ref>
# 4. **关键步骤**: 按 End 键移动光标到行尾
# 这一步非常重要!避免点击到段落中的链接导致位置偏移
browser_press_key: End
# 5. Paste image
browser_press_key: Meta+v
# 6. Wait for upload (only use textGone, no time parameter)
browser_wait_for textGone="正在上传媒体"
Why is the End key needed?
Problem: When a paragraph contains a link (such as [链接文字](url)), clicking the paragraph may:
- Trigger the link editing popup
- Position the cursor inside the link rather than at the end of the paragraph
Solution: After clicking the paragraph, immediately press the End key:
- Ensure the cursor moves to the end of the paragraph
- Avoid link interference
- The image will be correctly inserted after that paragraph
Positioning strategy
In the structure returned by browser_snapshot, search for after_text keywords:
textbox [ref=editor]:
generic [ref=p1]:
- StaticText: "元旦假期我在家里翻手机相册..." # 如果 after_text 包含这段文字,点击 p1
heading [ref=h1]:
- StaticText: "演示"
generic [ref=p2]:
- StaticText: "这东西到底有多省事儿?"
- link [ref=link1]: "Claude Code" # 注意:段落可能包含链接
...
Reverse insertion example
If there are 3 images with block_index 5, 12, 27 respectively:
- First insert the image with block_index=27 (after_text search + End + paste)
- Then insert the image with block_index=12
- Finally insert the image with block_index=5
Insert from largest to smallestThis can avoid position offset issues.
Step 6.5: Insert Dividers (Via Menu)
Important: The --- divider in Markdown cannot be pasted via the HTML <hr> tag (X Articles will ignore it). It must be inserted via the X Articles Insert menu.
Steps
For each divider (from dividers array), in reverse order of block_index:
# 1. Click the block element at block_index position
browser_click on the element at position block_index in the editor
# 2. Open Insert menu (Add Media button)
browser_click on "Insert" or "添加媒体" button
# 3. Click Divider menu item
browser_click on "Divider" or "分割线" menuitem
# Divider is inserted at cursor position
and the insertion order of images
It is recommended to insert all images first, then all dividers. Both follow the block_index from largest to smallestorder:
- Insert all images (starting from the largest block_index)
- Insert all dividers (starting from the largest block_index)
Step 7: Save Draft
- Verify content pasted (check word count indicator)
- Draft auto-saves, or click Save button if needed
- Click "Preview" to verify formatting
- Report: "Draft saved. Review and publish manually."
Critical Rules
- NEVER publish - Only save draft
- First image = cover - Upload first image as cover image
- Rich text conversion - Always convert Markdown to HTML before pasting
- Use clipboard API - Paste via clipboard for proper formatting
- Block index positioning - Use block_index for precise image/divider placement
- Reverse order insertion - Insert images and dividers from highest to lowest block_index
- H1 title handling - H1 is used as title only, not included in body
- Dividers via menu - Markdown
---must be inserted via Insert > Divider menu (HTML<hr>is ignored)
Supported Formatting
| Element | Support | Notes |
|---|---|---|
H2 (##) |
Native | Section headers |
Bold (**) |
Native | Strong emphasis |
Italic (*) |
Native | Emphasis |
Links ([](url)) |
Native | Hyperlinks |
| Ordered lists | Native | 1. 2. 3. |
| Unordered lists | Native | - bullets |
Blockquotes (>) |
Native | Quoted text |
| Code blocks | Converted | → Blockquotes |
| Tables | Converted | → PNG images (use table_to_image.py) |
| Mermaid | Converted | → PNG images (use mmdc) |
Dividers (---) |
Menu insert | → Insert > Divider |
Example Flow
User: "Publish /path/to/article.md to X"
# Step 1: Parse Markdown
python ~/.claude/skills/x-article-publisher/scripts/parse_markdown.py /path/to/article.md > /tmp/article.json
python ~/.claude/skills/x-article-publisher/scripts/parse_markdown.py /path/to/article.md --html-only > /tmp/article_html.html
- Navigate to https://x.com/compose/articles
- Upload cover image (browser_file_upload for cover only)
- Fill title (from JSON:
title) - Copy & paste HTML:
Then: browser_press_key Meta+vpython ~/.claude/skills/x-article-publisher/scripts/copy_to_clipboard.py html --file /tmp/article_html.html - For each content image, in reverse order of block_index:
python copy_to_clipboard.py image /path/to/img.jpg --quality 85- Click block element at
block_indexposition - browser_press_key Meta+v
- Wait until upload complete
- Click block element at
- Verify in preview
- "Draft saved. Please review and publish manually."
Best Practices
Why use block_index instead of text matching?
- Precise positioning: Does not depend on text content, and can correctly position even when multiple texts are similar
- Reliability: The index is deterministic and will not be confused by similar text
- Easy debugging:
after_textis still retained for manual verification
Why use Python instead of in-browser JavaScript?
- Local processing is more reliable: Python directly manipulates the system clipboard, unaffected by browser sandbox restrictions
- Image compression: Compress images before uploading (--quality 85) to reduce upload time
- Code reuse: The script stays fixed, with no need to rewrite the conversion logic each time
- Easy debugging: The script can be tested separately, making issues easy to locate
Waiting strategy
Important finding: Playwright MCP's browser_wait_for actual behavior is first wait time seconds, then check the condition, rather than polling!
// 实际执行的代码:
await new Promise(f => setTimeout(f, time * 1000)); // 先固定等待
await page.getByText("xxx").waitFor({ state: 'hidden' }); // 再检查
Correct usage:
- ✅ Use only
textGone, do not settime: Let Playwright poll and wait on its own - ✅ Use only
time: Fixed wait for the specified number of seconds - ❌ Using both
textGone+time: It will first wait time seconds then check, wasting time
# 推荐:只用 textGone,让它自动等待条件满足
browser_wait_for textGone="正在上传媒体"
# 或者:用 browser_snapshot 轮询检查状态
# 每次操作后检查返回的页面状态,无需额外等待
Image insertion efficiency
Browser operations per image are reduced from 5 steps to 2 steps:
- Old: Click → Add media → Media → Add photo → file_upload
- New: Click paragraph → Meta+v
Cover image vs content image
- Cover image: Use browser_file_upload (because there is a dedicated upload button)
- Content image: Use Python clipboard + paste (more efficient)
Troubleshooting
MCP connection issues
If the Playwright MCP tool is unavailable (error No such tool available or Not connected):
Option 1: Reconnect MCP (recommended)
执行 /mcp 命令,选择 playwright,选择 Restart
Option 2: Clean up residual processes and reconnect
# 杀掉所有残留的 playwright 进程
pkill -f "mcp-server-playwright"
pkill -f "@playwright/mcp"
# 然后执行 /mcp 重新连接
Configuration file location: ~/.claude/mcp_servers.json
Browser error handling
If you encounter Error: Browser is already in use error:
# 方案1:先关闭浏览器再重新打开
browser_close
browser_navigate: https://x.com/compose/articles
# 方案2:杀掉 Chrome 进程
pkill -f "Chrome.*--remote-debugging"
# 然后重新 navigate
Image position offset
If the image insertion position is incorrect (especially when clicking a paragraph containing a link):
Cause: When clicking a paragraph, you may accidentally trigger a link, causing the cursor position to be incorrect
Solution: After clicking,you must press the End keyto move the cursor to the end of the line
# 正确流程
1. browser_click 点击目标段落
2. browser_press_key: End # 关键步骤!
3. browser_press_key: Meta+v # 粘贴图片
4. browser_wait_for textGone="正在上传媒体"
Image path not found
If a relative-path image in Markdown cannot be found (e.g., ./assets/image.png is actually in another location):
Automatic search: parse_markdown.py will automatically search for a file with the same name in the following directories:
~/Downloads~/Desktop~/Pictures
stderr output example:
[parse_markdown] Image not found at '/path/to/assets/img.png', using '/Users/xxx/Downloads/img.png' instead
JSON field description:
original_path: The path specified in Markdown (resolved absolute path)path: The path actually used (if the automatic search succeeds, it will differ from original_path)exists:trueindicates the file was found,falseindicates it was not found (the upload will fail)
If it still cannot be found:
- Check the
missing_imagesfield in the JSON output - Manually copy the image to the same directory as the Markdown file, into the
assets/subdirectory - Or change the image path in Markdown to an absolute path