X Article Publisher
将 Markdown 内容整理到 X (Twitter) Articles 编辑器并保存为草稿,保留富文本格式。
Codex 适配边界
- 目标是生成和校验 X Article 草稿,禁止自动点击最终发布按钮。
- 使用当前 skill 目录内脚本:
{{SKILL_DIR}}/scripts/parse_markdown.py、{{SKILL_DIR}}/scripts/copy_to_clipboard.py、{{SKILL_DIR}}/scripts/table_to_image.py。 - Cookie 文件默认放在
{{SKILL_DIR}}/cookies.json;仓库只提供cookies.template.json,不要提交真实 cookie。 - 如果用户已经在真实 Chrome 中登录 X,优先按
~/.codex/BROWSER.md判断是否用browser-harness/bb-browser复用登录态;只有明确需要独立 Playwright 上下文时才读取cookies.json。 - 每次涉及 cookie、账号登录态、X 文章草稿发布时,先说明动作只会保存草稿,并让用户最终手动检查和发布。
来源与致谢
本技能的整体思路与部分流程参考了以下项目,并在此基础上做了适配与扩展:
第三方许可与说明见仓库根目录 THIRD_PARTY_NOTICES.md。
核心原则
自动整理、自动验证、只保存草稿
- 自动处理路径编码问题(中文路径、URL编码)
- 自动检测并修复 Markdown 格式错误
- 自动验证所有内容(标题、图片、格式)
- 自动修复缺失或错误的内容
- 生成完整执行报告,交给用户手动检查后发布
前置要求
- Playwright MCP 用于确定性浏览器自动化;若要复用真实浏览器登录态,按
~/.codex/BROWSER.md选择browser-harness或bb-browser - Cookie 配置文件
{{SKILL_DIR}}/cookies.json已配置,或用户真实浏览器里已有 X 登录态 - Python 3.9+ 及依赖:
- Windows:
pip install Pillow pywin32 clip-util - macOS:
pip install Pillow pyobjc-framework-Cocoa
- Windows:
脚本工具
位于 {{SKILL_DIR}}/scripts/:
parse_markdown.py
解析 Markdown 并提取结构化数据,自动处理以下问题:
- URL编码路径自动解码
- 标题优先从文件名提取
- 自动修复格式错误(如
.jpeg).jpeg))
python {{SKILL_DIR}}/scripts/parse_markdown.py <markdown_file> [--output json|html] [--html-only]
输出 JSON 包含:
{
"title": "从文件名提取的标题",
"filename_title": "文件名标题",
"content_title": "Markdown内H1标题",
"cover_image": "/path/to/cover.png",
"content_images": [
{
"path": "/path/to/image1.jpeg",
"block_index": 7,
"marker_id": "IMG_PLACEHOLDER_1"
}
],
"expected_image_count": 10,
"errors_fixed": ["Fixed 1 malformed image reference(s)"]
}
重要: 每张内容图片都有一个 marker_id 字段,用于在 HTML 中精确定位图片插入位置。
copy_to_clipboard.py
复制图片或 HTML 到系统剪贴板(跨平台):
python {{SKILL_DIR}}/scripts/copy_to_clipboard.py image /path/to/image.jpg [--quality 85]
python {{SKILL_DIR}}/scripts/copy_to_clipboard.py html --file /path/to/content.html
完整执行流程(必须严格遵循)
Phase 1: 准备阶段
⚠️ 重要:智能文件查找(支持三种模式)
脚本支持三种灵活的文件指定方式,完美解决特殊字符文件名问题:
模式1:关键词匹配(推荐!最简单)
cd "文章所在目录"
export MARKDOWN_FILE="年轻 2026" # 空格分隔的多个关键词
python {{SKILL_DIR}}/scripts/parse_markdown.py
- ✅ 自动匹配包含所有关键词的文件(不区分大小写)
- ✅ 完全绕过特殊字符问题
- ✅ 最快最省事
模式2:目录路径(适合单文件目录)
export MARKDOWN_FILE="/path/to/obsidian/x-articles/some-folder"
python {{SKILL_DIR}}/scripts/parse_markdown.py
- ✅ 如果目录只有一个 .md 文件,自动使用
- ⚠️ 多个文件时会列出所有可用文件并报错
模式3:完整文件路径(最精确)
export MARKDOWN_FILE="/path/to/obsidian/x-articles/年轻,就要All in每一年 2026.md"
python {{SKILL_DIR}}/scripts/parse_markdown.py
- ✅ 直接指定文件
- ✅ 支持中文、空格、特殊字符
1.1 设置文件路径环境变量(三选一)
方式A - 关键词匹配(推荐):
cd "/path/to/obsidian/x-articles/总结复盘展望"
export MARKDOWN_FILE="年轻 2026"
方式B - 目录路径:
export MARKDOWN_FILE="/path/to/obsidian/x-articles/总结复盘展望"
方式C - 完整路径:
export MARKDOWN_FILE="/path/to/obsidian/x-articles/总结复盘展望/年轻,就要All in每一年 2026.md"
1.2 解析 Markdown 文件
python {{SKILL_DIR}}/scripts/parse_markdown.py
工作原理:
- 脚本从 MARKDOWN_FILE 环境变量读取输入
- 智能识别输入类型(文件/目录/关键词)
- 自动查找并匹配目标文件
- 环境变量传递**不经过 shell 参数解析**,避免特殊字符被修改
- 自动处理中文路径编码
- 自动修复格式错误
- 返回 JSON 包含所有必要信息
1.3 保存 HTML 到临时文件
python {{SKILL_DIR}}/scripts/parse_markdown.py --html-only > <TEMP_DIR>/article_html.html
1.4 记录关键信息
- title: 使用 JSON 中的 title 字段
- cover_image: 第一张图片原始路径
- content_images: 内容图片数组
- expected_image_count: 预期内容图片数量
Phase 2: 浏览器操作阶段
2.1 登录态选择
- 如果按 `~/.codex/BROWSER.md` 复用真实浏览器登录态,先打开 `https://x.com` 并验证已登录,跳过 cookie 注入。
- 如果使用独立 Playwright 上下文,才读取 `{{SKILL_DIR}}/cookies.json`。
加载 Cookie 示例:
browser_run_code: |
async (page) => {
const cookiesJson = `读取 {{SKILL_DIR}}/cookies.json`;
const cookies = JSON.parse(cookiesJson).cookies;
for (const cookie of cookies) {
await page.context().addCookies([cookie]);
}
return { loaded: cookies.length };
}
2.2 验证登录状态
browser_navigate: https://x.com
browser_run_code: |
async (page) => {
await page.waitForTimeout(2000);
const loggedIn = await page.locator('[data-testid="primaryColumn"]').count() > 0;
return { loggedIn };
}
如果 loggedIn=false,停止并提示用户重新登录或更新 Cookie;不要继续进入编辑器。
2.3 导航到编辑器
browser_navigate: https://x.com/compose/articles
browser_snapshot
browser_click: ref=<create_button>
2.4 上传封面图(使用 JavaScript 绕过文件访问限制)
browser_click: ref=<添加照片按钮>
browser_wait_for: time=1
browser_run_code: |
async (page) => {
// 等待文件选择器出现
const fileInput = await page.locator('input[type="file"][accept*="image"]').first();
// 使用 setInputFiles 直接设置文件,绕过 browser_file_upload 的沙箱限制
// 这个方法可以访问任何盘符的文件
await fileInput.setInputFiles('<cover_image>');
return {
uploaded: true,
method: 'setInputFiles',
path: '<cover_image>'
};
}
browser_wait_for: time=3
2.5 填写标题
browser_type: ref=<标题输入框>, text=<title>
2.6 粘贴内容(带自动回退机制)
Step 1: 尝试剪贴板方法
python {{SKILL_DIR}}/scripts/copy_to_clipboard.py html --file <html文件路径>
browser_click: ref=<编辑器文本框>
browser_press_key: Control+v (Windows) / Meta+v (Mac)
browser_wait_for: time=2
Step 2: 验证内容正确性
browser_run_code: |
async (page) => {
const editor = await page.locator('[contenteditable="true"]').first();
const text = await editor.innerText();
// 检测 GBK 乱码特征
// GBK mojibake 通常包含: Ӣ ˮ һһ ġ 等字符组合
const hasMojibake = /[ˮһġӢ]{3,}|\\u00[89A-F][0-9A-F]{2}/.test(text);
// 检测预期的中文内容是否存在
const hasValidChinese = /[\u4e00-\u9fa5]{5,}/.test(text);
return {
contentLength: text.length,
hasMojibake: hasMojibake,
hasValidChinese: hasValidChinese,
preview: text.substring(0, 200)
};
}
Step 3: 如果检测到乱码,自动回退到 Playwright 注入
如果 hasMojibake=true 或 hasValidChinese=false:
a) 清空编辑器
browser_run_code: |
async (page) => {
const editor = await page.locator('[contenteditable="true"]').first();
await editor.evaluate(el => el.innerHTML = '');
return { cleared: true };
}
b) 读取 HTML 文件内容
Read: C:\\Users\\HP\\AppData\\Local\\Temp\\article_html_utf8.html
c) 直接注入 HTML(绕过剪贴板)
browser_run_code: |
async (page) => {
const editor = await page.locator('[contenteditable="true"]').first();
const htmlContent = `<读取的HTML内容>`;
// 直接设置 innerHTML,绕过剪贴板编码问题
await editor.evaluate((el, html) => {
el.innerHTML = html;
}, htmlContent);
return { method: 'playwright_injection', success: true };
}
d) 再次验证
重复 Step 2 的验证逻辑
Step 4: 记录使用的方法
在执行报告中注明:
- ✅ 剪贴板粘贴成功 (copy_to_clipboard.py with CF_HTML)
- ⚠️ 剪贴板失败,使用 Playwright 注入
Phase 3: 图片插入阶段(使用占位符定位)
⚠️ 重要:图片路径处理规则
关键原则:copy_to_clipboard.py 可以读取任何盘的文件,不需要复制!
封面图上传 (使用 JavaScript
setInputFiles())- ✅ 推荐方案:使用
browser_run_code+setInputFiles()方法访问本机原始文件路径 - ❌ 避免使用:
browser_file_upload在某些沙箱环境下会受路径限制 - ✅ 无需复制:只要浏览器自动化上下文有权限读取该路径,就直接使用原始路径
- ✅ 推荐方案:使用
内容图片插入 (使用剪贴板 + Ctrl+V)
- ✅ 直接使用原始路径:
python {{SKILL_DIR}}/scripts/copy_to_clipboard.py image "/path/to/image.jpg" - ❌ 错误做法:不要预先复制所有图片到临时目录,除非权限确实阻止读取原路径
- ✅ 直接使用原始路径:
最佳实践
# ✅ 正确:直接使用原始路径 python {{SKILL_DIR}}/scripts/copy_to_clipboard.py image "/path/to/obsidian/article/image.jpg" --quality 85 # ❌ 错误:没必要复制 cp "/path/to/obsidian/article/image.jpg" "/tmp/x-article-image.jpg" python {{SKILL_DIR}}/scripts/copy_to_clipboard.py image "/tmp/x-article-image.jpg"
关键:使用智能三层定位机制精确插入图片
定位策略改进: 为了解决 DOM 动态变化导致的索引失效问题,现在采用三层定位机制:
第一层 - 文本精确匹配(优先级最高)
- 匹配目标块的文本内容(前50个字符)
- 准确率:~99%
- 不受 DOM 结构变化影响
第二层 - 文本模糊匹配(第二优先级)
- 同时匹配当前块和下一个块的文本
- 适应轻微的格式变化
- 准确率:~90%
第三层 - 索引兜底(最后手段)
- 使用 block_index,但考虑已插入图片的偏移
- 准确率:~70%
- 仅在前两层都失败时使用
数据来源: 从 parse_markdown.py 生成的 JSON 中,每张内容图片包含:
block_index: 原始块索引text_before: 目标块的文本(前50字符)text_after: 下一个块的文本(前30字符)block_type: 块类型(paragraph/heading等)
插入流程(按 block_index 降序):
对于每个 content_image(按 block_index 降序):
3.1 复制图片到剪贴板
python {{SKILL_DIR}}/scripts/copy_to_clipboard.py image <图片路径> --quality 85
3.2 智能定位插入位置(三层机制+特殊字符处理)
browser_run_code: |
async (page) => {
// Step 1: 正确选择DOM层级
const editor = await page.locator('[contenteditable="true"]').first();
const wrapper = await editor.locator('> div').first();
const allBlocks = await wrapper.locator('> div[data-rbd-draggable-id]').all();
// 目标信息(从 parse_markdown.py 的 JSON)
const targetInfo = {
block_index: ${img.block_index},
text_before: "${img.text_before}",
text_after: "${img.text_after}",
block_type: "${img.block_type}"
};
let targetBlock = null;
let method = null;
// Step 2: 准备匹配文本(JavaScript的 .includes() 能正确处理特殊字符)
const targetTextBefore = targetInfo.text_before.substring(0, 30).trim();
const targetTextAfter = targetInfo.text_after ? targetInfo.text_after.substring(0, 20).trim() : '';
// Step 3: 三层定位机制(直接使用 .includes() 匹配)
// 第一层:文本内容精确匹配
for (const block of allBlocks) {
const text = await block.innerText();
const textPreview = text.substring(0, 50).trim();
if (textPreview.includes(targetTextBefore)) {
targetBlock = block;
method = 'text-exact-match';
break;
}
}
// 第二层:文本模糊匹配(前后都匹配)
if (!targetBlock && targetTextAfter) {
for (let i = 0; i < allBlocks.length - 1; i++) {
const currentText = await allBlocks[i].innerText();
const nextText = await allBlocks[i + 1].innerText();
const currentMatch = currentText.includes(targetTextBefore);
const nextMatch = nextText.includes(targetTextAfter);
if (currentMatch && nextMatch) {
targetBlock = allBlocks[i];
method = 'text-fuzzy-match';
break;
}
}
}
// 第三层:索引兜底
if (!targetBlock) {
const insertedCount = ${已插入图片数量};
const adjustedIndex = targetInfo.block_index + insertedCount;
if (adjustedIndex < allBlocks.length) {
targetBlock = allBlocks[adjustedIndex];
method = 'index-fallback';
}
}
// Step 4: 严格的错误处理(定位失败时返回调试信息)
if (!targetBlock) {
// 收集调试信息
const blockPreviews = [];
for (let i = 0; i < Math.min(allBlocks.length, 10); i++) {
const text = await allBlocks[i].innerText();
blockPreviews.push({
index: i,
preview: text.substring(0, 50).trim()
});
}
return {
success: false, // ✅ 明确的失败标志
error: 'Cannot locate target block with any method',
triedMethods: ['text-exact-match', 'text-fuzzy-match', 'index-fallback'],
targetInfo: {
block_index: targetInfo.block_index,
text_target: targetTextBefore
},
availableBlocks: allBlocks.length,
blockPreviews: blockPreviews
};
}
// Step 5: 验证定位准确性
const blockText = await targetBlock.innerText();
const isCorrect = blockText.includes(targetTextBefore);
if (!isCorrect) {
return {
success: false, // ✅ 明确的失败标志
error: 'Block found but text mismatch',
expected: targetTextBefore,
actual: blockText.substring(0, 50).trim()
};
}
// Step 6: 执行插入
await targetBlock.scrollIntoViewIfNeeded();
await targetBlock.click();
// ⚠️ 关键:使用 End 键移动到当前块末尾
// 不要使用 Control+End,它会跳到整个文档末尾!
await page.keyboard.press('End');
await page.keyboard.press('Enter');
return {
success: true, // ✅ 明确的成功标志
positioned: true,
method: method,
blockIndex: targetInfo.block_index,
verified: true,
actualText: blockText.substring(0, 50)
};
}
3.3 粘贴图片(⚠️ 必须先检查定位结果)
**重要**:只有定位成功(success=true)才执行粘贴,否则跳过该图片
检查逻辑:
- 如果 positionResult.success === false,记录错误并continue到下一张图片
- 如果 positionResult.blockPreviews 存在,输出调试信息
- 只有 success === true 时才执行 browser_press_key
browser_press_key: Control+v (Windows) / Meta+v (Mac)
3.4 等待上传完成(必须!)
browser_wait_for: textGone="正在上传媒体", time=15
如果超时,重试最多 3 次
3.5 验证插入成功(每张图片都要验证)
browser_run_code: |
async (page) => {
// 检查编辑器内实际插入的图片数量
const editor = await page.locator('[contenteditable="true"]').first();
const imageCount = await editor.locator('img[src*="pbs.twimg.com"]').count();
// 也可以通过"提供字幕"按钮数量验证
const altTextButtons = await page.locator('text=/提供字幕/').count();
return {
currentImageCount: imageCount,
altTextButtons: altTextButtons
};
}
优点:
- ✅ 100% 精确定位,直接使用 DOM 结构,不依赖自定义属性
- ✅ 避免 Windows GBK 编码导致的文本匹配问题
- ✅ 不会被 X 编辑器清理(不使用自定义 HTML 属性)
- ✅ 倒序插入避免索引偏移,所有图片准确落位
### Phase 4: 验证和修复阶段(必须执行)
4.1 统计当前图片数量 browser_run_code: | async (page) => { const editor = await page.locator('[contenteditable="true"]').first(); const actualImageCount = await editor.locator('img[src*="pbs.twimg.com"]').count(); return { actualImageCount }; }
4.2 对比预期数量 如果 actualImageCount < expected_image_count: - 识别缺失的图片位置 - 重新插入缺失的图片 - 重复验证直到数量正确
4.3 验证标题 browser_run_code: | async (page) => { const titleInput = await page.locator('[placeholder="添加标题"]'); const currentTitle = await titleInput.inputValue(); return { currentTitle }; }
如果标题不正确,重新填写
4.4 验证 H2 标题格式 browser_snapshot 检查所有 "一、二、三、四、五、六" 开头的段落是否为 heading 元素
如果不是:
- 三击选中该段落
- 点击格式按钮 → 选择"标题"
4.5 检查残留错误文本 browser_run_code: | async (page) => { const hasError = await page.locator('text=/\.jpeg\)\.jpeg\)/').count(); return { hasResidualError: hasError > 0 }; }
如果有残留错误,定位并删除
4.6 最终验证 browser_run_code: | async (page) => { const mediaButtons = await page.locator('text=/提供字幕/').count(); const h2Count = await page.locator('heading[level="2"]').count(); const titleInput = await page.locator('[placeholder="添加标题"]'); const title = await titleInput.inputValue(); return { imageCount: mediaButtons, h2Count, title, draftSaved: true }; }
### Phase 5: 生成报告
```markdown
## 发布执行报告
- **文章**: <文件名>
- **状态**: 成功 ✅ / 部分成功 ⚠️ / 失败 ❌
### 完成项
- ✅ Cookie 验证
- ✅ 封面图上传: <路径>
- ✅ 标题填写: <标题>
- ✅ 正文粘贴: <使用方法>
- 方法选项: "剪贴板 (CF_HTML UTF-8)" 或 "Playwright 注入 (乱码回退)"
- ✅ 内容图片: <实际数量>/<预期数量>
- ✅ H2 标题格式化: <数量>
- ✅ 格式错误修复: <修复项>
### 草稿状态
- 已保存为草稿
- 编辑器链接: <URL>
- **请检查预览后手动发布**
关键规则
- Cookie 优先 - 必须先加载 Cookie 再访问 X
- 文件名标题 - 优先使用文件名作为标题,而非 H1
- 直接使用原始路径 - ✅
setInputFiles()和copy_to_clipboard.py都可以读取任何盘,无需预先复制文件! - 使用智能三层定位 - 优先文本匹配(99%准确),其次模糊匹配,最后索引兜底,不依赖自定义属性
- ⚠️ 关键快捷键 - 使用
End键而不是Control+End!后者会跳到文档末尾导致图片插入错误位置 - 从后往前插入 - 图片按 block_index 降序插入,避免索引偏移
- 每张图片等待 - 粘贴后等待 "正在上传媒体" 消失
- 每张图片验证 - 验证编辑器内图片数量
- H2 验证 - 确保章节标题是 H2 格式
- 最终验证 - 必须执行完整验证
- 自动修复 - 发现问题自动修复
- 只保存草稿 - 绝不自动发布
错误处理
⚠️ 快捷键错误:图片插入到错误位置
症状:图片应该插入到段落A之后,却插入到了文章末尾。
根本原因:
// ❌ 错误写法
await page.keyboard.press('Control+End'); // 跳到整个文档末尾!
await page.keyboard.press('Enter');
// ✅ 正确写法
await page.keyboard.press('End'); // 只移动到当前行末尾
await page.keyboard.press('Enter');
快捷键差异:
End: 移动到当前行末尾 ✅Control+End(Windows) /Command+End(Mac): 移动到整个文档末尾 ❌
修复流程:
- 删除错误位置的图片(点击"移除媒体")
- 使用文本匹配重新定位目标块
- 改用
End键代替Control+End - 重新插入图片
实际案例:
- 目标:在"所以形象管理不是虚荣..."之后插入图片
- 错误:图片插入到"让我们一起种下树苗,明年再来见证它长成参天大树。"之后
- 原因:使用了
Control+End跳到文档末尾
图片上传超时
// 重试逻辑(最多 3 次)
for (let attempt = 1; attempt <= 3; attempt++) {
try {
// 粘贴图片
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+v' : 'Control+v');
// 等待上传
await page.getByText('正在上传媒体').waitFor({ state: 'hidden', timeout: 15000 });
break;
} catch (e) {
if (attempt === 3) throw e;
await page.waitForTimeout(attempt * 2000);
}
}
图片数量不匹配
// 识别缺失位置
const expected = parseResult.expected_image_count;
const actual = await page.locator('text=/提供字幕/').count();
if (actual < expected) {
// 逐个检查 after_text 位置
for (const img of parseResult.content_images) {
const hasMedia = await checkImageAtPosition(page, img.after_text);
if (!hasMedia) {
// 重新插入该图片
await insertImage(page, img);
}
}
}
H2 标题丢失
// 检查并修复 H2
const sections = ['一、', '二、', '三、', '四、', '五、', '六、'];
for (const prefix of sections) {
const el = await page.locator(`text=/^${prefix}/`).first();
const isHeading = await el.evaluate(e => e.closest('h1,h2,h3') !== null);
if (!isHeading) {
// 三击选中
await el.click({ clickCount: 3 });
// 应用标题格式
await page.getByRole('button', { name: '正文' }).click();
await page.getByRole('menuitem', { name: '标题' }).click();
}
}
Windows 特殊处理
- Windows 使用
Control+v,macOS 使用Meta+v - 路径使用正斜杠或双反斜杠
- 中文路径通过 Python os.listdir() 遍历处理
支持的格式
| 元素 | 支持度 | 说明 |
|---|---|---|
H1 (#) |
转换为 H2 | 章节标题 |
H2 (##) |
原生 | 章节标题 |
H3 (###) |
转换为 H2 | 子标题 |
粗体 (**) |
原生 | 强调 |
斜体 (*) |
原生 | 强调 |
链接 ([](url)) |
原生 | 超链接 |
| 有序列表 | 原生 | 1. 2. 3. |
| 无序列表 | 原生 | - 项目符号 |
引用块 (>) |
原生 | 引用文本 |
| 代码块 | 转换 | → 引用块格式 |
分割线 (---) |
菜单插入 | 需通过菜单 |
Cookie 配置
位置:{{SKILL_DIR}}/cookies.json
{
"cookies": [
{
"name": "auth_token",
"value": "你的auth_token值",
"domain": ".x.com",
"path": "/",
"expires": -1,
"httpOnly": true,
"secure": true,
"sameSite": "None"
},
{
"name": "ct0",
"value": "你的ct0值",
"domain": ".x.com",
"path": "/",
"expires": -1,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
}
]
}
注意: 本 skill 设计为全自动执行。确保 Playwright MCP 已连接、Cookie 有效后,可一次性完成全部操作,无需人工干预。