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
- Dedicated persistent browser profile (recommended) to avoid repeated login
- 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 ~/.codex/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_media, content_images, content_videos, 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.
open_x_articles_browser.sh
Open X Articles with a dedicated persistent profile:
bash ~/.codex/skills/x-article-publisher/scripts/open_x_articles_browser.sh
Defaults:
- Profile path:
~/.codex/browser-profiles/x-articles - Can override with env var:
X_ARTICLES_PROFILE=/custom/path
prepare_article_source.py
Auto-route source input:
- Feishu/Lark URL -> download to local markdown (with video fetch)
- Local markdown path -> pass through directly
python ~/.codex/skills/x-article-publisher/scripts/prepare_article_source.py "<source>"
Output JSON includes:
mode:feishu_urlorlocal_markdownmarkdown_path: local markdown path to publishvideos_downloaded/videos_appended: video handling summarycallouts_normalized: number of Feishu callout labels such asTipor[!TIP]removed while preserving quoted content
optimize_media_blocks.py
Reduce body media block count before upload by merging adjacent image runs into adaptive no-crop grid images:
python ~/.codex/skills/x-article-publisher/scripts/optimize_media_blocks.py article.md \
--max-body-media 24 \
--output article.optimized.md
Use this when parsed content_media is near or above the practical X Articles body-media limit. It preserves videos as separate upload blocks and merges only adjacent body images, leaving the first image/cover alone. Collages use a balanced grid with contain scaling: every source screenshot remains complete, without crop or cover behavior. The default final width is 1920px so text-heavy screenshots remain readable.
Persistent Profile (Recommended)
Always launch X Articles with the dedicated profile:
bash ~/.codex/skills/x-article-publisher/scripts/open_x_articles_browser.sh
Why:
- Avoids repeated login and lowers account risk from frequent sign-ins
- Isolated from your main Google Chrome profile
- Does not overwrite or log out your existing Chrome account session
Note:
- First run may still trigger one-time X security verification (new device/profile)
- After that, reuse the same profile path and login stays stable
Pre-Processing (Optional)
Before publishing, scan the Markdown for elements that need conversion:
Body Media Budget
X Articles can silently drop new body-media uploads after roughly 25 body media blocks. The file picker may accept a file while the editor never enters Uploading media... and the media count never increases.
Before browser upload, parse media count:
python ~/.codex/skills/x-article-publisher/scripts/parse_markdown.py article.md > article.json
jq '.content_media | length' article.json
If body media count is >= 24, run optimize_media_blocks.py and re-parse the optimized Markdown:
python ~/.codex/skills/x-article-publisher/scripts/optimize_media_blocks.py article.md \
--max-body-media 24 \
--output article.optimized.md > optimize.json
python ~/.codex/skills/x-article-publisher/scripts/parse_markdown.py article.optimized.md > article.json
Prefer merging adjacent image runs before upload. Do not merge videos; videos should keep their own block and original anchor. Reject or regenerate any collage that becomes an extreme vertical strip: saving media blocks never justifies clipped or unreadable screenshots in X Preview.
Feishu Callouts / Highlight Blocks
Feishu highlighted blocks may export to Markdown as blockquotes prefixed with Tip, Note, or [!TIP]. These labels are not useful in X Articles and can break reading flow. prepare_article_source.py removes those marker lines but keeps the quoted multiline content intact. If callouts_normalized > 0, this cleanup happened.
Video Upload Preflight
Before uploading video-heavy articles, inspect video size and bitrate. Large 40-90MB/high-bitrate videos can close or destabilize the browser session even when X eventually accepts smaller files.
When a video is large or upload is unstable, transcode to a 1280px-wide H.264/AAC upload copy:
ffmpeg -y -i input.mov \
-vf "scale='min(1280,iw)':-2" \
-c:v libx264 -preset medium -crf 25 -pix_fmt yuv420p \
-c:a aac -b:a 64k -movflags +faststart \
output.x1280.mp4
Use the transcoded file for X upload, but keep the original Markdown anchor and block order.
The -2 height is required: it rounds the calculated height to an even number so libx264 does not fail on outputs such as 1280x713. Run ffprobe on every output before replacing the Markdown path; a zero-byte or unreadable copy must never enter the upload queue.
GIF Upload Preflight
Treat large GIF files as video-like media, not static screenshots. Clipboard image paste can turn a GIF into a still image, and X can silently accept a large GIF file input while never adding a media block.
If a GIF is large, or if uploading it does not increase the editor media count, convert it to MP4 and insert the MP4 at the original GIF anchor:
ffmpeg -y -i input.gif \
-vf "scale='if(gt(iw,ih),min(1280,iw),-2)':'if(gt(iw,ih),-2,min(1280,ih))',format=yuv420p" \
-movflags +faststart -c:v libx264 -preset medium -crf 25 -an \
input.gif.mp4
In final audit, count this converted GIF as one video-like media block at the original GIF position.
Tables → PNG
# Extract table to temp file, then convert
python ~/.codex/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, Media Second, Dividers Last"
For articles with media (images/videos) and dividers, paste ALL text content first, then insert media and dividers at correct positions using block index.
- Route input source (
prepare_article_source.py) - Parse Markdown once and check body-media budget
- (Optional) Pre-process: Convert tables/mermaid to images; if body media is near/above 24, run
optimize_media_blocks.pyand re-parse - Parse final Markdown with Python script → get title, media, 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 content videos at positions specified by block_index
- Insert dividers at positions specified by block_index (via Insert > Divider menu)
- Open Preview and audit media count plus anchor order (
anchor text -> following media type) - Save as draft (NEVER auto-publish)
Input Routing
Two trigger modes are supported:
-
Feishu URL mode Input contains a Feishu/Lark doc link (
feishu.cn/larksuite.com/feishu.sg)- Run
prepare_article_source.py "<url>" - It calls
feishu2md dl --dumpto download markdown - It fetches video file blocks (if any) into local
static/ - It appends
<video src="...">entries into markdown for downstream upload
- Run
-
Local Markdown mode Input is a local
.md/.markdownfile path- Run
prepare_article_source.py "<path/to/file.md>" - It returns the original file directly (no download step)
- Run
Why feishu2md Misses Videos by Default
feishu2md can download media by token, but its default parser flow is image-only:
- In
cmd/download.go, it only iteratesparser.ImgTokensand callsDownloadImage(...) - In
core/parser.go, it only handlesDocxBlockTypeImageand collectsImgTokens DocxBlockTypeFile(where many embedded videos are represented) is not rendered to markdown nor downloaded
So videos often disappear in generated markdown unless an extra step is added.
prepare_article_source.py is that extra step for this skill.
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; simply 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:
- Wait for image upload to complete (
textGone="正在上传媒体") - Wait for initial page load (rare cases)
Do notuse browser_wait_for to wait for buttons or input fields 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 within the same message:
✅ 可以并行:
- 填写标题 (browser_type) + 复制HTML到剪贴板 (Bash)
- 解析Markdown生成JSON + 生成HTML文件
❌ 不能并行(有依赖):
- 必须先点击create才能上传封面图
- 必须先粘贴内容才能插入图片
4. Execute browser operations continuously
The page state returned by each browser operation includes all the element references needed. Directly use these references 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. Prepare work in advance
Before starting browser operations, complete all preparatory work first:
- Parse Markdown to obtain JSON data
- Generate HTML file to /tmp/
- Record information such as title, cover_image, content_media
This way the browser operation phase can be executed continuously without stopping midway to process data.
Step 0: Resolve Input Source
Run source resolver first:
python ~/.codex/skills/x-article-publisher/scripts/prepare_article_source.py "<source>" > /tmp/x_source.json
Then read normalized markdown path:
jq -r '.markdown_path' /tmp/x_source.json
Step 1: Parse Markdown (Python)
Use parse_markdown.py to extract all structured data:
python ~/.codex/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_media": [
{"type": "image", "path": "/path/to/img2.jpg", "original_path": "/md/dir/assets/img2.jpg", "exists": true, "block_index": 5, "after_text": "context..."},
{"type": "video", "path": "/path/to/video.mp4", "original_path": "/md/dir/assets/video.mp4", "exists": true, "block_index": 12, "after_text": "another..."}
],
"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": 18, "after_text": "another..."}
],
"content_videos": [
{"path": "/path/to/video.mp4", "original_path": "/md/dir/assets/video.mp4", "exists": true, "block_index": 12, "after_text": "another..."}
],
"html": "<p>Content...</p><h2>Section</h2>...",
"total_blocks": 45,
"missing_media": 0,
"missing_images": 0,
"missing_videos": 0
}
Key fields:
block_index: The media 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 media 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)content_media: Combined list (images + videos), preferred for unified workflowsmissing_media: Count of media files 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 a Error: Browser is already in use error:
# 方案1:关闭旧会话后,用持久化 profile 重新打开(推荐)
playwright-cli close-all
bash ~/.codex/skills/x-article-publisher/scripts/open_x_articles_browser.sh
# 方案2:如果 browser_close 无效(锁定),提示用户手动关闭 Chrome
# 方案3:使用已有标签页,直接导航到文章页
browser_tabs action=list # 查看现有标签
browser_navigate: https://x.com/compose/articles # 在当前标签导航
Best practice: Before starting each time, first use browser_tabs action=list to check the state, avoiding the creation of extra blank tabs.
Navigate to the editor
# 先确保使用持久化 profile 打开(不要用临时会话)
bash ~/.codex/skills/x-article-publisher/scripts/open_x_articles_browser.sh
Important: After the page loads, a draft list is displayed, 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 "添加标题"; they only appear after clicking create
- Wait for the editor to load: After clicking create, wait for the editor elements to appear
# 1. 使用持久化 profile 打开页面
bash ~/.codex/skills/x-article-publisher/scripts/open_x_articles_browser.sh
# 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 is needed on first run, prompt user to log in manually once in the persistent profile.
Step 3: Upload Cover Image
- Click "添加照片或视频" button
- Use browser_file_upload with the cover image path (from JSON output)
- Verify image uploaded
Step 4: Fill Title
- Find textbox with "添加标题" 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 ~/.codex/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
The after_text field of each image records the ending text of the paragraph before it (up to 80 characters). In the editor, search for the paragraph containing that text, then click to insert the image.
Operation steps
For each content image (from content_images array), In order from largest to smallest block_index:
# 1. Copy image to clipboard (with compression)
python ~/.codex/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 the keyword after_text :
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 values of 5, 12, and 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.
Rich Text Anchor Matching
X can split a Markdown sentence across multiple editor text nodes when the source contains bold spans, links, or mixed Chinese/English emphasis. A full after_text string may not exist as one DOM text node.
Fallback matching order:
- Try the normalized full anchor with Markdown markers removed (
**, heading marks, blockquote marks). - Strip parser-only code markers (
___CODE_BLOCK_START___and___CODE_BLOCK_END___); they are never visible in X. - Strip a leading Markdown/DOM list marker (
-,*,+, or•) before matching because X does not include it in the rendered list-item text. - Try progressively shorter prefixes.
- For split rich text, use a distinctive short prefix such as the opening clause, but only if it is unique enough in the article.
- In Preview, re-check with a longer exact nearby phrase whenever a short anchor such as
比方说could match an earlier paragraph.
Step 6.2: Insert Content Videos (File Upload)
Videos do not use the clipboard; use file upload (the same file chooser mechanism as the cover image).
For each content video (from content_videos array), In order from largest to smallest block_index。
Use file upload, never clipboard paste. Prefer writing the hidden input[type=file] after opening Insert > Media; filechooser events can be flaky on X Articles. For video-heavy drafts, upload one video per browser pass if the editor becomes unstable, then reopen the same draft URL and continue from the next missing anchor.
# 1. 在 browser_snapshot 中搜索包含 after_text 的段落并点击
browser_click: element="paragraph with target text", ref=<paragraph_ref>
# 2. 将光标移动到段落末尾
browser_press_key: End
# 3. 点击工具栏 Insert / Add Media
browser_click: element="Add Media button", ref=<add_media_ref>
# 4. 上传视频文件
browser_file_upload: paths=["/path/to/video.mp4"]
# 5. 等待上传完成
browser_wait_for textGone="正在上传媒体"
Observed X behavior:
- Video upload can create a full-page overlay that intercepts later clicks.
- Starting the next upload too early can skip media or place it under a later anchor.
- Large videos should be transcoded before upload.
- Editor-side remove-media counts can drift during long runs; final preview DOM is the source of truth.
- For adjacent mixed-media clusters (for example: video, then the next paragraph has another video plus an image), a correct count can still hide swapped order. Audit the local sequence, not only the first media after each anchor.
- If a local cluster is wrong, delete only that cluster in the editor, then reinsert it from bottom to top. Example: for
anchor A -> video1followed byanchor B -> video2 + image, insertimage at B, thenvideo2 at B, thenvideo1 at A. - X failed uploads can leave visible blocks saying
Something went wrong. Please try again later.with aDelete blockbutton. Delete those blocks before final Preview audit.
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 through the Insert menu in X Articles.
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
Insertion order with images
It is recommended to insert all images first, then all videos, and finally all dividers. All three follow the block_index order from largest to smallest:
- Insert all images (starting from the largest block_index)
- Insert all videos (starting from the largest block_index)
- Insert all dividers (starting from the largest block_index)
Step 6.8: Final Preview Audit
Before reporting success, open Preview and verify both counts and order:
img[alt="Image"]count matches body images.video[aria-label="Embedded video"]orPlay Videocount matches body videos.- Cover image exists separately from body images.
- For each
content_mediaitem, find itsafter_textanchor in Preview and confirm the next visible media item has the expected type. - For same-anchor media runs, confirm the next N visible media items preserve the Markdown order.
- For adjacent video/image clusters, confirm the local sequence around all involved anchors, not only the nearest media.
- Confirm Preview does not contain
Something went wrong. Please try again later..
If tag-based anchor lookup reports missing anchors even though counts are correct, use a geometry fallback. X Preview often renders ordinary paragraphs as div or span instead of p:
- Search visible
div, span, p, li, blockquote, h1, h2, h3elements using the same normalized anchor prefixes. - Choose the matching visible node with the shortest normalized text; this avoids selecting an outer article container.
- Read its bottom edge with
getBoundingClientRect().bottom. - Sort body media by
getBoundingClientRect().y, counting onlyimg[alt="Image"]and visiblevideoelements. - Confirm the first media below the anchor bottom has the expected type. For a same-anchor run, compare the next N media types with the Markdown order.
If one media item is under the wrong anchor, do not rebuild the entire draft:
- Delete only the misplaced media block in the editor.
- Insert the missing media at its original anchor.
- Reinsert the misplaced video/image at its own anchor.
- Re-run the Preview audit.
If a cluster is wrong:
- Delete the smallest contiguous cluster that contains the misplaced items.
- Reinsert from the latest anchor back to the earliest anchor.
- For multiple media under the same anchor, insert in reverse original order so the final visual order matches the Markdown.
- Re-run Preview and inspect
nextThree/local media sequence around the repaired anchors.
Step 7: Save Draft
- Verify content pasted (check word count indicator)
- Draft auto-saves, or click Save button if needed
- Click "预览" 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/video/divider placement
- Reverse order insertion - Insert images/videos/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) - Video upload method - Videos must use file upload, not clipboard paste
- Session stability - Always open X with dedicated persistent profile, never temporary profile
- Media budget - Keep body media at or below 24 when possible; X may silently ignore uploads after roughly 25 body media blocks
- Verify by position, not count only - Final audit must check media count and key anchors (
anchor text -> following media type/order), because a correct video count can still hide one misplaced video - Callout cleanup - Remove Feishu callout markers (
Tip,[!TIP]) while preserving the highlighted text and line breaks - Video preflight - Transcode oversized/high-bitrate videos to 1280px H.264/AAC before upload when stability matters
- Resume instead of restarting - If browser/session drift happens mid-upload, reopen the existing draft URL and continue from missing anchors; do not paste the body again
- GIF fallback - Upload GIFs through file input when preserving animation matters; if X silently ignores a large GIF, convert it to MP4 and upload that at the same anchor
- Adjacent cluster repair - For nearby video/image groups, verify local order in Preview; repair by deleting only that small cluster and reinserting from bottom to top
- Failed block cleanup - Delete
Something went wrong/Delete blockplaceholders before reporting a draft ready
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) |
Videos (.mp4/.mov/...) |
Upload as media | Use Insert/Add Media + file upload |
Dividers (---) |
Menu insert | → Insert > Divider |
Example Flow
User: "Publish /path/to/article.md to X"
# Step 0: Resolve source (URL or local markdown)
python ~/.codex/skills/x-article-publisher/scripts/prepare_article_source.py "/path/to/article.md_or_feishu_url" > /tmp/x_source.json
MD_PATH="$(jq -r '.markdown_path' /tmp/x_source.json)"
# Step 1: Parse Markdown
python ~/.codex/skills/x-article-publisher/scripts/parse_markdown.py "$MD_PATH" > /tmp/article.json
python ~/.codex/skills/x-article-publisher/scripts/parse_markdown.py "$MD_PATH" --html-only > /tmp/article_html.html
If /tmp/article.json has too many body media blocks, optimize and re-parse before opening X:
if [ "$(jq '.content_media | length' /tmp/article.json)" -ge 24 ]; then
python ~/.codex/skills/x-article-publisher/scripts/optimize_media_blocks.py "$MD_PATH" \
--max-body-media 24 \
--output /tmp/article.optimized.md > /tmp/article_optimize.json
MD_PATH=/tmp/article.optimized.md
python ~/.codex/skills/x-article-publisher/scripts/parse_markdown.py "$MD_PATH" > /tmp/article.json
python ~/.codex/skills/x-article-publisher/scripts/parse_markdown.py "$MD_PATH" --html-only > /tmp/article_html.html
fi
- 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 ~/.codex/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
- For each content video, in reverse order of block_index:
- Click block element at
block_indexposition - Click Insert/Add Media
- browser_file_upload with
/path/to/video.mp4 - 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 position 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
Important finding: Playwright MCP's browser_wait_for actual behavior is first wait time seconds, then check the condition, not 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: wait a fixed number of seconds - ❌ Use both
textGone+time: it will first wait time seconds and then check, wasting time
# 推荐:只用 textGone,让它自动等待条件满足
browser_wait_for textGone="正在上传媒体"
# 或者:用 browser_snapshot 轮询检查状态
# 每次操作后检查返回的页面状态,无需额外等待
Image insertion efficiency
The 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: ~/.codex/mcp_servers.json
Browser error handling
If you encounter the Error: Browser is already in use error:
# 方案1:关闭全部会话后,用持久化 profile 重开(推荐)
playwright-cli close-all
bash ~/.codex/skills/x-article-publisher/scripts/open_x_articles_browser.sh
# 方案2:杀掉 Chrome 进程
pkill -f "Chrome.*--remote-debugging"
# 然后重新打开持久化会话
bash ~/.codex/skills/x-article-publisher/scripts/open_x_articles_browser.sh
Image position offset
If the image insertion position is incorrect (especially when clicking a paragraph containing a link):
Cause: Clicking the paragraph may accidentally trigger the link, causing the cursor position to be wrong
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="正在上传媒体"
Media path not found
If a relative path media in Markdown cannot be found (e.g., ./assets/image.png、./assets/clip.mp4 is actually located elsewhere):
Automatic search: parse_markdown.py will automatically search for files 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 automatic search succeeds, it will differ from original_path)exists:trueindicates the file was found,falseindicates it was not found (upload will fail)
If it still cannot be found:
- Check the
missing_media/missing_images/missing_videosfield in the JSON output - Manually copy the image to the
assets/subdirectory in the same directory as the Markdown file - Or change the image path in Markdown to an absolute path