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:
pip install Pillow pyobjc-framework-Cocoa
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 (with block_index for positioning), html, total_blocks
copy_to_clipboard.py
Copy image or HTML to system clipboard:
# 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
Workflow
Strategy: "Text First, Images Later"
For articles with multiple images, paste ALL text content first, then insert images at correct positions using block index.
- Parse Markdown with Python script → get title, images 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
- Save as draft (NEVER auto-publish)
Efficiency Guidelines
Goal: Minimize wait time between operations to achieve a smooth automation 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
Only use browser_wait_for:
- to wait for image upload to complete (
textGone="正在上传媒体") - Wait for initial page load (very rare cases)
Do notuse browser_wait_for to wait for buttons or input boxes to appear - they are available immediately 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 get JSON data
- Generate HTML file to /tmp/
- Record information such as title, cover_image, content_images
In 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",
"content_images": [
{"path": "/path/to/img2.jpg", "block_index": 5, "after_text": "context for debugging..."},
{"path": "/path/to/img3.jpg", "block_index": 12, "after_text": "another context..."}
],
"html": "<p>Content...</p><h2>Section</h2>...",
"total_blocks": 45
}
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 positioning
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_navigate: https://x.com/compose/articles
Important: After the page loads, it shows the draft list, not the editor. You need to:
- Wait for the page to finish loading: Use
browser_snapshotto check the page state - 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 (Block Index Positioning)
Key improvement: Use block_index for precise locating instead of relying on text matching.
Locating principle
After pasting HTML, the content structure in the editor is a series of block elements (paragraphs, headings, quotes, etc.). Each image's block_index indicates that it should be inserted after the Nth block element.
Operation steps
- Get all block elements: Use browser_snapshot to get the editor content and find all child elements under the textbox
- Locate by index: Based on
block_indexclick the corresponding block element - Paste the image: Copy the image to the clipboard and then paste
For each content image (from content_images array):
# 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. Click the block element at block_index
# Example: if block_index=5, click the 6th block element (0-indexed)
browser_click on the element at position block_index in the editor
# 3. Paste image
browser_press_key: Meta+v
# 4. Wait for upload (use short time, returns immediately when done)
browser_wait_for textGone="正在上传媒体" time=2
Locating strategy
In the structure returned by browser_snapshot, the editor content is usually:
textbox [ref=xxx]:
generic [ref=block0]: # block_index 0
- paragraph content
heading [ref=block1]: # block_index 1
- h2 content
generic [ref=block2]: # block_index 2
- paragraph content
...
To insert an image after block_index=5 :
- Find the 6th child element under the editor textbox (index starts from 0)
- Click this element
- Paste image
Note: After each image is inserted, the actual positions of subsequent images will shift. It is recommended to insert images in block_index descending order, so that images inserted earlier will not affect the indices of subsequent images.
Reverse insertion example
If there are 3 images with block_index 5, 12, 27 respectively:
- First insert the image with block_index=27
- Then insert the image with block_index=12
- Finally insert the image with block_index=5
This way, each insertion will not affect the positions already located earlier.
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 placement
- Reverse order insertion - Insert images from highest to lowest block_index
- H1 title handling - H1 is used as title only, not included in body
Supported Formatting
- H2 headers (## )
- Blockquotes (> )
- Code blocks (
...) - converted to blockquotes since X doesn't support<pre><code> - Bold text (**)
- Hyperlinks (text)
- Ordered lists (1. 2. 3.)
- Unordered lists (- )
- Paragraphs
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; even if multiple places have similar text, it can still be positioned correctly
- 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 operates the system clipboard and is not restricted by the browser sandbox
- Image compression: Compress images before uploading (--quality 85) to reduce upload time
- Code reuse: The script stays fixed; there is no need to rewrite the conversion logic each time
- Easy debugging: The script can be tested separately, making issues easy to locate
Waiting strategy
Key understanding: browser_wait_for of textGone parameter will returnimmediately,time when the text disappears; it is only the maximum waiting time, not a fixed waiting time.
- ❌ Conservative waiting:
time=5ortime=10; if the upload only takes 2 seconds, all the remaining time is wasted - ✅ Short-interval polling:
time=2; returns immediately when the condition is met, waiting at most 2 seconds
# 正确用法:短 time 值,条件满足立即返回
browser_wait_for textGone="正在上传媒体" time=2
# 错误用法:固定长时间等待
browser_wait_for time=5 # 无条件等待5秒,浪费时间
Principle: Do not preset "how long to wait"; instead, set a reasonable maximum value and let condition detection return as soon as possible.
Image insertion efficiency
The browser operations for each 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)