Skill: cellinlab/cell-x-article-publisher (version 0.1.1)

--- name: x-article-publisher description: Publish existing Markdown articles to X (Twitter) Articles drafts with browser automation preparation, rich-text clipboard support, image/divider positioning, and cookie export for Playwright login reuse. Use when Codex needs to send a finished Markdown article to X Articles, prepare block-indexed publish data, troubleshoot X Articles draft publishing, or sync local X/Twitter cookies into Playwright storage state before automation. Do not trigger for X topic strategy, thread writing, or growth diagnosis. metadata: {"openclaw":{"homepage":"https://github.com/cellinlab/cell-skills/tree/main/skills/x-article-publisher"}} --- # X Article Publisher ## Overview 这个 Skill 只处理一件事： - 把已经写好的 Markdown 长文送进 X Articles 草稿 它覆盖的是发布准备和发布执行链路，不是内容生产链路。也就是说，它处理： - Markdown 解析 - 富文本 HTML 准备 - 封面图、正文图片、分割线定位 - 剪贴板复制 - Playwright 登录态准备 - 浏览器自动化发布与排查 它不处理： - X 选题 - Thread 写作 - 增长诊断 - 内容改写 如果用户真正缺的是文章本身，而不是发布动作，不要在这里偷偷扩 scope。 ## Quick Start 1. 先确认用户已经有一篇可发布的 Markdown 长文。 2. 发布前先跑解析脚本，拿到 title、HTML、封面图、正文图片和分割线位置。 3. 优先导出本机 X/Twitter cookies 给 Playwright，用已有登录态减少手动登录。 4. 默认只保存草稿，不自动发布。 当前 skill 目录内置的最小工具链： ```bash pip install -r skills/x-article-publisher/requirements.txt # 解析 Markdown python3 skills/x-article-publisher/scripts/parse_markdown.py article.md # 生成 HTML 并复制到剪贴板 python3 skills/x-article-publisher/scripts/parse_markdown.py article.md --save-html /tmp/x-article.html --output json python3 skills/x-article-publisher/scripts/copy_to_clipboard.py html --file /tmp/x-article.html # 导出或复用 X/Twitter cookies 缓存 python3 skills/x-article-publisher/scripts/export_x_cookies.py # 强制刷新缓存，或从其他浏览器读取 python3 skills/x-article-publisher/scripts/export_x_cookies.py --no-cache --browser edge ``` ## Default Contract 默认采用以下约定，除非用户另有说明： - 输入通常是现成 Markdown 文件 - `title`、`cover_image` 等 frontmatter 可以直接作为解析输入 - 目标平台是 X Articles，不是普通 tweet / thread - 默认只保存草稿，不自动发布 - 默认优先尝试 cookie 同步，再回退到人工登录 - 默认把 storage state 持久化到 `~/.cache/x-article-publisher/x-storage-state.json` - 默认优先复用有效缓存，而不是每次重新扫描 Chrome cookies - 如果运行环境没有浏览器自动化能力，就先把中间文件和 cookies 准备好，不假装已经发布成功 - 表格和 Mermaid 如需稳定呈现，应先转成图片再进入发布流程 ## Workflow ### Step 1: Scope the Request 先判断当前请求属于哪一类： - 已有 Markdown，要发到 X Articles - 发布流程报错，需要排查 - 仓库里还没有 X Articles 发布能力，需要补一个 publish-only 流程 如果用户没有现成文章，或者其实在问“X 上该写什么”，切给更合适的 Skill。 ### Step 2: Parse Before Browsing 先跑解析脚本，不要先打开浏览器： - 提取标题 - 读取 frontmatter 里的标题 / 封面覆盖值 - 在必要时把 HTTPS 远程图片下载到本地临时目录 - 没有显式封面时，识别第一张图作为封面 - 提取正文图片及 `block_index` - 提取分割线及 `block_index` - 生成可粘贴 HTML 只有解析结果完整，才进入浏览器自动化。 需要详细步骤时，读 [references/workflow.md](references/workflow.md)。 ### Step 3: Prefer Cookie Sync First 在打开 X Articles 编辑器之前，优先尝试 cookie 同步： - 从本机浏览器导出 `x.com` / `twitter.com` cookies - 转成 Playwright storage state JSON - 默认先检查持久化 cache 是否仍然有效 - 如果当前宿主支持 storage state / cookie 注入，在创建 browser context 前优先加载 - 如果宿主不支持，或注入后仍未登录，再回退到人工登录 不要默认让用户每次都手动登录。 需要具体规则时，读 [references/cookie-sync.md](references/cookie-sync.md)。 ### Step 4: Publish in the Stable Order 发布顺序不要乱： 1. 打开 X Articles 编辑器或文章列表页 2. 先探测当前 browser context 是否已经登录 3. 如果未登录，优先重建为带 `storage_state` 的 context，而不是先硬导航到编辑器 4. 如果落在列表页，先点 `Create` / `Write` 5. 上传封面图 6. 填标题 7. 通过剪贴板粘贴 HTML 正文 8. 按 `block_index` 反向插入正文图片 9. 按 `block_index` 反向插入分割线 10. 保存草稿 这里最重要的是两点： - 先文后图后分割线 - 图片和分割线按高索引到低索引插入，避免位置偏移 ### Step 5: Report the Result 成功时至少报告： - 最终标题 - 封面状态 - 正文图片数量 - 分割线数量 - 是否保存成草稿 - cookie 是否同步成功 失败时至少报告： - 卡在哪一步 - 关键报错 - 是否已经生成 HTML / storage state 等中间文件 - 还需要用户补什么 ## Hard Rules Do not: - 在这个 Skill 里代替用户写长文 - 自动点击最终发布按钮 - 跳过 Markdown 解析直接硬做浏览器操作 - 明明能做 cookie 同步却每次都让用户手动登录 - 发现宿主不支持 cookie 注入时还假装“已经同步成功” Always: - 先解析 Markdown，再进浏览器 - 优先尝试 cookie 同步 - 默认只保存草稿 - 把 block index 当作图片/分割线定位依据 - 诚实说明当前运行环境能不能真正把 cookies 注入到 Playwright ## Resource Map - [references/workflow.md](references/workflow.md) - 读这个文件，拿 Markdown 解析、浏览器自动化、图片和分割线插入顺序。 - [references/cookie-sync.md](references/cookie-sync.md) - 读这个文件，拿 browser-cookie3、Playwright storage state 和登录降级规则。 - [references/troubleshooting.md](references/troubleshooting.md) - 读这个文件，排查登录、剪贴板、图片定位、Playwright 工具不可用等问题。 - [scripts/parse_markdown.py](scripts/parse_markdown.py) - 解析 Markdown，输出标题、HTML、图片、分割线和 block index。 - [scripts/copy_to_clipboard.py](scripts/copy_to_clipboard.py) - 把 HTML 或图片复制到系统剪贴板。 - [scripts/table_to_image.py](scripts/table_to_image.py) - 把 Markdown 表格转成 PNG。 - [scripts/export_x_cookies.py](scripts/export_x_cookies.py) - 从本机浏览器导出 X/Twitter cookies，并转成 Playwright storage state JSON。

path: agents/openai.yaml
content: interface:
  display_name: "X 长文发布"
  short_description: "把现成 Markdown 长文发布到 X Articles 草稿，并优先同步本机 cookies 给 Playwright"
  default_prompt: "Use $x-article-publisher to publish this finished Markdown article to X Articles: parse the markdown first, prepare HTML/images/dividers, export local X/Twitter cookies into Playwright storage state if possible, then drive the browser to save a draft without auto-publishing."


path: references/cookie-sync.md
content: # Cookie Sync

这个文件只讲一件事：

- 如何优先把本机浏览器里的 X/Twitter 登录态同步给 Playwright

## 1. 为什么先做 cookie 同步

很多 X Articles 自动化失败，不是因为选择器不对，而是 Playwright 打开的是未登录上下文。

如果用户本机浏览器已经登录 X/Twitter，优先复用这份登录态，通常比重新手动登录更稳、更快。

## 2. 导出方式

运行：

```bash
python3 skills/x-article-publisher/scripts/export_x_cookies.py
```

默认行为：

- 默认只从 Chrome 读取 cookies
- 只导出 `x.com` / `twitter.com` 及其子域 cookies
- 默认输出到 `~/.cache/x-article-publisher/x-storage-state.json`
- 默认会先检查已有 cache 是否仍然有效
- 输出成 Playwright storage state JSON

如果你的登录态在其他浏览器里，再显式传：

```bash
python3 skills/x-article-publisher/scripts/export_x_cookies.py \
  --browser edge \
  --browser firefox \
  --no-cache
```

如果你就是想强制重新读取浏览器 cookies：

```bash
python3 skills/x-article-publisher/scripts/export_x_cookies.py --no-cache
```

如果你想调短 cache 的有效期：

```bash
python3 skills/x-article-publisher/scripts/export_x_cookies.py --max-age-hours 4
```

## 3. 成功后得到什么

输出文件是标准 Playwright storage state 格式，核心是：

- `cookies`
- `origins`

当前脚本只负责 cookies，不负责 localStorage。

对 X/Twitter 登录态来说，cookies 通常已经足够覆盖大多数场景。

cache 有效性的默认判断包括：

- 文件存在
- 文件修改时间没有超过 `--max-age-hours`
- `auth_token` 和 `ct0` 两个关键 auth cookies 仍然存在且未过期

## 4. 运行时如何使用

如果当前宿主支持在 Playwright 上下文创建前加载 storage state：

- 在创建 browser context 时直接使用导出的 JSON

典型方式是：

- `browser.new_context(storage_state="~/.cache/x-article-publisher/x-storage-state.json")`
- 或 Playwright CLI / MCP 里等价的 storage state 注入入口

如果你在用 `playwright-mcp`，优先在启动时预加载，而不是先打开一个未登录 context：

```bash
npx @playwright/mcp@latest \
  --storage-state ~/.cache/x-article-publisher/x-storage-state.json
```

这比“先打开页面，再补注入 cookies”更稳，因为首个请求就会带着登录态发出去。

如果当前宿主只支持打开一个现成浏览器标签，但不支持 cookie 注入：

- 保留 storage state 文件
- 再回退到人工登录

不要把“导出了 storage state 文件”和“Playwright 已经成功登录”混为一谈。

## 5. 什么时候回退到人工登录

以下情况应直接回退：

- 没有找到任何 X/Twitter cookies
- 宿主浏览器自动化工具不支持导入 storage state
- 导入后仍然打开的是未登录页面

更稳的顺序通常是：

1. 先打开 `https://x.com/home` 探测当前 context 是否已登录
2. 未登录时，优先重建为带 `--storage-state` 的 context
3. cache 失效时，再用 `--no-cache` 重新导出
4. 还是失败，最后才人工登录

回退时要明确说清：

- cookies 是否导出成功
- 失败点是在“导出”还是“注入”

## 6. 安全边界

这个 Skill 只负责在本机导出和转换 cookies。

默认不要：

- 把 cookies 提交到仓库
- 把 storage state 存进仓库工作区
- 在回复里直接展示敏感 cookie 值

如果你在自定义 Playwright 自动化脚本里成功完成了登录或注入，也可以把最新状态保存回同一个 cache 文件，例如：

```js
await context.storageState({
  path: `${process.env.HOME}/.cache/x-article-publisher/x-storage-state.json`,
});
```

这样下次 browser context 启动时可以继续复用。


path: references/troubleshooting.md
content: # Troubleshooting

## 1. Playwright 打开后还是未登录

先区分两件事：

- cookies 有没有成功导出
- storage state 有没有被当前宿主真正加载

先跑：

```bash
python3 skills/x-article-publisher/scripts/export_x_cookies.py
```

如果导出成功但 Playwright 仍未登录，问题通常在“注入”而不是“导出”。

如果确认已经登录但仍然进不了文章编辑器，再检查账号本身是否有 X Articles 能力。

如果当前宿主支持 `--storage-state`，优先排查：

- browser context 是不是在启动前就加载了 cache
- 是不是先开了未登录 context 再去导航

这两类问题通常比 cookies 导出本身更常见。

## 2. 打开的是文章列表页，不是编辑器

这通常不是脚本坏了，而是页面先落在草稿列表。

先检查：

- 页面里有没有 `Create` 或 `Write` 按钮
- 点击后标题输入框是否出现

不要在还没点创建入口时，就把“看不到标题框”判断成选择器失败。

## 3. cache 明明存在，但还是重复导出

先检查：

- cache 文件是不是在 `~/.cache/x-article-publisher/x-storage-state.json`
- `auth_token` 和 `ct0` 是否仍在 cache 中
- cache 文件修改时间有没有超过默认的 12 小时

如果就是要强制刷新，显式跑：

```bash
python3 skills/x-article-publisher/scripts/export_x_cookies.py --no-cache
```

## 4. 图片位置不对

先检查：

- `parse_markdown.py` 输出里的 `block_index`
- 浏览器里是否按从大到小顺序插入

不要回退到模糊的文字匹配。

## 5. 远程图片上传失败

如果 Markdown 里用的是 `https://...` 图片：

- 先确认 `parse_markdown.py` 有没有把它下载到本地临时目录
- 再确认临时文件是否还存在

X Articles 上传阶段需要的是本地文件，不是远程 URL。

## 6. 表格显示异常

优先把表格转图片，再插入 X Articles。

## 7. 粘贴后格式丢失

通常是：

- 没有把 HTML 作为富文本放进剪贴板
- 用了纯文本粘贴而不是正常粘贴

先确认 `copy_to_clipboard.py html` 成功执行。

## 8. Playwright 工具不可用

如果宿主里没有可用的浏览器自动化工具：

- 先完成 Markdown 解析
- 先准备 HTML 和 storage state
- 明确告诉用户“浏览器自动化这一步当前环境不能执行”

不要假装已经保存成草稿。


path: references/workflow.md
content: # Workflow

这个 workflow 只服务“把现成 Markdown 发到 X Articles 草稿”。

## 1. 先解析 Markdown

先运行：

```bash
python3 skills/x-article-publisher/scripts/parse_markdown.py article.md
```

目标是拿到这些结构数据：

- `title`
- `cover_image`
- `content_images`
- `dividers`
- `html`
- `total_blocks`
- `cover_source`

不要在没拿到这些中间数据之前就开始浏览器自动化。

当前解析器还支持两类常见输入：

- frontmatter 里的 `title` / `cover_image` / `cover`
- HTTPS 远程图片，会先下载到本地临时目录再交给后续流程

## 2. 预处理需要转图片的内容

如果文章里有表格或 Mermaid，优先先转图片：

- 表格：`table_to_image.py`
- Mermaid：优先用 `mmdc`

原因很简单：

- X Articles 对表格和复杂图形支持不稳定
- 先转图片更可控

## 3. 浏览器执行顺序

稳定顺序如下：

1. 先确认可复用的 storage state cache 是否存在且有效
2. 用带 `storage_state` 的 browser context 启动浏览器
3. 先打开 `https://x.com/home` 探测当前 context 是否已登录
4. 再打开 X Articles 编辑器或文章列表页
5. 如果当前是列表页，先点 `Create` / `Write`
6. 上传封面
7. 填标题
8. 粘贴 HTML 正文
9. 反向插入正文图片
10. 反向插入分割线
11. 保存草稿

如果迟迟看不到编辑器，要先怀疑两件事：

- 当前账号没有 X Articles 能力
- 实际落在的是文章列表页，还没点创建按钮

不要把顺序写反成：

1. 先开一个未登录 context
2. 直接导航到编辑器
3. 再尝试补注入 cookies

这种顺序很容易让首个请求直接落到登录页，然后后续状态变得混乱。

## 4. 先文后图后分割线

正文应先整体进入编辑器。

然后再做两种补充插入：

- 正文图片
- 分割线

如果先插图再粘正文，位置很容易错。

## 5. 为什么用 block index

图片和分割线都用 `block_index` 定位。

不要依赖“前后文字匹配”，因为：

- 段落可能重复
- 短句不够唯一
- 粘贴后文本结构可能发生微小变化

更稳的方式是：

- 先统计块元素顺序
- 再用 `block_index` 定位

## 6. 为什么反向插入

图片和分割线按从大到小的索引插入。

这样做的原因：

- 先插靠后的元素，不会影响靠前元素的位置
- 可以减少多次插入造成的索引漂移

## 7. 草稿边界

默认只保存草稿。

不要替用户自动点击最终发布按钮。


path: requirements.txt
content: Pillow>=10.0
browser-cookie3>=0.20


path: scripts/copy_to_clipboard.py
content: #!/usr/bin/env python3
"""
Copy image or HTML to system clipboard for X Articles publishing.
"""

from __future__ import annotations

import argparse
import io
import os
import sys
from pathlib import Path


def compress_image(image_path: str, quality: int = 85, max_size: tuple[int, int] = (2000, 2000)) -> bytes:
    from PIL import Image

    image = Image.open(image_path)
    if image.mode in ("RGBA", "P"):
        image = image.convert("RGB")
    image.thumbnail(max_size, Image.Resampling.LANCZOS)

    buffer = io.BytesIO()
    image.save(buffer, format="JPEG", quality=quality, optimize=True)
    return buffer.getvalue()


def copy_image_to_clipboard_macos(image_path: str, quality: int | None = None) -> bool:
    try:
        from AppKit import NSPasteboard, NSPasteboardTypePNG, NSPasteboardTypeTIFF
        from Foundation import NSData
        from PIL import Image
    except ImportError as exc:
        print(f"Error: Missing dependency: {exc}", file=sys.stderr)
        print("Install with: pip install Pillow pyobjc-framework-Cocoa", file=sys.stderr)
        return False

    try:
        if quality:
            image_data = compress_image(image_path, quality)
        else:
            image_data = Path(image_path).read_bytes()

        pasteboard = NSPasteboard.generalPasteboard()
        pasteboard.clearContents()
        ext = Path(image_path).suffix.lower()

        if ext == ".png":
            ns_data = NSData.dataWithBytes_length_(image_data, len(image_data))
            pasteboard.setData_forType_(ns_data, NSPasteboardTypePNG)
        else:
            image = Image.open(io.BytesIO(image_data))
            tiff_buffer = io.BytesIO()
            image.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 Exception as exc:
        print(f"Error copying image: {exc}", file=sys.stderr)
        return False


def copy_html_to_clipboard_macos(html: str) -> bool:
    try:
        from AppKit import NSPasteboard, NSPasteboardTypeHTML, NSPasteboardTypeString
        from Foundation import NSData
    except ImportError as exc:
        print(f"Error: Missing dependency: {exc}", file=sys.stderr)
        print("Install with: pip install pyobjc-framework-Cocoa", file=sys.stderr)
        return False

    try:
        pasteboard = NSPasteboard.generalPasteboard()
        pasteboard.clearContents()
        html_data = html.encode("utf-8")
        ns_data = NSData.dataWithBytes_length_(html_data, len(html_data))
        pasteboard.setData_forType_(ns_data, NSPasteboardTypeHTML)
        pasteboard.setString_forType_(html, NSPasteboardTypeString)
        return True
    except Exception as exc:
        print(f"Error copying HTML: {exc}", file=sys.stderr)
        return False


def copy_image_to_clipboard_windows(image_path: str, quality: int | None = None) -> bool:
    try:
        import win32clipboard
        from PIL import Image
    except ImportError as exc:
        print(f"Error: Missing dependency: {exc}", file=sys.stderr)
        print("Install with: pip install Pillow pywin32", file=sys.stderr)
        return False

    try:
        if quality:
            image_data = compress_image(image_path, quality)
            image = Image.open(io.BytesIO(image_data))
        else:
            image = Image.open(image_path)

        if image.mode in ("RGBA", "P", "LA"):
            image = image.convert("RGB")

        output = io.BytesIO()
        image.save(output, format="BMP")
        dib = output.getvalue()[14:]
        output.close()

        win32clipboard.OpenClipboard()
        try:
            win32clipboard.EmptyClipboard()
            win32clipboard.SetClipboardData(win32clipboard.CF_DIB, dib)
        finally:
            win32clipboard.CloseClipboard()
        return True
    except Exception as exc:
        print(f"Error copying image: {exc}", file=sys.stderr)
        return False


def copy_html_to_clipboard_windows(html: str) -> bool:
    try:
        from clipboard import Clipboard
    except ImportError as exc:
        print(f"Error: Missing dependency: {exc}", file=sys.stderr)
        print("Install with: pip install clip-util", file=sys.stderr)
        return False

    try:
        with Clipboard() as clipboard:
            clipboard["html"] = html
        return True
    except Exception as exc:
        print(f"Error copying HTML: {exc}", file=sys.stderr)
        return False


def copy_image_to_clipboard(image_path: str, quality: int | None = None) -> bool:
    if sys.platform == "darwin":
        return copy_image_to_clipboard_macos(image_path, quality)
    if sys.platform == "win32":
        return copy_image_to_clipboard_windows(image_path, quality)
    print(f"Error: Unsupported platform: {sys.platform}", file=sys.stderr)
    return False


def copy_html_to_clipboard(html: str) -> bool:
    if sys.platform == "darwin":
        return copy_html_to_clipboard_macos(html)
    if sys.platform == "win32":
        return copy_html_to_clipboard_windows(html)
    print(f"Error: Unsupported platform: {sys.platform}", file=sys.stderr)
    return False


def main() -> int:
    parser = argparse.ArgumentParser(description="Copy image or HTML to clipboard")
    subparsers = parser.add_subparsers(dest="type", required=True)

    image_parser = subparsers.add_parser("image", help="Copy image to clipboard")
    image_parser.add_argument("path", help="Path to image file")
    image_parser.add_argument("--quality", type=int, default=None, help="JPEG quality (1-100)")

    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)
            return 1
        ok = copy_image_to_clipboard(args.path, args.quality)
        if ok:
            print(f"Image copied to clipboard: {args.path}")
        return 0 if ok else 1

    if args.file:
        if not os.path.exists(args.file):
            print(f"Error: File not found: {args.file}", file=sys.stderr)
            return 1
        html = Path(args.file).read_text(encoding="utf-8")
    elif args.content:
        html = args.content
    else:
        html = sys.stdin.read()

    ok = copy_html_to_clipboard(html)
    if ok:
        print(f"HTML copied to clipboard ({len(html)} chars)")
    return 0 if ok else 1


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/export_x_cookies.py
content: #!/usr/bin/env python3
"""
Export X/Twitter cookies from local browsers to Playwright storage state JSON.
"""

from __future__ import annotations

import argparse
import json
import os
import sys
import time
from http.cookiejar import Cookie
from pathlib import Path


TARGET_DOMAINS = ("x.com", "twitter.com")
AUTH_COOKIE_NAMES = ("auth_token", "ct0")
DEFAULT_CACHE_ROOT = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
DEFAULT_CACHE_PATH = DEFAULT_CACHE_ROOT / "x-article-publisher" / "x-storage-state.json"


def normalize_same_site(raw_value: str | None) -> str:
    if not raw_value:
        return "Lax"
    value = raw_value.strip().lower()
    if value == "strict":
        return "Strict"
    if value == "none":
        return "None"
    return "Lax"


def cookie_to_playwright(cookie: Cookie) -> dict:
    rest = getattr(cookie, "_rest", {}) or {}
    same_site = normalize_same_site(rest.get("SameSite") or rest.get("samesite"))
    expires = float(cookie.expires) if cookie.expires is not None else -1
    return {
        "name": cookie.name,
        "value": cookie.value,
        "domain": cookie.domain,
        "path": cookie.path or "/",
        "expires": expires,
        "httpOnly": bool("HttpOnly" in rest or "httponly" in rest or cookie.has_nonstandard_attr("HttpOnly")),
        "secure": bool(cookie.secure),
        "sameSite": same_site,
    }


def matches_x_domain(cookie: Cookie) -> bool:
    domain = (cookie.domain or "").lower()
    if domain.startswith("."):
        domain = domain[1:]
    return any(domain == target or domain.endswith(f".{target}") for target in TARGET_DOMAINS)


def load_browser_cookies(browser_name: str, domain_name: str):
    try:
        import browser_cookie3
    except ImportError:
        print("Error: browser-cookie3 not installed. Run: pip install browser-cookie3", file=sys.stderr)
        raise SystemExit(1)

    loaders = {
        "chrome": browser_cookie3.chrome,
        "chromium": browser_cookie3.chromium,
        "edge": browser_cookie3.edge,
        "firefox": browser_cookie3.firefox,
        "opera": browser_cookie3.opera,
        "brave": browser_cookie3.brave if hasattr(browser_cookie3, "brave") else browser_cookie3.chrome,
    }

    if browser_name not in loaders:
        raise ValueError(f"Unsupported browser: {browser_name}")

    return loaders[browser_name](domain_name=domain_name)


def gather_cookies(browser_names: list[str]) -> tuple[list[dict], dict[str, int]]:
    seen: set[tuple[str, str, str, str]] = set()
    exported: list[dict] = []
    counts: dict[str, int] = {}

    for browser_name in browser_names:
        browser_count = 0
        for domain_name in TARGET_DOMAINS:
            try:
                cookie_jar = load_browser_cookies(browser_name, domain_name)
            except Exception as exc:
                if browser_count == 0:
                    counts[browser_name] = -1
                print(
                    f"[export_x_cookies] Skipping {browser_name} ({domain_name}): {exc}",
                    file=sys.stderr,
                )
                continue

            for cookie in cookie_jar:
                if not matches_x_domain(cookie):
                    continue
                key = (cookie.domain, cookie.path, cookie.name, cookie.value)
                if key in seen:
                    continue
                seen.add(key)
                exported.append(cookie_to_playwright(cookie))
                browser_count += 1

        counts[browser_name] = browser_count

    return exported, counts


def is_cache_valid(cache_path: Path, max_age_hours: float = 12.0) -> bool:
    if not cache_path.exists():
        return False

    age_hours = (time.time() - cache_path.stat().st_mtime) / 3600
    if age_hours > max_age_hours:
        return False

    try:
        state = json.loads(cache_path.read_text(encoding="utf-8"))
    except Exception:
        return False

    cookies = {cookie.get("name"): cookie for cookie in state.get("cookies", [])}
    now = time.time()
    for name in AUTH_COOKIE_NAMES:
        cookie = cookies.get(name)
        if not cookie:
            return False

        expires = cookie.get("expires", -1)
        if isinstance(expires, (int, float)) and expires != -1 and expires < now:
            return False

    return True


def main() -> int:
    parser = argparse.ArgumentParser(description="Export X/Twitter cookies to Playwright storage state")
    parser.add_argument(
        "--browser",
        action="append",
        choices=["chrome", "chromium", "edge", "firefox", "opera", "brave"],
        help="Browser(s) to read from. Defaults to chrome. Pass multiple times to expand.",
    )
    parser.add_argument("--output", "-o", default=str(DEFAULT_CACHE_PATH), help="Output JSON path")
    parser.add_argument("--allow-empty", action="store_true", help="Write an empty storage state instead of failing")
    parser.add_argument(
        "--use-cache",
        dest="use_cache",
        action="store_true",
        default=True,
        help="Reuse a valid cached storage state when possible. Enabled by default.",
    )
    parser.add_argument(
        "--no-cache",
        dest="use_cache",
        action="store_false",
        help="Ignore cached storage state and export fresh cookies from the browser.",
    )
    parser.add_argument(
        "--max-age-hours",
        type=float,
        default=12.0,
        help="Maximum cache age in hours before forcing a fresh export.",
    )
    args = parser.parse_args()

    output_path = Path(args.output).expanduser()
    if args.use_cache and is_cache_valid(output_path, max_age_hours=args.max_age_hours):
        print(str(output_path))
        print("cookies=cached")
        return 0

    browsers = args.browser or ["chrome"]
    cookies, counts = gather_cookies(browsers)

    if not cookies and not args.allow_empty:
        print("Error: no X/Twitter cookies found in selected browsers", file=sys.stderr)
        return 1

    storage_state = {
        "cookies": cookies,
        "origins": [],
        "meta": {
            "browsers": browsers,
            "counts": counts,
            "generated_at": int(time.time()),
        },
    }

    output_path.parent.mkdir(parents=True, exist_ok=True)
    output_path.write_text(json.dumps(storage_state, ensure_ascii=False, indent=2), encoding="utf-8")
    print(str(output_path))
    print(f"cookies={len(cookies)}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/parse_markdown.py
content: #!/usr/bin/env python3
"""
Parse Markdown for X Articles publishing.

Extracts:
- title
- cover image
- content images with block_index
- dividers with block_index
- rich-text HTML content
"""

from __future__ import annotations

import argparse
import hashlib
import json
import os
import re
import sys
import tempfile
import urllib.parse
import urllib.request
from pathlib import Path


SEARCH_DIRS = [
    Path.home() / "Downloads",
    Path.home() / "Desktop",
    Path.home() / "Pictures",
]
FRONTMATTER_COVER_KEYS = ("cover_image", "coverImage", "cover", "image", "featureImage", "feature_image")
REMOTE_IMAGE_DIR = Path(tempfile.gettempdir()) / "x-article-publisher-images"

def parse_frontmatter(content: str) -> tuple[dict[str, str], str]:
    lines = content.splitlines()
    if not lines or lines[0].strip() != "---":
        return {}, content

    for idx in range(1, len(lines)):
        if lines[idx].strip() == "---":
            metadata: dict[str, str] = {}
            for raw_line in lines[1:idx]:
                line = raw_line.strip()
                if not line or line.startswith("#") or ":" not in raw_line:
                    continue
                key, value = raw_line.split(":", 1)
                key = key.strip()
                value = value.strip()
                if not key:
                    continue
                if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
                    value = value[1:-1]
                metadata[key] = value.strip()
            return metadata, "\n".join(lines[idx + 1 :]).lstrip()

    return {}, content


def pick_frontmatter_value(metadata: dict[str, str], keys: tuple[str, ...]) -> str | None:
    for key in keys:
        value = metadata.get(key)
        if value:
            return value
    return None


def extension_from_content_type(content_type: str | None) -> str:
    mapping = {
        "image/jpeg": ".jpg",
        "image/jpg": ".jpg",
        "image/png": ".png",
        "image/gif": ".gif",
        "image/webp": ".webp",
    }
    return mapping.get((content_type or "").lower(), ".png")


def get_remote_image_path(url: str) -> tuple[str, bool]:
    if not url.startswith("https://"):
        print(f"[parse_markdown] WARNING: skipping non-HTTPS image: '{url}'", file=sys.stderr)
        return url, False

    REMOTE_IMAGE_DIR.mkdir(parents=True, exist_ok=True)
    parsed = urllib.parse.urlparse(url)
    suffix = Path(parsed.path).suffix.lower()
    if suffix not in {".png", ".jpg", ".jpeg", ".gif", ".webp"}:
        suffix = ""
    digest = hashlib.md5(url.encode("utf-8")).hexdigest()[:10]
    existing = sorted(REMOTE_IMAGE_DIR.glob(f"remote_{digest}.*"))
    if existing:
        return str(existing[0]), True

    try:
        request = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
        with urllib.request.urlopen(request, timeout=30) as response:
            if not suffix:
                suffix = extension_from_content_type(response.headers.get_content_type())
            output_path = REMOTE_IMAGE_DIR / f"remote_{digest}{suffix}"
            output_path.write_bytes(response.read())
        return str(output_path), True
    except Exception as exc:
        print(f"[parse_markdown] WARNING: failed to download '{url}': {exc}", file=sys.stderr)
        return url, False


def resolve_image_path(image_reference: str, base_path: Path) -> tuple[str, str, bool]:
    if image_reference.startswith(("https://", "http://")):
        path, exists = get_remote_image_path(image_reference)
        return path, image_reference, exists

    decoded_reference = urllib.parse.unquote(image_reference)
    resolved_path = str(base_path / decoded_reference) if not os.path.isabs(decoded_reference) else decoded_reference
    filename = os.path.basename(decoded_reference)
    full_path, exists = find_image_file(resolved_path, filename)
    return full_path, resolved_path, exists


def find_image_file(original_path: str, filename: str) -> tuple[str, bool]:
    if os.path.isfile(original_path):
        return original_path, True

    for search_dir in SEARCH_DIRS:
        candidate = search_dir / filename
        if candidate.is_file():
            print(
                f"[parse_markdown] Image not found at '{original_path}', using '{candidate}' instead",
                file=sys.stderr,
            )
            return str(candidate), True

    print(
        f"[parse_markdown] WARNING: Image not found: '{original_path}' "
        f"(also searched {[str(d) for d in SEARCH_DIRS]})",
        file=sys.stderr,
    )
    return original_path, False


def split_into_blocks(markdown: str) -> list[str]:
    blocks: list[str] = []
    current_block: list[str] = []
    in_code_block = False
    code_block_lines: list[str] = []

    for line in markdown.split("\n"):
        stripped = line.strip()

        if stripped.startswith("```"):
            if in_code_block:
                in_code_block = False
                if code_block_lines:
                    blocks.append("___CODE_BLOCK_START___" + "\n".join(code_block_lines) + "___CODE_BLOCK_END___")
                code_block_lines = []
            else:
                if current_block:
                    blocks.append("\n".join(current_block))
                    current_block = []
                in_code_block = True
            continue

        if in_code_block:
            code_block_lines.append(line)
            continue

        if not stripped:
            if current_block:
                blocks.append("\n".join(current_block))
                current_block = []
            continue

        if re.match(r"^---+$", stripped):
            if current_block:
                blocks.append("\n".join(current_block))
                current_block = []
            blocks.append("___DIVIDER___")
            continue

        if stripped.startswith(("#", ">")):
            if current_block:
                blocks.append("\n".join(current_block))
                current_block = []
            blocks.append(stripped)
            continue

        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))

    if code_block_lines:
        blocks.append("___CODE_BLOCK_START___" + "\n".join(code_block_lines) + "___CODE_BLOCK_END___")

    return blocks


def extract_images_and_dividers(markdown: str, base_path: Path) -> tuple[list[dict], list[dict], str, int]:
    blocks = split_into_blocks(markdown)
    images: list[dict] = []
    dividers: list[dict] = []
    clean_blocks: list[str] = []

    image_pattern = re.compile(r"^!\[([^\]]*)\]\(([^)]+)\)$")

    for block in blocks:
        stripped = block.strip()

        if stripped == "___DIVIDER___":
            block_index = len(clean_blocks)
            after_text = ""
            if clean_blocks:
                prev_block = clean_blocks[-1].strip()
                lines = [line for line in prev_block.split("\n") if line.strip()]
                after_text = lines[-1][:80] if lines else ""
            dividers.append({"block_index": block_index, "after_text": after_text})
            continue

        match = image_pattern.match(stripped)
        if match:
            alt_text = match.group(1)
            image_path = match.group(2)
            full_path, original_path, exists = resolve_image_path(image_path, base_path)

            block_index = len(clean_blocks)
            after_text = ""
            if clean_blocks:
                prev_block = clean_blocks[-1].strip()
                lines = [line for line in prev_block.split("\n") if line.strip()]
                after_text = lines[-1][:80] if lines else ""

            images.append(
                {
                    "path": full_path,
                    "original_path": original_path,
                    "exists": exists,
                    "alt": alt_text,
                    "block_index": block_index,
                    "after_text": after_text,
                }
            )
        else:
            clean_blocks.append(block)

    return images, dividers, "\n\n".join(clean_blocks), len(clean_blocks)


def extract_title(markdown: str) -> tuple[str, str]:
    lines = markdown.strip().split("\n")
    title = "Untitled"
    title_line_idx: int | None = None

    for idx, line in enumerate(lines):
        stripped = line.strip()
        if not stripped:
            continue
        if stripped.startswith("# "):
            title = stripped[2:].strip()
            title_line_idx = idx
            break
        if stripped.startswith("## "):
            title = stripped[3:].strip()
            break
        if not stripped.startswith("!["):
            title = stripped[:100]
            break

    if title_line_idx is not None:
        lines.pop(title_line_idx)
        markdown = "\n".join(lines)

    return title, markdown


def markdown_to_html(markdown: str) -> str:
    html = markdown

    def convert_code_block(match: re.Match[str]) -> str:
        code_content = match.group(1)
        lines = code_content.strip().split("\n")
        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)
    html = re.sub(r"^## (.+)$", r"<h2>\1</h2>", html, flags=re.MULTILINE)
    html = re.sub(r"^### (.+)$", r"<h3>\1</h3>", html, flags=re.MULTILINE)
    html = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", html)
    html = re.sub(r"\*([^*]+)\*", r"<em>\1</em>", html)
    html = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r'<a href="\2">\1</a>', html)
    html = re.sub(r"^> (.+)$", r"<blockquote>\1</blockquote>", html, flags=re.MULTILINE)
    html = re.sub(r"^- (.+)$", r"<li>\1</li>", html, flags=re.MULTILINE)
    html = re.sub(r"^\d+\. (.+)$", r"<li>\1</li>", html, flags=re.MULTILINE)
    html = re.sub(r"((?:<li>.*?</li>\n?)+)", r"<ul>\1</ul>", html)

    parts = html.split("\n\n")
    processed: list[str] = []
    for part in parts:
        part = part.strip()
        if not part:
            continue
        if part.startswith(("<h2>", "<h3>", "<blockquote>", "<ul>", "<ol>")):
            processed.append(part)
        else:
            processed.append(f"<p>{part.replace(chr(10), '<br>')}</p>")

    return "".join(processed)


def parse_markdown_file(filepath: str, title_override: str | None = None, cover_override: str | None = None) -> dict:
    path = Path(filepath)
    base_path = path.parent
    metadata, content = parse_frontmatter(path.read_text(encoding="utf-8"))

    title, content = extract_title(content)
    if title_override:
        title = title_override
    elif frontmatter_title := metadata.get("title"):
        title = frontmatter_title
    images, dividers, clean_markdown, total_blocks = extract_images_and_dividers(content, base_path)
    html = markdown_to_html(clean_markdown)

    cover_image = None
    cover_exists = True
    content_images = images[1:] if len(images) > 1 else []

    cover_source = cover_override or pick_frontmatter_value(metadata, FRONTMATTER_COVER_KEYS)
    if cover_source:
        cover_image, _, cover_exists = resolve_image_path(cover_source, base_path)
        content_images = images
    elif images:
        cover_image = images[0]["path"]
        cover_exists = images[0]["exists"]

    missing = [img for img in images if not img["exists"]]
    if cover_image and not cover_exists:
        missing = [*missing, {"path": cover_image}]
    if missing:
        print(f"[parse_markdown] WARNING: {len(missing)} image(s) not found", file=sys.stderr)

    return {
        "title": title,
        "cover_image": cover_image,
        "cover_exists": cover_exists,
        "content_images": content_images,
        "dividers": dividers,
        "html": html,
        "total_blocks": total_blocks,
        "source_file": str(path.absolute()),
        "missing_images": len(missing),
        "cover_source": "frontmatter" if cover_source else ("first_content_image" if cover_image else "none"),
    }


def main() -> int:
    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")
    parser.add_argument("--html-only", action="store_true", help="Output only HTML content")
    parser.add_argument("--title", help="Override title from markdown/frontmatter")
    parser.add_argument("--cover", help="Override cover image from frontmatter")
    parser.add_argument("--save-html", help="Save HTML output to a file")
    args = parser.parse_args()

    if not os.path.exists(args.file):
        print(f"Error: File not found: {args.file}", file=sys.stderr)
        return 1

    result = parse_markdown_file(args.file, title_override=args.title, cover_override=args.cover)
    if args.save_html:
        Path(args.save_html).write_text(result["html"], encoding="utf-8")
        print(f"[parse_markdown] HTML saved to: {args.save_html}", file=sys.stderr)
    if args.html_only or args.output == "html":
        print(result["html"])
    else:
        print(json.dumps(result, ensure_ascii=False, indent=2))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/table_to_image.py
content: #!/usr/bin/env python3
"""
Convert Markdown table to PNG image.
"""

from __future__ import annotations

import re
import sys
from pathlib import Path

try:
    from PIL import Image, ImageDraw, ImageFont
except ImportError:
    print("Error: Pillow not installed. Run: pip install pillow", file=sys.stderr)
    raise SystemExit(1)


def parse_markdown_table(content: str) -> tuple[list[str], list[list[str]], list[str]]:
    lines = [line.strip() for line in content.strip().split("\n") if line.strip()]
    if len(lines) < 2:
        raise ValueError("Table must have at least 2 rows")

    def parse_cells(line: str) -> list[str]:
        line = line.strip()
        if line.startswith("|"):
            line = line[1:]
        if line.endswith("|"):
            line = line[:-1]
        return [cell.strip() for cell in line.split("|")]

    separator_idx = -1
    for idx, line in enumerate(lines):
        if re.match(r"^\|?[\s]*:?-{2,}:?[\s]*(\|[\s]*:?-{2,}:?[\s]*)*\|?$", line):
            separator_idx = idx
            break

    if separator_idx > 0:
        headers = parse_cells(lines[separator_idx - 1])
        separator_cells = parse_cells(lines[separator_idx])
        alignments: list[str] = []
        for cell in separator_cells:
            cell = cell.strip()
            if cell.startswith(":") and cell.endswith(":"):
                alignments.append("center")
            elif cell.endswith(":"):
                alignments.append("right")
            else:
                alignments.append("left")
        rows = [parse_cells(line) for line in lines[separator_idx + 1 :]]
    else:
        headers = []
        rows = [parse_cells(line) for line in lines]
        alignments = ["left"] * (len(rows[0]) if rows else 0)

    return headers, rows, alignments


def get_font(size: int):
    font_paths = [
        "/System/Library/Fonts/PingFang.ttc",
        "/System/Library/Fonts/Helvetica.ttc",
        "/Library/Fonts/Arial.ttf",
        "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
        "/usr/share/fonts/TTF/DejaVuSans.ttf",
        "C:/Windows/Fonts/msyh.ttc",
        "C:/Windows/Fonts/arial.ttf",
    ]

    for path in font_paths:
        try:
            return ImageFont.truetype(path, size)
        except OSError:
            continue

    return ImageFont.load_default()


def render_table_to_image(headers: list[str], rows: list[list[str]], alignments: list[str], scale: int = 2) -> Image.Image:
    font_size = 14 * scale
    padding_x = 16 * scale
    padding_y = 12 * scale
    margin = 20 * scale
    border_radius = 8 * scale

    background = (255, 255, 255)
    header_background = (249, 250, 251)
    text_color = (55, 65, 81)
    header_text = (17, 24, 39)
    border_color = (229, 231, 235)

    regular_font = get_font(font_size)
    bold_font = get_font(font_size)

    col_count = len(headers) if headers else (len(rows[0]) if rows else 0)
    col_widths = [0] * col_count

    temp = Image.new("RGB", (1, 1))
    temp_draw = ImageDraw.Draw(temp)

    for idx, header in enumerate(headers):
        bbox = temp_draw.textbbox((0, 0), header, font=bold_font)
        col_widths[idx] = max(col_widths[idx], bbox[2] - bbox[0])

    for row in rows:
        for idx, cell in enumerate(row):
            if idx < col_count:
                bbox = temp_draw.textbbox((0, 0), cell, font=regular_font)
                col_widths[idx] = max(col_widths[idx], bbox[2] - bbox[0])

    col_widths = [width + padding_x * 2 for width in col_widths]
    row_height = font_size + padding_y * 2
    header_height = row_height if headers else 0
    table_width = sum(col_widths)
    table_height = header_height + len(rows) * row_height

    image = Image.new("RGB", (table_width + margin * 2, table_height + margin * 2), background)
    draw = ImageDraw.Draw(image)

    x0, y0 = margin, margin
    x1, y1 = margin + table_width, margin + table_height
    draw.rounded_rectangle([x0, y0, x1, y1], radius=border_radius, outline=border_color, width=scale)

    y = margin
    if headers:
        draw.rounded_rectangle([x0, y0, x1, y0 + row_height], radius=border_radius, fill=header_background)
        draw.rectangle([x0, y0 + row_height - border_radius, x1, y0 + row_height], fill=header_background)
        draw.line([(x0, y + row_height), (x1, y + row_height)], fill=border_color, width=scale)

        x = margin
        for idx, header in enumerate(headers):
            bbox = draw.textbbox((0, 0), header, font=bold_font)
            text_width = bbox[2] - bbox[0]
            alignment = alignments[idx] if idx < len(alignments) else "left"
            if alignment == "center":
                text_x = x + (col_widths[idx] - text_width) // 2
            elif alignment == "right":
                text_x = x + col_widths[idx] - text_width - padding_x
            else:
                text_x = x + padding_x
            draw.text((text_x, y + padding_y), header, fill=header_text, font=bold_font)
            x += col_widths[idx]
        y += row_height

    for row_idx, row in enumerate(rows):
        if row_idx < len(rows) - 1:
            draw.line([(x0, y + row_height), (x1, y + row_height)], fill=border_color, width=scale)

        x = margin
        for idx, cell in enumerate(row):
            if idx >= col_count:
                break
            bbox = draw.textbbox((0, 0), cell, font=regular_font)
            text_width = bbox[2] - bbox[0]
            alignment = alignments[idx] if idx < len(alignments) else "left"
            if alignment == "center":
                text_x = x + (col_widths[idx] - text_width) // 2
            elif alignment == "right":
                text_x = x + col_widths[idx] - text_width - padding_x
            else:
                text_x = x + padding_x
            draw.text((text_x, y + padding_y), cell, fill=text_color, font=regular_font)
            x += col_widths[idx]
        y += row_height

    return image


def main() -> int:
    if len(sys.argv) < 3:
        print("Usage: python3 table_to_image.py <input.md> <output.png> [--scale N]", file=sys.stderr)
        return 1

    input_path = sys.argv[1]
    output_path = sys.argv[2]
    scale = 2
    if "--scale" in sys.argv:
        idx = sys.argv.index("--scale")
        if idx + 1 < len(sys.argv):
            try:
                scale = int(sys.argv[idx + 1])
            except ValueError:
                pass

    content = Path(input_path).read_text(encoding="utf-8")
    headers, rows, alignments = parse_markdown_table(content)
    image = render_table_to_image(headers, rows, alignments, scale=scale)
    image.save(output_path)
    print(f"Table image saved to {output_path}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)