
X Article Publisher
- 96 installs
- 77 repo stars
- Updated January 22, 2026
- joeseesun/qiaomu-x-article-publisher
Helps with ai & agent building tasks.
About
x-article-publisher is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- x-article-publisher
- AI & Agent Building
- AI-coding skill
X Article Publisher by the numbers
- 96 all-time installs (skills.sh)
- Ranked #4,561 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/joeseesun/qiaomu-x-article-publisher --skill x-article-publisherAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 96 |
|---|---|
| repo stars | ★ 77 |
| Last updated | January 22, 2026 |
| Repository | joeseesun/qiaomu-x-article-publisher ↗ |
What it does
Helps with ai & agent building tasks.
Files
X Article Publisher
Publish Markdown content to X (Twitter) Articles editor, preserving formatting with rich text conversion.
Prerequisites
- X Premium Plus subscription
- Python 3.9+ with dependencies:
pip install Pillow pyobjc-framework-Cocoa patchright
🎉 首次使用:一次认证,告别重复登录
X Article Publisher 现在支持持久化认证,无需每次手动登录!
🔧 初始化认证(仅需一次)
首次使用前,运行认证设置:
cd ~/.claude/skills/x-article-publisher/scripts
python auth_manager.py setup流程: 1. ✅ 浏览器窗口自动打开 X 登录页面 2. 🔐 手动登录你的 X 账号(需 Premium+ 订阅) 3. ✅ 完成 2FA 验证(如已启用) 4. 🏠 登录成功后自动跳转到 Home 时间线 5. 💾 认证状态自动保存(有效期 7 天)
📋 认证管理命令
# 检查认证状态
python auth_manager.py status
# 验证认证是否有效
python auth_manager.py validate
# 清除认证数据(需重新登录)
python auth_manager.py clear
# 重新认证(清除 + 设置)
python auth_manager.py reauth🚀 自动化工作流
认证设置完成后,skill 执行时会自动: 1. ✅ 检查认证状态 2. 🔓 如已认证,直接使用保存的浏览器状态(无需登录) 3. ⚠️ 如未认证,提示运行 auth_manager.py setup
注意:认证数据存储在 ~/.claude/skills/x-article-publisher/data/browser_state/,已通过 .gitignore 排除,不会提交到 Git。
---
Scripts
Located in ~/.claude/skills/x-article-publisher/scripts/:
publish_article.py (主脚本 - 一键发布)
推荐使用 - 自动完成所有发布步骤:
# 基本用法
python publish_article.py --file article.md
# 显示浏览器(调试用)
python publish_article.py --file article.md --show-browser
# 自定义标题
python publish_article.py --file article.md --title "自定义标题"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.htmlWorkflow (简化版)
前提:已完成认证设置(python auth_manager.py setup)
🚀 一键发布(推荐)
直接运行 publish_article.py,自动完成所有步骤:
cd ~/.claude/skills/x-article-publisher/scripts
python publish_article.py --file /path/to/article.md脚本会自动: 1. ✅ 检查认证状态 2. 📄 解析 Markdown 文件 3. 🌐 启动已认证的浏览器 4. 📍 导航到 X Articles 编辑器 5. 🔘 点击 create 按钮 6. 🖼️ 上传封面图(如有) 7. 📝 填写标题 8. 📋 粘贴 HTML 内容 9. ✅ 保存草稿(不会自动发布)
手动工作流(高级用户)
如需更精细控制,可分步执行: 1. Parse Markdown: python parse_markdown.py article.md 2. 手动操作浏览器发布
---
🧠 智能增强功能
智能标题生成
当文章没有 H1 标题时,parse_markdown.py 会返回 needs_title_generation: true。
Claude 应该自动: 1. 阅读文章内容,理解核心观点 2. 生成一个吸引人点击的标题(15-25字为佳) 3. 使用 --title "生成的标题" 参数发布
好标题的特点:
- 包含数字或具体细节("3个方法"、"90%的人不知道")
- 激发好奇心("为什么..."、"如何..."、"...的真相")
- 与读者切身相关
- 避免标题党,但要有吸引力
示例:
# 解析文章
python parse_markdown.py article.md
# 如果 needs_title_generation: true,Claude 生成标题后:
python publish_article.py --file article.md --title "AI时代,普通人的3个生存法则"智能封面图生成
当文章没有封面图时,parse_markdown.py 会返回 needs_cover_generation: true。
Claude 应该自动: 1. 阅读文章,提炼核心概念(1-3个关键词) 2. 调用 gemini-image-generator 或 jimeng-image-generator skill 生成封面图 3. 封面图风格建议:
- 简洁大气,避免复杂细节
- 可以是抽象概念的可视化
- 或是带有核心关键词的文字海报
4. 将生成的图片路径插入到文章开头作为封面
封面图生成提示词模板:
为一篇关于「{文章主题}」的文章生成封面图。
风格:简洁、现代、科技感
元素:{1-3个核心视觉元素}
文字:可选,如果加文字只放{1-3个关键词}
尺寸:16:9 横版工作流示例:
# 1. 解析文章
python parse_markdown.py article.md
# 输出: needs_cover_generation: true
# 2. Claude 调用生图 skill 生成封面(假设保存到 /tmp/cover.png)
# 3. 将封面图插入文章开头,或手动上传注意:封面图上传目前需要在浏览器中手动操作,脚本会打开编辑器后等待用户操作。
---
技术细节
parse_markdown.py 输出格式
{
"title": "Article Title",
"title_source": "h1", // "h1", "h2", "first_line", or "none"
"needs_title_generation": false, // true if no H1 title
"cover_image": "/path/to/first-image.jpg",
"needs_cover_generation": false, // true if no cover image
"content_images": [
{"path": "/path/to/img2.jpg", "block_index": 5}
],
"html": "<p>Content...</p><h2>Section</h2>...",
"total_blocks": 45
}字段说明:
title_source: 标题来源h1: 来自 H1 标题(最理想)h2: 来自第一个 H2 标题first_line: 来自第一行文本none: 无法提取标题needs_title_generation: 是否需要 Claude 生成更好的标题needs_cover_generation: 是否需要 Claude 生成封面图
Critical Rules
1. NEVER auto-publish - Only save as draft 2. NO automatic cover images - User adds cover manually, never insert first image as cover 3. Clean placeholders - Remove all remaining @@@IMG_X@@@ markers after image insertion 4. H1 title handling - H1 is used as title only, not included in body
Supported Formatting
- H2 headers (## )
- Blockquotes (> )
- Code blocks (converted to blockquotes)
- Bold text (**)
- Hyperlinks (text)
- Ordered/Unordered lists
- Paragraphs
Example
User: "Publish /path/to/article.md to X"
cd ~/.claude/skills/x-article-publisher/scripts
python publish_article.py --file /path/to/article.mdOutput:
📄 解析文件:/path/to/article.md
📝 标题:文章标题
🖼️ 封面图:/path/to/cover.jpg
📷 内容图:2 张
🌐 启动浏览器...
📍 导航到 X Articles...
🔘 点击 create 按钮...
📝 填写标题...
📋 粘贴内容...
✅ 草稿已创建!
💡 请在浏览器中检查并手动发布
🖥️ 浏览器保持打开,请检查草稿并手动发布
⏎ 完成后按回车键关闭浏览器...技术经验参考: 浏览器自动化调试技巧详见 skill-development-guide
# ============================================================================
# X Article Publisher - Data Directory .gitignore
# ============================================================================
#
# PURPOSE:
# 保护敏感的浏览器认证数据不被提交到 Git
#
# EXCLUDED:
# - browser_state/state.json (session cookies)
# - browser_state/browser_profile/ (persistent cookies + fingerprint)
# - auth_info.json (认证元数据)
# ============================================================================
# 浏览器认证状态文件
browser_state/state.json
browser_state/browser_profile/
# 认证元数据
auth_info.json
# 保留目录结构
!browser_state/.gitkeep
# Python cache
__pycache__/
*.pyc
Changelog
所有重要变更都会记录在这个文件中。
格式基于 Keep a Changelog, 版本号遵循 语义化版本。
---
[1.0.0] - 2026-01-14
新增 ✨
- Markdown 到 X Articles 自动转换
- 持久化浏览器认证(7天有效期)
- 智能图片处理(封面图 + 内容图)
- 一键发布命令
- 完全自包含(内置 browser_auth 框架)
- 认证管理命令(status/validate/reauth/clear)
技术亮点 🔧
- 使用 Patchright 绕过自动化检测
- 混合认证方案(user_data_dir + state.json)
- 配置驱动的验证策略
- 模块化设计,易于扩展
安全设计 🔐
- 只保存草稿,不自动发布
- 认证数据通过 .gitignore 排除
- 无硬编码凭据
文档 📖
- 完整的 README.md
- SKILL.md(Claude Code 技术文档)
- 故障排查指南
- 使用场景示例
致谢 🙏
基于 @wshuyi 王树义老师 的原版改进。
---
[Unreleased]
计划新增
- 自动生成标题功能
- 自动生成封面图功能
- 批量发布模式
- 发布历史记录
---
贡献
如果你发现 Bug 或有功能建议,欢迎:
- 提交 Issue: https://github.com/joeseesun/qiaomu-x-article-publisher/issues
- 提交 Pull Request
---
_格式说明:_
新增- 新功能变更- 现有功能的变化废弃- 即将移除的功能移除- 已移除的功能修复- Bug 修复安全- 安全相关的修复
# ============================================================================
# X Article Publisher - Data Directory .gitignore
# ============================================================================
#
# PURPOSE:
# 保护敏感的浏览器认证数据不被提交到 Git
#
# EXCLUDED:
# - browser_state/state.json (session cookies)
# - browser_state/browser_profile/ (persistent cookies + fingerprint)
# - auth_info.json (认证元数据)
# ============================================================================
# 浏览器认证状态文件
browser_state/state.json
browser_state/browser_profile/
# 认证元数据
auth_info.json
# 保留目录结构
!browser_state/.gitkeep
# Placeholder to preserve directory structure in Git
"""
==============================================================================
Browser Authentication Framework - 通用浏览器认证框架
==============================================================================
PURPOSE:
支持多网站配置驱动的认证管理,解决 Playwright session cookie bug
CORE COMPONENTS:
- SiteConfig: 配置驱动的验证策略
- BrowserAuthManager: 通用认证管理器(核心类)
- BrowserFactory: 浏览器实例工厂
SOLUTION:
混合认证方案 (user_data_dir + state.json 手动注入)
解决 Playwright #36139: Session cookies 持久化问题
"""
from .config import SiteConfig, DEFAULT_BROWSER_ARGS, DEFAULT_USER_AGENT
from .browser_factory import BrowserFactory
from .auth_manager import BrowserAuthManager
from .exceptions import (
BrowserAuthError,
AuthenticationError,
ValidationError,
ConfigurationError,
StateFileError
)
__version__ = "1.0.0"
__all__ = [
'SiteConfig',
'BrowserFactory',
'BrowserAuthManager',
'DEFAULT_BROWSER_ARGS',
'DEFAULT_USER_AGENT',
'BrowserAuthError',
'AuthenticationError',
'ValidationError',
'ConfigurationError',
'StateFileError',
]
# ~/.claude/skills/shared-lib/browser_auth/auth_manager.py
"""
Browser Authentication Framework - 核心认证管理器
"""
import json
import time
import re
from pathlib import Path
from typing import Dict, Any
from patchright.sync_api import sync_playwright, BrowserContext, Page
from .config import SiteConfig
from .browser_factory import BrowserFactory
from .exceptions import AuthenticationError, ValidationError, StateFileError
class BrowserAuthManager:
"""
通用浏览器认证管理器
核心功能:
1. 交互式登录设置(setup_auth)
2. 认证状态检查(is_authenticated)
3. 认证验证(validate_auth)
4. 获取已认证上下文(get_authenticated_context)
5. 清除认证(clear_auth)
混合认证方案:
- user_data_dir: 保证浏览器指纹一致性
- state.json: 手动注入 session cookies
"""
def __init__(self, site_config: SiteConfig, state_dir: Path):
"""
初始化认证管理器
Args:
site_config: 网站配置
state_dir: 状态存储目录(如 ~/.claude/skills/x-publisher/data/browser_state)
"""
self.config = site_config
self.state_dir = Path(state_dir)
self.state_file = self.state_dir / "state.json"
self.profile_dir = self.state_dir / "browser_profile"
self.auth_info_file = self.state_dir.parent / "auth_info.json"
# 确保目录存在
self.state_dir.mkdir(parents=True, exist_ok=True)
def is_authenticated(self) -> bool:
"""
快速检查认证状态(不启动浏览器)
检查 state.json 是否存在且未过期(7天)
Returns:
True 如果已认证
"""
if not self.state_file.exists():
return False
# 检查文件年龄(7天过期)
age_days = (time.time() - self.state_file.stat().st_mtime) / 86400
if age_days > 7:
print(f"⚠️ Browser state is {age_days:.1f} days old, may need re-authentication")
return True
def get_auth_info(self) -> Dict[str, Any]:
"""
获取认证信息元数据
Returns:
包含认证状态和时间戳的字典
"""
info = {
'site_name': self.config.site_name,
'authenticated': self.is_authenticated(),
'state_file': str(self.state_file),
'state_exists': self.state_file.exists()
}
if self.auth_info_file.exists():
try:
with open(self.auth_info_file, 'r') as f:
saved_info = json.load(f)
info.update(saved_info)
except Exception:
pass
if info['state_exists']:
age_hours = (time.time() - self.state_file.stat().st_mtime) / 3600
info['state_age_hours'] = age_hours
return info
def _check_success_indicators(self, page: Page) -> bool:
"""
根据 success_indicators 配置检查登录是否成功
验证优先级:
1. URL 检查(最快)
2. Cookie 检查(快速)
3. DOM 元素检查(需等待)
4. 自定义验证函数(可选)
Args:
page: Playwright Page 实例
Returns:
True 如果验证成功
"""
indicators = self.config.success_indicators
# 1. URL 包含检查
if 'url_contains' in indicators:
if indicators['url_contains'] in page.url:
return True
# 2. URL 正则匹配
if 'url_pattern' in indicators:
if re.match(indicators['url_pattern'], page.url):
return True
# 3. Cookie 存在性检查
if 'cookie_exists' in indicators:
cookies = page.context.cookies()
cookie_names = [c['name'] for c in cookies]
if indicators['cookie_exists'] in cookie_names:
return True
# 4. DOM 元素存在性检查
if 'element_exists' in indicators:
try:
selector = indicators['element_exists']
# 等待最多 5 秒
element = page.wait_for_selector(selector, timeout=5000)
if element:
return True
except Exception:
pass
# 5. 自定义验证函数
if self.config.custom_validator:
try:
return self.config.custom_validator(page)
except Exception as e:
print(f" ⚠️ Custom validator error: {e}")
return False
def setup_auth(self, headless: bool = False) -> bool:
"""
交互式登录设置
工作流程:
1. 启动 persistent context with user_data_dir
2. 导航到 login_url
3. 等待用户手动登录
4. 根据 success_indicators 验证登录成功
5. 保存 state.json + browser_profile
Args:
headless: 是否无头模式(登录时应为 False)
Returns:
True 如果认证成功
"""
print(f"🔐 Starting authentication setup for {self.config.site_name}...")
print(f" Timeout: {self.config.login_timeout_minutes} minutes")
playwright = None
context = None
try:
playwright = sync_playwright().start()
# 启动 persistent context
context = BrowserFactory.launch_persistent_context(
playwright,
user_data_dir=self.profile_dir,
state_file=self.state_file,
headless=headless
)
# 导航到登录页面
page = context.new_page()
page.goto(self.config.login_url, wait_until="domcontentloaded")
# 检查是否已经登录
if self._check_success_indicators(page):
print(" ✅ Already authenticated!")
self._save_browser_state(context)
return True
# 等待手动登录
print(f"\n ⏳ Please log in to {self.config.site_name}...")
print(f" ⏱️ Waiting up to {self.config.login_timeout_minutes} minutes for login...")
# 轮询检查登录状态
timeout_seconds = self.config.login_timeout_minutes * 60
start_time = time.time()
while time.time() - start_time < timeout_seconds:
if self._check_success_indicators(page):
print(f" ✅ Login successful!")
self._save_browser_state(context)
self._save_auth_info()
return True
time.sleep(2) # 每 2 秒检查一次
print(f" ❌ Authentication timeout")
return False
except Exception as e:
print(f" ❌ Error: {e}")
return False
finally:
if context:
try:
context.close()
except Exception:
pass
if playwright:
try:
playwright.stop()
except Exception:
pass
def _save_browser_state(self, context: BrowserContext):
"""保存浏览器状态到 state.json"""
try:
context.storage_state(path=str(self.state_file))
print(f" 💾 Saved browser state to: {self.state_file}")
except Exception as e:
raise StateFileError(f"Failed to save browser state: {e}")
def _save_auth_info(self):
"""保存认证元数据"""
try:
info = {
'site_name': self.config.site_name,
'authenticated_at': time.time(),
'authenticated_at_iso': time.strftime('%Y-%m-%d %H:%M:%S')
}
with open(self.auth_info_file, 'w') as f:
json.dump(info, f, indent=2)
except Exception:
pass # 非关键错误
def validate_auth(self) -> bool:
"""
验证现有认证是否有效(启动浏览器测试)
Returns:
True 如果认证有效
"""
if not self.is_authenticated():
return False
print(f"🔍 Validating authentication for {self.config.site_name}...")
playwright = None
context = None
try:
playwright = sync_playwright().start()
# 启动 persistent context + 注入 cookies
context = BrowserFactory.launch_persistent_context(
playwright,
user_data_dir=self.profile_dir,
state_file=self.state_file,
headless=True
)
# 访问登录后的页面进行验证
page = context.new_page()
page.goto(self.config.login_url, wait_until="domcontentloaded")
# 检查验证指标
is_valid = self._check_success_indicators(page)
if is_valid:
print(" ✅ Authentication is valid")
else:
print(" ❌ Authentication is invalid")
return is_valid
except Exception as e:
print(f" ❌ Validation error: {e}")
return False
finally:
if context:
try:
context.close()
except Exception:
pass
if playwright:
try:
playwright.stop()
except Exception:
pass
def get_authenticated_context(self) -> BrowserContext:
"""
获取已认证的浏览器上下文(供 skill 使用)
工作流程:
1. 检查 is_authenticated()
2. 启动 persistent context
3. 手动注入 cookies from state.json
4. 返回 context(调用者负责关闭)
Returns:
已认证的 BrowserContext
Raises:
AuthenticationError: 如果未认证
"""
if not self.is_authenticated():
raise AuthenticationError(
f"Not authenticated for {self.config.site_name}. "
f"Please run setup_auth() first."
)
playwright = sync_playwright().start()
context = BrowserFactory.launch_persistent_context(
playwright,
user_data_dir=self.profile_dir,
state_file=self.state_file,
headless=True
)
return context
def clear_auth(self):
"""
清除所有认证数据
删除:
- state.json(session cookies)
- browser_profile(persistent cookies + 浏览器指纹)
- auth_info.json(认证元数据)
"""
print(f"🧹 Clearing authentication data for {self.config.site_name}...")
# 删除 state.json
if self.state_file.exists():
self.state_file.unlink()
print(f" ✓ Removed {self.state_file}")
# 删除 browser profile 目录
if self.profile_dir.exists():
import shutil
shutil.rmtree(self.profile_dir)
print(f" ✓ Removed {self.profile_dir}")
# 删除 auth_info.json
if self.auth_info_file.exists():
self.auth_info_file.unlink()
print(f" ✓ Removed {self.auth_info_file}")
print(" ✅ Authentication cleared")
# ~/.claude/skills/shared-lib/browser_auth/browser_factory.py
"""
Browser Authentication Framework - 浏览器工厂
负责创建配置好的浏览器上下文,处理 Playwright session cookie bug
"""
import json
from pathlib import Path
from typing import Optional
from patchright.sync_api import Playwright, BrowserContext
from .config import DEFAULT_BROWSER_ARGS, DEFAULT_USER_AGENT
class BrowserFactory:
"""
浏览器实例工厂
核心功能:
1. 创建 persistent context with user_data_dir(保证浏览器指纹一致性)
2. 手动注入 state.json 中的 cookies(解决 Playwright session cookie bug)
参考:https://github.com/microsoft/playwright/issues/36139
"""
@staticmethod
def launch_persistent_context(
playwright: Playwright,
user_data_dir: Path,
state_file: Optional[Path] = None,
headless: bool = True,
user_agent: str = DEFAULT_USER_AGENT,
browser_args: list = None
) -> BrowserContext:
"""
启动持久化浏览器上下文
Args:
playwright: Playwright 实例
user_data_dir: 浏览器 profile 目录
state_file: state.json 文件路径(用于手动注入 cookies)
headless: 是否无头模式
user_agent: 自定义 User-Agent
browser_args: 额外的浏览器启动参数
Returns:
配置好的 BrowserContext
"""
if browser_args is None:
browser_args = DEFAULT_BROWSER_ARGS
# 确保目录存在
user_data_dir.mkdir(parents=True, exist_ok=True)
# 启动 persistent context
context = playwright.chromium.launch_persistent_context(
user_data_dir=str(user_data_dir),
channel="chrome", # 使用真实 Chrome,提高信任度
headless=headless,
no_viewport=True,
ignore_default_args=["--enable-automation"],
user_agent=user_agent,
args=browser_args
)
# Cookie 手动注入(Playwright bug workaround)
# Session cookies (expires=-1) 不会自动持久化到 user_data_dir
if state_file and state_file.exists():
BrowserFactory._inject_cookies(context, state_file)
return context
@staticmethod
def _inject_cookies(context: BrowserContext, state_file: Path):
"""
从 state.json 手动注入 cookies
这是解决 Playwright #36139 bug 的关键步骤:
- Persistent cookies 会自动保存到 user_data_dir
- Session cookies 必须手动注入
"""
try:
with open(state_file, 'r') as f:
state = json.load(f)
if 'cookies' in state and len(state['cookies']) > 0:
context.add_cookies(state['cookies'])
# print(f" 🔧 注入 {len(state['cookies'])} cookies")
except Exception as e:
# 非致命错误,首次 setup 时 state.json 不存在
pass
# ~/.claude/skills/shared-lib/browser_auth/config.py
"""
Browser Authentication Framework - 站点配置
"""
from dataclasses import dataclass, field
from typing import Dict, Any, Optional, Callable
@dataclass
class SiteConfig:
"""
网站认证配置
使用配置驱动的验证策略,支持多种验证方式:
- URL 模式匹配(最快)
- Cookie 存在性检查(快速)
- DOM 元素检查(需等待渲染)
- 自定义验证函数(复杂场景)
"""
# 基础信息
site_name: str # 网站标识,如 "notebooklm" | "x-twitter"
login_url: str # 登录页面 URL
# 验证策略(任一满足即认为登录成功,按优先级执行)
success_indicators: Dict[str, Any] = field(default_factory=dict)
# {
# "url_contains": "home", # URL 包含关键字
# "url_pattern": r"^https://x\.com/home", # URL 正则匹配
# "element_exists": "nav[aria-label='Primary']", # 元素存在
# "cookie_exists": "auth_token" # Cookie 存在
# }
# 超时配置
login_timeout_minutes: int = 10
# 可选:自定义验证函数(复杂场景)
# 签名: validate_fn(page: Page) -> bool
custom_validator: Optional[Callable] = None
def __post_init__(self):
"""配置验证"""
if not self.site_name:
raise ValueError("site_name 不能为空")
if not self.login_url:
raise ValueError("login_url 不能为空")
if not self.success_indicators and not self.custom_validator:
raise ValueError("必须提供 success_indicators 或 custom_validator")
# 默认浏览器配置常量
DEFAULT_BROWSER_ARGS = [
'--disable-blink-features=AutomationControlled', # 隐藏 navigator.webdriver
'--disable-dev-shm-usage',
'--no-sandbox',
'--no-first-run',
'--no-default-browser-check'
]
DEFAULT_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
"""
Browser Authentication Framework - 异常定义
"""
class BrowserAuthError(Exception):
"""浏览器认证基础异常"""
pass
class AuthenticationError(BrowserAuthError):
"""认证失败异常"""
pass
class ValidationError(BrowserAuthError):
"""验证失败异常"""
pass
class ConfigurationError(BrowserAuthError):
"""配置错误异常"""
pass
class StateFileError(BrowserAuthError):
"""状态文件错误异常"""
pass
MIT License
Copyright (c) 2026 Qiaomu (乔木)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
{
"name": "qiaomu-x-article-publisher",
"version": "1.0.0",
"description": "Publish Markdown articles to X (Twitter) Articles editor with proper formatting. Self-contained with built-in browser authentication. Supports persistent login, smart title/cover generation, and one-command publishing.",
"author": "Qiaomu",
"dependencies": {
"python": ">=3.9",
"pip_packages": [
"Pillow",
"pyobjc-framework-Cocoa",
"patchright"
]
},
"platforms": ["macOS"],
"tags": ["publishing", "x", "twitter", "articles", "markdown", "automation", "qiaomu"],
"triggers": [
"publish to X",
"post article to Twitter",
"X article",
"publish markdown to X"
]
}
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
# Authentication & Browser State (CRITICAL - NEVER COMMIT)
data/auth_info.json
data/browser_state/state.json
data/browser_state/browser_profile/
# IDE
.vscode/
.idea/
*.swp
*.swo
# macOS
.DS_Store
.AppleDouble
.LSOverride
# Temporary files
*.log
*.tmp
.cache/
# Keep directory structure
!data/.gitkeep
!data/browser_state/.gitkeep
Changelog
所有重要变更都会记录在这个文件中。
格式基于 Keep a Changelog, 版本号遵循 语义化版本。
---
[1.0.0] - 2026-01-14
新增 ✨
- Markdown 到 X Articles 自动转换
- 持久化浏览器认证(7天有效期)
- 智能图片处理(封面图 + 内容图)
- 一键发布命令
- 完全自包含(内置 browser_auth 框架)
- 认证管理命令(status/validate/reauth/clear)
技术亮点 🔧
- 使用 Patchright 绕过自动化检测
- 混合认证方案(user_data_dir + state.json)
- 配置驱动的验证策略
- 模块化设计,易于扩展
安全设计 🔐
- 只保存草稿,不自动发布
- 认证数据通过 .gitignore 排除
- 无硬编码凭据
文档 📖
- 完整的 README.md(简洁版)
- README_FULL.md(详细版)
- SKILL.md(Claude Code 技术文档)
- 故障排查指南
- 使用场景示例
---
[Unreleased]
计划新增
- 自动生成标题功能
- 自动生成封面图功能
- 批量发布模式
- 发布历史记录
---
贡献
如果你发现 Bug 或有功能建议,欢迎:
- 提交 Issue: https://github.com/[your-username]/qiaomu-x-article-publisher/issues
- 提交 Pull Request
---
_格式说明:_
新增- 新功能变更- 现有功能的变化废弃- 即将移除的功能移除- 已移除的功能修复- Bug 修复安全- 安全相关的修复
贡献指南
感谢你考虑为 Qiaomu X Article Publisher 做贡献!
如何贡献
报告 Bug
1. 在 Issues 中搜索,确保问题未被报告 2. 创建新 Issue,包含:
- 清晰的标题
- 重现步骤
- 预期行为 vs 实际行为
- 环境信息(Python版本、macOS版本)
- 相关日志或截图
提出功能建议
1. 检查 路线图 确认功能未在计划中 2. 创建 Issue,说明:
- 功能描述
- 使用场景
- 为什么这个功能有用
提交 Pull Request
开发流程
1. Fork 仓库 2. 创建特性分支:
git checkout -b feature/your-feature-name3. 进行你的改动 4. 测试你的改动 5. 提交改动:
git commit -m "feat: add some feature"6. 推送到你的 Fork:
git push origin feature/your-feature-name7. 创建 Pull Request
提交信息规范
使用 Conventional Commits 格式:
feat:- 新功能fix:- Bug 修复docs:- 文档更新style:- 代码格式(不影响功能)refactor:- 重构test:- 测试相关chore:- 构建/工具相关
示例:
feat: add automatic title generation
fix: handle image upload timeout
docs: update installation guide代码规范
- Python 代码遵循 PEP 8
- 添加必要的注释
- 更新相关文档
- 保持向后兼容
Pull Request 检查清单
- [ ] 代码已测试
- [ ] 文档已更新
- [ ] CHANGELOG.md 已更新
- [ ] 提交信息符合规范
- [ ] 无合并冲突
开发环境设置
# 克隆仓库
git clone https://github.com/[your-username]/qiaomu-x-article-publisher.git
cd qiaomu-x-article-publisher
# 安装依赖
pip install Pillow pyobjc-framework-Cocoa patchright
# 运行测试
cd scripts
python auth_manager.py status测试
在提交 PR 前,请确保:
1. 认证流程正常:
python auth_manager.py setup
python auth_manager.py validate2. 发布流程正常:
python publish_article.py --file test-article.md --show-browser3. 无明显错误或警告
行为准则
- 尊重他人
- 包容不同观点
- 专注于建设性反馈
- 保持专业
问题?
如有疑问,欢迎:
- 创建 Issue 询问
- 发邮件到 [your-email]
---
感谢你的贡献! 🎉
# ============================================================================
# X Article Publisher - Data Directory .gitignore
# ============================================================================
#
# PURPOSE:
# 保护敏感的浏览器认证数据不被提交到 Git
#
# EXCLUDED:
# - browser_state/state.json (session cookies)
# - browser_state/browser_profile/ (persistent cookies + fingerprint)
# - auth_info.json (认证元数据)
# ============================================================================
# 浏览器认证状态文件
browser_state/state.json
browser_state/browser_profile/
# 认证元数据
auth_info.json
# 保留目录结构
!browser_state/.gitkeep
# Placeholder to preserve directory structure in Git
"""
==============================================================================
Browser Authentication Framework - 通用浏览器认证框架
==============================================================================
PURPOSE:
支持多网站配置驱动的认证管理,解决 Playwright session cookie bug
CORE COMPONENTS:
- SiteConfig: 配置驱动的验证策略
- BrowserAuthManager: 通用认证管理器(核心类)
- BrowserFactory: 浏览器实例工厂
SOLUTION:
混合认证方案 (user_data_dir + state.json 手动注入)
解决 Playwright #36139: Session cookies 持久化问题
"""
from .config import SiteConfig, DEFAULT_BROWSER_ARGS, DEFAULT_USER_AGENT
from .browser_factory import BrowserFactory
from .auth_manager import BrowserAuthManager
from .exceptions import (
BrowserAuthError,
AuthenticationError,
ValidationError,
ConfigurationError,
StateFileError
)
__version__ = "1.0.0"
__all__ = [
'SiteConfig',
'BrowserFactory',
'BrowserAuthManager',
'DEFAULT_BROWSER_ARGS',
'DEFAULT_USER_AGENT',
'BrowserAuthError',
'AuthenticationError',
'ValidationError',
'ConfigurationError',
'StateFileError',
]
# ~/.claude/skills/shared-lib/browser_auth/auth_manager.py
"""
Browser Authentication Framework - 核心认证管理器
"""
import json
import time
import re
from pathlib import Path
from typing import Dict, Any
from patchright.sync_api import sync_playwright, BrowserContext, Page
from .config import SiteConfig
from .browser_factory import BrowserFactory
from .exceptions import AuthenticationError, ValidationError, StateFileError
class BrowserAuthManager:
"""
通用浏览器认证管理器
核心功能:
1. 交互式登录设置(setup_auth)
2. 认证状态检查(is_authenticated)
3. 认证验证(validate_auth)
4. 获取已认证上下文(get_authenticated_context)
5. 清除认证(clear_auth)
混合认证方案:
- user_data_dir: 保证浏览器指纹一致性
- state.json: 手动注入 session cookies
"""
def __init__(self, site_config: SiteConfig, state_dir: Path):
"""
初始化认证管理器
Args:
site_config: 网站配置
state_dir: 状态存储目录(如 ~/.claude/skills/x-publisher/data/browser_state)
"""
self.config = site_config
self.state_dir = Path(state_dir)
self.state_file = self.state_dir / "state.json"
self.profile_dir = self.state_dir / "browser_profile"
self.auth_info_file = self.state_dir.parent / "auth_info.json"
# 确保目录存在
self.state_dir.mkdir(parents=True, exist_ok=True)
def is_authenticated(self) -> bool:
"""
快速检查认证状态(不启动浏览器)
检查 state.json 是否存在且未过期(7天)
Returns:
True 如果已认证
"""
if not self.state_file.exists():
return False
# 检查文件年龄(7天过期)
age_days = (time.time() - self.state_file.stat().st_mtime) / 86400
if age_days > 7:
print(f"⚠️ Browser state is {age_days:.1f} days old, may need re-authentication")
return True
def get_auth_info(self) -> Dict[str, Any]:
"""
获取认证信息元数据
Returns:
包含认证状态和时间戳的字典
"""
info = {
'site_name': self.config.site_name,
'authenticated': self.is_authenticated(),
'state_file': str(self.state_file),
'state_exists': self.state_file.exists()
}
if self.auth_info_file.exists():
try:
with open(self.auth_info_file, 'r') as f:
saved_info = json.load(f)
info.update(saved_info)
except Exception:
pass
if info['state_exists']:
age_hours = (time.time() - self.state_file.stat().st_mtime) / 3600
info['state_age_hours'] = age_hours
return info
def _check_success_indicators(self, page: Page) -> bool:
"""
根据 success_indicators 配置检查登录是否成功
验证优先级:
1. URL 检查(最快)
2. Cookie 检查(快速)
3. DOM 元素检查(需等待)
4. 自定义验证函数(可选)
Args:
page: Playwright Page 实例
Returns:
True 如果验证成功
"""
indicators = self.config.success_indicators
# 1. URL 包含检查
if 'url_contains' in indicators:
if indicators['url_contains'] in page.url:
return True
# 2. URL 正则匹配
if 'url_pattern' in indicators:
if re.match(indicators['url_pattern'], page.url):
return True
# 3. Cookie 存在性检查
if 'cookie_exists' in indicators:
cookies = page.context.cookies()
cookie_names = [c['name'] for c in cookies]
if indicators['cookie_exists'] in cookie_names:
return True
# 4. DOM 元素存在性检查
if 'element_exists' in indicators:
try:
selector = indicators['element_exists']
# 等待最多 5 秒
element = page.wait_for_selector(selector, timeout=5000)
if element:
return True
except Exception:
pass
# 5. 自定义验证函数
if self.config.custom_validator:
try:
return self.config.custom_validator(page)
except Exception as e:
print(f" ⚠️ Custom validator error: {e}")
return False
def setup_auth(self, headless: bool = False) -> bool:
"""
交互式登录设置
工作流程:
1. 启动 persistent context with user_data_dir
2. 导航到 login_url
3. 等待用户手动登录
4. 根据 success_indicators 验证登录成功
5. 保存 state.json + browser_profile
Args:
headless: 是否无头模式(登录时应为 False)
Returns:
True 如果认证成功
"""
print(f"🔐 Starting authentication setup for {self.config.site_name}...")
print(f" Timeout: {self.config.login_timeout_minutes} minutes")
playwright = None
context = None
try:
playwright = sync_playwright().start()
# 启动 persistent context
context = BrowserFactory.launch_persistent_context(
playwright,
user_data_dir=self.profile_dir,
state_file=self.state_file,
headless=headless
)
# 导航到登录页面
page = context.new_page()
page.goto(self.config.login_url, wait_until="domcontentloaded")
# 检查是否已经登录
if self._check_success_indicators(page):
print(" ✅ Already authenticated!")
self._save_browser_state(context)
return True
# 等待手动登录
print(f"\n ⏳ Please log in to {self.config.site_name}...")
print(f" ⏱️ Waiting up to {self.config.login_timeout_minutes} minutes for login...")
# 轮询检查登录状态
timeout_seconds = self.config.login_timeout_minutes * 60
start_time = time.time()
while time.time() - start_time < timeout_seconds:
if self._check_success_indicators(page):
print(f" ✅ Login successful!")
self._save_browser_state(context)
self._save_auth_info()
return True
time.sleep(2) # 每 2 秒检查一次
print(f" ❌ Authentication timeout")
return False
except Exception as e:
print(f" ❌ Error: {e}")
return False
finally:
if context:
try:
context.close()
except Exception:
pass
if playwright:
try:
playwright.stop()
except Exception:
pass
def _save_browser_state(self, context: BrowserContext):
"""保存浏览器状态到 state.json"""
try:
context.storage_state(path=str(self.state_file))
print(f" 💾 Saved browser state to: {self.state_file}")
except Exception as e:
raise StateFileError(f"Failed to save browser state: {e}")
def _save_auth_info(self):
"""保存认证元数据"""
try:
info = {
'site_name': self.config.site_name,
'authenticated_at': time.time(),
'authenticated_at_iso': time.strftime('%Y-%m-%d %H:%M:%S')
}
with open(self.auth_info_file, 'w') as f:
json.dump(info, f, indent=2)
except Exception:
pass # 非关键错误
def validate_auth(self) -> bool:
"""
验证现有认证是否有效(启动浏览器测试)
Returns:
True 如果认证有效
"""
if not self.is_authenticated():
return False
print(f"🔍 Validating authentication for {self.config.site_name}...")
playwright = None
context = None
try:
playwright = sync_playwright().start()
# 启动 persistent context + 注入 cookies
context = BrowserFactory.launch_persistent_context(
playwright,
user_data_dir=self.profile_dir,
state_file=self.state_file,
headless=True
)
# 访问登录后的页面进行验证
page = context.new_page()
page.goto(self.config.login_url, wait_until="domcontentloaded")
# 检查验证指标
is_valid = self._check_success_indicators(page)
if is_valid:
print(" ✅ Authentication is valid")
else:
print(" ❌ Authentication is invalid")
return is_valid
except Exception as e:
print(f" ❌ Validation error: {e}")
return False
finally:
if context:
try:
context.close()
except Exception:
pass
if playwright:
try:
playwright.stop()
except Exception:
pass
def get_authenticated_context(self) -> BrowserContext:
"""
获取已认证的浏览器上下文(供 skill 使用)
工作流程:
1. 检查 is_authenticated()
2. 启动 persistent context
3. 手动注入 cookies from state.json
4. 返回 context(调用者负责关闭)
Returns:
已认证的 BrowserContext
Raises:
AuthenticationError: 如果未认证
"""
if not self.is_authenticated():
raise AuthenticationError(
f"Not authenticated for {self.config.site_name}. "
f"Please run setup_auth() first."
)
playwright = sync_playwright().start()
context = BrowserFactory.launch_persistent_context(
playwright,
user_data_dir=self.profile_dir,
state_file=self.state_file,
headless=True
)
return context
def clear_auth(self):
"""
清除所有认证数据
删除:
- state.json(session cookies)
- browser_profile(persistent cookies + 浏览器指纹)
- auth_info.json(认证元数据)
"""
print(f"🧹 Clearing authentication data for {self.config.site_name}...")
# 删除 state.json
if self.state_file.exists():
self.state_file.unlink()
print(f" ✓ Removed {self.state_file}")
# 删除 browser profile 目录
if self.profile_dir.exists():
import shutil
shutil.rmtree(self.profile_dir)
print(f" ✓ Removed {self.profile_dir}")
# 删除 auth_info.json
if self.auth_info_file.exists():
self.auth_info_file.unlink()
print(f" ✓ Removed {self.auth_info_file}")
print(" ✅ Authentication cleared")
# ~/.claude/skills/shared-lib/browser_auth/browser_factory.py
"""
Browser Authentication Framework - 浏览器工厂
负责创建配置好的浏览器上下文,处理 Playwright session cookie bug
"""
import json
from pathlib import Path
from typing import Optional
from patchright.sync_api import Playwright, BrowserContext
from .config import DEFAULT_BROWSER_ARGS, DEFAULT_USER_AGENT
class BrowserFactory:
"""
浏览器实例工厂
核心功能:
1. 创建 persistent context with user_data_dir(保证浏览器指纹一致性)
2. 手动注入 state.json 中的 cookies(解决 Playwright session cookie bug)
参考:https://github.com/microsoft/playwright/issues/36139
"""
@staticmethod
def launch_persistent_context(
playwright: Playwright,
user_data_dir: Path,
state_file: Optional[Path] = None,
headless: bool = True,
user_agent: str = DEFAULT_USER_AGENT,
browser_args: list = None
) -> BrowserContext:
"""
启动持久化浏览器上下文
Args:
playwright: Playwright 实例
user_data_dir: 浏览器 profile 目录
state_file: state.json 文件路径(用于手动注入 cookies)
headless: 是否无头模式
user_agent: 自定义 User-Agent
browser_args: 额外的浏览器启动参数
Returns:
配置好的 BrowserContext
"""
if browser_args is None:
browser_args = DEFAULT_BROWSER_ARGS
# 确保目录存在
user_data_dir.mkdir(parents=True, exist_ok=True)
# 启动 persistent context
context = playwright.chromium.launch_persistent_context(
user_data_dir=str(user_data_dir),
channel="chrome", # 使用真实 Chrome,提高信任度
headless=headless,
no_viewport=True,
ignore_default_args=["--enable-automation"],
user_agent=user_agent,
args=browser_args
)
# Cookie 手动注入(Playwright bug workaround)
# Session cookies (expires=-1) 不会自动持久化到 user_data_dir
if state_file and state_file.exists():
BrowserFactory._inject_cookies(context, state_file)
return context
@staticmethod
def _inject_cookies(context: BrowserContext, state_file: Path):
"""
从 state.json 手动注入 cookies
这是解决 Playwright #36139 bug 的关键步骤:
- Persistent cookies 会自动保存到 user_data_dir
- Session cookies 必须手动注入
"""
try:
with open(state_file, 'r') as f:
state = json.load(f)
if 'cookies' in state and len(state['cookies']) > 0:
context.add_cookies(state['cookies'])
# print(f" 🔧 注入 {len(state['cookies'])} cookies")
except Exception as e:
# 非致命错误,首次 setup 时 state.json 不存在
pass
# ~/.claude/skills/shared-lib/browser_auth/config.py
"""
Browser Authentication Framework - 站点配置
"""
from dataclasses import dataclass, field
from typing import Dict, Any, Optional, Callable
@dataclass
class SiteConfig:
"""
网站认证配置
使用配置驱动的验证策略,支持多种验证方式:
- URL 模式匹配(最快)
- Cookie 存在性检查(快速)
- DOM 元素检查(需等待渲染)
- 自定义验证函数(复杂场景)
"""
# 基础信息
site_name: str # 网站标识,如 "notebooklm" | "x-twitter"
login_url: str # 登录页面 URL
# 验证策略(任一满足即认为登录成功,按优先级执行)
success_indicators: Dict[str, Any] = field(default_factory=dict)
# {
# "url_contains": "home", # URL 包含关键字
# "url_pattern": r"^https://x\.com/home", # URL 正则匹配
# "element_exists": "nav[aria-label='Primary']", # 元素存在
# "cookie_exists": "auth_token" # Cookie 存在
# }
# 超时配置
login_timeout_minutes: int = 10
# 可选:自定义验证函数(复杂场景)
# 签名: validate_fn(page: Page) -> bool
custom_validator: Optional[Callable] = None
def __post_init__(self):
"""配置验证"""
if not self.site_name:
raise ValueError("site_name 不能为空")
if not self.login_url:
raise ValueError("login_url 不能为空")
if not self.success_indicators and not self.custom_validator:
raise ValueError("必须提供 success_indicators 或 custom_validator")
# 默认浏览器配置常量
DEFAULT_BROWSER_ARGS = [
'--disable-blink-features=AutomationControlled', # 隐藏 navigator.webdriver
'--disable-dev-shm-usage',
'--no-sandbox',
'--no-first-run',
'--no-default-browser-check'
]
DEFAULT_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
"""
Browser Authentication Framework - 异常定义
"""
class BrowserAuthError(Exception):
"""浏览器认证基础异常"""
pass
class AuthenticationError(BrowserAuthError):
"""认证失败异常"""
pass
class ValidationError(BrowserAuthError):
"""验证失败异常"""
pass
class ConfigurationError(BrowserAuthError):
"""配置错误异常"""
pass
class StateFileError(BrowserAuthError):
"""状态文件错误异常"""
pass
MIT License
Copyright (c) 2026 Qiaomu
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
{
"name": "qiaomu-x-article-publisher",
"version": "1.0.0",
"description": "Publish Markdown articles to X (Twitter) Articles editor with proper formatting. Self-contained with built-in browser authentication. Supports persistent login, smart title/cover generation, and one-command publishing.",
"author": "Qiaomu",
"dependencies": {
"python": ">=3.9",
"pip_packages": [
"Pillow",
"pyobjc-framework-Cocoa",
"patchright"
]
},
"platforms": ["macOS"],
"tags": ["publishing", "x", "twitter", "articles", "markdown", "automation", "qiaomu"],
"triggers": [
"publish to X",
"post article to Twitter",
"X article",
"publish markdown to X"
]
}
Qiaomu X Article Publisher - 完整文档
完整功能文档、使用场景、故障排查和开发指南。
目录
---
详细使用方法
Markdown 格式示例
# 为什么每个开发者都应该学会写作

写作不仅是记录思考,更是深度思考的催化剂。
## 写作的三个好处
1. **强迫你组织思路** - 模糊的想法无法写成清晰的文字
2. **建立个人品牌** - 优质内容吸引同类人
3. **异步沟通的力量** - 写一次,影响无数人
> "Writing is thinking on paper."
## 如何开始
最简单的方法:**每天写 500 字**。命令行选项
# 基本发布
python publish_article.py --file article.md
# 自定义标题
python publish_article.py --file article.md --title "更吸引人的标题"
# 显示浏览器(调试用)
python publish_article.py --file article.md --show-browser认证管理
# 查看认证状态
python auth_manager.py status
# 验证认证是否有效
python auth_manager.py validate
# 重新认证
python auth_manager.py reauth
# 清除认证数据
python auth_manager.py clear---
典型工作流
场景 1:日常写作发布
# 1. 用你喜欢的编辑器写 Markdown
vim ~/articles/new-post.md
# 2. 一键发布
cd ~/.claude/skills/qiaomu-x-article-publisher/scripts
python publish_article.py --file ~/articles/new-post.md
# 3. 在浏览器中检查草稿
# 4. 满意后手动点击"发布"场景 2:与 Claude Code 协作
你:帮我写一篇关于 AI 代理的文章
Claude:[生成文章内容,保存为 Markdown]
你:发布到 X Articles
Claude:[自动调用 Skill 发布]场景 3:批量发布
#!/bin/bash
# 批量发布脚本
for file in ~/articles/*.md; do
echo "发布: $file"
python publish_article.py --file "$file"
sleep 5 # 避免频率限制
done---
技术架构
核心设计哲学
1. 完全自包含 - 内置 browser_auth 框架,无需外部依赖 2. 安全第一 - 只保存草稿,不自动发布;认证数据加密存储 3. 用户友好 - 单条命令完成所有操作 4. 可扩展 - 模块化设计,易于添加新功能
目录结构
qiaomu-x-article-publisher/
├── scripts/
│ ├── publish_article.py # 主发布脚本
│ ├── auth_manager.py # 认证管理
│ ├── parse_markdown.py # Markdown 解析器
│ ├── site_config.py # X 站点配置
│ └── copy_to_clipboard.py # 剪贴板工具
├── lib/
│ └── browser_auth/ # 浏览器认证框架(内置)
├── data/
│ ├── auth_info.json # 认证元数据(gitignore)
│ └── browser_state/ # 浏览器状态(gitignore)
├── README.md # 简洁版说明
├── README_FULL.md # 本文件(完整文档)
├── SKILL.md # Claude Code 技术文档
├── CHANGELOG.md # 变更日志
└── metadata.json # Skill 元数据---
故障排查
问题 1:导入 browser_auth 失败
错误: ModuleNotFoundError: No module named 'browser_auth'
原因: Skill 目录不完整
解决:
ls ~/.claude/skills/qiaomu-x-article-publisher/lib/browser_auth/
# 如果不存在,重新克隆仓库问题 2:认证失效
现象: 提示"认证已过期"
解决:
python auth_manager.py reauth问题 3:找不到"撰写"按钮
原因: 1. X 界面语言不是中文 2. Premium Plus 订阅未激活
解决: 1. 在 X 设置中将语言改为中文 2. 检查订阅:https://twitter.com/i/premium_plus_sign_up
问题 4:图片上传失败
原因: 图片路径不正确
解决:
# 检查图片是否存在
ls /path/to/image.jpg
# 使用绝对路径或相对路径---
路线图
v1.0(当前)
- ✅ Markdown 自动转换
- ✅ 持久化认证
- ✅ 图片上传
- ✅ 完全自包含
v1.1(计划)
- [ ] 自动生成标题(集成 Claude)
- [ ] 自动生成封面图
- [ ] 批量发布模式
- [ ] 发布历史记录
v1.2(未来)
- [ ] 支持表格、脚注
- [ ] 草稿同步到本地
- [ ] 发布时间预约
- [ ] 发布统计分析
---
贡献指南
欢迎贡献!
开发环境设置
git clone https://github.com/[your-username]/qiaomu-x-article-publisher.git
cd qiaomu-x-article-publisher
pip install Pillow pyobjc-framework-Cocoa patchright提交 Pull Request
1. Fork 这个仓库 2. 创建特性分支 (git checkout -b feature/AmazingFeature) 3. 提交改动 (git commit -m 'Add AmazingFeature') 4. 推送到分支 (git push origin feature/AmazingFeature) 5. 开启 Pull Request
---
查看 README.md 返回简洁版说明
Qiaomu X Article Publisher
🚀 一键发布 Markdown 文章到 X (Twitter) Articles,让写作更流畅
---
📖 为什么需要这个工具?
X Articles 是一个强大的长文发布平台,但直接在网页编辑器写作体验不够流畅:
- ❌ 格式工具栏操作繁琐
- ❌ 无法用熟悉的 Markdown 语法
- ❌ 缺少本地版本管理
- ❌ 需要手动上传图片
这个 Skill 解决了这些痛点:
- ✅ 用 Markdown 写作,自动转换为 X Articles 格式
- ✅ 本地文件管理,配合 Git 版本控制
- ✅ 持久化登录,7天免重复认证
- ✅ 智能图片处理,一键上传
- ✅ 完全自包含,开箱即用
---
✨ 核心功能
🔐 持久化认证
一次登录,7天内无需重复认证。基于内置浏览器认证框架实现。
📝 Markdown 完整支持
自动转换以下格式:
- 标题(H1-H6)
- 粗体、斜体
- 列表(有序/无序)
- 引用块
- 代码块(转为引用样式)
- 超链接
- 图片(封面图 + 内容图)
🖼️ 智能图片处理
- 自动识别封面图(文章第一张图)
- 内容图片自动上传
- 支持本地路径和相对路径
🤖 智能增强(可选)
- 无标题时,可请求 Claude 生成吸引人的标题
- 无封面图时,可调用图片生成 Skill 创建封面
⚡ 一键发布
单条命令完成所有步骤,只保存为草稿,不会自动发布(安全第一)。
---
🚀 快速开始
安装(与 Claude Code 对话)
最简单的方式 - 直接告诉 Claude Code:
安装这个 Claude skill:https://github.com/[your-username]/qiaomu-x-article-publisherClaude Code 会自动完成所有安装步骤!
手动安装
# 1. 克隆到 Claude skills 目录
git clone https://github.com/[your-username]/qiaomu-x-article-publisher.git \
~/.claude/skills/qiaomu-x-article-publisher
# 2. 安装 Python 依赖
pip install Pillow pyobjc-framework-Cocoa patchright
# 3. 首次认证
cd ~/.claude/skills/qiaomu-x-article-publisher/scripts
python auth_manager.py setup---
📖 使用示例
示例 1:基本发布
cd ~/.claude/skills/qiaomu-x-article-publisher/scripts
python publish_article.py --file ~/articles/my-post.md示例 2:与 Claude Code 协作
你:帮我写一篇关于 AI 的文章
Claude:[生成文章并保存]
你:发布到 X Articles
Claude:[自动调用 Skill 发布]---
🎯 完整功能文档
查看完整功能、使用场景、故障排查请访问: 👉 完整 README.md
---
📋 系统要求
- macOS
- Python 3.9+
- X Premium Plus 订阅
---
🔧 常见问题
Q: 认证过期怎么办? A: python auth_manager.py reauth
Q: 会自动发布吗? A: 不会,只保存为草稿,需手动发布
Q: 支持 Windows/Linux 吗? A: 目前仅支持 macOS(使用 pyobjc 剪贴板功能)
---
📝 更新日志
v1.0.0 (2026-01-14) - 首次发布 🎉
- Markdown 自动转换
- 持久化认证(7天)
- 智能图片处理
- 完全自包含
---
📄 许可证
MIT License - 查看 LICENSE
---
⭐ 如果有帮助,请给个 Star!
#!/usr/bin/env python3
"""
==============================================================================
X (Twitter) Article Publisher - Authentication Manager
==============================================================================
PURPOSE:
消除 X Article Publisher 每次执行都需要手动登录的痛点
使用共享浏览器认证框架实现持久化登录(7天有效期)
ARCHITECTURE:
Thin wrapper around shared-lib/browser_auth framework
Enables passwordless workflow for X article publishing
CLI INTERFACE:
- setup [--headless] [--timeout N] : 首次登录设置
- status : 检查认证状态
- validate : 验证认证有效性
- clear : 清除认证数据
- reauth [--timeout N] : 重新认证 (clear + setup)
USAGE FLOW:
1. First time: `python auth_manager.py setup`
2. Auto-login: skill 自动使用已保存的认证状态
3. Refresh: `python auth_manager.py reauth` (if expired)
"""
import sys
import argparse
from pathlib import Path
# ============================================================================
# PATH CONFIGURATION - 使用skill内部的browser_auth库
# ============================================================================
SKILL_DIR = Path(__file__).parent.parent
sys.path.insert(0, str(SKILL_DIR / "lib"))
sys.path.insert(0, str(Path(__file__).parent))
from browser_auth import BrowserAuthManager
from site_config import X_TWITTER_CONFIG
# ============================================================================
# PATH CONSTANTS
# ============================================================================
DATA_DIR = SKILL_DIR / "data"
BROWSER_STATE_DIR = DATA_DIR / "browser_state"
# ============================================================================
# X AUTHENTICATION MANAGER
# ============================================================================
class XAuthManager:
"""
X (Twitter) 认证管理器
DESIGN:
Delegates all core logic to BrowserAuthManager
Provides X-specific CLI and error messages
CORE METHODS:
- is_authenticated() -> bool
- get_auth_info() -> Dict
- setup_auth(headless, timeout_minutes) -> bool
- validate_auth() -> bool
- clear_auth() -> bool
- get_authenticated_context() -> BrowserContext
"""
def __init__(self):
"""初始化认证管理器,委托给共享框架"""
self.manager = BrowserAuthManager(
site_config=X_TWITTER_CONFIG,
state_dir=BROWSER_STATE_DIR
)
# 便捷访问属性
self.state_file = self.manager.state_file
self.auth_info_file = self.manager.auth_info_file
self.browser_state_dir = self.manager.state_dir
def is_authenticated(self) -> bool:
"""检查是否已认证(委托到共享框架)"""
return self.manager.is_authenticated()
def get_auth_info(self):
"""获取认证信息(委托到共享框架)"""
return self.manager.get_auth_info()
def setup_auth(self, headless: bool = False, timeout_minutes: int = 10) -> bool:
"""
交互式登录设置
Args:
headless: 是否无头模式(登录时应为 False)
timeout_minutes: 超时时间(分钟)
Returns:
True 如果认证成功
"""
self.manager.config.login_timeout_minutes = timeout_minutes
return self.manager.setup_auth(headless=headless)
def validate_auth(self) -> bool:
"""验证现有认证(委托到共享框架)"""
return self.manager.validate_auth()
def clear_auth(self) -> bool:
"""清除认证数据(委托到共享框架)"""
return self.manager.clear_auth()
def get_authenticated_context(self):
"""获取已认证的浏览器上下文(供 skill 使用)"""
return self.manager.get_authenticated_context()
# ============================================================================
# CLI INTERFACE
# ============================================================================
def main():
"""CLI 入口点"""
parser = argparse.ArgumentParser(
description='X (Twitter) Authentication Manager for Article Publisher'
)
# 子命令解析器
subparsers = parser.add_subparsers(dest='command', help='Commands')
# setup 命令
setup_parser = subparsers.add_parser('setup', help='Setup authentication')
setup_parser.add_argument('--headless', action='store_true',
help='Run in headless mode')
setup_parser.add_argument('--timeout', type=float, default=10,
help='Login timeout in minutes (default: 10)')
# status 命令
subparsers.add_parser('status', help='Check authentication status')
# validate 命令
subparsers.add_parser('validate', help='Validate authentication')
# clear 命令
subparsers.add_parser('clear', help='Clear authentication')
# reauth 命令 (clear + setup)
reauth_parser = subparsers.add_parser('reauth',
help='Re-authenticate (clear + setup)')
reauth_parser.add_argument('--timeout', type=float, default=10,
help='Login timeout in minutes (default: 10)')
args = parser.parse_args()
auth = XAuthManager()
# ========================================================================
# COMMAND HANDLERS
# ========================================================================
if args.command == 'setup':
print("\n" + "="*70)
print(" 🐦 X (Twitter) Authentication Setup")
print("="*70)
print("\n📝 Prerequisites:")
print(" ✓ X Premium+ subscription (required for Articles)")
print(" ✓ X account credentials ready")
print()
print("📖 Instructions:")
print(" 1. Browser window will open to X login page")
print(" 2. Sign in with your X account")
print(" 3. Complete 2FA if enabled")
print(" 4. Wait for redirect to Home timeline")
print(" 5. Authentication will be saved automatically")
print()
print("⏱️ Timeout: {} minutes\n".format(int(args.timeout)))
success = auth.setup_auth(
headless=args.headless,
timeout_minutes=int(args.timeout)
)
if success:
print("\n" + "="*70)
print(" ✅ Authentication setup complete!")
print("="*70)
print("\n 🎉 You can now publish articles without logging in!")
print(" 📅 Authentication valid for 7 days\n")
else:
print("\n" + "="*70)
print(" ❌ Authentication setup failed")
print("="*70)
print("\n 💡 Troubleshooting:")
print(" - Ensure you completed login within timeout")
print(" - Check your X credentials")
print(" - Verify Premium+ subscription is active\n")
sys.exit(0 if success else 1)
elif args.command == 'status':
info = auth.get_auth_info()
print("\n" + "="*70)
print(" 🐦 X (Twitter) Authentication Status")
print("="*70)
for key, value in info.items():
print(f" {key}: {value}")
print("="*70 + "\n")
sys.exit(0 if info['authenticated'] else 1)
elif args.command == 'validate':
print("\n🔍 Validating X authentication...")
is_valid = auth.validate_auth()
if is_valid:
print("\n✅ Authentication is valid")
print(" You can publish articles now!\n")
else:
print("\n❌ Authentication is invalid")
print(" Please run: python auth_manager.py setup\n")
sys.exit(0 if is_valid else 1)
elif args.command == 'clear':
print("\n🗑️ Clearing X authentication data...")
success = auth.clear_auth()
if success:
print("\n✅ Authentication data cleared")
print(" Run 'setup' to re-authenticate\n")
else:
print("\n❌ Failed to clear authentication data\n")
sys.exit(0 if success else 1)
elif args.command == 'reauth':
print("\n🔄 Re-authenticating X account...")
# Step 1: Clear existing auth
print("\n Step 1/2: Clearing old authentication...")
auth.clear_auth()
# Step 2: Setup new auth
print("\n Step 2/2: Setting up new authentication...")
print(" Browser will open shortly...\n")
success = auth.setup_auth(timeout_minutes=int(args.timeout))
if success:
print("\n✅ Re-authentication complete!")
print(" Ready to publish articles\n")
else:
print("\n❌ Re-authentication failed")
print(" Please try again or check credentials\n")
sys.exit(0 if success else 1)
else:
parser.print_help()
sys.exit(1)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Copy image or HTML to system clipboard for X Articles publishing.
Supports:
- Image files (jpg, png, gif, webp) - copies as image data
- HTML content - copies as rich text for paste
- Optional image compression before copying
Usage:
# Copy image to clipboard
python copy_to_clipboard.py image /path/to/image.jpg
# Copy image with compression (quality 0-100)
python copy_to_clipboard.py image /path/to/image.jpg --quality 80
# Copy HTML to clipboard
python copy_to_clipboard.py html "<p>Hello</p>"
# Copy HTML from file
python copy_to_clipboard.py html --file /path/to/content.html
macOS Requirements:
pip install Pillow pyobjc-framework-Cocoa
"""
import argparse
import io
import os
import sys
from pathlib import Path
def compress_image(image_path: str, quality: int = 85, max_size: tuple = (2000, 2000)) -> bytes:
"""Compress image and return as bytes."""
from PIL import Image
img = Image.open(image_path)
# Convert to RGB if necessary (for JPEG)
if img.mode in ('RGBA', 'P'):
img = img.convert('RGB')
# Resize if too large
img.thumbnail(max_size, Image.Resampling.LANCZOS)
# Save to bytes
buffer = io.BytesIO()
img.save(buffer, format='JPEG', quality=quality, optimize=True)
return buffer.getvalue()
def copy_image_to_clipboard_macos(image_path: str, quality: int = None) -> bool:
"""Copy image to macOS clipboard using AppKit."""
try:
from AppKit import NSPasteboard, NSPasteboardTypePNG, NSPasteboardTypeTIFF
from Foundation import NSData
# Compress if quality specified, otherwise use original
if quality:
image_data = compress_image(image_path, quality)
else:
with open(image_path, 'rb') as f:
image_data = f.read()
# Create NSData from image bytes
ns_data = NSData.dataWithBytes_length_(image_data, len(image_data))
# Get pasteboard and clear it
pasteboard = NSPasteboard.generalPasteboard()
pasteboard.clearContents()
# Determine type based on file extension
ext = Path(image_path).suffix.lower()
if ext in ('.png',):
pasteboard.setData_forType_(ns_data, NSPasteboardTypePNG)
else:
# For JPEG and others, use TIFF (more compatible)
from PIL import Image
img = Image.open(io.BytesIO(image_data))
tiff_buffer = io.BytesIO()
img.save(tiff_buffer, format='TIFF')
tiff_data = NSData.dataWithBytes_length_(tiff_buffer.getvalue(), len(tiff_buffer.getvalue()))
pasteboard.setData_forType_(tiff_data, NSPasteboardTypeTIFF)
return True
except ImportError as e:
print(f"Error: Missing dependency: {e}", file=sys.stderr)
print("Install with: pip install Pillow pyobjc-framework-Cocoa", file=sys.stderr)
return False
except Exception as e:
print(f"Error copying image: {e}", file=sys.stderr)
return False
def copy_html_to_clipboard_macos(html: str) -> bool:
"""Copy HTML to macOS clipboard as rich text."""
try:
from AppKit import NSPasteboard, NSPasteboardTypeHTML, NSPasteboardTypeString
from Foundation import NSData
# Get pasteboard and clear it
pasteboard = NSPasteboard.generalPasteboard()
pasteboard.clearContents()
# Set HTML content
html_data = html.encode('utf-8')
ns_data = NSData.dataWithBytes_length_(html_data, len(html_data))
pasteboard.setData_forType_(ns_data, NSPasteboardTypeHTML)
# Also set plain text version
pasteboard.setString_forType_(html, NSPasteboardTypeString)
return True
except ImportError as e:
print(f"Error: Missing dependency: {e}", file=sys.stderr)
print("Install with: pip install pyobjc-framework-Cocoa", file=sys.stderr)
return False
except Exception as e:
print(f"Error copying HTML: {e}", file=sys.stderr)
return False
def main():
parser = argparse.ArgumentParser(description='Copy to clipboard for X Articles')
subparsers = parser.add_subparsers(dest='type', required=True)
# Image subcommand
img_parser = subparsers.add_parser('image', help='Copy image to clipboard')
img_parser.add_argument('path', help='Path to image file')
img_parser.add_argument('--quality', type=int, default=None,
help='JPEG quality (1-100), enables compression')
img_parser.add_argument('--max-width', type=int, default=2000,
help='Max width for resize')
img_parser.add_argument('--max-height', type=int, default=2000,
help='Max height for resize')
# HTML subcommand
html_parser = subparsers.add_parser('html', help='Copy HTML to clipboard')
html_parser.add_argument('content', nargs='?', help='HTML content')
html_parser.add_argument('--file', '-f', help='Read HTML from file')
args = parser.parse_args()
if args.type == 'image':
if not os.path.exists(args.path):
print(f"Error: Image not found: {args.path}", file=sys.stderr)
sys.exit(1)
success = copy_image_to_clipboard_macos(args.path, args.quality)
if success:
print(f"Image copied to clipboard: {args.path}")
if args.quality:
print(f" (compressed with quality={args.quality})")
sys.exit(0 if success else 1)
elif args.type == 'html':
if args.file:
if not os.path.exists(args.file):
print(f"Error: File not found: {args.file}", file=sys.stderr)
sys.exit(1)
with open(args.file, 'r', encoding='utf-8') as f:
html = f.read()
elif args.content:
html = args.content
else:
# Read from stdin
html = sys.stdin.read()
success = copy_html_to_clipboard_macos(html)
if success:
print(f"HTML copied to clipboard ({len(html)} chars)")
sys.exit(0 if success else 1)
if __name__ == '__main__':
main()
#!/usr/bin/env python3
"""调试编辑器页面结构"""
import sys
import time
from pathlib import Path
from patchright.sync_api import sync_playwright
SKILL_DIR = Path(__file__).parent.parent
BROWSER_STATE_DIR = SKILL_DIR / "data" / "browser_state"
BROWSER_PROFILE_DIR = BROWSER_STATE_DIR / "browser_profile"
STATE_FILE = BROWSER_STATE_DIR / "state.json"
sys.path.insert(0, str(SKILL_DIR / "lib"))
from browser_auth import BrowserFactory
def main():
print("🔍 调试 X Articles 编辑器...")
playwright = sync_playwright().start()
context = BrowserFactory.launch_persistent_context(
playwright,
user_data_dir=BROWSER_PROFILE_DIR,
state_file=STATE_FILE,
headless=False
)
page = context.new_page()
# 先导航到文章列表页
print("📍 导航到文章列表页...")
page.goto("https://x.com/compose/articles", wait_until="domcontentloaded")
time.sleep(5)
# 查找并点击右上角的"新建文章"按钮(羽毛笔图标)
print("🔍 查找新建文章按钮...")
# 尝试多种选择器找到这个按钮
create_selectors = [
'a[href="/compose/articles/new"]', # 直接链接
'button[aria-label*="新建"]',
'button[aria-label*="撰写"]',
'button[aria-label*="Create"]',
'button[aria-label*="Compose"]',
'[data-testid="createArticle"]',
'[data-testid="newArticle"]',
'svg[aria-label*="新建"]',
]
for selector in create_selectors:
try:
elem = page.query_selector(selector)
if elem:
print(f" ✅ 找到: {selector}")
elem.click()
time.sleep(3)
break
except:
pass
else:
# 如果没找到,尝试用位置查找(右上角区域的按钮)
print(" ⚠️ 未通过选择器找到,尝试查找页面上所有可点击元素...")
# 打印所有 a 标签
links = page.query_selector_all("a")
print(f"\n 📎 所有链接 ({len(links)}):")
for i, link in enumerate(links[:30]):
href = link.get_attribute("href") or ""
text = link.inner_text().strip()[:30] if link.inner_text() else ""
aria = link.get_attribute("aria-label") or ""
if "article" in href.lower() or "compose" in href.lower():
print(f" [{i}] href='{href}' text='{text}' aria='{aria}'")
# 打印所有按钮
buttons = page.query_selector_all("button")
print(f"\n 🔘 所有按钮 ({len(buttons)}):")
for i, btn in enumerate(buttons[:20]):
aria = btn.get_attribute("aria-label") or ""
text = btn.inner_text().strip()[:30] if btn.inner_text() else ""
testid = btn.get_attribute("data-testid") or ""
print(f" [{i}] aria='{aria}' text='{text}' testid='{testid}'")
print(" 等待编辑器加载...")
time.sleep(5)
# 截图
screenshot_path = "/tmp/x_editor_debug.png"
page.screenshot(path=screenshot_path, full_page=True)
print(f"📸 截图已保存: {screenshot_path}")
print(f"🔗 当前URL: {page.url}")
# 查找输入框
print("\n📝 查找输入框:")
inputs = page.query_selector_all("input, textarea, [contenteditable='true']")
for i, inp in enumerate(inputs[:15]):
placeholder = inp.get_attribute("placeholder") or ""
tag = inp.evaluate("el => el.tagName")
print(f" [{i}] <{tag}> placeholder='{placeholder[:30]}'")
# 查找所有 contenteditable 元素
print("\n✏️ contenteditable 元素:")
editables = page.query_selector_all("[contenteditable='true']")
for i, ed in enumerate(editables[:10]):
text = ed.inner_text()[:50] if ed.inner_text() else ""
print(f" [{i}] '{text}'")
# 查找按钮
print("\n🔘 按钮:")
buttons = page.query_selector_all("button")
for i, btn in enumerate(buttons[:15]):
text = btn.inner_text().strip()[:30] if btn.inner_text() else ""
if text:
print(f" [{i}] {text}")
# 检查页面 HTML 结构
print("\n📄 主内容区域:")
main_content = page.query_selector('main, [role="main"], [data-testid="primaryColumn"]')
if main_content:
html = main_content.inner_html()[:500]
print(f" 内容: {html[:200]}...")
else:
print(" 未找到 main 内容区域")
# 检查是否有弹窗或 modal
print("\n🪟 弹窗/Modal:")
modals = page.query_selector_all('[role="dialog"], [aria-modal="true"], .modal')
for i, modal in enumerate(modals[:5]):
text = modal.inner_text()[:100] if modal.inner_text() else ""
print(f" [{i}] {text}")
# 检查所有文本内容
print("\n📝 页面可见文本:")
body_text = page.inner_text("body")[:500]
print(f" {body_text}")
print("\n⏳ 浏览器将在 60 秒后关闭...")
time.sleep(60)
context.close()
playwright.stop()
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""调试脚本 - 查看 X Articles 页面结构"""
import sys
import time
from pathlib import Path
from patchright.sync_api import sync_playwright
SKILL_DIR = Path(__file__).parent.parent
BROWSER_STATE_DIR = SKILL_DIR / "data" / "browser_state"
BROWSER_PROFILE_DIR = BROWSER_STATE_DIR / "browser_profile"
STATE_FILE = BROWSER_STATE_DIR / "state.json"
sys.path.insert(0, str(SKILL_DIR / "lib"))
from browser_auth import BrowserFactory
def main():
print("🔍 调试 X Articles 页面...")
playwright = sync_playwright().start()
context = BrowserFactory.launch_persistent_context(
playwright,
user_data_dir=BROWSER_PROFILE_DIR,
state_file=STATE_FILE,
headless=False # 显示浏览器
)
page = context.new_page()
# 导航到 Articles
print("📍 导航到 X Articles...")
page.goto("https://x.com/compose/articles", wait_until="domcontentloaded")
time.sleep(3)
# 截图
screenshot_path = "/tmp/x_articles_debug.png"
page.screenshot(path=screenshot_path, full_page=True)
print(f"📸 截图已保存: {screenshot_path}")
# 打印页面标题和URL
print(f"📄 页面标题: {page.title()}")
print(f"🔗 当前URL: {page.url}")
# 查找所有按钮
print("\n🔘 页面上的按钮:")
buttons = page.query_selector_all("button")
for i, btn in enumerate(buttons[:20]): # 只打印前20个
text = btn.inner_text().strip()[:50] if btn.inner_text() else ""
print(f" [{i}] {text}")
# 查找所有链接
print("\n🔗 页面上的链接:")
links = page.query_selector_all("a")
for i, link in enumerate(links[:20]):
text = link.inner_text().strip()[:50] if link.inner_text() else ""
href = link.get_attribute("href") or ""
print(f" [{i}] {text} -> {href[:50]}")
# 查找可能的"create"相关元素
print("\n🔍 查找 'create' 相关元素:")
create_elements = page.query_selector_all("[data-testid*='create'], [aria-label*='create'], button:has-text('create'), a:has-text('create')")
for elem in create_elements:
print(f" 找到: {elem.inner_text()[:50]}")
# 等待用户查看
print("\n⏳ 浏览器将在 60 秒后关闭...")
time.sleep(60)
context.close()
playwright.stop()
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Parse Markdown for X Articles publishing.
Extracts:
- Title (from first H1/H2 or first line)
- Cover image (first image)
- Content images with block index for precise positioning
- HTML content (images stripped)
Usage:
python parse_markdown.py <markdown_file> [--output json|html]
Output (JSON):
{
"title": "Article Title",
"cover_image": "/path/to/cover.jpg",
"content_images": [
{"path": "/path/to/img.jpg", "block_index": 3, "after_text": "context..."},
...
],
"html": "<p>Content...</p><h2>Section</h2>...",
"total_blocks": 25
}
The block_index indicates which block element (0-indexed) the image should follow.
This allows precise positioning without relying on text matching.
"""
import argparse
import json
import os
import re
import sys
from pathlib import Path
def split_into_blocks(markdown: str) -> list[str]:
"""Split markdown into logical blocks (paragraphs, headers, quotes, code blocks, etc.)."""
blocks = []
current_block = []
in_code_block = False
code_block_lines = []
lines = markdown.split('\n')
for line in lines:
stripped = line.strip()
# Handle code block boundaries
if stripped.startswith('```'):
if in_code_block:
# End of code block
in_code_block = False
if code_block_lines:
# Mark as code block with special prefix for later processing
# Use ___CODE_BLOCK_START___ and ___CODE_BLOCK_END___ to preserve content
blocks.append('___CODE_BLOCK_START___' + '\n'.join(code_block_lines) + '___CODE_BLOCK_END___')
code_block_lines = []
else:
# Start of code block
if current_block:
blocks.append('\n'.join(current_block))
current_block = []
in_code_block = True
continue
# If inside code block, collect ALL lines (including empty lines)
if in_code_block:
code_block_lines.append(line)
continue
# Empty line signals end of block
if not stripped:
if current_block:
blocks.append('\n'.join(current_block))
current_block = []
continue
# Headers, blockquotes are their own blocks
if stripped.startswith(('#', '>')):
if current_block:
blocks.append('\n'.join(current_block))
current_block = []
blocks.append(stripped)
continue
# Image on its own line is its own block
if re.match(r'^!\[.*\]\(.*\)$', stripped):
if current_block:
blocks.append('\n'.join(current_block))
current_block = []
blocks.append(stripped)
continue
current_block.append(line)
if current_block:
blocks.append('\n'.join(current_block))
# Handle unclosed code block
if code_block_lines:
blocks.append('___CODE_BLOCK_START___' + '\n'.join(code_block_lines) + '___CODE_BLOCK_END___')
return blocks
def resolve_image_path(img_path: str, base_path: Path) -> str:
"""Resolve image path to absolute path."""
if os.path.isabs(img_path):
return img_path
# Try multiple resolution strategies
full_path = None
# Strategy 1: Relative to markdown file directory
candidate1 = base_path / img_path
if candidate1.exists():
full_path = str(candidate1)
# Strategy 2: Path might be relative to a parent directory
# (e.g., Obsidian paths like "01.inbox/papers/.../image.png")
if not full_path:
# Walk up the directory tree to find the root
current = base_path
for _ in range(10): # Max 10 levels up
parent = current.parent
candidate = parent / img_path
if candidate.exists():
full_path = str(candidate)
break
if parent == current: # Reached root
break
current = parent
# Strategy 3: Check common knowledge base roots
if not full_path:
common_roots = [
Path.home() / "乔木新知识库",
Path.home() / "Documents",
Path.home() / "Obsidian",
]
for root in common_roots:
candidate = root / img_path
if candidate.exists():
full_path = str(candidate)
break
# Fallback: Use original resolution (might not exist)
if not full_path:
full_path = str(base_path / img_path)
return full_path
def extract_images_with_placeholders(markdown: str, base_path: Path) -> tuple[list[dict], str, int]:
"""Extract images and replace them with placeholders in markdown.
Returns:
(image_list, markdown_with_placeholders, total_blocks)
The placeholder format is: ___IMG_PLACEHOLDER_{index}___
This will be converted to HTML comment: <!-- IMG_PLACEHOLDER_{index} -->
"""
blocks = split_into_blocks(markdown)
images = []
result_blocks = []
img_pattern = re.compile(r'^!\[([^\]]*)\]\(([^)]+)\)$')
image_index = 0
for i, block in enumerate(blocks):
match = img_pattern.match(block.strip())
if match:
alt_text = match.group(1)
img_path = match.group(2)
full_path = resolve_image_path(img_path, base_path)
# Create placeholder
placeholder_id = f"IMG_PLACEHOLDER_{image_index}"
images.append({
"path": full_path,
"alt": alt_text,
"placeholder_id": placeholder_id,
"index": image_index
})
# Insert placeholder block (will be converted to HTML comment)
result_blocks.append(f"___{placeholder_id}___")
image_index += 1
else:
result_blocks.append(block)
result_markdown = '\n\n'.join(result_blocks)
return images, result_markdown, len(result_blocks)
def extract_images_with_block_index(markdown: str, base_path: Path) -> tuple[list[dict], str, int]:
"""Extract images with their block index position (legacy method).
Returns:
(image_list, markdown_without_images, total_blocks)
"""
blocks = split_into_blocks(markdown)
images = []
clean_blocks = []
img_pattern = re.compile(r'^!\[([^\]]*)\]\(([^)]+)\)$')
for i, block in enumerate(blocks):
match = img_pattern.match(block.strip())
if match:
alt_text = match.group(1)
img_path = match.group(2)
full_path = resolve_image_path(img_path, base_path)
# block_index is the index in clean_blocks (without images)
# i.e., this image should be inserted after clean_blocks[block_index-1]
block_index = len(clean_blocks)
# Get context from previous block for reference
after_text = ""
is_after_h2 = False
if clean_blocks:
prev_block = clean_blocks[-1].strip()
# Check if previous block is an H2
is_after_h2 = prev_block.startswith('## ')
# Get last line of previous block
lines = [l for l in prev_block.split('\n') if l.strip()]
after_text = lines[-1][:80] if lines else ""
images.append({
"path": full_path,
"alt": alt_text,
"block_index": block_index,
"after_text": after_text, # Keep for reference/debugging
"is_after_h2": is_after_h2 # Flag to indicate if this image is right after H2
})
else:
clean_blocks.append(block)
clean_markdown = '\n\n'.join(clean_blocks)
return images, clean_markdown, len(clean_blocks)
def extract_title(markdown: str) -> tuple[str, str, str]:
"""Extract title from first H1, H2, or first non-empty line.
Returns:
(title, markdown_without_title, title_source):
- title: Title string
- markdown: Markdown with H1 title removed
- title_source: "h1", "h2", "first_line", or "none"
If title is from H1, it's removed from markdown to avoid duplication.
"""
lines = markdown.strip().split('\n')
title = "Untitled"
title_line_idx = None
title_source = "none"
for idx, line in enumerate(lines):
stripped = line.strip()
if not stripped:
continue
# H1 - use as title and mark for removal
if stripped.startswith('# '):
title = stripped[2:].strip()
title_line_idx = idx
title_source = "h1"
break
# H2 - use as title but don't remove (it's a section header)
if stripped.startswith('## '):
title = stripped[3:].strip()
title_source = "h2"
break
# First non-empty, non-image line
if not stripped.startswith('!['):
title = stripped[:100]
title_source = "first_line"
break
# Remove H1 title line from markdown to avoid duplication
if title_line_idx is not None:
lines.pop(title_line_idx)
markdown = '\n'.join(lines)
return title, markdown, title_source
def convert_markdown_table(table_text: str) -> str:
"""Convert markdown table to HTML table."""
lines = [line.strip() for line in table_text.strip().split('\n') if line.strip()]
if len(lines) < 2:
return table_text
# Parse table structure
rows = []
for line in lines:
# Split by | and clean up
cells = [cell.strip() for cell in line.split('|')]
# Remove empty first/last cells (from leading/trailing |)
if cells and not cells[0]:
cells = cells[1:]
if cells and not cells[-1]:
cells = cells[:-1]
rows.append(cells)
if len(rows) < 2:
return table_text
# Second row is separator (|-----|-----|)
header_row = rows[0]
data_rows = rows[2:] if len(rows) > 2 else []
# Build HTML table
html_parts = ['<table>']
# Header
html_parts.append('<thead><tr>')
for cell in header_row:
html_parts.append(f'<th>{cell}</th>')
html_parts.append('</tr></thead>')
# Body
if data_rows:
html_parts.append('<tbody>')
for row in data_rows:
html_parts.append('<tr>')
for cell in row:
html_parts.append(f'<td>{cell}</td>')
html_parts.append('</tr>')
html_parts.append('</tbody>')
html_parts.append('</table>')
return ''.join(html_parts)
def markdown_to_html(markdown: str) -> str:
"""Convert markdown to HTML for X Articles rich text paste."""
html = markdown
# Process code blocks first (marked with ___CODE_BLOCK_START___ and ___CODE_BLOCK_END___)
# Convert to blockquote format since X Articles doesn't support <pre><code>
def convert_code_block(match):
code_content = match.group(1)
lines = code_content.strip().split('\n')
# Join non-empty lines with <br> for display
formatted = '<br>'.join(line for line in lines if line.strip())
return f'<blockquote>{formatted}</blockquote>'
html = re.sub(r'___CODE_BLOCK_START___(.*?)___CODE_BLOCK_END___', convert_code_block, html, flags=re.DOTALL)
# Convert image placeholders to a visible marker that will appear in the editor
# Using a distinctive text marker that's easy to find and less likely to be transformed
def convert_img_placeholder(match):
index = match.group(1)
# Return a visible marker wrapped in a paragraph
# Using @@@ prefix/suffix to make it distinctive and searchable
return f'<p>@@@IMG_{index}@@@</p>'
html = re.sub(r'___IMG_PLACEHOLDER_(\d+)___', convert_img_placeholder, html)
# Convert markdown tables to HTML tables
# Match table blocks (lines starting with |)
def process_table(match):
return convert_markdown_table(match.group(0))
# Match consecutive lines starting with |
html = re.sub(r'(?:^\|.+$\n?)+', process_table, html, flags=re.MULTILINE)
# Horizontal rules - must be processed before headers to avoid conflicts
html = re.sub(r'^(?:---+|\*\*\*+|___+)\s*$', r'<hr>', html, flags=re.MULTILINE)
# Headers (H2-H6, H1 is title)
html = re.sub(r'^###### (.+)$', r'<h6>\1</h6>', html, flags=re.MULTILINE)
html = re.sub(r'^##### (.+)$', r'<h5>\1</h5>', html, flags=re.MULTILINE)
html = re.sub(r'^#### (.+)$', r'<h4>\1</h4>', html, flags=re.MULTILINE)
html = re.sub(r'^### (.+)$', r'<h3>\1</h3>', html, flags=re.MULTILINE)
html = re.sub(r'^## (.+)$', r'<h2>\1</h2>', html, flags=re.MULTILINE)
# Inline code (must be before bold/italic to avoid conflicts)
html = re.sub(r'`([^`]+)`', r'<code>\1</code>', html)
# Strikethrough
html = re.sub(r'~~(.+?)~~', r'<del>\1</del>', html)
# Bold (must be before italic)
html = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', html)
# Italic
html = re.sub(r'\*([^*]+)\*', r'<em>\1</em>', html)
# Links
html = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'<a href="\2">\1</a>', html)
# Blockquotes (regular markdown blockquotes, not code blocks)
html = re.sub(r'^> (.+)$', r'<blockquote>\1</blockquote>', html, flags=re.MULTILINE)
# Unordered lists
html = re.sub(r'^- (.+)$', r'<li>\1</li>', html, flags=re.MULTILINE)
# Ordered lists
html = re.sub(r'^\d+\. (.+)$', r'<li>\1</li>', html, flags=re.MULTILINE)
# Wrap consecutive <li> in <ul>
html = re.sub(r'((?:<li>.*?</li>\n?)+)', r'<ul>\1</ul>', html)
# Paragraphs - split by double newlines
parts = html.split('\n\n')
processed_parts = []
for part in parts:
part = part.strip()
if not part:
continue
# Skip if already a block element
if part.startswith(('<h2>', '<h3>', '<h4>', '<h5>', '<h6>', '<blockquote>', '<ul>', '<ol>', '<table>', '<hr>')):
processed_parts.append(part)
else:
# Wrap in paragraph, convert single newlines to <br>
part = part.replace('\n', '<br>')
processed_parts.append(f'<p>{part}</p>')
return ''.join(processed_parts)
def parse_markdown_file(filepath: str, use_placeholders: bool = True) -> dict:
"""Parse a markdown file and return structured data.
Args:
filepath: Path to the markdown file
use_placeholders: If True, use placeholder-based image positioning (recommended).
If False, use legacy block_index method.
"""
path = Path(filepath)
base_path = path.parent
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
# Extract title first (and remove H1 from markdown)
title, content, title_source = extract_title(content)
if use_placeholders:
# New method: Extract images and insert placeholders
images, markdown_with_placeholders, total_blocks = extract_images_with_placeholders(content, base_path)
# Convert to HTML (placeholders will become visible markers)
html = markdown_to_html(markdown_with_placeholders)
else:
# Legacy method: Extract images with block indices
images, clean_markdown, total_blocks = extract_images_with_block_index(content, base_path)
html = markdown_to_html(clean_markdown)
# Determine if title needs generation
needs_title_generation = title_source not in ("h1",) # Only h1 is a proper title
return {
"title": title,
"title_source": title_source, # "h1", "h2", "first_line", or "none"
"needs_title_generation": needs_title_generation,
"cover_image": None, # No automatic cover image
"needs_cover_generation": False, # User handles cover manually
"content_images": images,
"html": html,
"total_blocks": total_blocks,
"source_file": str(path.absolute()),
"use_placeholders": use_placeholders # Flag to indicate which method was used
}
def main():
parser = argparse.ArgumentParser(description='Parse Markdown for X Articles')
parser.add_argument('file', help='Markdown file to parse')
parser.add_argument('--output', choices=['json', 'html'], default='json',
help='Output format (default: json)')
parser.add_argument('--html-only', action='store_true',
help='Output only HTML content')
args = parser.parse_args()
if not os.path.exists(args.file):
print(f"Error: File not found: {args.file}", file=sys.stderr)
sys.exit(1)
result = parse_markdown_file(args.file)
if args.html_only:
print(result['html'])
elif args.output == 'json':
print(json.dumps(result, ensure_ascii=False, indent=2))
else:
print(result['html'])
if __name__ == '__main__':
main()
# ~/.claude/skills/x-article-publisher/scripts/site_config.py
r"""
==============================================================================
X (Twitter) 站点配置 - Site Configuration for X Authentication
==============================================================================
PURPOSE:
定义 X (Twitter) 的认证验证策略,使用共享浏览器认证框架
VALIDATION STRATEGY:
1. URL 正则匹配 "^https://x\.com/home" (精确检查登录后跳转)
2. DOM 元素 "nav[aria-label='Primary']" 存在 (主导航验证)
CRITICAL:
X 对自动化检测敏感,使用真实 Chrome + 浏览器指纹保持一致性
ARCHITECTURE:
Config-driven validation → BrowserAuthManager → Persistent authentication
"""
import sys
from pathlib import Path
# ============================================================================
# PATH CONFIGURATION - 使用skill内部的browser_auth库
# ============================================================================
SKILL_DIR = Path(__file__).parent.parent
sys.path.insert(0, str(SKILL_DIR / "lib"))
from browser_auth import SiteConfig
# ============================================================================
# X (TWITTER) CONFIGURATION
# ============================================================================
X_TWITTER_CONFIG = SiteConfig(
site_name="x-twitter",
login_url="https://x.com/i/flow/login",
# 验证策略:登录成功后跳转到 Home 时间线
success_indicators={
"url_pattern": r"^https://x\.com/home", # 精确 URL 匹配
"element_exists": "nav[aria-label='Primary']" # 主导航元素验证
},
login_timeout_minutes=10 # 登录超时时间(包括 2FA 验证)
)
Qiaomu X Article Publisher
🚀 一键发布 Markdown 文章到 X (Twitter) Articles,让写作更流畅
基于 @wshuyi 王树义老师 的原版改进,添加持久化认证、完全自包含设计。
---
📖 为什么需要这个工具?
X Articles 是一个强大的长文发布平台,但直接在网页编辑器写作体验不够流畅:
- ❌ 格式工具栏操作繁琐
- ❌ 无法用熟悉的 Markdown 语法
- ❌ 缺少本地版本管理
- ❌ 需要手动上传图片
- ❌ 每次都要重新登录
这个 Skill 解决了这些痛点:
- ✅ 用 Markdown 写作,自动转换为 X Articles 格式
- ✅ 本地文件管理,配合 Git 版本控制
- ✅ 持久化登录,7天免重复认证(新增)
- ✅ 智能图片处理,一键上传
- ✅ 完全自包含,开箱即用(新增)
---
✨ 核心功能
🔐 持久化认证(新增)
一次登录,7天内无需重复认证。基于内置浏览器认证框架实现。
📝 Markdown 完整支持
自动转换以下格式:
- 标题(H1-H6)
- 粗体、斜体
- 列表(有序/无序)
- 引用块
- 代码块(转为引用样式)
- 超链接
- 图片(封面图 + 内容图)
🖼️ 智能图片处理
- 自动识别封面图(文章第一张图)
- 内容图片自动上传
- 支持本地路径和相对路径
⚡ 一键发布
单条命令完成所有步骤,只保存为草稿,不会自动发布(安全第一)。
---
🚀 快速开始
安装(与 Claude Code 对话)
最简单的方式 - 直接告诉 Claude Code:
安装这个 Claude skill:https://github.com/joeseesun/qiaomu-x-article-publisherClaude Code 会自动完成所有安装步骤!
手动安装
# 1. 克隆到 Claude skills 目录
git clone https://github.com/joeseesun/qiaomu-x-article-publisher.git \
~/.claude/skills/qiaomu-x-article-publisher
# 2. 安装 Python 依赖
pip install Pillow pyobjc-framework-Cocoa patchright
# 3. 首次认证
cd ~/.claude/skills/qiaomu-x-article-publisher/scripts
python auth_manager.py setup---
📖 使用示例
示例 1:基本发布
cd ~/.claude/skills/qiaomu-x-article-publisher/scripts
python publish_article.py --file ~/articles/my-post.md示例 2:与 Claude Code 协作
你:帮我写一篇关于 AI 的文章
Claude:[生成文章并保存]
你:发布到 X Articles
Claude:[自动调用 Skill 发布]示例 3:自定义标题
python publish_article.py --file article.md --title "更吸引人的标题"---
🎯 完整功能文档
查看完整功能、使用场景、故障排查请访问: 👉 完整 README_FULL.md
---
📋 系统要求
- macOS
- Python 3.9+
- X Premium Plus 订阅
---
🔧 常见问题
Q: 认证过期怎么办? A: python auth_manager.py reauth
Q: 会自动发布吗? A: 不会,只保存为草稿,需手动发布
Q: 支持 Windows/Linux 吗? A: 目前仅支持 macOS(使用 pyobjc 剪贴板功能)
---
📚 学习资源
- 📖 OpenCode + OMO 玩Skill的教程 - 详细的 Skill 开发指南
- 🎓 完整使用文档 - 本项目详细文档
---
🙏 致谢
本项目基于 @wshuyi 王树义老师 的原版改进:
原版特点:
- 开创性地实现了 Markdown 到 X Articles 的自动转换
- 清晰的代码架构和文档
本版本改进:
- ✨ 新增持久化认证(7天免登录)
- ✨ 完全自包含(内置 browser_auth 框架)
- ✨ 完善的文档和故障排查指南
- ✨ Claude Code 一键安装支持
感谢王树义老师的开源贡献!
---
📝 更新日志
v1.0.0 (2026-01-14) - 首次发布 🎉
- Markdown 自动转换
- 持久化认证(7天)
- 智能图片处理
- 完全自包含
- 详细文档
查看完整更新日志:CHANGELOG.md
---
🤝 贡献
欢迎贡献!查看 CONTRIBUTING.md 了解如何参与。
---
📄 许可证
MIT License - 查看 LICENSE
---
📞 联系方式
- 👤 作者:Qiaomu (乔木)
- 🐦 X (Twitter): @vista8
- 🌐 个人网站:qiaomu.ai
- 💻 GitHub: @joeseesun
- 📧 Email: vista8@gmail.com
---
⭐ 如果这个工具对你有帮助,请给个 Star!
_让 Markdown 写作与 X Articles 发布无缝衔接_ ✨
#!/usr/bin/env python3
"""
==============================================================================
X (Twitter) Article Publisher - Authentication Manager
==============================================================================
PURPOSE:
消除 X Article Publisher 每次执行都需要手动登录的痛点
使用共享浏览器认证框架实现持久化登录(7天有效期)
ARCHITECTURE:
Thin wrapper around shared-lib/browser_auth framework
Enables passwordless workflow for X article publishing
CLI INTERFACE:
- setup [--headless] [--timeout N] : 首次登录设置
- status : 检查认证状态
- validate : 验证认证有效性
- clear : 清除认证数据
- reauth [--timeout N] : 重新认证 (clear + setup)
USAGE FLOW:
1. First time: `python auth_manager.py setup`
2. Auto-login: skill 自动使用已保存的认证状态
3. Refresh: `python auth_manager.py reauth` (if expired)
"""
import sys
import argparse
from pathlib import Path
# ============================================================================
# PATH CONFIGURATION - 使用skill内部的browser_auth库
# ============================================================================
SKILL_DIR = Path(__file__).parent.parent
sys.path.insert(0, str(SKILL_DIR / "lib"))
sys.path.insert(0, str(Path(__file__).parent))
from browser_auth import BrowserAuthManager
from site_config import X_TWITTER_CONFIG
# ============================================================================
# PATH CONSTANTS
# ============================================================================
DATA_DIR = SKILL_DIR / "data"
BROWSER_STATE_DIR = DATA_DIR / "browser_state"
# ============================================================================
# X AUTHENTICATION MANAGER
# ============================================================================
class XAuthManager:
"""
X (Twitter) 认证管理器
DESIGN:
Delegates all core logic to BrowserAuthManager
Provides X-specific CLI and error messages
CORE METHODS:
- is_authenticated() -> bool
- get_auth_info() -> Dict
- setup_auth(headless, timeout_minutes) -> bool
- validate_auth() -> bool
- clear_auth() -> bool
- get_authenticated_context() -> BrowserContext
"""
def __init__(self):
"""初始化认证管理器,委托给共享框架"""
self.manager = BrowserAuthManager(
site_config=X_TWITTER_CONFIG,
state_dir=BROWSER_STATE_DIR
)
# 便捷访问属性
self.state_file = self.manager.state_file
self.auth_info_file = self.manager.auth_info_file
self.browser_state_dir = self.manager.state_dir
def is_authenticated(self) -> bool:
"""检查是否已认证(委托到共享框架)"""
return self.manager.is_authenticated()
def get_auth_info(self):
"""获取认证信息(委托到共享框架)"""
return self.manager.get_auth_info()
def setup_auth(self, headless: bool = False, timeout_minutes: int = 10) -> bool:
"""
交互式登录设置
Args:
headless: 是否无头模式(登录时应为 False)
timeout_minutes: 超时时间(分钟)
Returns:
True 如果认证成功
"""
self.manager.config.login_timeout_minutes = timeout_minutes
return self.manager.setup_auth(headless=headless)
def validate_auth(self) -> bool:
"""验证现有认证(委托到共享框架)"""
return self.manager.validate_auth()
def clear_auth(self) -> bool:
"""清除认证数据(委托到共享框架)"""
return self.manager.clear_auth()
def get_authenticated_context(self):
"""获取已认证的浏览器上下文(供 skill 使用)"""
return self.manager.get_authenticated_context()
# ============================================================================
# CLI INTERFACE
# ============================================================================
def main():
"""CLI 入口点"""
parser = argparse.ArgumentParser(
description='X (Twitter) Authentication Manager for Article Publisher'
)
# 子命令解析器
subparsers = parser.add_subparsers(dest='command', help='Commands')
# setup 命令
setup_parser = subparsers.add_parser('setup', help='Setup authentication')
setup_parser.add_argument('--headless', action='store_true',
help='Run in headless mode')
setup_parser.add_argument('--timeout', type=float, default=10,
help='Login timeout in minutes (default: 10)')
# status 命令
subparsers.add_parser('status', help='Check authentication status')
# validate 命令
subparsers.add_parser('validate', help='Validate authentication')
# clear 命令
subparsers.add_parser('clear', help='Clear authentication')
# reauth 命令 (clear + setup)
reauth_parser = subparsers.add_parser('reauth',
help='Re-authenticate (clear + setup)')
reauth_parser.add_argument('--timeout', type=float, default=10,
help='Login timeout in minutes (default: 10)')
args = parser.parse_args()
auth = XAuthManager()
# ========================================================================
# COMMAND HANDLERS
# ========================================================================
if args.command == 'setup':
print("\n" + "="*70)
print(" 🐦 X (Twitter) Authentication Setup")
print("="*70)
print("\n📝 Prerequisites:")
print(" ✓ X Premium+ subscription (required for Articles)")
print(" ✓ X account credentials ready")
print()
print("📖 Instructions:")
print(" 1. Browser window will open to X login page")
print(" 2. Sign in with your X account")
print(" 3. Complete 2FA if enabled")
print(" 4. Wait for redirect to Home timeline")
print(" 5. Authentication will be saved automatically")
print()
print("⏱️ Timeout: {} minutes\n".format(int(args.timeout)))
success = auth.setup_auth(
headless=args.headless,
timeout_minutes=int(args.timeout)
)
if success:
print("\n" + "="*70)
print(" ✅ Authentication setup complete!")
print("="*70)
print("\n 🎉 You can now publish articles without logging in!")
print(" 📅 Authentication valid for 7 days\n")
else:
print("\n" + "="*70)
print(" ❌ Authentication setup failed")
print("="*70)
print("\n 💡 Troubleshooting:")
print(" - Ensure you completed login within timeout")
print(" - Check your X credentials")
print(" - Verify Premium+ subscription is active\n")
sys.exit(0 if success else 1)
elif args.command == 'status':
info = auth.get_auth_info()
print("\n" + "="*70)
print(" 🐦 X (Twitter) Authentication Status")
print("="*70)
for key, value in info.items():
print(f" {key}: {value}")
print("="*70 + "\n")
sys.exit(0 if info['authenticated'] else 1)
elif args.command == 'validate':
print("\n🔍 Validating X authentication...")
is_valid = auth.validate_auth()
if is_valid:
print("\n✅ Authentication is valid")
print(" You can publish articles now!\n")
else:
print("\n❌ Authentication is invalid")
print(" Please run: python auth_manager.py setup\n")
sys.exit(0 if is_valid else 1)
elif args.command == 'clear':
print("\n🗑️ Clearing X authentication data...")
success = auth.clear_auth()
if success:
print("\n✅ Authentication data cleared")
print(" Run 'setup' to re-authenticate\n")
else:
print("\n❌ Failed to clear authentication data\n")
sys.exit(0 if success else 1)
elif args.command == 'reauth':
print("\n🔄 Re-authenticating X account...")
# Step 1: Clear existing auth
print("\n Step 1/2: Clearing old authentication...")
auth.clear_auth()
# Step 2: Setup new auth
print("\n Step 2/2: Setting up new authentication...")
print(" Browser will open shortly...\n")
success = auth.setup_auth(timeout_minutes=int(args.timeout))
if success:
print("\n✅ Re-authentication complete!")
print(" Ready to publish articles\n")
else:
print("\n❌ Re-authentication failed")
print(" Please try again or check credentials\n")
sys.exit(0 if success else 1)
else:
parser.print_help()
sys.exit(1)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Copy image or HTML to system clipboard for X Articles publishing.
Supports:
- Image files (jpg, png, gif, webp) - copies as image data
- HTML content - copies as rich text for paste
- Optional image compression before copying
Usage:
# Copy image to clipboard
python copy_to_clipboard.py image /path/to/image.jpg
# Copy image with compression (quality 0-100)
python copy_to_clipboard.py image /path/to/image.jpg --quality 80
# Copy HTML to clipboard
python copy_to_clipboard.py html "<p>Hello</p>"
# Copy HTML from file
python copy_to_clipboard.py html --file /path/to/content.html
macOS Requirements:
pip install Pillow pyobjc-framework-Cocoa
"""
import argparse
import io
import os
import sys
from pathlib import Path
def compress_image(image_path: str, quality: int = 85, max_size: tuple = (2000, 2000)) -> bytes:
"""Compress image and return as bytes."""
from PIL import Image
img = Image.open(image_path)
# Convert to RGB if necessary (for JPEG)
if img.mode in ('RGBA', 'P'):
img = img.convert('RGB')
# Resize if too large
img.thumbnail(max_size, Image.Resampling.LANCZOS)
# Save to bytes
buffer = io.BytesIO()
img.save(buffer, format='JPEG', quality=quality, optimize=True)
return buffer.getvalue()
def copy_image_to_clipboard_macos(image_path: str, quality: int = None) -> bool:
"""Copy image to macOS clipboard using AppKit."""
try:
from AppKit import NSPasteboard, NSPasteboardTypePNG, NSPasteboardTypeTIFF
from Foundation import NSData
# Compress if quality specified, otherwise use original
if quality:
image_data = compress_image(image_path, quality)
else:
with open(image_path, 'rb') as f:
image_data = f.read()
# Create NSData from image bytes
ns_data = NSData.dataWithBytes_length_(image_data, len(image_data))
# Get pasteboard and clear it
pasteboard = NSPasteboard.generalPasteboard()
pasteboard.clearContents()
# Determine type based on file extension
ext = Path(image_path).suffix.lower()
if ext in ('.png',):
pasteboard.setData_forType_(ns_data, NSPasteboardTypePNG)
else:
# For JPEG and others, use TIFF (more compatible)
from PIL import Image
img = Image.open(io.BytesIO(image_data))
tiff_buffer = io.BytesIO()
img.save(tiff_buffer, format='TIFF')
tiff_data = NSData.dataWithBytes_length_(tiff_buffer.getvalue(), len(tiff_buffer.getvalue()))
pasteboard.setData_forType_(tiff_data, NSPasteboardTypeTIFF)
return True
except ImportError as e:
print(f"Error: Missing dependency: {e}", file=sys.stderr)
print("Install with: pip install Pillow pyobjc-framework-Cocoa", file=sys.stderr)
return False
except Exception as e:
print(f"Error copying image: {e}", file=sys.stderr)
return False
def copy_html_to_clipboard_macos(html: str) -> bool:
"""Copy HTML to macOS clipboard as rich text."""
try:
from AppKit import NSPasteboard, NSPasteboardTypeHTML, NSPasteboardTypeString
from Foundation import NSData
# Get pasteboard and clear it
pasteboard = NSPasteboard.generalPasteboard()
pasteboard.clearContents()
# Set HTML content
html_data = html.encode('utf-8')
ns_data = NSData.dataWithBytes_length_(html_data, len(html_data))
pasteboard.setData_forType_(ns_data, NSPasteboardTypeHTML)
# Also set plain text version
pasteboard.setString_forType_(html, NSPasteboardTypeString)
return True
except ImportError as e:
print(f"Error: Missing dependency: {e}", file=sys.stderr)
print("Install with: pip install pyobjc-framework-Cocoa", file=sys.stderr)
return False
except Exception as e:
print(f"Error copying HTML: {e}", file=sys.stderr)
return False
def main():
parser = argparse.ArgumentParser(description='Copy to clipboard for X Articles')
subparsers = parser.add_subparsers(dest='type', required=True)
# Image subcommand
img_parser = subparsers.add_parser('image', help='Copy image to clipboard')
img_parser.add_argument('path', help='Path to image file')
img_parser.add_argument('--quality', type=int, default=None,
help='JPEG quality (1-100), enables compression')
img_parser.add_argument('--max-width', type=int, default=2000,
help='Max width for resize')
img_parser.add_argument('--max-height', type=int, default=2000,
help='Max height for resize')
# HTML subcommand
html_parser = subparsers.add_parser('html', help='Copy HTML to clipboard')
html_parser.add_argument('content', nargs='?', help='HTML content')
html_parser.add_argument('--file', '-f', help='Read HTML from file')
args = parser.parse_args()
if args.type == 'image':
if not os.path.exists(args.path):
print(f"Error: Image not found: {args.path}", file=sys.stderr)
sys.exit(1)
success = copy_image_to_clipboard_macos(args.path, args.quality)
if success:
print(f"Image copied to clipboard: {args.path}")
if args.quality:
print(f" (compressed with quality={args.quality})")
sys.exit(0 if success else 1)
elif args.type == 'html':
if args.file:
if not os.path.exists(args.file):
print(f"Error: File not found: {args.file}", file=sys.stderr)
sys.exit(1)
with open(args.file, 'r', encoding='utf-8') as f:
html = f.read()
elif args.content:
html = args.content
else:
# Read from stdin
html = sys.stdin.read()
success = copy_html_to_clipboard_macos(html)
if success:
print(f"HTML copied to clipboard ({len(html)} chars)")
sys.exit(0 if success else 1)
if __name__ == '__main__':
main()
#!/usr/bin/env python3
"""调试脚本 - 查看 X Articles 页面结构"""
import sys
import time
from pathlib import Path
from patchright.sync_api import sync_playwright
SKILL_DIR = Path(__file__).parent.parent
BROWSER_STATE_DIR = SKILL_DIR / "data" / "browser_state"
BROWSER_PROFILE_DIR = BROWSER_STATE_DIR / "browser_profile"
STATE_FILE = BROWSER_STATE_DIR / "state.json"
sys.path.insert(0, str(SKILL_DIR / "lib"))
from browser_auth import BrowserFactory
def main():
print("🔍 调试 X Articles 页面...")
playwright = sync_playwright().start()
context = BrowserFactory.launch_persistent_context(
playwright,
user_data_dir=BROWSER_PROFILE_DIR,
state_file=STATE_FILE,
headless=False # 显示浏览器
)
page = context.new_page()
# 导航到 Articles
print("📍 导航到 X Articles...")
page.goto("https://x.com/compose/articles", wait_until="domcontentloaded")
time.sleep(3)
# 截图
screenshot_path = "/tmp/x_articles_debug.png"
page.screenshot(path=screenshot_path, full_page=True)
print(f"📸 截图已保存: {screenshot_path}")
# 打印页面标题和URL
print(f"📄 页面标题: {page.title()}")
print(f"🔗 当前URL: {page.url}")
# 查找所有按钮
print("\n🔘 页面上的按钮:")
buttons = page.query_selector_all("button")
for i, btn in enumerate(buttons[:20]): # 只打印前20个
text = btn.inner_text().strip()[:50] if btn.inner_text() else ""
print(f" [{i}] {text}")
# 查找所有链接
print("\n🔗 页面上的链接:")
links = page.query_selector_all("a")
for i, link in enumerate(links[:20]):
text = link.inner_text().strip()[:50] if link.inner_text() else ""
href = link.get_attribute("href") or ""
print(f" [{i}] {text} -> {href[:50]}")
# 查找可能的"create"相关元素
print("\n🔍 查找 'create' 相关元素:")
create_elements = page.query_selector_all("[data-testid*='create'], [aria-label*='create'], button:has-text('create'), a:has-text('create')")
for elem in create_elements:
print(f" 找到: {elem.inner_text()[:50]}")
# 等待用户查看
print("\n⏳ 浏览器将在 60 秒后关闭...")
time.sleep(60)
context.close()
playwright.stop()
if __name__ == "__main__":
main()
# ~/.claude/skills/x-article-publisher/scripts/site_config.py
r"""
==============================================================================
X (Twitter) 站点配置 - Site Configuration for X Authentication
==============================================================================
PURPOSE:
定义 X (Twitter) 的认证验证策略,使用共享浏览器认证框架
VALIDATION STRATEGY:
1. URL 正则匹配 "^https://x\.com/home" (精确检查登录后跳转)
2. DOM 元素 "nav[aria-label='Primary']" 存在 (主导航验证)
CRITICAL:
X 对自动化检测敏感,使用真实 Chrome + 浏览器指纹保持一致性
ARCHITECTURE:
Config-driven validation → BrowserAuthManager → Persistent authentication
"""
import sys
from pathlib import Path
# ============================================================================
# PATH CONFIGURATION - 使用skill内部的browser_auth库
# ============================================================================
SKILL_DIR = Path(__file__).parent.parent
sys.path.insert(0, str(SKILL_DIR / "lib"))
from browser_auth import SiteConfig
# ============================================================================
# X (TWITTER) CONFIGURATION
# ============================================================================
X_TWITTER_CONFIG = SiteConfig(
site_name="x-twitter",
login_url="https://x.com/i/flow/login",
# 验证策略:登录成功后跳转到 Home 时间线
success_indicators={
"url_pattern": r"^https://x\.com/home", # 精确 URL 匹配
"element_exists": "nav[aria-label='Primary']" # 主导航元素验证
},
login_timeout_minutes=10 # 登录超时时间(包括 2FA 验证)
)