Skill: viv888-ai/redbookskills (version 1.0.0)

--- name: RedBookSkills description: | 将图文/视频内容自动发布到小红书（XHS）。 支持三类任务：发布图文、发布视频、仅启动测试浏览器（不发布）。 metadata: trigger: 发布内容到小红书 source: Angiin/Post-to-xhs --- # Post-to-xhs 你是“小红书发布助手”。目标是在用户确认后，调用本 Skill 的脚本完成发布。 ## 输入判断 优先按以下顺序判断： 1. 用户明确要求"测试浏览器 / 启动浏览器 / 检查登录 / 只打开不发布"：进入测试浏览器流程。 2. 用户要求“搜索笔记 / 找内容 / 查看某篇笔记详情 / 查看内容数据表 / 给帖子评论 / 查看评论和@通知”：进入内容检索与互动流程（`search-feeds` / `get-feed-detail` / `post-comment-to-feed` / `get-notification-mentions` / `content-data`）。 3. 用户已提供 `标题 + 正文 + 视频(本地路径或URL)`：直接进入视频发布流程。 4. 用户已提供 `标题 + 正文 + 图片(本地路径或URL)`：直接进入图文发布流程。 5. 用户只提供网页 URL：先提取网页内容与图片/视频，再给出可发布草稿，等待用户确认。 6. 信息不全：先补齐缺失信息，不要直接发布。 ## 必做约束 - 发布前必须让用户确认最终标题、正文和图片/视频。 - 图文发布时，没有图片不得发布（小红书发图文必须有图片）。 - 视频发布时，没有视频不得发布。图片和视频不可混合使用（二选一）。 - 默认使用无头模式；若检测到未登录，切换有窗口模式登录。 - 标题长度不超过 38（中文/中文标点按 2，英文数字按 1）。 - 用户要求"仅测试浏览器"时，不得触发发布命令。 - 如果使用文件路径，必定使用绝对路径，禁止使用相对路径 ## 测试浏览器流程（不发布） 1. 启动 post-to-xhs 专用 Chrome（默认有窗口模式，便于人工观察）。 2. 如用户要求静默运行，再使用无头模式。 3. 可选：执行登录状态检查并回传结果。 4. 结束后如用户要求，关闭测试浏览器实例。 ## 图文发布流程 1. 准备输入（标题、正文、图片 URL 或本地图片）。 2. 如需文件输入，先写入 `title.txt`、`content.txt`。 3. 执行发布命令（默认无头）。 4. 回传执行结果（成功/失败 + 关键信息）。 ## 视频发布流程 1. 准备输入（标题、正文、视频文件路径或 URL）。 2. 如需文件输入，先写入 `title.txt`、`content.txt`。 3. 执行视频发布命令（默认无头）。视频上传后需等待处理完成。 4. 回传执行结果（成功/失败 + 关键信息）。 ## 内容检索与互动流程（搜索/详情/评论/内容数据） 1. 先检查小红书主页登录状态（`XHS_HOME_URL`，非创作者中心）。 2. 执行 `search-feeds` 获取笔记列表（默认会先抓取搜索下拉推荐词，结果字段为 `recommended_keywords`）。 3. 若用户需要详情，从搜索结果中取 `id` + `xsecToken` 再执行 `get-feed-detail`。 4. 若用户需要发表评论，执行 `post-comment-to-feed`（一级评论；必填 `feed_id` / `xsec_token` / `content`）。 5. 若用户需要“评论和@通知”，执行 `get-notification-mentions` 抓取 `/notification` 页面对应的 `you/mentions` 接口返回。 6. 若用户需要“笔记基础信息表”，执行 `content-data` 获取曝光/观看/点赞等指标。 7. 回传结构化结果（数量、核心字段、链接）。 ## 常用命令 ### 参数顺序提醒（`cdp_publish.py` / `publish_pipeline.py`） 请严格按下面顺序写命令，避免 `unrecognized arguments`： - 全局参数放在子命令前：`--host --port --headless --account --timing-jitter --reuse-existing-tab` - 子命令参数放在子命令后：如 `search-feeds` 的 `--keyword --sort-by --note-type` 示例（正确）： ```bash python scripts/cdp_publish.py --reuse-existing-tab search-feeds --keyword "春招" --sort-by 最新 --note-type 图文 ``` ### 0) 启动 / 测试浏览器（不发布） 默认 CDP 地址为 `127.0.0.1:9222`，可通过 `--host` / `--port` 指定（例如 `10.0.0.12:9222`）。 ```bash # 启动测试浏览器（有窗口，推荐） python scripts/chrome_launcher.py # 可选-指定端口启动（默认端口为 9222） python scripts/chrome_launcher.py --port 9223 # 可选-无头启动测试浏览器 python scripts/chrome_launcher.py --headless # 可选-指定端口 + 无头 python scripts/chrome_launcher.py --headless --port 9223 # 检查当前登录状态 python scripts/cdp_publish.py check-login # 可选：优先复用已有标签页（减少有窗口模式下切到前台） python scripts/cdp_publish.py --reuse-existing-tab check-login # 指定端口检查登录 python scripts/cdp_publish.py --port 9222 check-login # 指定端口 + 优先复用已有标签页 python scripts/cdp_publish.py --port 9222 --reuse-existing-tab check-login # 连接远程 CDP 检查登录（远程 Chrome 需已开启调试端口） python scripts/cdp_publish.py --host 10.0.0.12 --port 9222 check-login # 重启测试浏览器 python scripts/chrome_launcher.py --restart # 指定端口重启 python scripts/chrome_launcher.py --restart --port 9223 # 关闭测试浏览器 python scripts/chrome_launcher.py --kill # 指定端口关闭 python scripts/chrome_launcher.py --kill --port 9223 ``` ### 1) 首次登录 ```bash python scripts/cdp_publish.py login # 指定端口登录 python scripts/cdp_publish.py --port 9223 login # 远程 CDP 登录（不会自动重启远程 Chrome） python scripts/cdp_publish.py --host 10.0.0.12 --port 9222 login ``` ### 2) 无头发布 or 有头发布（推荐有窗口发布） 图片 url ```bash python scripts/publish_pipeline.py --headless \ --title-file title.txt \ --content-file content.txt \ --image-urls "URL1" "URL2" ``` ```bash python scripts/publish_pipeline.py --title-file title.txt \ --preview \ --content-file content.txt \ --image-urls "URL1" "URL2" # 可选：优先复用已有标签页（减少有窗口模式下切到前台） python scripts/publish_pipeline.py --reuse-existing-tab --title-file title.txt \ --content-file content.txt \ --image-urls "URL1" "URL2" # 远程 CDP 发布（远程 Chrome 需预先启动并可访问） python scripts/publish_pipeline.py --host 10.0.0.12 --title-file title.txt \ --content-file content.txt \ --image-urls "URL1" "URL2" ``` 远程模式说明：当 `--host` 不是 `127.0.0.1/localhost` 时，脚本会跳过本地 `chrome_launcher.py` 的自动启动/重启逻辑。 发布模式说明：`publish_pipeline.py` 默认自动点击发布；如需停留在发布页人工确认，请加 `--preview`。 ### 3) 无头发布 or 有头发布 使用本地图片发布 ```bash python scripts/publish_pipeline.py --headless \ --title-file title.txt \ --content-file content.txt \ --images "./images/pic1.jpg" "./images/pic2.jpg" ``` ```bash python scripts/publish_pipeline.py --title-file title.txt \ --content-file content.txt \ --images "./images/pic1.jpg" "./images/pic2.jpg" # WSL/远程 CDP + Windows/UNC 路径：跳过本地文件预校验 python scripts/publish_pipeline.py --headless \ --title-file title.txt \ --content-file content.txt \ --images "\\\\wsl.localhost\\Ubuntu\\home\\user\\pic1.jpg" \ --skip-file-check ``` 说明：当控制端在 WSL 运行，且传入 Windows/UNC 路径（如 `\\wsl.localhost\...`）时，可加 `--skip-file-check`，避免 Linux 侧 `os.path.isfile()` 误判不存在。 ### 3.5) 视频发布（本地视频文件） ```bash python scripts/publish_pipeline.py --headless \ --title-file title.txt \ --content-file content.txt \ --video "C:/videos/my_video.mp4" ``` ```bash python scripts/publish_pipeline.py --title-file title.txt \ --content-file content.txt \ --video "C:/videos/my_video.mp4" ``` ### 3.6) 视频发布（视频 URL） ```bash python scripts/publish_pipeline.py --headless \ --title-file title.txt \ --content-file content.txt \ --video-url "https://example.com/video.mp4" ``` ```bash python scripts/publish_pipeline.py --title-file title.txt \ --content-file content.txt \ --video-url "https://example.com/video.mp4" ``` ### 4) 多账号发布 /切换 ```bash python scripts/cdp_publish.py list-accounts python scripts/cdp_publish.py add-account work --alias "工作号" python scripts/cdp_publish.py --port 9223 --account work login python scripts/publish_pipeline.py --port 9223 --account work --headless --title-file title.txt --content-file content.txt --image-urls "URL1" ``` ### 5) 搜索内容 / 获取笔记详情 ```bash # 搜索笔记 python scripts/cdp_publish.py search-feeds --keyword "春招" # 可选：带筛选搜索 python scripts/cdp_publish.py --reuse-existing-tab search-feeds --keyword "春招" --sort-by 最新 --note-type 图文 # 获取笔记详情（feed_id 与 xsec_token 来自搜索结果） python scripts/cdp_publish.py get-feed-detail \ --feed-id 67abc1234def567890123456 \ --xsec-token XSEC_TOKEN ``` 说明：`search-feeds` 输出中包含 `recommended_keywords_count` 与 `recommended_keywords`，表示回车前搜索框下拉推荐词。 说明：`check-login` 与主页登录检查默认启用本地缓存（12h，仅缓存“已登录”），到期后自动重新网页校验。 ### 6) 给笔记发表评论（一级评论） ```bash # 直接传评论文本 python scripts/cdp_publish.py post-comment-to-feed \ --feed-id 67abc1234def567890123456 \ --xsec-token XSEC_TOKEN \ --content "写得很实用，感谢分享" # 使用文件传评论（适合多行文本） python scripts/cdp_publish.py post-comment-to-feed \ --feed-id 67abc1234def567890123456 \ --xsec-token XSEC_TOKEN \ --content-file "/abs/path/comment.txt" ``` ### 7) 获取内容数据表（content_data） ```bash # 获取笔记基础信息表（曝光/观看/封面点击率/点赞/评论/收藏/涨粉/分享/人均观看时长/弹幕） python scripts/cdp_publish.py content-data # 下划线别名 python scripts/cdp_publish.py content_data # 可选：导出 CSV python scripts/cdp_publish.py --reuse-existing-tab content-data --csv-file "/abs/path/content_data.csv" ``` ### 8) 获取评论和@通知（notification mentions） ```bash # 抓取 /notification 页面触发的 you/mentions 接口数据 python scripts/cdp_publish.py get-notification-mentions # 下划线别名 python scripts/cdp_publish.py get_notification_mentions ``` ## 失败处理 - 登录失败：提示用户重新扫码登录并重试。 - 图片下载失败：提示更换图片 URL 或改用本地图片。 - 页面选择器失效：提示检查 `scripts/cdp_publish.py` 中选择器并更新。

path: AGENTS.md
content: # 仓库指南

## 项目结构与模块组织
本仓库是一个用于小红书自动化的 Python Skill 包。
- `scripts/` 包含全部可执行逻辑：`publish_pipeline.py`（主流程）、`cdp_publish.py`（CDP 自动化与账号命令）、`chrome_launcher.py`（Chrome 生命周期管理）以及辅助模块（`image_downloader.py`、`account_manager.py`、`run_lock.py`）。
- `config/accounts.json.example` 是账号与 Profile 的配置模板。
- `docs/` 存放集成说明（例如 `docs/claude-code-integration.md`）。
- `images/publish_temp/` 是临时素材目录占位。
- 根目录关键文件：`README.md`、`SKILL.md`、`requirements.txt`。

## 文件修改 /工作流
- 当新增或者修改功能之后，注意同步修改 SKILL.md 以及 README.md
- 新增功能时，建议在 plan.md 中先规划好，再实现，然后优化其中的内容


## 构建、测试与开发命令
- `python -m venv .venv && source .venv/bin/activate`：创建并激活本地虚拟环境。
- `pip install -r requirements.txt`：安装运行依赖。
- `python scripts/chrome_launcher.py`：启动启用 CDP 的 Chrome（有界面）。
- `python scripts/cdp_publish.py check-login`：检查当前登录状态。
- `python scripts/publish_pipeline.py --headless --title "T" --content "C" --image-urls "https://example.com/a.jpg"`：执行端到端发布流程。
- `python scripts/chrome_launcher.py --kill`：关闭测试浏览器。

## 代码风格与命名规范
- 遵循 PEP 8，使用 4 空格缩进，并为模块提供清晰 docstring。
- 函数/变量使用 `snake_case`，类使用 `PascalCase`，并显式添加类型注解（如 `list[str]`、`str | None`）。
- CLI 参数优先使用长参数名，`argparse` 说明保持清晰、可读。
- 日志输出沿用 `[module]` 前缀风格，便于排查。
- 考虑到操作随机值，模拟人机交互，避免被系统检测

## 测试指南
当前仓库尚未配置自动化测试套件。提交 PR 前请完成冒烟验证：
- 启动或重启浏览器：`python scripts/chrome_launcher.py --restart`
- 验证登录：`python scripts/cdp_publish.py check-login`
- 先在测试账号执行非破坏流程（不加 `--auto-publish`）。
若新增自动化测试，请放在 `tests/` 目录下，文件命名为 `test_*.py`，并补充运行说明。


## 安全与配置建议
- 禁止提交真实 Cookie、账号令牌或个人 Chrome Profile 路径。
- 本地账号配置请基于 `config/accounts.json.example` 复制为未跟踪的 `config/accounts.json` 后再使用。


path: docs/claude-code-integration.md
content: # Claude Code 集成指南

本项目可作为 Claude Code 的 Skill 使用，让 AI 助手能够自动发布内容到小红书，或仅启动测试浏览器（不发布）。

## 安装

将项目复制到 Claude Code 的 skills 目录：

```bash
cp -r Post-to-xhs ~/.claude/skills/post-to-xhs
```

## 创建 SKILL.md

在 `~/.claude/skills/post-to-xhs/` 目录下创建 `SKILL.md` 文件：

```markdown
# 小红书内容发布

根据用户输入自动判断发布方式，简化发布流程。

## 工作流程

- 用户要求“启动浏览器/检查登录/仅测试不发布” → 进入测试浏览器流程
- 用户提供完整内容 + 图片/图片URL → 直接进入发布流程
- 用户提供网页 URL → WebFetch 提取内容和图片 → 适当总结 → 发布流程

## 测试浏览器命令（不发布）

```bash
# 启动测试浏览器（有窗口，推荐）
python scripts/chrome_launcher.py

# 无头启动测试浏览器
python scripts/chrome_launcher.py --headless

# 检查登录状态
python scripts/cdp_publish.py check-login

# 重启测试浏览器
python scripts/chrome_launcher.py --restart

# 关闭测试浏览器
python scripts/chrome_launcher.py --kill
```

## 发布命令

### 无头模式（推荐）

```bash
python scripts/publish_pipeline.py --headless \
    --title-file title.txt \
    --content-file content.txt \
    --image-urls "URL1" "URL2"
```

### 有窗口模式

```bash
python scripts/publish_pipeline.py \
    --preview \
    --title-file title.txt \
    --content-file content.txt \
    --image-urls "URL1" "URL2"
```

## 标题规则

标题长度 ≤ 38，计算规则：
- 中文字符和中文标点：每个计 2
- 英文字母/数字/空格/ASCII标点：每个计 1

## 注意事项

- 发布前必须让用户确认内容
- 小红书发布必须有图片
- 如需登录，脚本会自动切换到有窗口模式
- 用户仅要求测试浏览器时，不执行发布命令
```

## 使用示例

配置完成后，在 Claude Code 中可以这样使用：

```
用户: 发布这个链接到小红书 https://example.com/article

Claude: [使用 WebFetch 提取内容和图片]
        [总结内容，生成标题]
        [请求用户确认]
        [执行发布脚本]

用户: 帮我先打开小红书测试浏览器，不要发布

Claude: [执行 python scripts/chrome_launcher.py]
        [执行 python scripts/cdp_publish.py check-login]
        [回传浏览器和登录状态]
```

## 账号管理

支持多账号发布：

```bash
# 添加账号
python scripts/cdp_publish.py add-account work --alias "工作账号"

# 登录账号
python scripts/cdp_publish.py --account work login

# 使用指定账号发布
python scripts/publish_pipeline.py --account work --headless ...
```


path: README.md
content: # 小红书发布技能

## 功能说明

此技能用于将图文内容发布到小红书平台。

## 文件结构

- `scripts/` - 包含所有发布相关的脚本
  - `title.txt` - 发布标题
  - `content.txt` - 发布内容
  - `publish_pipeline.py` - 发布主脚本

## 使用方法

1. 准备标题和内容文件
2. 确保已安装Python环境
3. 运行发布脚本

## 注意事项

- 需要配置小红书账号信息
- 需要安装依赖库：`pip install -r requirements.txt`
- 首次使用需要登录小红书账号

## 测试说明

由于环境限制，当前仅提供测试功能。实际使用时需要：
1. 安装必要的依赖库
2. 配置小红书账号信息
3. 在有界面的环境中运行（因为涉及浏览器自动化）

path: requirements.txt
content: requests>=2.28.0
websockets>=12.0


path: scripts/account_manager.py
content: """
Multi-account manager for Xiaohongshu publishing.

Manages multiple Xiaohongshu accounts with separate Chrome profiles:
- Each account has its own user-data-dir for cookie isolation
- Accounts are stored in a JSON config file
- Supports add/remove/list/switch operations

Usage:
    python account_manager.py list
    python account_manager.py add <name> [--alias <alias>]
    python account_manager.py remove <name>
    python account_manager.py info <name>
    python account_manager.py set-default <name>
"""

import json
import os
import sys
import shutil
from typing import Optional

# Config file location
CONFIG_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "config")
ACCOUNTS_FILE = os.path.join(CONFIG_DIR, "accounts.json")

# Base directory for account profiles
PROFILES_BASE = os.path.join(os.environ.get("LOCALAPPDATA", os.path.expanduser("~")),
                              "Google", "Chrome", "XiaohongshuProfiles")

# Default account name (for backward compatibility)
DEFAULT_PROFILE_NAME = "default"


def _ensure_config_dir():
    """Ensure the config directory exists."""
    os.makedirs(CONFIG_DIR, exist_ok=True)


def _load_accounts() -> dict:
    """Load accounts from config file."""
    _ensure_config_dir()
    if os.path.exists(ACCOUNTS_FILE):
        try:
            with open(ACCOUNTS_FILE, "r", encoding="utf-8") as f:
                return json.load(f)
        except (json.JSONDecodeError, IOError):
            pass
    # Default structure
    return {
        "default_account": DEFAULT_PROFILE_NAME,
        "accounts": {
            DEFAULT_PROFILE_NAME: {
                "alias": "默认账号",
                "profile_dir": os.path.join(PROFILES_BASE, DEFAULT_PROFILE_NAME),
                "created_at": None,
            }
        }
    }


def _save_accounts(data: dict):
    """Save accounts to config file."""
    _ensure_config_dir()
    with open(ACCOUNTS_FILE, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)


def get_profile_dir(account_name: Optional[str] = None) -> str:
    """
    Get the Chrome profile directory for a given account.

    Args:
        account_name: Account name. If None, uses the default account.

    Returns:
        Path to the Chrome user-data-dir for this account.
    """
    data = _load_accounts()

    if account_name is None:
        account_name = data.get("default_account", DEFAULT_PROFILE_NAME)

    if account_name not in data["accounts"]:
        # Fallback to default
        account_name = DEFAULT_PROFILE_NAME
        if account_name not in data["accounts"]:
            # Create default account entry
            data["accounts"][account_name] = {
                "alias": "默认账号",
                "profile_dir": os.path.join(PROFILES_BASE, account_name),
                "created_at": None,
            }
            _save_accounts(data)

    return data["accounts"][account_name]["profile_dir"]


def get_default_account() -> str:
    """Get the name of the default account."""
    data = _load_accounts()
    return data.get("default_account", DEFAULT_PROFILE_NAME)


def set_default_account(account_name: str) -> bool:
    """
    Set the default account.

    Returns True if successful, False if account doesn't exist.
    """
    data = _load_accounts()
    if account_name not in data["accounts"]:
        return False
    data["default_account"] = account_name
    _save_accounts(data)
    return True


def list_accounts() -> list[dict]:
    """
    List all registered accounts.

    Returns a list of dicts with account info.
    """
    data = _load_accounts()
    default = data.get("default_account", DEFAULT_PROFILE_NAME)
    result = []
    for name, info in data["accounts"].items():
        result.append({
            "name": name,
            "alias": info.get("alias", ""),
            "profile_dir": info.get("profile_dir", ""),
            "is_default": name == default,
        })
    return result


def add_account(name: str, alias: Optional[str] = None) -> bool:
    """
    Add a new account.

    Args:
        name: Unique account name (used as identifier)
        alias: Display name / description

    Returns True if added, False if name already exists.
    """
    data = _load_accounts()
    if name in data["accounts"]:
        return False

    from datetime import datetime
    profile_dir = os.path.join(PROFILES_BASE, name)
    os.makedirs(profile_dir, exist_ok=True)

    data["accounts"][name] = {
        "alias": alias or name,
        "profile_dir": profile_dir,
        "created_at": datetime.now().isoformat(),
    }
    _save_accounts(data)
    return True


def remove_account(name: str, delete_profile: bool = False) -> bool:
    """
    Remove an account.

    Args:
        name: Account name to remove
        delete_profile: If True, also delete the Chrome profile directory

    Returns True if removed, False if not found or is default.
    """
    data = _load_accounts()
    if name not in data["accounts"]:
        return False

    # Don't allow removing the default account if it's the only one
    if name == data.get("default_account") and len(data["accounts"]) == 1:
        return False

    profile_dir = data["accounts"][name].get("profile_dir", "")
    del data["accounts"][name]

    # If we removed the default, set a new default
    if name == data.get("default_account"):
        data["default_account"] = next(iter(data["accounts"].keys()))

    _save_accounts(data)

    # Optionally delete the profile directory
    if delete_profile and profile_dir and os.path.isdir(profile_dir):
        try:
            shutil.rmtree(profile_dir)
        except Exception:
            pass

    return True


def get_account_info(name: str) -> Optional[dict]:
    """Get info for a specific account."""
    data = _load_accounts()
    if name not in data["accounts"]:
        return None
    info = data["accounts"][name].copy()
    info["name"] = name
    info["is_default"] = name == data.get("default_account")
    return info


def account_exists(name: str) -> bool:
    """Check if an account exists."""
    data = _load_accounts()
    return name in data["accounts"]


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------

def main():
    import argparse

    parser = argparse.ArgumentParser(description="Xiaohongshu Account Manager")
    sub = parser.add_subparsers(dest="command", required=True)

    # list
    sub.add_parser("list", help="List all accounts")

    # add
    p_add = sub.add_parser("add", help="Add a new account")
    p_add.add_argument("name", help="Account name (unique identifier)")
    p_add.add_argument("--alias", help="Display name / description")

    # remove
    p_rm = sub.add_parser("remove", help="Remove an account")
    p_rm.add_argument("name", help="Account name to remove")
    p_rm.add_argument("--delete-profile", action="store_true",
                      help="Also delete the Chrome profile directory")

    # info
    p_info = sub.add_parser("info", help="Show account info")
    p_info.add_argument("name", help="Account name")

    # set-default
    p_def = sub.add_parser("set-default", help="Set the default account")
    p_def.add_argument("name", help="Account name to set as default")

    # get-profile-dir (for internal use)
    p_dir = sub.add_parser("get-profile-dir", help="Get profile directory for an account")
    p_dir.add_argument("--account", help="Account name (default: default account)")

    args = parser.parse_args()

    if args.command == "list":
        accounts = list_accounts()
        if not accounts:
            print("No accounts configured.")
            return
        print(f"{'Name':<20} {'Alias':<20} {'Default':<10}")
        print("-" * 50)
        for acc in accounts:
            default_mark = "*" if acc["is_default"] else ""
            print(f"{acc['name']:<20} {acc['alias']:<20} {default_mark:<10}")

    elif args.command == "add":
        if add_account(args.name, args.alias):
            print(f"Account '{args.name}' added.")
            print(f"Profile dir: {get_profile_dir(args.name)}")
            print("\nTo log in to this account, run:")
            print(f"  python cdp_publish.py --account {args.name} login")
        else:
            print(f"Error: Account '{args.name}' already exists.", file=sys.stderr)
            sys.exit(1)

    elif args.command == "remove":
        if remove_account(args.name, args.delete_profile):
            print(f"Account '{args.name}' removed.")
        else:
            print(f"Error: Cannot remove account '{args.name}'.", file=sys.stderr)
            sys.exit(1)

    elif args.command == "info":
        info = get_account_info(args.name)
        if info:
            print(f"Name: {info['name']}")
            print(f"Alias: {info.get('alias', '')}")
            print(f"Profile dir: {info.get('profile_dir', '')}")
            print(f"Default: {'Yes' if info.get('is_default') else 'No'}")
            print(f"Created: {info.get('created_at', 'Unknown')}")
        else:
            print(f"Error: Account '{args.name}' not found.", file=sys.stderr)
            sys.exit(1)

    elif args.command == "set-default":
        if set_default_account(args.name):
            print(f"Default account set to '{args.name}'.")
        else:
            print(f"Error: Account '{args.name}' not found.", file=sys.stderr)
            sys.exit(1)

    elif args.command == "get-profile-dir":
        print(get_profile_dir(args.account))


if __name__ == "__main__":
    main()


path: scripts/cdp_publish.py
content: """
CDP-based Xiaohongshu publisher.

Connects to a Chrome instance via Chrome DevTools Protocol to automate
publishing articles on Xiaohongshu (RED) creator center.

CLI usage:
    # Basic commands
    python cdp_publish.py [--host HOST] [--port PORT] check-login [--headless] [--account NAME] [--reuse-existing-tab]
    python cdp_publish.py [--host HOST] [--port PORT] fill --title "标题" --content "正文" --images img1.jpg [--headless] [--account NAME] [--reuse-existing-tab]
    python cdp_publish.py [--host HOST] [--port PORT] publish --title "标题" --content "正文" --images img1.jpg [--headless] [--account NAME] [--reuse-existing-tab]
    python cdp_publish.py [--host HOST] [--port PORT] click-publish [--headless] [--account NAME] [--reuse-existing-tab]
    python cdp_publish.py [--host HOST] [--port PORT] search-feeds --keyword "关键词" [--sort-by 综合|最新|最多点赞|最多评论|最多收藏]
    python cdp_publish.py [--host HOST] [--port PORT] get-feed-detail --feed-id FEED_ID --xsec-token TOKEN
    python cdp_publish.py [--host HOST] [--port PORT] post-comment-to-feed --feed-id FEED_ID --xsec-token TOKEN --content "评论内容"
    python cdp_publish.py [--host HOST] [--port PORT] get-notification-mentions [--wait-seconds 18]
    python cdp_publish.py [--host HOST] [--port PORT] content-data [--page-num 1] [--page-size 10] [--type 0]

    # Account management
    python cdp_publish.py [--host HOST] [--port PORT] login [--account NAME]           # open browser for QR login
    python cdp_publish.py [--host HOST] [--port PORT] re-login [--account NAME]        # clear cookies and re-login same account
    python cdp_publish.py [--host HOST] [--port PORT] switch-account [--account NAME]  # clear cookies + open login for new account
    python cdp_publish.py [--host HOST] [--port PORT] list-accounts                    # list all configured accounts
    python cdp_publish.py [--host HOST] [--port PORT] add-account NAME [--alias ALIAS] # add a new account
    python cdp_publish.py [--host HOST] [--port PORT] remove-account NAME              # remove an account

Library usage:
    from cdp_publish import XiaohongshuPublisher

    publisher = XiaohongshuPublisher()
    publisher.connect()
    publisher.check_login()
    publisher.publish(
        title="Article title",
        content="Article body text",
        image_paths=["/path/to/img1.jpg", "/path/to/img2.jpg"],
    )
"""

import json
import os
import random
import time
import sys
import csv
import base64
from datetime import datetime
from zoneinfo import ZoneInfo
from urllib.parse import parse_qs, urlparse
from typing import Any

# Add scripts dir to path so sibling modules can be imported in both
# "python scripts/cdp_publish.py" and "import scripts.cdp_publish" modes.
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
if SCRIPT_DIR not in sys.path:
    sys.path.insert(0, SCRIPT_DIR)

# Ensure UTF-8 output on Windows consoles
if sys.platform == "win32":
    os.environ.setdefault("PYTHONIOENCODING", "utf-8")
    try:
        sys.stdout.reconfigure(encoding="utf-8", errors="replace")
        sys.stderr.reconfigure(encoding="utf-8", errors="replace")
    except Exception:
        pass

import requests
import websockets.sync.client as ws_client
from feed_explorer import (
    SEARCH_BASE_URL,
    LOCATION_OPTIONS,
    NOTE_TYPE_OPTIONS,
    PUBLISH_TIME_OPTIONS,
    SEARCH_SCOPE_OPTIONS,
    SORT_BY_OPTIONS,
    FeedExplorer,
    FeedExplorerError,
    SearchFilters,
    make_feed_detail_url,
    make_search_url,
)
from run_lock import SingleInstanceError, single_instance

# ---------------------------------------------------------------------------
# Configuration - centralised selectors and URLs for easy maintenance
# ---------------------------------------------------------------------------

CDP_HOST = "127.0.0.1"
CDP_PORT = 9222

# Xiaohongshu URLs
XHS_CREATOR_URL = "https://creator.xiaohongshu.com/publish/publish"
XHS_HOME_URL = "https://www.xiaohongshu.com"
XHS_NOTIFICATION_URL = "https://www.xiaohongshu.com/notification"
XHS_CREATOR_LOGIN_CHECK_URL = "https://creator.xiaohongshu.com"
XHS_HOME_LOGIN_MODAL_KEYWORD = "登录后推荐更懂你的笔记"
XHS_CONTENT_DATA_URL = "https://creator.xiaohongshu.com/statistics/data-analysis"
XHS_CONTENT_DATA_API_PATH = "/api/galaxy/creator/datacenter/note/analyze/list"
XHS_NOTIFICATION_MENTIONS_API_PATH = "/api/sns/web/v1/you/mentions"
XHS_SEARCH_RECOMMEND_API_PATH = "/api/sns/web/v1/search/recommend"
XHS_FEED_INACCESSIBLE_KEYWORDS = (
    "当前笔记暂时无法浏览",
    "该内容因违规已被删除",
    "该笔记已被删除",
    "内容不存在",
    "笔记不存在",
    "已失效",
    "私密笔记",
    "仅作者可见",
    "因用户设置，你无法查看",
    "因违规无法查看",
)

# DOM selectors (update these when Xiaohongshu changes their page structure)
# Last verified: 2026-02
SELECTORS = {
    # "上传图文" tab - must click before uploading images
    "image_text_tab": "div.creator-tab",
    "image_text_tab_text": "上传图文",
    # "上传视频" tab - must click before uploading video
    "video_tab": "div.creator-tab",
    "video_tab_text": "上传视频",
    # Upload area - the file input element for images (visible after clicking tab)
    "upload_input": "input.upload-input",
    "upload_input_alt": 'input[type="file"]',
    # Title input field (visible after image upload)
    "title_input": 'input[placeholder*="填写标题"]',
    "title_input_alt": "input.d-text",
    # Content editor area - TipTap/ProseMirror contenteditable div
    "content_editor": "div.tiptap.ProseMirror",
    "content_editor_alt": 'div.ProseMirror[contenteditable="true"]',
    # Publish button
    "publish_button_text": "发布",
    "schedule_publish_button_text": "定时发布",
    # Login indicator - URL-based check (redirect to /login if not logged in)
    "login_indicator": '.user-info, .creator-header, [class*="user"]',
}

# Timing
PAGE_LOAD_WAIT = 3  # seconds to wait after navigation
TAB_CLICK_WAIT = 2  # seconds to wait after clicking tab
UPLOAD_WAIT = 6  # seconds to wait after image upload for editor to appear
VIDEO_PROCESS_TIMEOUT = 120  # seconds to wait for video processing
VIDEO_PROCESS_POLL = 3  # seconds between video processing status checks
ACTION_INTERVAL = 1  # seconds between actions
MAX_TIMING_JITTER_RATIO = 0.7
DEFAULT_LOGIN_CACHE_TTL_HOURS = 12.0
LOGIN_CACHE_FILE = os.path.abspath(
    os.path.join(SCRIPT_DIR, "..", "tmp", "login_status_cache.json")
)


def _normalize_timing_jitter(value: float) -> float:
    """Clamp timing jitter to a safe range."""
    return max(0.0, min(MAX_TIMING_JITTER_RATIO, value))


def _is_local_host(host: str) -> bool:
    """Return True when host points to the local machine."""
    return host.strip().lower() in {"127.0.0.1", "localhost", "::1"}


def _resolve_account_name(account_name: str | None) -> str:
    """Resolve explicit or default account name for cache scoping."""
    if account_name and account_name.strip():
        return account_name.strip()
    try:
        from account_manager import get_default_account
        resolved = get_default_account()
        if isinstance(resolved, str) and resolved.strip():
            return resolved.strip()
    except Exception:
        pass
    return "default"


def _build_search_filters_from_args(args) -> SearchFilters | None:
    """Build search filter object from parsed CLI arguments."""
    filters = SearchFilters(
        sort_by=getattr(args, "sort_by", None),
        note_type=getattr(args, "note_type", None),
        publish_time=getattr(args, "publish_time", None),
        search_scope=getattr(args, "search_scope", None),
        location=getattr(args, "location", None),
    )
    return filters if filters.selected_items() else None


def _format_post_time(post_time_ms: Any) -> str:
    """Format note publish time in Asia/Shanghai timezone."""
    if not isinstance(post_time_ms, (int, float)):
        return "-"
    try:
        dt = datetime.fromtimestamp(post_time_ms / 1000, tz=ZoneInfo("Asia/Shanghai"))
        return dt.strftime("%Y-%m-%d %H:%M")
    except Exception:
        return "-"

def validate_schedule_post_time(dt_str: str | None) -> bool:
    """
    Validate a datetime string in the format 'yyyy-MM-dd HH:mm'.
    
    Rules:
    1. If input is None or empty, return False.
    2. The datetime format must strictly match '%Y-%m-%d %H:%M'.
    3. The datetime must fall within the range:
       [ current_time , current_time + 14 days ).
       
    Returns:
        bool: True if valid, False otherwise.
    """
    if not dt_str:
        return False
    
    try:
        dt = datetime.strptime(dt_str, "%Y-%m-%d %H:%M")
    except ValueError:
        return False

    now = datetime.now().replace(second=0, microsecond=0)
    upper_bound = now + timedelta(days=14)
    return now <= dt < upper_bound

def _format_cover_click_rate(value: Any) -> str:
    """Format cover click rate as percentage text."""
    if not isinstance(value, (int, float)):
        return "-"
    normalized = value * 100 if 0 <= value <= 1 else value
    return f"{normalized:.2f}%"


def _format_view_time_avg(value: Any) -> str:
    """Format average view duration in seconds."""
    if not isinstance(value, (int, float)):
        return "-"
    return f"{int(value)}s"


def _metric_or_dash(note: dict[str, Any], field: str) -> Any:
    """Return field value if present, otherwise '-'."""
    value = note.get(field)
    return "-" if value is None else value


def _map_note_infos_to_content_rows(note_infos: list[dict[str, Any]]) -> list[dict[str, Any]]:
    """Map note_infos payload to content table rows."""
    rows: list[dict[str, Any]] = []
    for note in note_infos:
        rows.append({
            "标题": note.get("title") or "-",
            "发布时间": _format_post_time(note.get("post_time")),
            "曝光": _metric_or_dash(note, "imp_count"),
            "观看": _metric_or_dash(note, "read_count"),
            "封面点击率": _format_cover_click_rate(note.get("coverClickRate")),
            "点赞": _metric_or_dash(note, "like_count"),
            "评论": _metric_or_dash(note, "comment_count"),
            "收藏": _metric_or_dash(note, "fav_count"),
            "涨粉": _metric_or_dash(note, "increase_fans_count"),
            "分享": _metric_or_dash(note, "share_count"),
            "人均观看时长": _format_view_time_avg(note.get("view_time_avg")),
            "弹幕": _metric_or_dash(note, "danmaku_count"),
            "操作": "详情数据",
            "_id": note.get("id") or "",
        })
    return rows


def _write_content_data_csv(csv_file: str, rows: list[dict[str, Any]]) -> str:
    """Write content rows to a UTF-8 CSV file and return absolute path."""
    abs_path = os.path.abspath(csv_file)
    parent = os.path.dirname(abs_path)
    if parent:
        os.makedirs(parent, exist_ok=True)

    columns = [
        "标题",
        "发布时间",
        "曝光",
        "观看",
        "封面点击率",
        "点赞",
        "评论",
        "收藏",
        "涨粉",
        "分享",
        "人均观看时长",
        "弹幕",
        "操作",
        "_id",
    ]
    with open(abs_path, "w", encoding="utf-8-sig", newline="") as csv_handle:
        writer = csv.DictWriter(csv_handle, fieldnames=columns, extrasaction="ignore")
        writer.writeheader()
        for row in rows:
            writer.writerow(row)

    return abs_path


class CDPError(Exception):
    """Error communicating with Chrome via CDP."""


class XiaohongshuPublisher:
    """Automates publishing to Xiaohongshu via CDP."""

    def __init__(
        self,
        host: str = CDP_HOST,
        port: int = CDP_PORT,
        timing_jitter: float = 0.25,
        account_name: str | None = None,
    ):
        self.host = host
        self.port = port
        self.ws = None
        self._msg_id = 0
        self.timing_jitter = _normalize_timing_jitter(timing_jitter)
        self.account_name = (account_name or "default").strip() or "default"
        self.login_cache_ttl_hours = DEFAULT_LOGIN_CACHE_TTL_HOURS
        self.login_cache_ttl_seconds = self.login_cache_ttl_hours * 3600
        self.login_cache_file = LOGIN_CACHE_FILE

    def _login_cache_key(self, scope: str) -> str:
        """Build a unique cache key for one login scope."""
        return f"{self.host}:{self.port}:{self.account_name}:{scope}"

    def _load_login_cache(self) -> dict[str, Any]:
        """Load login cache payload from local JSON file."""
        if not os.path.exists(self.login_cache_file):
            return {"entries": {}}

        try:
            with open(self.login_cache_file, "r", encoding="utf-8") as cache_file:
                payload = json.load(cache_file)
        except Exception:
            return {"entries": {}}

        if not isinstance(payload, dict):
            return {"entries": {}}
        entries = payload.get("entries")
        if not isinstance(entries, dict):
            payload["entries"] = {}
        return payload

    def _save_login_cache(self, payload: dict[str, Any]):
        """Persist login cache payload to local JSON file."""
        parent = os.path.dirname(self.login_cache_file)
        if parent:
            os.makedirs(parent, exist_ok=True)
        with open(self.login_cache_file, "w", encoding="utf-8") as cache_file:
            json.dump(payload, cache_file, ensure_ascii=False, indent=2)

    def _get_cached_login_status(self, scope: str) -> bool | None:
        """Return cached login status when cache is still fresh."""
        if self.login_cache_ttl_seconds <= 0:
            return None

        payload = self._load_login_cache()
        entries = payload.get("entries", {})
        entry = entries.get(self._login_cache_key(scope))
        if not isinstance(entry, dict):
            return None

        checked_at = entry.get("checked_at")
        logged_in = entry.get("logged_in")
        if not isinstance(checked_at, (int, float)) or not isinstance(logged_in, bool):
            return None

        age_seconds = time.time() - float(checked_at)
        if age_seconds < 0 or age_seconds > self.login_cache_ttl_seconds:
            return None

        if not logged_in:
            return None

        age_minutes = int(age_seconds // 60)
        print(
            "[cdp_publish] Using cached login status "
            f"({scope}, age={age_minutes}m, ttl={self.login_cache_ttl_hours:g}h)."
        )
        return logged_in

    def _set_login_cache(self, scope: str, logged_in: bool):
        """Save positive login status cache for a specific scope."""
        if not logged_in:
            self._clear_login_cache(scope=scope)
            return

        payload = self._load_login_cache()
        entries = payload.setdefault("entries", {})
        entries[self._login_cache_key(scope)] = {
            "logged_in": True,
            "checked_at": int(time.time()),
        }
        self._save_login_cache(payload)

    def _clear_login_cache(self, scope: str | None = None):
        """Clear login cache entries for current host/port/account."""
        payload = self._load_login_cache()
        entries = payload.get("entries", {})
        if not isinstance(entries, dict) or not entries:
            return

        changed = False
        if scope:
            key = self._login_cache_key(scope)
            if key in entries:
                entries.pop(key, None)
                changed = True
        else:
            prefix = self._login_cache_key("")
            for key in list(entries.keys()):
                if key.startswith(prefix):
                    entries.pop(key, None)
                    changed = True

        if changed:
            payload["entries"] = entries
            self._save_login_cache(payload)

    def _sleep(self, base_seconds: float, minimum_seconds: float = 0.05):
        """Sleep with optional randomized jitter to avoid rigid timing patterns."""
        base = max(minimum_seconds, float(base_seconds))
        if self.timing_jitter <= 0:
            time.sleep(base)
            return

        delta = base * self.timing_jitter
        low = max(minimum_seconds, base - delta)
        high = max(low, base + delta)
        time.sleep(random.uniform(low, high))

    # ------------------------------------------------------------------
    # CDP connection management
    # ------------------------------------------------------------------

    def _get_targets(self) -> list[dict]:
        """Get list of available browser targets (tabs). Retries once on failure."""
        url = f"http://{self.host}:{self.port}/json"
        for attempt in range(2):
            try:
                resp = requests.get(url, timeout=5)
                resp.raise_for_status()
                return resp.json()
            except Exception as e:
                if attempt == 0:
                    if _is_local_host(self.host):
                        print(f"[cdp_publish] CDP connection failed ({e}), restarting Chrome...")
                        from chrome_launcher import ensure_chrome
                        ensure_chrome(port=self.port)
                    else:
                        print(
                            f"[cdp_publish] CDP connection failed ({e}), retrying remote endpoint "
                            f"{self.host}:{self.port}..."
                        )
                    self._sleep(2, minimum_seconds=1.0)
                else:
                    raise CDPError(f"Cannot reach Chrome on {self.host}:{self.port}: {e}")

    def _find_or_create_tab(
        self,
        target_url_prefix: str = "",
        reuse_existing_tab: bool = False,
    ) -> str:
        """
        Find a tab to connect.

        Default behavior is backward-compatible: create a new tab first.
        When `reuse_existing_tab` is enabled, prefer reusing an existing page tab
        to reduce focus switching in headed mode.
        """
        targets = self._get_targets()
        pages = [
            t for t in targets
            if t.get("type") == "page" and t.get("webSocketDebuggerUrl")
        ]

        if target_url_prefix:
            for t in pages:
                if t.get("url", "").startswith(target_url_prefix):
                    return t["webSocketDebuggerUrl"]

        if reuse_existing_tab and pages:
            url = pages[0].get("url", "")
            print(
                "[cdp_publish] Reusing existing tab to reduce focus switching: "
                f"{url}"
            )
            return pages[0]["webSocketDebuggerUrl"]

        # Create a new tab
        resp = requests.put(
            f"http://{self.host}:{self.port}/json/new?{XHS_CREATOR_URL}",
            timeout=5,
        )
        if resp.ok:
            ws_url = resp.json().get("webSocketDebuggerUrl", "")
            if ws_url:
                return ws_url

        # Fallback: use first available page
        if pages:
            return pages[0]["webSocketDebuggerUrl"]

        raise CDPError("No browser tabs available.")

    def connect(self, target_url_prefix: str = "", reuse_existing_tab: bool = False):
        """Connect to a Chrome tab via WebSocket."""
        ws_url = self._find_or_create_tab(
            target_url_prefix=target_url_prefix,
            reuse_existing_tab=reuse_existing_tab,
        )
        if not ws_url:
            raise CDPError("Could not obtain WebSocket URL for any tab.")

        print(f"[cdp_publish] Connecting to {ws_url}")
        self.ws = ws_client.connect(ws_url)
        print("[cdp_publish] Connected to Chrome tab.")

    def disconnect(self):
        """Close the WebSocket connection."""
        if self.ws:
            self.ws.close()
            self.ws = None

    # ------------------------------------------------------------------
    # CDP command helpers
    # ------------------------------------------------------------------

    def _send(self, method: str, params: dict | None = None) -> dict:
        """Send a CDP command and return the result."""
        if not self.ws:
            raise CDPError("Not connected. Call connect() first.")

        self._msg_id += 1
        msg = {"id": self._msg_id, "method": method}
        if params:
            msg["params"] = params

        self.ws.send(json.dumps(msg))

        # Wait for the matching response
        while True:
            raw = self.ws.recv()
            data = json.loads(raw)
            if data.get("id") == self._msg_id:
                if "error" in data:
                    raise CDPError(f"CDP error: {data['error']}")
                return data.get("result", {})
            # else: it's an event, skip it

    def _evaluate(self, expression: str) -> Any:
        """Execute JavaScript in the page and return the result value."""
        result = self._send("Runtime.evaluate", {
            "expression": expression,
            "returnByValue": True,
            "awaitPromise": True,
        })
        remote_obj = result.get("result", {})
        if remote_obj.get("subtype") == "error":
            raise CDPError(f"JS error: {remote_obj.get('description', remote_obj)}")
        return remote_obj.get("value")

    def _navigate(self, url: str):
        """Navigate the current tab to the given URL and wait for load."""
        print(f"[cdp_publish] Navigating to {url}")
        self._send("Page.enable")
        self._send("Page.navigate", {"url": url})
        self._sleep(PAGE_LOAD_WAIT, minimum_seconds=1.0)

    # ------------------------------------------------------------------
    # Login check
    # ------------------------------------------------------------------

    def check_login(self) -> bool:
        """
        Navigate to Xiaohongshu creator center and check if the user is logged in.

        Returns True if logged in. If not logged in, prints instructions
        and returns False.
        """
        scope = "creator"
        cached_status = self._get_cached_login_status(scope)
        if cached_status is not None:
            if cached_status:
                print("[cdp_publish] Login confirmed (cached).")
            return cached_status

        self._navigate(XHS_CREATOR_LOGIN_CHECK_URL)
        self._sleep(2, minimum_seconds=1.0)

        # Check if we got redirected to a login page
        current_url = self._evaluate("window.location.href")
        print(f"[cdp_publish] Current URL: {current_url}")

        if "login" in current_url.lower():
            self._set_login_cache(scope, logged_in=False)
            print(
                "\n[cdp_publish] NOT LOGGED IN.\n"
                "  Please scan the QR code in the Chrome window to log in,\n"
                "  then run this script again.\n"
            )
            return False

        self._set_login_cache(scope, logged_in=True)
        print("[cdp_publish] Login confirmed.")
        return True

    def _home_login_prompt_visible(self, keyword: str) -> bool:
        """Return True when home page login prompt modal is visible."""
        keyword_literal = json.dumps(keyword)
        visible = self._evaluate(f"""
            (() => {{
                const keyword = {keyword_literal};
                const normalize = (text) => (text || "").replace(/\\s+/g, " ").trim();
                const containsKeyword = (text) => normalize(text).includes(keyword);

                const modalSelectors = [
                    "[class*='login']",
                    "[class*='modal']",
                    "[class*='popup']",
                    "[class*='dialog']",
                    "[class*='mask']",
                ];

                for (const selector of modalSelectors) {{
                    const nodes = document.querySelectorAll(selector);
                    for (const node of nodes) {{
                        if (!(node instanceof HTMLElement)) {{
                            continue;
                        }}
                        if (node.offsetParent === null) {{
                            continue;
                        }}
                        if (containsKeyword(node.textContent) || containsKeyword(node.innerText)) {{
                            return true;
                        }}
                    }}
                }}

                if (document.body && containsKeyword(document.body.innerText)) {{
                    return true;
                }}
                return false;
            }})()
        """)
        return bool(visible)

    def check_home_login(
        self,
        keyword: str = XHS_HOME_LOGIN_MODAL_KEYWORD,
        wait_seconds: float = 8.0,
    ) -> bool:
        """
        Check login state on Xiaohongshu home page.

        Login prompt modal keyword (default: "登录后推荐更懂你的笔记") indicates
        unauthenticated state for the xiaohongshu.com home/feed domain.
        """
        scope = "home"
        cached_status = self._get_cached_login_status(scope)
        if cached_status is not None:
            if cached_status:
                print("[cdp_publish] Home login confirmed (cached).")
            return cached_status

        self._navigate(XHS_HOME_URL)
        self._sleep(2, minimum_seconds=1.0)

        current_url = self._evaluate("window.location.href")
        print(f"[cdp_publish] Home URL: {current_url}")
        if isinstance(current_url, str) and "login" in current_url.lower():
            self._set_login_cache(scope, logged_in=False)
            print(
                "\n[cdp_publish] NOT LOGGED IN (HOME).\n"
                "  Please log in on xiaohongshu.com and run this command again.\n"
            )
            return False

        deadline = time.time() + max(1.0, wait_seconds)
        while time.time() < deadline:
            if self._home_login_prompt_visible(keyword):
                self._set_login_cache(scope, logged_in=False)
                print(
                    "\n[cdp_publish] NOT LOGGED IN (HOME).\n"
                    f"  Detected login prompt keyword: {keyword}\n"
                    "  Please log in on xiaohongshu.com and run this command again.\n"
                )
                return False
            self._sleep(0.7, minimum_seconds=0.2)

        self._set_login_cache(scope, logged_in=True)
        print("[cdp_publish] Home login confirmed.")
        return True

    def clear_cookies(self, domain: str = ".xiaohongshu.com"):
        """
        Clear all cookies for the given domain to force re-login.

        Used when switching accounts.
        """
        print(f"[cdp_publish] Clearing cookies for {domain}...")
        self._send("Network.enable")
        self._send("Network.clearBrowserCookies")
        # Also clear storage
        self._send("Storage.clearDataForOrigin", {
            "origin": "https://www.xiaohongshu.com",
            "storageTypes": "cookies,local_storage,session_storage",
        })
        self._send("Storage.clearDataForOrigin", {
            "origin": "https://creator.xiaohongshu.com",
            "storageTypes": "cookies,local_storage,session_storage",
        })
        self._clear_login_cache()
        print("[cdp_publish] Cookies and storage cleared.")

    def open_login_page(self):
        """
        Navigate to the Xiaohongshu login page for QR code scanning.

        Used for initial login or after clearing cookies for account switch.
        """
        self._navigate(XHS_CREATOR_LOGIN_CHECK_URL)
        self._sleep(2, minimum_seconds=1.0)
        current_url = self._evaluate("window.location.href")
        if "login" not in current_url.lower():
            # Already logged in, navigate to login page explicitly
            self._navigate("https://creator.xiaohongshu.com/login")
            self._sleep(2, minimum_seconds=1.0)
        self._clear_login_cache()
        print(
            "\n[cdp_publish] Login page is open.\n"
            "  Please scan the QR code in the Chrome window to log in.\n"
        )

    # ------------------------------------------------------------------
    # Feed discovery actions
    # ------------------------------------------------------------------

    def _prepare_search_input_keyword(self, keyword: str) -> dict[str, Any]:
        """Focus search input and type keyword without submitting."""
        keyword_literal = json.dumps(keyword, ensure_ascii=False)
        result = self._evaluate(f"""
            (async () => {{
                const keyword = {keyword_literal};
                const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

                const isVisible = (node) => {{
                    if (!(node instanceof HTMLElement)) {{
                        return false;
                    }}
                    if (node.offsetParent === null) {{
                        return false;
                    }}
                    const rect = node.getBoundingClientRect();
                    return rect.width >= 8 && rect.height >= 8;
                }};

                const selectors = [
                    "#search-input",
                    "input.search-input",
                    "input[type='search']",
                    "input[placeholder*='搜索']",
                    "[class*='search'] input",
                ];

                let inputEl = null;
                for (const selector of selectors) {{
                    const nodes = document.querySelectorAll(selector);
                    for (const node of nodes) {{
                        if (!(node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement)) {{
                            continue;
                        }}
                        if (node.disabled || !isVisible(node)) {{
                            continue;
                        }}
                        inputEl = node;
                        break;
                    }}
                    if (inputEl) {{
                        break;
                    }}
                }}

                if (!inputEl) {{
                    return {{ ok: false, reason: "search_input_not_found" }};
                }}

                const setValue = (value) => {{
                    const proto = inputEl instanceof HTMLTextAreaElement
                        ? HTMLTextAreaElement.prototype
                        : HTMLInputElement.prototype;
                    const descriptor = Object.getOwnPropertyDescriptor(proto, "value");
                    if (descriptor && typeof descriptor.set === "function") {{
                        descriptor.set.call(inputEl, value);
                    }} else {{
                        inputEl.value = value;
                    }}
                    inputEl.dispatchEvent(new Event("input", {{ bubbles: true }}));
                    inputEl.dispatchEvent(new Event("change", {{ bubbles: true }}));
                }};

                inputEl.focus();
                await sleep(120);
                setValue("");
                await sleep(80);

                let typed = "";
                for (const ch of Array.from(keyword)) {{
                    typed += ch;
                    setValue(typed);
                    await sleep(55 + Math.floor(Math.random() * 70));
                }}
                await sleep(220);
                return {{ ok: true, reason: "" }};
            }})()
        """)
        if not isinstance(result, dict):
            return {"ok": False, "reason": "unexpected_result"}
        reason = result.get("reason")
        return {
            "ok": bool(result.get("ok")),
            "reason": reason if isinstance(reason, str) else "unknown",
        }

    def _extract_recommend_keywords_from_payload(
        self,
        payload: dict[str, Any],
        keyword: str,
        max_suggestions: int,
    ) -> list[str]:
        """Extract recommendation keywords from search recommend API payload."""
        ignored_texts = {
            "历史记录",
            "猜你想搜",
            "相关搜索",
            "热门搜索",
            "大家都在搜",
            "清空历史",
            "删除历史",
        }

        def normalize_text(value: str) -> str:
            return " ".join(value.split()).strip()

        def push_text(output: list[str], seen: set[str], value: str):
            normalized = normalize_text(value)
            if not normalized or normalized == keyword:
                return
            if normalized in ignored_texts:
                return
            if len(normalized) < 2 or len(normalized) > 36:
                return
            if normalized in seen:
                return
            seen.add(normalized)
            output.append(normalized)

        ordered: list[str] = []
        seen: set[str] = set()
        stack: list[Any] = [payload]
        while stack:
            node = stack.pop()
            if isinstance(node, dict):
                for key, value in node.items():
                    if isinstance(value, str):
                        key_lc = key.lower()
                        if any(
                            hint in key_lc
                            for hint in (
                                "word",
                                "query",
                                "keyword",
                                "text",
                                "title",
                                "name",
                                "suggest",
                            )
                        ):
                            push_text(ordered, seen, value)
                        continue
                    if isinstance(value, (dict, list)):
                        stack.append(value)
            elif isinstance(node, list):
                for item in node:
                    if isinstance(item, str):
                        push_text(ordered, seen, item)
                        continue
                    if isinstance(item, (dict, list)):
                        stack.append(item)

        keyword_prefix = keyword[:2]
        ranked: list[tuple[int, int, str]] = []
        for idx, text in enumerate(ordered):
            score = 0
            if keyword and (keyword in text or text in keyword):
                score += 3
            elif keyword_prefix and keyword_prefix in text:
                score += 1
            ranked.append((score, idx, text))
        ranked.sort(key=lambda item: (-item[0], item[1]))
        return [item[2] for item in ranked[: max(1, max_suggestions)]]

    def _capture_search_recommendations_via_network(
        self,
        keyword: str,
        wait_seconds: float = 8.0,
        max_suggestions: int = 12,
    ) -> dict[str, Any]:
        """Capture recommend API response from real page traffic."""
        if not self.ws:
            raise CDPError("Not connected. Call connect() first.")

        self._send("Network.enable", {"maxPostDataSize": 65536})
        self._send("Network.setCacheDisabled", {"cacheDisabled": True})

        typed = self._prepare_search_input_keyword(keyword)
        if not typed.get("ok"):
            reason = typed.get("reason") or "type_keyword_failed"
            return {"ok": False, "reason": str(reason), "suggestions": []}

        deadline = time.time() + max(2.0, float(wait_seconds))
        request_meta_by_id: dict[str, dict[str, str]] = {}
        exact_match: tuple[str, str] | None = None
        fallback_match: tuple[str, str] | None = None

        while time.time() < deadline:
            timeout = min(1.0, max(0.1, deadline - time.time()))
            try:
                raw = self.ws.recv(timeout=timeout)
            except TimeoutError:
                continue

            message = json.loads(raw)
            method = message.get("method")
            params = message.get("params", {})

            if method == "Network.requestWillBeSent":
                request_id = params.get("requestId")
                request = params.get("request", {})
                if isinstance(request_id, str):
                    request_meta_by_id[request_id] = {
                        "url": request.get("url", ""),
                        "method": str(request.get("method", "")).upper(),
                    }
                continue

            if method != "Network.responseReceived":
                continue

            request_id = params.get("requestId")
            if not isinstance(request_id, str):
                continue

            request_meta = request_meta_by_id.get(request_id, {})
            request_url = request_meta.get("url", "")
            if XHS_SEARCH_RECOMMEND_API_PATH not in request_url:
                continue
            if request_meta.get("method") == "OPTIONS":
                continue

            status = int(params.get("response", {}).get("status") or 0)
            if status != 200:
                continue

            fallback_match = (request_id, request_url)
            try:
                query = parse_qs(urlparse(request_url).query)
                request_keyword = (query.get("keyword") or [""])[0].strip()
            except Exception:
                request_keyword = ""

            if request_keyword == keyword:
                exact_match = (request_id, request_url)
                break

        target = exact_match or fallback_match
        if not target:
            return {"ok": False, "reason": "recommend_request_timeout", "suggestions": []}

        request_id, request_url = target
        body_result = self._send("Network.getResponseBody", {"requestId": request_id})
        body_text = body_result.get("body", "")
        if body_result.get("base64Encoded"):
            body_text = base64.b64decode(body_text).decode("utf-8", errors="replace")

        try:
            payload = json.loads(body_text)
        except json.JSONDecodeError:
            return {"ok": False, "reason": "recommend_invalid_json", "suggestions": []}
        if not isinstance(payload, dict):
            return {"ok": False, "reason": "recommend_invalid_payload", "suggestions": []}

        suggestions = self._extract_recommend_keywords_from_payload(
            payload=payload,
            keyword=keyword,
            max_suggestions=max_suggestions,
        )
        return {
            "ok": True,
            "reason": "",
            "request_url": request_url,
            "suggestions": suggestions,
        }

    def search_feeds(
        self,
        keyword: str,
        filters: SearchFilters | None = None,
    ) -> dict[str, Any]:
        """
        Search Xiaohongshu feeds by keyword and optional filters.

        Returns:
            {
                "keyword": str,
                "recommended_keywords": list[str],  # dropdown related terms
                "feeds": list[dict[str, Any]],      # extracted from __INITIAL_STATE__
            }
        """
        if not self.ws:
            raise CDPError("Not connected. Call connect() first.")

        keyword = keyword.strip()
        if not keyword:
            raise CDPError("Keyword cannot be empty.")

        self._navigate(SEARCH_BASE_URL)
        self._sleep(2, minimum_seconds=1.0)

        explorer = FeedExplorer(
            self._evaluate,
            self._sleep,
            move_mouse=self._move_mouse,
            click_mouse=self._click_mouse,
        )

        recommendation_result = self._capture_search_recommendations_via_network(keyword=keyword)
        recommended_keywords = recommendation_result.get("suggestions", [])

        if not recommendation_result.get("ok"):
            reason = recommendation_result.get("reason") or "recommend_api_failed"
            print(
                "[cdp_publish] Warning: failed to capture search recommendations via API. "
                f"reason={reason}"
            )

        # Always navigate with keyword URL to keep feed extraction stable.
        search_url = make_search_url(keyword)
        self._navigate(search_url)
        self._sleep(2, minimum_seconds=1.0)

        try:
            feeds = explorer.search_feeds(keyword=keyword, filters=filters)
        except FeedExplorerError as e:
            raise CDPError(str(e)) from e

        print(
            f"[cdp_publish] Search completed. keyword={keyword}, "
            f"recommended_keywords={len(recommended_keywords)}, feeds={len(feeds)}"
        )
        return {
            "keyword": keyword,
            "recommended_keywords": recommended_keywords,
            "feeds": feeds,
        }

    def get_feed_detail(self, feed_id: str, xsec_token: str) -> dict[str, Any]:
        """
        Get feed detail from note page initial state.

        Returns a detail object containing `note` and `comments` (if available).
        """
        if not self.ws:
            raise CDPError("Not connected. Call connect() first.")

        feed_id = feed_id.strip()
        xsec_token = xsec_token.strip()
        if not feed_id:
            raise CDPError("feed_id cannot be empty.")
        if not xsec_token:
            raise CDPError("xsec_token cannot be empty.")

        detail_url = make_feed_detail_url(feed_id, xsec_token)
        self._navigate(detail_url)
        self._sleep(2, minimum_seconds=1.0)

        explorer = FeedExplorer(self._evaluate, self._sleep)
        try:
            detail = explorer.get_feed_detail(feed_id=feed_id)
        except FeedExplorerError as e:
            raise CDPError(str(e)) from e

        print(f"[cdp_publish] Feed detail loaded. feed_id={feed_id}")
        return detail

    def _check_feed_page_accessible(self):
        """
        Check whether the currently opened feed detail page is accessible.

        Raises:
            CDPError: If page is inaccessible due to privacy/deletion/violation.
        """
        keyword_list_literal = json.dumps(
            list(XHS_FEED_INACCESSIBLE_KEYWORDS),
            ensure_ascii=False,
        )
        issue = self._evaluate(f"""
            (() => {{
                const wrappers = document.querySelectorAll(
                    ".access-wrapper, .error-wrapper, .not-found-wrapper, .blocked-wrapper"
                );
                if (!wrappers.length) {{
                    return "";
                }}

                let text = "";
                for (const el of wrappers) {{
                    const chunk = (el.innerText || el.textContent || "").trim();
                    if (chunk) {{
                        text += (text ? " " : "") + chunk;
                    }}
                }}
                const fullText = text.trim();
                if (!fullText) {{
                    return "";
                }}

                const keywords = {keyword_list_literal};
                for (const kw of keywords) {{
                    if (fullText.includes(kw)) {{
                        return kw;
                    }}
                }}
                return fullText.slice(0, 180);
            }})()
        """)
        if isinstance(issue, str) and issue.strip():
            raise CDPError(f"Feed page is not accessible: {issue.strip()}")

    def _fill_comment_content(self, content: str) -> int:
        """
        Fill comment content into feed detail page input.

        Returns:
            Filled character length.
        """
        content_literal = json.dumps(content, ensure_ascii=False)
        result = self._evaluate(f"""
            (() => {{
                const commentText = {content_literal};
                const candidates = [
                    "div.input-box div.content-edit p.content-input",
                    "div.input-box div.content-edit [contenteditable='true']",
                    "div.input-box .content-input",
                    "p.content-input",
                    "[class*='content-edit'] [contenteditable='true']",
                ];

                let inputEl = null;
                for (const selector of candidates) {{
                    const node = document.querySelector(selector);
                    if (!(node instanceof HTMLElement)) {{
                        continue;
                    }}
                    if (node.offsetParent === null) {{
                        continue;
                    }}
                    inputEl = node;
                    break;
                }}

                if (!inputEl) {{
                    return {{ ok: false, reason: "comment_input_not_found" }};
                }}

                inputEl.focus();

                if (inputEl instanceof HTMLInputElement || inputEl instanceof HTMLTextAreaElement) {{
                    inputEl.value = commentText;
                    inputEl.dispatchEvent(new Event("input", {{ bubbles: true }}));
                    inputEl.dispatchEvent(new Event("change", {{ bubbles: true }}));
                    return {{
                        ok: true,
                        length: inputEl.value.trim().length,
                    }};
                }}

                const asEditable = inputEl;
                if (!asEditable.isContentEditable && asEditable.tagName.toLowerCase() !== "p") {{
                    const nested = asEditable.querySelector("[contenteditable='true'], p.content-input");
                    if (nested instanceof HTMLElement) {{
                        nested.focus();
                        inputEl = nested;
                    }}
                }}

                if (inputEl.tagName.toLowerCase() === "p") {{
                    inputEl.textContent = commentText;
                }} else {{
                    const lines = commentText.split("\\n");
                    const escapeHtml = (text) => text
                        .replaceAll("&", "&amp;")
                        .replaceAll("<", "&lt;")
                        .replaceAll(">", "&gt;");
                    const html = lines.map((line) => {{
                        if (!line.trim()) {{
                            return "<p><br></p>";
                        }}
                        return "<p>" + escapeHtml(line) + "</p>";
                    }}).join("");
                    inputEl.innerHTML = html || "<p><br></p>";
                }}

                inputEl.dispatchEvent(new Event("input", {{ bubbles: true }}));
                inputEl.dispatchEvent(new Event("change", {{ bubbles: true }}));

                const finalText = (
                    inputEl.innerText ||
                    inputEl.textContent ||
                    ""
                ).trim();
                return {{
                    ok: true,
                    length: finalText.length,
                }};
            }})()
        """)
        if not isinstance(result, dict) or not result.get("ok"):
            reason = "unknown"
            if isinstance(result, dict):
                reason = str(result.get("reason", reason))
            raise CDPError(f"Failed to fill comment content: {reason}")

        return int(result.get("length", 0))

    def post_comment_to_feed(self, feed_id: str, xsec_token: str, content: str) -> dict[str, Any]:
        """
        Post a top-level comment to a feed detail page.
        """
        if not self.ws:
            raise CDPError("Not connected. Call connect() first.")

        feed_id = feed_id.strip()
        xsec_token = xsec_token.strip()
        content = content.strip()

        if not feed_id:
            raise CDPError("feed_id cannot be empty.")
        if not xsec_token:
            raise CDPError("xsec_token cannot be empty.")
        if not content:
            raise CDPError("content cannot be empty.")

        detail_url = make_feed_detail_url(feed_id, xsec_token)
        self._navigate(detail_url)
        self._sleep(2, minimum_seconds=1.0)
        self._check_feed_page_accessible()

        input_rect_js = """
            (function() {
                const selectors = [
                    "div.input-box div.content-edit span",
                    "div.input-box div.content-edit p.content-input",
                    "div.input-box div.content-edit",
                    "div.input-box",
                ];
                for (const selector of selectors) {
                    const el = document.querySelector(selector);
                    if (!(el instanceof HTMLElement) || el.offsetParent === null) {
                        continue;
                    }
                    const r = el.getBoundingClientRect();
                    if (r.width < 8 || r.height < 8) {
                        continue;
                    }
                    return { x: r.x, y: r.y, width: r.width, height: r.height };
                }
                return null;
            })();
        """
        try:
            self._click_element_by_cdp("comment input box", input_rect_js)
            self._sleep(0.4, minimum_seconds=0.15)
        except CDPError:
            print(
                "[cdp_publish] Warning: Could not click comment input via CDP. "
                "Falling back to direct focus."
            )

        filled_len = self._fill_comment_content(content)
        self._sleep(0.6, minimum_seconds=0.2)

        submit_rect_js = """
            (function() {
                const selectors = [
                    "div.bottom button.submit",
                    "div.bottom button[class*='submit']",
                    "button.submit",
                    "button[class*='submit']",
                    "button[type='submit']",
                ];
                for (const selector of selectors) {
                    const el = document.querySelector(selector);
                    if (!(el instanceof HTMLButtonElement) || el.offsetParent === null) {
                        continue;
                    }
                    if (el.disabled) {
                        continue;
                    }
                    const r = el.getBoundingClientRect();
                    if (r.width < 8 || r.height < 8) {
                        continue;
                    }
                    return { x: r.x, y: r.y, width: r.width, height: r.height };
                }
                const fallbackTexts = new Set(["发送", "提交", "评论"]);
                const buttons = document.querySelectorAll("button");
                for (const button of buttons) {
                    if (!(button instanceof HTMLButtonElement) || button.offsetParent === null) {
                        continue;
                    }
                    if (button.disabled) {
                        continue;
                    }
                    const text = (button.textContent || "").replace(/\\s+/g, " ").trim();
                    if (!fallbackTexts.has(text)) {
                        continue;
                    }
                    const r = button.getBoundingClientRect();
                    if (r.width < 8 || r.height < 8) {
                        continue;
                    }
                    return { x: r.x, y: r.y, width: r.width, height: r.height };
                }
                return null;
            })();
        """
        self._click_element_by_cdp("comment submit button", submit_rect_js)
        self._sleep(1.0, minimum_seconds=0.4)

        print(f"[cdp_publish] Comment posted. feed_id={feed_id}, length={filled_len}")
        return {
            "feed_id": feed_id,
            "xsec_token": xsec_token,
            "content_length": filled_len,
            "success": True,
        }

    def _schedule_click_notification_mentions_tab(self) -> str:
        """Schedule a click on mentions tab after evaluate returns."""
        clicked_text = self._evaluate("""
            (() => {
                const keywordSet = new Set([
                    "评论和@",
                    "评论和 @",
                    "评论与@",
                    "提到我的",
                    "@我的",
                    "mentions",
                ]);
                const selectors = [
                    "[role='tab']",
                    "button",
                    "a",
                    "div[class*='tab']",
                    "div[class*='menu-item']",
                    "li[class*='tab-item']",
                    "li[class*='tab']",
                ];
                const seen = new Set();
                const candidates = [];
                for (const selector of selectors) {
                    const nodes = document.querySelectorAll(selector);
                    for (const node of nodes) {
                        if (!(node instanceof HTMLElement)) {
                            continue;
                        }
                        if (node.offsetParent === null) {
                            continue;
                        }
                        if (seen.has(node)) {
                            continue;
                        }
                        seen.add(node);
                        candidates.push(node);
                    }
                }

                for (const node of candidates) {
                    const text = (node.innerText || node.textContent || "")
                        .replace(/\\s+/g, " ")
                        .trim();
                    if (!text) {
                        continue;
                    }
                    if (text.length > 24) {
                        continue;
                    }
                    const normalized = text.replace(/\\d+/g, "").replace(/\\s+/g, "");
                    const exactMatches = [
                        normalized,
                        text.replace(/\\d+/g, "").trim(),
                    ];
                    if (!exactMatches.some((candidate) => keywordSet.has(candidate))) {
                        continue;
                    }
                    window.setTimeout(() => {
                        try {
                            node.click();
                        } catch (error) {
                            // ignored
                        }
                    }, 80);
                    return text;
                }
                return "";
            })()
        """)
        if isinstance(clicked_text, str):
            return clicked_text.strip()
        return ""

    def _fetch_notification_mentions_via_page(self) -> dict[str, Any] | None:
        """Fetch mentions API directly in page context using logged-in cookies."""
        result = self._evaluate("""
            (() => fetch(
                "https://edith.xiaohongshu.com/api/sns/web/v1/you/mentions?num=20&cursor=",
                {
                    method: "GET",
                    credentials: "include",
                    headers: {
                        "Accept": "application/json, text/plain, */*",
                    },
                }
            ).then(async (resp) => {
                const text = await resp.text();
                return {
                    ok: resp.ok,
                    status: resp.status,
                    url: resp.url,
                    body: text,
                };
            }).catch((error) => {
                return {
                    ok: false,
                    error: String(error),
                };
            }))()
        """)
        if not isinstance(result, dict):
            return None
        if not result.get("ok"):
            return None
        if int(result.get("status", 0)) != 200:
            return None
        body = result.get("body")
        if not isinstance(body, str) or not body.strip():
            return None
        try:
            payload = json.loads(body)
        except json.JSONDecodeError:
            return None
        if not isinstance(payload, dict):
            return None

        data = payload.get("data")
        items: list[Any] = []
        if isinstance(data, dict):
            for key in ("message_list", "items", "mentions", "list"):
                value = data.get(key)
                if isinstance(value, list):
                    items = value
                    break

        return {
            "request_url": result.get("url") or (
                "https://edith.xiaohongshu.com/api/sns/web/v1/you/mentions?num=20&cursor="
            ),
            "count": len(items),
            "has_more": data.get("has_more") if isinstance(data, dict) else None,
            "cursor": data.get("cursor") if isinstance(data, dict) else None,
            "items": items,
            "raw_payload": payload,
            "capture_mode": "page_fetch",
        }

    def get_notification_mentions(self, wait_seconds: float = 18.0) -> dict[str, Any]:
        """
        Capture notification mentions API payload from notification page requests.

        The API is captured from real browser traffic to preserve platform
        signatures/cookies generated by page scripts.
        """
        if not self.ws:
            raise CDPError("Not connected. Call connect() first.")
        wait_seconds = max(5.0, float(wait_seconds))

        self._send("Page.enable")
        self._send("Network.enable", {"maxPostDataSize": 65536})
        self._send("Network.setCacheDisabled", {"cacheDisabled": True})
        self._send("Page.navigate", {"url": XHS_NOTIFICATION_URL})
        self._sleep(1.2, minimum_seconds=0.5)

        direct_payload = self._fetch_notification_mentions_via_page()
        if direct_payload is not None:
            return direct_payload

        clicked_tab = self._schedule_click_notification_mentions_tab()
        if clicked_tab:
            print(f"[cdp_publish] Notification tab clicked: {clicked_tab}")

        request_meta_by_id: dict[str, dict[str, str]] = {}
        target_request_id = ""
        target_request_url = ""
        deadline = time.time() + wait_seconds

        while time.time() < deadline:
            timeout = min(1.0, max(0.1, deadline - time.time()))
            try:
                raw = self.ws.recv(timeout=timeout)
            except TimeoutError:
                continue

            message = json.loads(raw)
            method = message.get("method")
            params = message.get("params", {})

            if method == "Network.requestWillBeSent":
                request_id = params.get("requestId")
                request = params.get("request", {})
                if isinstance(request_id, str):
                    request_meta_by_id[request_id] = {
                        "url": request.get("url", ""),
                        "method": str(request.get("method", "")).upper(),
                    }
                continue

            if method == "Network.responseReceived":
                request_id = params.get("requestId")
                if not isinstance(request_id, str):
                    continue

                request_meta = request_meta_by_id.get(request_id, {})
                request_url = request_meta.get("url", "")
                if XHS_NOTIFICATION_MENTIONS_API_PATH not in request_url:
                    continue

                if request_meta.get("method") == "OPTIONS":
                    continue

                status = params.get("response", {}).get("status")
                if status != 200:
                    raise CDPError(
                        "Notification mentions API responded with non-200 status: "
                        f"{status}, url={request_url}"
                    )

                target_request_id = request_id
                target_request_url = request_url
                break

        if not target_request_id:
            raise CDPError(
                "Timed out waiting for notification mentions request. "
                "Please open notification page manually and retry."
            )

        body_result = self._send("Network.getResponseBody", {"requestId": target_request_id})
        body_text = body_result.get("body", "")
        if body_result.get("base64Encoded"):
            body_text = base64.b64decode(body_text).decode("utf-8", errors="replace")

        try:
            payload = json.loads(body_text)
        except json.JSONDecodeError as e:
            raise CDPError(
                "Failed to decode notification mentions API JSON: "
                f"{e}; preview={body_text[:300]}"
            ) from e

        if not isinstance(payload, dict):
            raise CDPError("Unexpected notification mentions payload structure.")

        data = payload.get("data")
        items: list[Any] = []
        if isinstance(data, dict):
            for key in ("message_list", "items", "mentions", "list"):
                value = data.get(key)
                if isinstance(value, list):
                    items = value
                    break

        return {
            "request_url": target_request_url,
            "count": len(items),
            "has_more": data.get("has_more") if isinstance(data, dict) else None,
            "cursor": data.get("cursor") if isinstance(data, dict) else None,
            "items": items,
            "raw_payload": payload,
            "capture_mode": "network_capture",
        }

    def get_content_data(
        self,
        page_num: int = 1,
        page_size: int = 10,
        note_type: int = 0,
    ) -> dict[str, Any]:
        """
        Fetch creator content data table from data-analysis API.

        Args:
            page_num: Page number (1-based).
            page_size: Rows per page.
            note_type: API type filter value (default: 0).
        """
        if not self.ws:
            raise CDPError("Not connected. Call connect() first.")
        if page_num < 1:
            raise CDPError("--page-num must be >= 1.")
        if page_size < 1:
            raise CDPError("--page-size must be >= 1.")
        # Important: direct fetch to this API can be rejected (e.g. 406) when
        # anti-bot headers are not present. We therefore capture the real
        # browser request generated by page scripts and read response body via CDP.
        self._send("Page.enable")
        self._send("Network.enable", {"maxPostDataSize": 65536})
        self._send("Page.navigate", {"url": XHS_CONTENT_DATA_URL})

        request_url_by_id: dict[str, str] = {}
        target_request_id = ""
        target_request_url = ""
        deadline = time.time() + 18

        while time.time() < deadline:
            timeout = min(1.0, max(0.1, deadline - time.time()))
            try:
                raw = self.ws.recv(timeout=timeout)
            except TimeoutError:
                continue

            message = json.loads(raw)
            method = message.get("method")
            params = message.get("params", {})

            if method == "Network.requestWillBeSent":
                request_id = params.get("requestId")
                request = params.get("request", {})
                if isinstance(request_id, str):
                    request_url_by_id[request_id] = request.get("url", "")
                continue

            if method == "Network.responseReceived":
                request_id = params.get("requestId")
                if not isinstance(request_id, str):
                    continue

                request_url = request_url_by_id.get(request_id, "")
                if XHS_CONTENT_DATA_API_PATH not in request_url:
                    continue

                status = params.get("response", {}).get("status")
                if status != 200:
                    raise CDPError(
                        "Content data API responded with non-200 status: "
                        f"{status}, url={request_url}"
                    )

                target_request_id = request_id
                target_request_url = request_url
                break

        if not target_request_id:
            raise CDPError(
                "Timed out waiting for content data request. "
                "Please open data-analysis page manually and retry."
            )

        body_result = self._send("Network.getResponseBody", {"requestId": target_request_id})
        body_text = body_result.get("body", "")
        if body_result.get("base64Encoded"):
            body_text = base64.b64decode(body_text).decode("utf-8", errors="replace")

        try:
            payload = json.loads(body_text)
        except json.JSONDecodeError as e:
            raise CDPError(
                "Failed to decode content data API JSON: "
                f"{e}; preview={body_text[:300]}"
            ) from e

        if not isinstance(payload, dict):
            raise CDPError("Unexpected content data payload structure.")

        data = payload.get("data")
        note_infos = data.get("note_infos") if isinstance(data, dict) else []
        if not isinstance(note_infos, list):
            note_infos = []
        rows = _map_note_infos_to_content_rows(note_infos)

        query = parse_qs(urlparse(target_request_url).query)
        resolved_page_num = int((query.get("page_num") or ["1"])[0])
        resolved_page_size = int((query.get("page_size") or ["10"])[0])
        resolved_type = int((query.get("type") or ["0"])[0])

        if (
            page_num != resolved_page_num
            or page_size != resolved_page_size
            or note_type != resolved_type
        ):
            print(
                "[cdp_publish] Warning: Requested pagination/filter differs from "
                "captured page request. Returning captured data instead."
            )

        return {
            "request_url": target_request_url,
            "requested_page_num": page_num,
            "requested_page_size": page_size,
            "requested_type": note_type,
            "resolved_page_num": resolved_page_num,
            "resolved_page_size": resolved_page_size,
            "resolved_type": resolved_type,
            "total": data.get("total") if isinstance(data, dict) else None,
            "count_returned": len(rows),
            "rows": rows,
        }

    # ------------------------------------------------------------------
    # Publishing actions
    # ------------------------------------------------------------------

    def _click_tab(self, tab_selector: str, tab_text: str):
        """Click a publish-mode tab by selector and text content."""
        print(f"[cdp_publish] Clicking '{tab_text}' tab...")
        selector_alt = (
            "div.creator-tab, .creator-tab, [class*='creator-tab'], [role='tab'], button, div"
        )
        selector_alt_literal = json.dumps(selector_alt)
        tab_text_literal = json.dumps(tab_text)

        clicked = self._evaluate(f"""
            (function() {{
                var targetText = {tab_text_literal};
                var fuzzyKeywords = [targetText];
                if (targetText.indexOf('图文') !== -1) {{
                    fuzzyKeywords.push('图文', '上传图文');
                }}
                if (targetText.indexOf('视频') !== -1) {{
                    fuzzyKeywords.push('视频', '上传视频');
                }}

                function matches(text) {{
                    var t = (text || '').trim();
                    if (!t) return false;
                    if (t === targetText) return true;
                    for (var i = 0; i < fuzzyKeywords.length; i++) {{
                        var keyword = fuzzyKeywords[i];
                        if (keyword && t.indexOf(keyword) !== -1) {{
                            return true;
                        }}
                    }}
                    return false;
                }}

                var tabs = document.querySelectorAll('{tab_selector}');
                for (var i = 0; i < tabs.length; i++) {{
                    if (matches(tabs[i].textContent)) {{
                        tabs[i].click();
                        return true;
                    }}
                }}

                var allTabs = document.querySelectorAll({selector_alt_literal});
                for (var j = 0; j < allTabs.length; j++) {{
                    if (matches(allTabs[j].textContent)) {{
                        allTabs[j].click();
                        return true;
                    }}
                }}
                return false;
            }})();
        """)

        if not clicked:
            if "图文" in tab_text:
                upload_ready = self._evaluate(
                    f"!!document.querySelector('{SELECTORS['upload_input']}') || "
                    f"!!document.querySelector('{SELECTORS['upload_input_alt']}')"
                )
                if upload_ready:
                    print(
                        "[cdp_publish] '上传图文' tab not found, but upload input is ready. "
                        "Continuing..."
                    )
                    return

            raise CDPError(
                f"Could not find '{tab_text}' tab. "
                "The page structure may have changed."
            )

        print(f"[cdp_publish] Tab '{tab_text}' clicked, waiting for upload area...")
        self._sleep(TAB_CLICK_WAIT, minimum_seconds=0.8)

    def _click_image_text_tab(self):
        """Click the '上传图文' tab to switch to image+text publish mode."""
        self._click_tab(SELECTORS["image_text_tab"], SELECTORS["image_text_tab_text"])

    def _click_video_tab(self):
        """Click the '上传视频' tab to switch to video publish mode."""
        self._click_tab(SELECTORS["video_tab"], SELECTORS["video_tab_text"])

    def _upload_images(self, image_paths: list[str]):
        """Upload images via the file input element."""
        if not image_paths:
            print("[cdp_publish] No images to upload, skipping.")
            return

        # Normalize paths (forward slashes for CDP)
        normalized = [p.replace("\\", "/") for p in image_paths]

        print(f"[cdp_publish] Uploading {len(image_paths)} image(s)...")

        # Enable DOM domain
        self._send("DOM.enable")

        # Get the document root
        doc = self._send("DOM.getDocument")
        root_id = doc["root"]["nodeId"]

        # Try primary selector, then fallback
        node_id = 0
        for selector in (SELECTORS["upload_input"], SELECTORS["upload_input_alt"]):
            result = self._send("DOM.querySelector", {
                "nodeId": root_id,
                "selector": selector,
            })
            node_id = result.get("nodeId", 0)
            if node_id:
                break

        if not node_id:
            raise CDPError(
                "Could not find file input element.\n"
                "The page structure may have changed. Check references/publish-workflow.md."
            )

        # Use DOM.setFileInputFiles to set the files
        self._send("DOM.setFileInputFiles", {
            "nodeId": node_id,
            "files": normalized,
        })

        print("[cdp_publish] Images uploaded. Waiting for editor to appear...")
        self._sleep(UPLOAD_WAIT, minimum_seconds=2.0)

    def _upload_video(self, video_path: str):
        """Upload a video file via the file input element."""
        normalized = video_path.replace("\\", "/")
        print(f"[cdp_publish] Uploading video: {normalized}")

        # Enable DOM domain
        self._send("DOM.enable")

        # Get the document root
        doc = self._send("DOM.getDocument")
        root_id = doc["root"]["nodeId"]

        # Find the file input for video upload
        node_id = 0
        for selector in (SELECTORS["upload_input"], SELECTORS["upload_input_alt"]):
            result = self._send("DOM.querySelector", {
                "nodeId": root_id,
                "selector": selector,
            })
            node_id = result.get("nodeId", 0)
            if node_id:
                break

        if not node_id:
            raise CDPError(
                "Could not find file input element for video upload.\n"
                "The page structure may have changed."
            )

        # Set the video file
        self._send("DOM.setFileInputFiles", {
            "nodeId": node_id,
            "files": [normalized],
        })

        print("[cdp_publish] Video file submitted. Waiting for processing...")

    def _wait_video_processing(self):
        """Wait for the video to finish processing after upload.

        The Xiaohongshu creator page shows a progress/processing indicator
        while the video is being uploaded and transcoded. We wait until the
        title input or content editor becomes available, which signals
        that the video has been processed.
        """
        print("[cdp_publish] Waiting for video processing to complete...")
        deadline = time.time() + VIDEO_PROCESS_TIMEOUT
        last_pct = ""

        while time.time() < deadline:
            # Check if the title input has appeared (signals processing done)
            for selector in (SELECTORS["title_input"], SELECTORS["title_input_alt"]):
                found = self._evaluate(f"!!document.querySelector('{selector}')")
                if found:
                    print("[cdp_publish] Video processing complete - editor is ready.")
                    time.sleep(1)  # small extra buffer
                    return

            # Try to read progress text for user feedback
            pct = self._evaluate("""
                (function() {
                    // Look for progress percentage text
                    var els = document.querySelectorAll(
                        '[class*="progress"], [class*="percent"], [class*="upload"]'
                    );
                    for (var i = 0; i < els.length; i++) {
                        var t = els[i].textContent.trim();
                        if (t && /\\d+%/.test(t)) return t;
                    }
                    return '';
                })()
            """) or ""
            if pct and pct != last_pct:
                print(f"[cdp_publish] Video processing: {pct}")
                last_pct = pct

            time.sleep(VIDEO_PROCESS_POLL)

        raise CDPError(
            f"Video processing did not complete within {VIDEO_PROCESS_TIMEOUT}s. "
            "The video may be too large or processing is slow."
        )

    def _fill_title(self, title: str):
        """Fill in the article title."""
        print(f"[cdp_publish] Setting title: {title[:40]}...")
        self._sleep(ACTION_INTERVAL, minimum_seconds=0.25)

        for selector in (SELECTORS["title_input"], SELECTORS["title_input_alt"]):
            found = self._evaluate(f"!!document.querySelector('{selector}')")
            if found:
                escaped_title = json.dumps(title)
                self._evaluate(f"""
                    (function() {{
                        var el = document.querySelector('{selector}');
                        var nativeSetter = Object.getOwnPropertyDescriptor(
                            window.HTMLInputElement.prototype, 'value'
                        ).set;
                        el.focus();
                        nativeSetter.call(el, {escaped_title});
                        el.dispatchEvent(new Event('input', {{ bubbles: true }}));
                        el.dispatchEvent(new Event('change', {{ bubbles: true }}));
                    }})();
                """)
                print("[cdp_publish] Title set.")
                return

        raise CDPError("Could not find title input element.")

    def _fill_content(self, content: str):
        """Fill in the article body content using the TipTap/ProseMirror editor."""
        print(f"[cdp_publish] Setting content ({len(content)} chars)...")
        self._sleep(ACTION_INTERVAL, minimum_seconds=0.25)

        for selector in (SELECTORS["content_editor"], SELECTORS["content_editor_alt"]):
            found = self._evaluate(f"!!document.querySelector('{selector}')")
            if found:
                escaped = json.dumps(content)
                self._evaluate(f"""
                    (function() {{
                        var el = document.querySelector('{selector}');
                        el.focus();
                        var text = {escaped};
                        var paragraphs = text.split('\\n').filter(function(p) {{ return p.trim(); }});
                        var html = [];
                        for (var i = 0; i < paragraphs.length; i++) {{
                            html.push('<p>' + paragraphs[i] + '</p>');
                            if (i < paragraphs.length - 1) {{
                                html.push('<p><br></p>');
                            }}
                        }}
                        el.innerHTML = html.join('');
                        el.dispatchEvent(new Event('input', {{ bubbles: true }}));
                    }})();
                """)
                print("[cdp_publish] Content set.")
                return

        raise CDPError("Could not find content editor element.")

    def _set_schedule_post_time(self, post_time: str | None):
        """Set schedle publish time if necessary"""
        if post_time == None:
            return
        
        print(f"[cdp_publish] Setting schedule publish time: {post_time}")
        self._sleep(ACTION_INTERVAL, minimum_seconds=0.25)

        post_time_enabled = self._evaluate(f"""
            (async function() {{
                try {{
                    const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

                    // Click scheduled publish btn
                    var selector = '.post-time-wrapper .d-switch';
                    var element = document.querySelector(selector);
                    element.click();
                    await sleep(150);
                    
                    // Set publish time
                    const el = document.querySelector('.date-picker-container input');
                    if (el == null) {{
                        return 'Schedule publish date-picker input is missing.';
                    }}
                    var nativeSetter = Object.getOwnPropertyDescriptor(
                        window.HTMLInputElement.prototype, 'value'
                    ).set;
                    el.focus();
                    nativeSetter.call(el, '{post_time}');

                    el.dispatchEvent(new Event('input', {{ bubbles: true }}));
                    el.dispatchEvent(new Event('change', {{ bubbles: true }}));
                    return 'ok';
                }} catch (err) {{
                    return String(err);
                }}
            }})();
        """)

        if not post_time_enabled == 'ok':
            raise CDPError("Could not set scheduled publish time. Reason:" + post_time_enabled)
        
        print("[cdp_publish] Schedule publish time set.")
        return

    def _like_note(self):
        """Like the current note."""
        print("[cdp_publish] Liking note...")
        self._sleep(ACTION_INTERVAL, minimum_seconds=0.25)

        liked = self._evaluate("""
            (function() {{
                // Try various like button selectors
                var selectors = [
                    '.like-button, [class*="like"], [class*="heart"]',
                    'button[aria-label*="like"], button[aria-label*="赞"]',
                    '[data-testid*="like"], [data-testid*="heart"]',
                    'svg[class*="like"], svg[class*="heart"]'
                ];

                for (var sel of selectors) {{
                    var elements = document.querySelectorAll(sel);
                    for (var el of elements) {{
                        // Check if it's not already liked
                        if (!el.classList.contains('liked') && !el.classList.contains('active')) {{
                            el.click();
                            return true;
                        }}
                    }}
                }}
                return false;
            }})();
        """)

        if liked:
            print("[cdp_publish] Note liked.")
        else:
            print("[cdp_publish] Could not find like button or already liked.")

        return liked

    def _collect_note(self):
        """Collect the current note."""
        print("[cdp_publish] Collecting note...")
        self._sleep(ACTION_INTERVAL, minimum_seconds=0.25)

        collected = self._evaluate("""
            (function() {{
                // Try various collect button selectors
                var selectors = [
                    '.collect-button, [class*="collect"], [class*="bookmark"]',
                    'button[aria-label*="collect"], button[aria-label*="收藏"]',
                    '[data-testid*="collect"], [data-testid*="bookmark"]',
                    'svg[class*="collect"], svg[class*="bookmark"]'
                ];

                for (var sel of selectors) {{
                    var elements = document.querySelectorAll(sel);
                    for (var el of elements) {{
                        // Check if it's not already collected
                        if (!el.classList.contains('collected') && !el.classList.contains('active')) {{
                            el.click();
                            return true;
                        }}
                    }}
                }}
                return false;
            }})();
        """)

        if collected:
            print("[cdp_publish] Note collected.")
        else:
            print("[cdp_publish] Could not find collect button or already collected.")

        return collected

    def _move_mouse(self, x: float, y: float):
        """Move mouse cursor via CDP to support hover-driven UI."""
        self._send("Input.dispatchMouseEvent", {
            "type": "mouseMoved",
            "x": float(x),
            "y": float(y),
        })

    def _click_mouse(self, x: float, y: float):
        """Perform a real left-click via CDP at the given coordinates."""
        for event_type in ("mousePressed", "mouseReleased"):
            self._send("Input.dispatchMouseEvent", {
                "type": event_type,
                "x": float(x),
                "y": float(y),
                "button": "left",
                "clickCount": 1,
            })
            time.sleep(0.05)

    def _click_element_by_cdp(self, description: str, js_get_rect: str):
        """Click an element using CDP Input.dispatchMouseEvent for reliable clicks.

        Modern web frameworks (Vue/React) often ignore JS .click() calls.
        Dispatching real mouse events via CDP always works.

        Args:
            description: Human-readable description for logging.
            js_get_rect: JavaScript expression that returns {x, y, width, height}
                         of the element to click, or null if not found.
        """
        rect = self._evaluate(js_get_rect)
        if not rect:
            raise CDPError(
                f"Could not find {description}. "
                "Please click it manually in the browser."
            )

        # Compute center of the element
        cx = rect["x"] + rect["width"] / 2
        cy = rect["y"] + rect["height"] / 2
        print(f"[cdp_publish] Clicking {description} at ({cx:.0f}, {cy:.0f})...")

        # Dispatch a full mouse click sequence via CDP
        for event_type in ("mousePressed", "mouseReleased"):
            self._send("Input.dispatchMouseEvent", {
                "type": event_type,
                "x": cx,
                "y": cy,
                "button": "left",
                "clickCount": 1,
            })
            time.sleep(0.05)

    def _click_publish(self, scheduled: bool = False):
        """Click the publish button using CDP mouse events."""
        print("[cdp_publish] Clicking publish button...")
        self._sleep(ACTION_INTERVAL, minimum_seconds=0.25)

        btn_text = SELECTORS["schedule_publish_button_text"] if scheduled else SELECTORS["publish_button_text"]

        # JavaScript to locate the publish button and return its bounding rect
        js_get_rect = f"""
            (function() {{
                // Strategy 1: search <button> elements by exact text
                var buttons = document.querySelectorAll('button');
                for (var i = 0; i < buttons.length; i++) {{
                    var t = buttons[i].textContent.trim();
                    if (t === '{btn_text}') {{
                        var r = buttons[i].getBoundingClientRect();
                        return {{ x: r.x, y: r.y, width: r.width, height: r.height }};
                    }}
                }}
                // Strategy 2: search d-button-content / d-text spans
                var spans = document.querySelectorAll(
                    '.d-button-content .d-text, .d-button-content span'
                );
                for (var i = 0; i < spans.length; i++) {{
                    if (spans[i].textContent.trim() === '{btn_text}') {{
                        var el = spans[i].closest(
                            'button, [role="button"], .d-button, [class*="btn"], [class*="button"]'
                        );
                        if (!el) el = spans[i];
                        var r = el.getBoundingClientRect();
                        return {{ x: r.x, y: r.y, width: r.width, height: r.height }};
                    }}
                }}
                return null;
            }})();
        """

        self._click_element_by_cdp("publish button", js_get_rect)
        print("[cdp_publish] Publish button clicked.")

        # Wait for publish success and get note link
        self._sleep(5, minimum_seconds=2.0)
        note_link = self._evaluate("""
            (function() {
                // Try to find note link in success message
                var links = document.querySelectorAll('a[href*="xiaohongshu.com/explore"]');
                if (links.length > 0) {
                    return links[0].href;
                }
                // Try to find note ID in page
                var noteId = document.body.textContent.match(/\\b[0-9a-fA-F]{24}\\b/);
                if (noteId) {
                    return 'https://www.xiaohongshu.com/explore/' + noteId[0];
                }
                return null;
            })();
        """)

        return note_link

    # ------------------------------------------------------------------
    # Main publish workflow
    # ------------------------------------------------------------------

    def publish(
        self,
        title: str,
        content: str,
        image_paths: list[str] | None = None,
        post_time: str | None = None,
    ):
        """
        Execute the full publish workflow:
        1. Navigate to creator publish page
        2. Click '上传图文' tab
        3. Upload images (this triggers the editor to appear)
        4. Fill title
        5. Fill content
        6. Set schedule publish time (if necessary)

        Args:
            title: Article title
            content: Article body text (paragraphs separated by newlines)
            image_paths: List of local file paths to images to upload
            post_time: Optional scheduled publish time (e.g. "2026-03-01 10:00")
        """
        if not self.ws:
            raise CDPError("Not connected. Call connect() first.")

        if not image_paths:
            raise CDPError("At least one image is required to publish on Xiaohongshu.")
        
        if post_time and not validate_schedule_post_time(post_time):
            raise CDPError(
                "Scheduled publish time is invalid. "
                "It must follow the format 'yyyy-MM-dd HH:mm' and fall within the next 14 days."
            )

        # Step 1: Navigate to publish page
        self._navigate(XHS_CREATOR_URL)
        self._sleep(2, minimum_seconds=1.0)

        # Step 2: Click '上传图文' tab
        self._click_image_text_tab()

        # Step 3: Upload images (editor appears after upload)
        self._upload_images(image_paths)

        # Step 4: Fill title
        self._fill_title(title)

        # Step 5: Fill content
        self._fill_content(content)

        # Step 6: Set schedule publish time (if provided)
        self._set_schedule_post_time(post_time)

        print(
            "\n[cdp_publish] Content has been filled in.\n"
            "  Please review in the browser before publishing.\n"
        )

    def publish_video(
        self,
        title: str,
        content: str,
        video_path: str,
    ):
        """
        Execute the full video publish workflow:
        1. Navigate to creator publish page
        2. Click '上传视频' tab
        3. Upload video file and wait for processing
        4. Fill title
        5. Fill content

        Args:
            title: Article title
            content: Article body text (paragraphs separated by newlines)
            video_path: Local file path to the video to upload
        """
        if not self.ws:
            raise CDPError("Not connected. Call connect() first.")

        if not video_path:
            raise CDPError("A video file is required to publish video on Xiaohongshu.")

        # Step 1: Navigate to publish page
        self._navigate(XHS_CREATOR_URL)
        time.sleep(2)

        # Step 2: Click '上传视频' tab
        self._click_video_tab()

        # Step 3: Upload video and wait for processing
        self._upload_video(video_path)
        self._wait_video_processing()

        # Step 4: Fill title
        self._fill_title(title)

        # Step 5: Fill content
        self._fill_content(content)

        print(
            "\n[cdp_publish] Video content has been filled in.\n"
            "  Please review in the browser before publishing.\n"
        )


# ---------------------------------------------------------------------------
# CLI entry point
# ---------------------------------------------------------------------------

def main():
    import argparse
    from chrome_launcher import ensure_chrome, restart_chrome

    parser = argparse.ArgumentParser(description="Xiaohongshu CDP Publisher")
    parser.add_argument(
        "--host",
        default=CDP_HOST,
        help=f"CDP host (default: {CDP_HOST})",
    )
    parser.add_argument("--port", type=int, default=CDP_PORT,
                        help=f"CDP remote debugging port (default: {CDP_PORT})")
    parser.add_argument("--headless", action="store_true",
                        help="Use headless Chrome (no GUI window)")
    parser.add_argument("--account", help="Account name to use (default: default account)")
    parser.add_argument(
        "--timing-jitter",
        type=float,
        default=0.25,
        help=(
            "Timing jitter ratio for operation delays (default: 0.25). "
            "Set 0 to disable random jitter."
        ),
    )
    parser.add_argument(
        "--reuse-existing-tab",
        action="store_true",
        help=(
            "Prefer reusing an existing tab before creating a new one. "
            "Useful in headed mode to reduce foreground focus switching."
        ),
    )
    sub = parser.add_subparsers(dest="command", required=True)

    # check-login
    sub.add_parser("check-login", help="Check login status (exit 0=logged in, 1=not)")

    # fill - fill form without clicking publish
    p_fill = sub.add_parser("fill", help="Fill title/content/images or video without publishing")
    p_fill.add_argument("--title", required=True)
    p_fill.add_argument("--content", default=None)
    p_fill.add_argument("--content-file", default=None, help="Read content from file")
    p_fill_media = p_fill.add_mutually_exclusive_group(required=True)
    p_fill_media.add_argument("--images", nargs="+", help="Local image file paths")
    p_fill_media.add_argument("--video", help="Local video file path")

    # publish - fill form and click publish
    p_pub = sub.add_parser("publish", help="Fill form and click publish")
    p_pub.add_argument("--title", required=True)
    p_pub.add_argument("--content", default=None)
    p_pub.add_argument("--content-file", default=None, help="Read content from file")
    p_pub_media = p_pub.add_mutually_exclusive_group(required=True)
    p_pub_media.add_argument("--images", nargs="+", help="Local image file paths")
    p_pub_media.add_argument("--video", help="Local video file path")

    # click-publish - just click the publish button on current page
    sub.add_parser("click-publish", help="Click publish button on already-filled page")

    # search-feeds - search note feeds by keyword
    p_search = sub.add_parser(
        "search-feeds",
        aliases=["search_feeds"],
        help="Search Xiaohongshu feeds by keyword",
    )
    p_search.add_argument("--keyword", required=True, help="Search keyword")
    p_search.add_argument("--sort-by", choices=SORT_BY_OPTIONS, help="Sort by option")
    p_search.add_argument("--note-type", choices=NOTE_TYPE_OPTIONS, help="Note type filter")
    p_search.add_argument(
        "--publish-time",
        choices=PUBLISH_TIME_OPTIONS,
        help="Publish time filter",
    )
    p_search.add_argument(
        "--search-scope",
        choices=SEARCH_SCOPE_OPTIONS,
        help="Search scope filter",
    )
    p_search.add_argument("--location", choices=LOCATION_OPTIONS, help="Location filter")

    # get-feed-detail - get note detail by feed id and token
    p_detail = sub.add_parser(
        "get-feed-detail",
        aliases=["get_feed_detail"],
        help="Get feed detail by feed id and xsec token",
    )
    p_detail.add_argument("--feed-id", required=True, help="Feed id")
    p_detail.add_argument("--xsec-token", required=True, help="xsec token")

    # post-comment-to-feed - post top-level comment to feed detail
    p_comment = sub.add_parser(
        "post-comment-to-feed",
        aliases=["post_comment_to_feed"],
        help="Post a top-level comment to feed detail",
    )
    p_comment.add_argument("--feed-id", required=True, help="Feed id")
    p_comment.add_argument("--xsec-token", required=True, help="xsec token")
    p_comment_content = p_comment.add_mutually_exclusive_group(required=True)
    p_comment_content.add_argument("--content", help="Comment content")
    p_comment_content.add_argument("--content-file", help="Read comment content from file")

    # get-notification-mentions - capture notification mentions API response
    p_mentions = sub.add_parser(
        "get-notification-mentions",
        aliases=["get_notification_mentions"],
        help="Capture notification mentions API payload from /notification page",
    )
    p_mentions.add_argument(
        "--wait-seconds",
        type=float,
        default=18.0,
        help="Max seconds to wait for mentions API request (default: 18)",
    )

    # content-data - fetch creator content data table
    p_content_data = sub.add_parser(
        "content-data",
        aliases=["content_data"],
        help="Fetch creator content data table from statistics page",
    )
    p_content_data.add_argument(
        "--page-num",
        type=int,
        default=1,
        help="Page number (default: 1)",
    )
    p_content_data.add_argument(
        "--page-size",
        type=int,
        default=10,
        help="Page size (default: 10)",
    )
    p_content_data.add_argument(
        "--type",
        dest="note_type",
        type=int,
        default=0,
        help="Type filter value used by API (default: 0)",
    )
    p_content_data.add_argument(
        "--csv-file",
        help="Optional CSV output path",
    )

    # login - open browser for QR code login (always headed)
    sub.add_parser("login", help="Open browser for QR code login (always headed mode)")

    # re-login - clear cookies and re-login the same account (always headed)
    sub.add_parser("re-login", help="Clear cookies and re-login same account (always headed)")

    # switch-account - clear cookies and open login page (always headed)
    sub.add_parser("switch-account",
                   help="Clear cookies and open login page for new account (always headed)")

    # list-accounts - list all configured accounts
    sub.add_parser("list-accounts", help="List all configured accounts")

    # add-account - add a new account
    p_add = sub.add_parser("add-account", help="Add a new account")
    p_add.add_argument("name", help="Account name (unique identifier)")
    p_add.add_argument("--alias", help="Display name / description")

    # remove-account - remove an account
    p_rm = sub.add_parser("remove-account", help="Remove an account")
    p_rm.add_argument("name", help="Account name to remove")
    p_rm.add_argument("--delete-profile", action="store_true",
                      help="Also delete the Chrome profile directory")

    # set-default-account - set default account
    p_def = sub.add_parser("set-default-account", help="Set the default account")
    p_def.add_argument("name", help="Account name to set as default")

    args = parser.parse_args()
    host = args.host
    port = args.port
    headless = args.headless
    account = args.account
    cache_account_name = _resolve_account_name(account)
    reuse_existing_tab = args.reuse_existing_tab
    timing_jitter = _normalize_timing_jitter(args.timing_jitter)
    local_mode = _is_local_host(host)

    if timing_jitter != args.timing_jitter:
        print(
            "[cdp_publish] Warning: --timing-jitter out of range. "
            f"Clamped to {timing_jitter:.2f}."
        )
    # Account management commands that don't need Chrome
    if args.command == "list-accounts":
        from account_manager import list_accounts
        accounts = list_accounts()
        if not accounts:
            print("No accounts configured.")
            return
        print(f"{'Name':<20} {'Alias':<25} {'Default':<10}")
        print("-" * 55)
        for acc in accounts:
            default_mark = "*" if acc["is_default"] else ""
            print(f"{acc['name']:<20} {acc['alias']:<25} {default_mark:<10}")
        return

    elif args.command == "add-account":
        from account_manager import add_account, get_profile_dir
        if add_account(args.name, args.alias):
            print(f"Account '{args.name}' added.")
            print(f"Profile dir: {get_profile_dir(args.name)}")
            print("\nTo log in to this account, run:")
            print(f"  python cdp_publish.py --account {args.name} login")
        else:
            print(f"Error: Account '{args.name}' already exists.", file=sys.stderr)
            sys.exit(1)
        return

    elif args.command == "remove-account":
        from account_manager import remove_account
        if remove_account(args.name, args.delete_profile):
            print(f"Account '{args.name}' removed.")
        else:
            print(f"Error: Cannot remove account '{args.name}'.", file=sys.stderr)
            sys.exit(1)
        return

    elif args.command == "set-default-account":
        from account_manager import set_default_account
        if set_default_account(args.name):
            print(f"Default account set to '{args.name}'.")
        else:
            print(f"Error: Account '{args.name}' not found.", file=sys.stderr)
            sys.exit(1)
        return

    # Commands that require Chrome - login/re-login/switch-account always headed
    if args.command in ("login", "re-login", "switch-account"):
        headless = False

    if local_mode:
        if not ensure_chrome(port=port, headless=headless, account=account):
            print("Failed to start Chrome. Exiting.")
            sys.exit(1)
    else:
        print(
            f"[cdp_publish] Remote CDP mode enabled: {host}:{port}. "
            "Skipping local Chrome launch/restart."
        )

    print(f"[cdp_publish] Timing jitter ratio: {timing_jitter:.2f}")
    print(f"[cdp_publish] Login cache: enabled (ttl={DEFAULT_LOGIN_CACHE_TTL_HOURS:g}h).")
    if reuse_existing_tab:
        print("[cdp_publish] Tab selection mode: prefer reusing existing tab.")

    publisher = XiaohongshuPublisher(
        host=host,
        port=port,
        timing_jitter=timing_jitter,
        account_name=cache_account_name,
    )
    try:
        if args.command == "check-login":
            publisher.connect(reuse_existing_tab=reuse_existing_tab)
            logged_in = publisher.check_login()
            if not logged_in and headless:
                print(
                    "[cdp_publish] Headless mode: cannot scan QR code.\n"
                    "  Run with 'login' command or without --headless to log in."
                )
            sys.exit(0 if logged_in else 1)

        elif args.command in ("fill", "publish"):
            content = args.content
            if args.content_file:
                with open(args.content_file, encoding="utf-8") as f:
                    content = f.read().strip()
            if not content:
                print("Error: --content or --content-file required.", file=sys.stderr)
                sys.exit(1)

            publisher.connect(reuse_existing_tab=reuse_existing_tab)
            if getattr(args, "video", None):
                publisher.publish_video(
                    title=args.title, content=content, video_path=args.video
                )
            else:
                publisher.publish(
                    title=args.title, content=content, image_paths=args.images
                )
            print("FILL_STATUS: READY_TO_PUBLISH")

            if args.command == "publish":
                publisher._click_publish()
                print("PUBLISH_STATUS: PUBLISHED")

        elif args.command == "click-publish":
            publisher.connect(
                target_url_prefix="https://creator.xiaohongshu.com/publish",
                reuse_existing_tab=reuse_existing_tab,
            )
            publisher._click_publish()
            print("PUBLISH_STATUS: PUBLISHED")

        elif args.command in ("search-feeds", "search_feeds"):
            publisher.connect(reuse_existing_tab=reuse_existing_tab)
            if not publisher.check_home_login():
                print("NOT_LOGGED_IN")
                sys.exit(1)

            filters = _build_search_filters_from_args(args)
            search_result = publisher.search_feeds(keyword=args.keyword, filters=filters)
            feeds = search_result.get("feeds", [])
            recommended_keywords = search_result.get("recommended_keywords", [])
            payload = {
                "keyword": args.keyword,
                "recommended_keywords_count": len(recommended_keywords),
                "recommended_keywords": recommended_keywords,
                "count": len(feeds),
                "feeds": feeds,
            }
            print("SEARCH_FEEDS_RESULT:")
            print(json.dumps(payload, ensure_ascii=False, indent=2))

        elif args.command in ("get-feed-detail", "get_feed_detail"):
            publisher.connect(reuse_existing_tab=reuse_existing_tab)
            if not publisher.check_home_login():
                print("NOT_LOGGED_IN")
                sys.exit(1)

            detail = publisher.get_feed_detail(
                feed_id=args.feed_id,
                xsec_token=args.xsec_token,
            )
            payload = {
                "feed_id": args.feed_id,
                "xsec_token": args.xsec_token,
                "detail": detail,
            }
            print("GET_FEED_DETAIL_RESULT:")
            print(json.dumps(payload, ensure_ascii=False, indent=2))

        elif args.command in ("post-comment-to-feed", "post_comment_to_feed"):
            publisher.connect(reuse_existing_tab=reuse_existing_tab)
            if not publisher.check_home_login():
                print("NOT_LOGGED_IN")
                sys.exit(1)

            comment_content = args.content
            if args.content_file:
                with open(args.content_file, encoding="utf-8") as f:
                    comment_content = f.read().strip()
            if not comment_content:
                print("Error: --content or --content-file required.", file=sys.stderr)
                sys.exit(1)

            payload = publisher.post_comment_to_feed(
                feed_id=args.feed_id,
                xsec_token=args.xsec_token,
                content=comment_content,
            )
            print("POST_COMMENT_RESULT:")
            print(json.dumps(payload, ensure_ascii=False, indent=2))

        elif args.command in ("get-notification-mentions", "get_notification_mentions"):
            publisher.connect(reuse_existing_tab=reuse_existing_tab)
            if not publisher.check_home_login():
                print("NOT_LOGGED_IN")
                sys.exit(1)

            payload = publisher.get_notification_mentions(wait_seconds=args.wait_seconds)
            print("GET_NOTIFICATION_MENTIONS_RESULT:")
            print(json.dumps(payload, ensure_ascii=False, indent=2))

        elif args.command in ("content-data", "content_data"):
            publisher.connect(reuse_existing_tab=reuse_existing_tab)
            if not publisher.check_login():
                print("NOT_LOGGED_IN")
                sys.exit(1)

            payload = publisher.get_content_data(
                page_num=args.page_num,
                page_size=args.page_size,
                note_type=args.note_type,
            )
            print("CONTENT_DATA_RESULT:")
            print(json.dumps(payload, ensure_ascii=False, indent=2))

            if args.csv_file:
                csv_path = _write_content_data_csv(
                    csv_file=args.csv_file,
                    rows=payload.get("rows", []),
                )
                print(f"CONTENT_DATA_CSV: {csv_path}")

        elif args.command == "login":
            # Ensure headed mode for QR scanning
            if local_mode:
                restart_chrome(port=port, headless=False, account=account)
            publisher.connect(reuse_existing_tab=reuse_existing_tab)
            publisher.open_login_page()
            print("LOGIN_READY")

        elif args.command == "re-login":
            # Ensure headed mode, clear cookies, re-open login page for same account
            if local_mode:
                restart_chrome(port=port, headless=False, account=account)
            publisher.connect(reuse_existing_tab=reuse_existing_tab)
            publisher.clear_cookies()
            publisher._sleep(1, minimum_seconds=0.5)
            publisher.open_login_page()
            print("RE_LOGIN_READY")

        elif args.command == "switch-account":
            # Ensure headed mode, clear cookies, open login page
            if local_mode:
                restart_chrome(port=port, headless=False, account=account)
            publisher.connect(reuse_existing_tab=reuse_existing_tab)
            publisher.clear_cookies()
            publisher._sleep(1, minimum_seconds=0.5)
            publisher.open_login_page()
            print("SWITCH_ACCOUNT_READY")

    finally:
        publisher.disconnect()


if __name__ == "__main__":
    try:
        with single_instance("post_to_xhs_publish"):
            main()
    except SingleInstanceError as e:
        print(f"Error: {e}", file=sys.stderr)
        sys.exit(3)


path: scripts/chrome_launcher.py
content: """
Chrome launcher with CDP remote debugging support.

Manages a dedicated Chrome instance for Xiaohongshu publishing:
- Detects if Chrome is already listening on the debug port
- Launches Chrome with a dedicated user-data-dir for login persistence
- Waits for the debug port to become available
- Supports headless mode for automated publishing without GUI
- Supports switching between headless and headed mode (e.g. for login)
- Supports multiple accounts with separate profile directories
"""

import os
import sys
import time
import socket
import subprocess
from typing import Optional

CDP_PORT = 9222
PROFILE_DIR_NAME = "XiaohongshuProfile"
STARTUP_TIMEOUT = 15  # seconds to wait for Chrome to start

# Track the Chrome process we launched so we can kill it later
_chrome_process: subprocess.Popen | None = None
# Track the current account being used
_current_account: Optional[str] = None


def get_chrome_path() -> str:
    """Find Chrome executable on Windows/macOS/Linux."""
    candidates = []

    if sys.platform == "win32":
        for env_var in ("PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA"):
            base = os.environ.get(env_var, "")
            if base:
                candidates.append(
                    os.path.join(base, "Google", "Chrome", "Application", "chrome.exe")
                )
    elif sys.platform == "darwin":
        candidates.extend(
            [
                "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
                os.path.expanduser("~/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
            ]
        )
    else:
        candidates.extend(
            [
                "/usr/bin/google-chrome",
                "/usr/bin/google-chrome-stable",
                "/usr/bin/chromium-browser",
                "/usr/bin/chromium",
            ]
        )

    for path in candidates:
        if os.path.isfile(path):
            return path

    import shutil
    found = (
        shutil.which("google-chrome")
        or shutil.which("google-chrome-stable")
        or shutil.which("chromium-browser")
        or shutil.which("chromium")
        or shutil.which("chrome")
        or shutil.which("chrome.exe")
    )
    if found:
        return found

    raise FileNotFoundError(
        "Chrome not found. Please install Google Chrome or set its path manually."
    )


def get_user_data_dir(account: Optional[str] = None) -> str:
    """
    Return the Chrome profile directory path for a given account.

    Args:
        account: Account name. If None, uses the default account from account_manager.

    Returns:
        Path to the Chrome user-data-dir for this account.
    """
    try:
        from account_manager import get_profile_dir
        return get_profile_dir(account)
    except ImportError:
        # Fallback if account_manager not available
        local_app_data = os.environ.get("LOCALAPPDATA", "")
        if not local_app_data:
            local_app_data = os.path.expanduser("~")
        return os.path.join(local_app_data, "Google", "Chrome", PROFILE_DIR_NAME)


def is_port_open(port: int, host: str = "127.0.0.1") -> bool:
    """Check if a TCP port is accepting connections."""
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.settimeout(1)
        try:
            s.connect((host, port))
            return True
        except (ConnectionRefusedError, socket.timeout, OSError):
            return False


def launch_chrome(
    port: int = CDP_PORT,
    headless: bool = False,
    account: Optional[str] = None,
) -> subprocess.Popen | None:
    """
    Launch Chrome with remote debugging enabled.

    Args:
        port: CDP remote debugging port.
        headless: If True, launch Chrome in headless mode (no GUI window).
        account: Account name to use. If None, uses the default account.

    Returns the Popen object if a new process was started, or None if Chrome
    was already running on the target port.
    """
    global _chrome_process, _current_account

    if is_port_open(port):
        print(f"[chrome_launcher] Chrome already running on port {port}.")
        return None

    chrome_path = get_chrome_path()
    user_data_dir = get_user_data_dir(account)
    _current_account = account

    cmd = [
        chrome_path,
        f"--remote-debugging-port={port}",
        f"--user-data-dir={user_data_dir}",
        "--no-first-run",
        "--no-default-browser-check",
    ]

    if headless:
        cmd.append("--headless=new")

    mode_label = "headless" if headless else "headed"
    account_label = account or "default"
    print(f"[chrome_launcher] Launching Chrome ({mode_label}, account: {account_label})...")
    print(f"  executable : {chrome_path}")
    print(f"  profile dir: {user_data_dir}")
    print(f"  debug port : {port}")

    proc = subprocess.Popen(
        cmd,
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
    )
    _chrome_process = proc

    # Wait for the debug port to become available
    deadline = time.time() + STARTUP_TIMEOUT
    while time.time() < deadline:
        if is_port_open(port):
            print(f"[chrome_launcher] Chrome is ready on port {port}.")
            return proc
        time.sleep(0.5)

    print(
        f"[chrome_launcher] WARNING: Chrome started but port {port} not responding "
        f"after {STARTUP_TIMEOUT}s. It may still be initializing.",
        file=sys.stderr,
    )
    return proc


def kill_chrome(port: int = CDP_PORT):
    """
    Kill the Chrome instance on the given debug port.

    Tries multiple strategies:
    1. Send CDP Browser.close command via HTTP
    2. Terminate the tracked subprocess
    3. Kill by port on Windows (taskkill)
    """
    global _chrome_process

    # Strategy 1: CDP Browser.close
    try:
        import requests
        resp = requests.get(f"http://127.0.0.1:{port}/json/version", timeout=2)
        if resp.ok:
            ws_url = resp.json().get("webSocketDebuggerUrl")
            if ws_url:
                import websockets.sync.client as ws_client
                ws = ws_client.connect(ws_url)
                ws.send('{"id":1,"method":"Browser.close"}')
                try:
                    ws.recv(timeout=2)
                except Exception:
                    pass
                ws.close()
                print("[chrome_launcher] Sent Browser.close via CDP.")
    except Exception:
        pass

    # Wait briefly for Chrome to shut down
    time.sleep(1)

    # Strategy 2: Terminate tracked subprocess
    if _chrome_process and _chrome_process.poll() is None:
        try:
            _chrome_process.terminate()
            _chrome_process.wait(timeout=5)
            print("[chrome_launcher] Terminated tracked Chrome process.")
        except Exception:
            try:
                _chrome_process.kill()
            except Exception:
                pass
    _chrome_process = None

    # Strategy 3: Windows taskkill by port (fallback)
    if sys.platform == "win32" and is_port_open(port):
        try:
            result = subprocess.run(
                ["netstat", "-ano"],
                capture_output=True, text=True, timeout=5
            )
            for line in result.stdout.splitlines():
                if f":{port}" in line and "LISTENING" in line:
                    pid = line.strip().split()[-1]
                    subprocess.run(
                        ["taskkill", "/F", "/PID", pid],
                        capture_output=True, timeout=5
                    )
                    print(f"[chrome_launcher] Killed process {pid} via taskkill.")
                    break
        except Exception:
            pass

    # Wait for port to be released
    deadline = time.time() + 5
    while time.time() < deadline:
        if not is_port_open(port):
            return
        time.sleep(0.5)

    if is_port_open(port):
        print(f"[chrome_launcher] WARNING: port {port} still open after kill attempt.",
              file=sys.stderr)


def restart_chrome(
    port: int = CDP_PORT,
    headless: bool = False,
    account: Optional[str] = None,
) -> subprocess.Popen | None:
    """
    Kill the current Chrome instance and relaunch with the specified mode.

    Useful for switching between headless and headed mode (e.g. when login
    is needed during a headless session), or switching accounts.

    Args:
        port: CDP remote debugging port.
        headless: If True, relaunch in headless mode.
        account: Account name to use. If None, uses the default account.

    Returns the Popen object for the new Chrome process.
    """
    account_label = account or "default"
    mode_label = "headless" if headless else "headed"
    print(f"[chrome_launcher] Restarting Chrome ({mode_label}, account: {account_label})...")
    kill_chrome(port)
    time.sleep(1)
    return launch_chrome(port, headless=headless, account=account)


def ensure_chrome(
    port: int = CDP_PORT,
    headless: bool = False,
    account: Optional[str] = None,
) -> bool:
    """
    Ensure Chrome is running with remote debugging on the given port.

    Args:
        port: CDP remote debugging port.
        headless: If True, launch in headless mode when starting a new instance.
            If Chrome is already running, this parameter is ignored.
        account: Account name to use. If None, uses the default account.

    Returns True if Chrome is available, False otherwise.
    """
    if is_port_open(port):
        return True
    try:
        launch_chrome(port, headless=headless, account=account)
        return is_port_open(port)
    except FileNotFoundError as e:
        print(f"[chrome_launcher] Error: {e}", file=sys.stderr)
        return False


def get_current_account() -> Optional[str]:
    """Get the name of the currently active account."""
    return _current_account


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser(description="Chrome Launcher for CDP")
    parser.add_argument("--port", type=int, default=CDP_PORT,
                        help=f"CDP remote debugging port (default: {CDP_PORT})")
    parser.add_argument("--headless", action="store_true", help="Launch in headless mode")
    parser.add_argument("--kill", action="store_true", help="Kill the running Chrome instance")
    parser.add_argument("--restart", action="store_true", help="Restart Chrome")
    parser.add_argument("--account", help="Account name to use (default: default account)")
    args = parser.parse_args()

    if args.kill:
        kill_chrome(port=args.port)
        print("[chrome_launcher] Chrome killed.")
    elif args.restart:
        restart_chrome(port=args.port, headless=args.headless, account=args.account)
        print("[chrome_launcher] Chrome restarted.")
    elif ensure_chrome(port=args.port, headless=args.headless, account=args.account):
        print("[chrome_launcher] Chrome is ready for CDP connections.")
    else:
        print("[chrome_launcher] Failed to start Chrome.", file=sys.stderr)
        sys.exit(1)


path: scripts/feed_explorer.py
content: """
Feed search and detail extraction helpers for Xiaohongshu.

This module focuses on extracting data from `window.__INITIAL_STATE__` on
Xiaohongshu pages. It is designed to be reused by CDP automation flows.
"""

from __future__ import annotations

import json
import time
from dataclasses import dataclass
from typing import Any, Callable
from urllib.parse import urlencode


SEARCH_BASE_URL = "https://www.xiaohongshu.com/search_result"
FEED_DETAIL_URL_TEMPLATE = (
    "https://www.xiaohongshu.com/explore/{feed_id}"
    "?xsec_token={xsec_token}&xsec_source=pc_feed"
)


SORT_BY_OPTIONS = ("综合", "最新", "最多点赞", "最多评论", "最多收藏")
NOTE_TYPE_OPTIONS = ("不限", "视频", "图文")
PUBLISH_TIME_OPTIONS = ("不限", "一天内", "一周内", "半年内")
SEARCH_SCOPE_OPTIONS = ("不限", "已看过", "未看过", "已关注")
LOCATION_OPTIONS = ("不限", "同城", "附近")

_FILTER_VALUE_OPTIONS = {
    "sort_by": SORT_BY_OPTIONS,
    "note_type": NOTE_TYPE_OPTIONS,
    "publish_time": PUBLISH_TIME_OPTIONS,
    "search_scope": SEARCH_SCOPE_OPTIONS,
    "location": LOCATION_OPTIONS,
}
_ALL_FILTER_OPTION_VALUES = tuple(
    dict.fromkeys(
        [
            *SORT_BY_OPTIONS,
            *NOTE_TYPE_OPTIONS,
            *PUBLISH_TIME_OPTIONS,
            *SEARCH_SCOPE_OPTIONS,
            *LOCATION_OPTIONS,
        ]
    )
)


class FeedExplorerError(Exception):
    """Raised when search/detail extraction fails."""


@dataclass(slots=True)
class SearchFilters:
    """Filter options for Xiaohongshu search page."""

    sort_by: str | None = None
    note_type: str | None = None
    publish_time: str | None = None
    search_scope: str | None = None
    location: str | None = None

    def selected_items(self) -> list[tuple[str, str]]:
        """Return selected non-empty filter items as (field_name, value)."""
        items: list[tuple[str, str]] = []
        for name in _FILTER_VALUE_OPTIONS:
            value = getattr(self, name)
            if value:
                items.append((name, value))
        return items

    def validate(self):
        """Validate selected filter values."""
        for name, value in self.selected_items():
            valid_values = _FILTER_VALUE_OPTIONS[name]
            if value not in valid_values:
                raise FeedExplorerError(
                    f"Invalid value for {name}: {value}. "
                    f"Valid options: {', '.join(valid_values)}"
                )


def make_search_url(keyword: str) -> str:
    """Build Xiaohongshu search URL for feed search."""
    if not keyword.strip():
        raise FeedExplorerError("Keyword cannot be empty.")
    query = urlencode({"keyword": keyword.strip(), "source": "web_explore_feed"})
    return f"{SEARCH_BASE_URL}?{query}"


def make_feed_detail_url(feed_id: str, xsec_token: str) -> str:
    """Build feed detail URL from feed id and xsec token."""
    feed_id = feed_id.strip()
    xsec_token = xsec_token.strip()
    if not feed_id:
        raise FeedExplorerError("feed_id cannot be empty.")
    if not xsec_token:
        raise FeedExplorerError("xsec_token cannot be empty.")
    return FEED_DETAIL_URL_TEMPLATE.format(feed_id=feed_id, xsec_token=xsec_token)


class FeedExplorer:
    """
    Reusable extractor for feed search/detail pages.

    Args:
        evaluate: A callable that executes JavaScript and returns result value.
        sleep: A callable compatible with `sleep(base_seconds, minimum_seconds=...)`.
    """

    def __init__(
        self,
        evaluate: Callable[[str], Any],
        sleep: Callable[..., None],
        move_mouse: Callable[[float, float], None] | None = None,
        click_mouse: Callable[[float, float], None] | None = None,
    ):
        self._evaluate = evaluate
        self._sleep = sleep
        self._move_mouse = move_mouse
        self._click_mouse = click_mouse

    def _option_ordered_values(self, filters: SearchFilters) -> list[str]:
        """Return filter values in deterministic UI order."""
        ordered_keys = ("sort_by", "note_type", "publish_time", "search_scope", "location")
        values: list[str] = []
        for key in ordered_keys:
            value = getattr(filters, key)
            if value:
                values.append(value)
        return values

    def _wait_js_condition(
        self,
        condition_js: str,
        timeout_seconds: float = 20.0,
        poll_seconds: float = 0.5,
    ) -> bool:
        """Wait until JavaScript condition becomes truthy."""
        deadline = time.time() + timeout_seconds
        while time.time() < deadline:
            try:
                ok = self._evaluate(condition_js)
                if ok:
                    return True
            except Exception:
                # Ignore transient DOM errors and continue polling.
                pass
            self._sleep(poll_seconds, minimum_seconds=min(0.2, poll_seconds))
        return False

    def _wait_for_search_state(self):
        """Wait until search feed data is available in __INITIAL_STATE__."""
        ready = self._wait_js_condition(
            """
            (() => {
                const state = window.__INITIAL_STATE__;
                return !!(
                    state &&
                    state.search &&
                    state.search.feeds
                );
            })()
            """,
            timeout_seconds=25.0,
            poll_seconds=0.6,
        )
        if not ready:
            raise FeedExplorerError(
                "Timed out waiting for search data in window.__INITIAL_STATE__."
            )

    def _wait_for_detail_state(self):
        """Wait until detail note data is available in __INITIAL_STATE__."""
        ready = self._wait_js_condition(
            """
            (() => {
                const state = window.__INITIAL_STATE__;
                return !!(
                    state &&
                    state.note &&
                    state.note.noteDetailMap
                );
            })()
            """,
            timeout_seconds=25.0,
            poll_seconds=0.6,
        )
        if not ready:
            raise FeedExplorerError(
                "Timed out waiting for feed detail in window.__INITIAL_STATE__."
            )

    def _find_filter_button_rect(self) -> dict[str, float] | None:
        """Return visible filter button rect."""
        rect = self._evaluate(
            """
            (() => {
                const btn = document.querySelector("div.filter, [class*='filter']");
                if (!(btn instanceof HTMLElement) || btn.offsetParent === null) {
                    return null;
                }
                const r = btn.getBoundingClientRect();
                return { x: r.x, y: r.y, width: r.width, height: r.height };
            })()
            """
        )
        return rect if isinstance(rect, dict) else None

    def _find_filter_panel_rect(self) -> dict[str, float] | None:
        """Return visible filter panel rect when available."""
        option_values_literal = json.dumps(list(_ALL_FILTER_OPTION_VALUES), ensure_ascii=False)
        rect = self._evaluate(f"""
            (() => {{
                const optionValues = {option_values_literal};
                const normalize = (text) => (text || "").replace(/\\s+/g, " ").trim();
                const root = document.querySelector("div.filter, [class*='filter']");
                if (!(root instanceof HTMLElement) || root.offsetParent === null) {{
                    return null;
                }}
                const selectors = [
                    ".filter-panel",
                    "[class*='filter-panel']",
                    "[class*='filter-pop']",
                ];
                const nodes = root.querySelectorAll(selectors.join(","));
                for (const node of nodes) {{
                    if (!(node instanceof HTMLElement) || node.offsetParent === null) {{
                        continue;
                    }}
                    const text = normalize(node.innerText || node.textContent);
                    if (!text) {{
                        continue;
                    }}
                    if (!optionValues.some((option) => text.includes(option))) {{
                        continue;
                    }}
                    const r = node.getBoundingClientRect();
                    if (r.width < 60 || r.height < 30) {{
                        continue;
                    }}
                    return {{ x: r.x, y: r.y, width: r.width, height: r.height }};
                }}
                return null;
            }})()
        """)
        return rect if isinstance(rect, dict) else None

    def _find_filter_option_rect(self, filter_value: str) -> dict[str, float] | None:
        """Return target filter option rect from visible filter panel only."""
        option_literal = json.dumps(filter_value, ensure_ascii=False)
        rect = self._evaluate(f"""
            (() => {{
                const targetText = {option_literal};
                const normalize = (text) => (text || "").replace(/\\s+/g, " ").trim();
                const root = document.querySelector("div.filter, [class*='filter']");
                if (!(root instanceof HTMLElement) || root.offsetParent === null) {{
                    return null;
                }}
                const panel = root.querySelector(
                    ".filter-panel, [class*='filter-panel'], [class*='filter-pop']"
                );
                if (!(panel instanceof HTMLElement) || panel.offsetParent === null) {{
                    return null;
                }}
                const nodes = panel.querySelectorAll("button, [role='button'], div, span, li, a");
                for (const el of nodes) {{
                    if (!(el instanceof HTMLElement) || el.offsetParent === null) {{
                        continue;
                    }}
                    const text = normalize(el.textContent);
                    if (text !== targetText) {{
                        continue;
                    }}

                    const r = el.getBoundingClientRect();
                    if (r.width < 12 || r.height < 10 || r.width > 260 || r.height > 96) {{
                        continue;
                    }}

                    let hasSameTextChild = false;
                    for (const child of el.children) {{
                        if (normalize(child.textContent) === targetText) {{
                            hasSameTextChild = true;
                            break;
                        }}
                    }}
                    if (hasSameTextChild) {{
                        continue;
                    }}
                    return {{ x: r.x, y: r.y, width: r.width, height: r.height }};
                }}
                return null;
            }})()
        """)
        return rect if isinstance(rect, dict) else None

    def _open_filter_panel_via_hover_mouse(self) -> tuple[bool, str]:
        """Open hover panel and keep cursor inside panel area."""
        if not self._move_mouse:
            return False, "mouse_dispatch_not_available"

        button_rect = self._find_filter_button_rect()
        if not button_rect:
            return False, "filter_button_not_found"

        bx = float(button_rect["x"]) + float(button_rect["width"]) / 2
        by = float(button_rect["y"]) + float(button_rect["height"]) / 2
        near_x = bx - 18.0
        near_y = by + 18.0

        for attempt in range(20):
            if attempt == 0:
                self._move_mouse(bx, by)
            else:
                if attempt % 2 == 0:
                    self._move_mouse(near_x, near_y)
                else:
                    self._move_mouse(bx, by)
            self._sleep(0.08, minimum_seconds=0.03)

            panel_rect = self._find_filter_panel_rect()
            if panel_rect:
                enter_x = float(panel_rect["x"]) + float(panel_rect["width"]) - 18.0
                enter_y = float(panel_rect["y"]) + min(28.0, float(panel_rect["height"]) - 10.0)
                self._move_mouse(enter_x, enter_y)
                self._sleep(0.06, minimum_seconds=0.02)
                if self._find_filter_panel_rect():
                    return True, "ok"

        return False, "filter_panel_not_found"

    def _apply_filters_in_single_panel(self, option_values: list[str]) -> tuple[bool, str]:
        """Apply all filters in one hover session/panel."""
        if not option_values:
            return True, "ok"
        if not self._move_mouse or not self._click_mouse:
            return False, "mouse_dispatch_not_available"

        opened, reason = self._open_filter_panel_via_hover_mouse()
        if not opened:
            return False, reason

        for value in option_values:
            applied = False
            for _ in range(8):
                option_rect = self._find_filter_option_rect(value)
                if not option_rect:
                    opened, reason = self._open_filter_panel_via_hover_mouse()
                    if not opened:
                        continue
                    option_rect = self._find_filter_option_rect(value)

                if not option_rect:
                    self._sleep(0.07, minimum_seconds=0.03)
                    continue

                ox = float(option_rect["x"]) + float(option_rect["width"]) / 2
                oy = float(option_rect["y"]) + float(option_rect["height"]) / 2
                self._move_mouse(ox, oy)
                self._sleep(0.05, minimum_seconds=0.02)
                self._click_mouse(ox, oy)
                self._sleep(0.16, minimum_seconds=0.06)

                panel_rect = self._find_filter_panel_rect()
                if panel_rect:
                    keep_x = float(panel_rect["x"]) + float(panel_rect["width"]) - 18.0
                    keep_y = float(panel_rect["y"]) + min(28.0, float(panel_rect["height"]) - 10.0)
                    self._move_mouse(keep_x, keep_y)
                    self._sleep(0.05, minimum_seconds=0.02)

                applied = True
                break

            if not applied:
                return False, f"option_not_found:{value}"

        return True, "ok"

    def _try_apply_filter_via_hover_mouse(self, filter_value: str) -> tuple[bool, str]:
        """
        Apply one filter option via real CDP mouse hover/click.

        This path is used because some Xiaohongshu filter panels are CSS-hover
        driven and cannot be reliably opened via synthetic JS events.
        """
        if not self._move_mouse or not self._click_mouse:
            return False, "mouse_dispatch_not_available"

        button_rect = self._find_filter_button_rect()
        if not button_rect:
            return False, "filter_button_not_found"

        bx = float(button_rect["x"]) + float(button_rect["width"]) / 2
        by = float(button_rect["y"]) + float(button_rect["height"]) / 2
        below_x = bx
        below_y = float(button_rect["y"]) + float(button_rect["height"]) + 28

        self._move_mouse(bx, by)
        self._sleep(0.12, minimum_seconds=0.05)

        for idx in range(24):
            panel_rect = self._find_filter_panel_rect()
            if panel_rect:
                px = float(panel_rect["x"]) + min(28.0, float(panel_rect["width"]) * 0.25)
                py = float(panel_rect["y"]) + min(24.0, float(panel_rect["height"]) * 0.25)
                self._move_mouse(px, py)
                self._sleep(0.05, minimum_seconds=0.02)

            option_rect = self._find_filter_option_rect(filter_value)
            if option_rect:
                ox = float(option_rect["x"]) + float(option_rect["width"]) / 2
                oy = float(option_rect["y"]) + float(option_rect["height"]) / 2
                self._move_mouse(ox, oy)
                self._sleep(0.08, minimum_seconds=0.03)
                self._click_mouse(ox, oy)
                return True, "ok"

            if idx % 2 == 0:
                self._move_mouse(bx, by)
            else:
                self._move_mouse(below_x, below_y)
            self._sleep(0.12, minimum_seconds=0.05)

        return False, "option_not_found"

    def _apply_single_filter_js_fallback(self, filter_value: str) -> dict[str, Any]:
        """Fallback path using in-page synthetic events only."""
        option_literal = json.dumps(filter_value)
        option_values_literal = json.dumps(list(_ALL_FILTER_OPTION_VALUES), ensure_ascii=False)
        result = self._evaluate(f"""
            (async () => {{
                const targetText = {option_literal};
                const optionValues = {option_values_literal};
                const filterBtn = document.querySelector("div.filter, [class*='filter']");
                if (!filterBtn) {{
                    return {{ ok: false, reason: "filter_button_not_found" }};
                }}

                const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
                const normalizeText = (text) => (text || "").replace(/\\s+/g, " ").trim();

                const openFilterPanel = () => {{
                    filterBtn.dispatchEvent(new MouseEvent("mouseenter", {{ bubbles: true }}));
                    filterBtn.dispatchEvent(new MouseEvent("mouseover", {{ bubbles: true }}));
                    filterBtn.dispatchEvent(new MouseEvent("mousemove", {{ bubbles: true }}));
                }};

                const findVisiblePanel = () => {{
                    const selectors = [
                        ".filter-panel",
                        "[class*='filter-panel']",
                        "[class*='filter-pop']",
                        "[class*='popover']",
                        "[class*='popup']",
                        "[role='menu']",
                        "[role='listbox']",
                    ];
                    const nodes = document.querySelectorAll(selectors.join(","));
                    for (const node of nodes) {{
                        if (!(node instanceof HTMLElement)) {{
                            continue;
                        }}
                        if (node.offsetParent === null) {{
                            continue;
                        }}
                        const text = normalizeText(node.innerText || node.textContent);
                        if (!optionValues.some((option) => text.includes(option))) {{
                            continue;
                        }}
                        return node;
                    }}
                    return null;
                }};

                openFilterPanel();
                let panel = null;
                for (let i = 0; i < 20; i++) {{
                    panel = findVisiblePanel();
                    if (panel) {{
                        break;
                    }}
                    if (i === 6 || i === 12) {{
                        openFilterPanel();
                    }}
                    await sleep(120);
                }}

                if (!panel) {{
                    return {{ ok: false, reason: "filter_panel_not_found" }};
                }}

                const findOption = () => {{
                    const candidates = panel.querySelectorAll(
                        "[class*='tag'], [role='button'], button, span, div, li"
                    );
                    for (const el of candidates) {{
                        if (!(el instanceof HTMLElement) || el.offsetParent === null) {{
                            continue;
                        }}
                        const text = normalizeText(el.textContent);
                        if (text !== targetText) {{
                            continue;
                        }}
                        let hasSameTextChild = false;
                        for (const child of el.children) {{
                            if (normalizeText(child.textContent) === targetText) {{
                                hasSameTextChild = true;
                                break;
                            }}
                        }}
                        if (hasSameTextChild) {{
                            continue;
                        }}
                        return el;
                    }}
                    return null;
                }};

                let optionEl = null;
                for (let i = 0; i < 12; i++) {{
                    optionEl = findOption();
                    if (optionEl) {{
                        break;
                    }}
                    await sleep(80);
                }}

                if (!optionEl) {{
                    return {{ ok: false, reason: "option_not_found" }};
                }}

                optionEl.click();
                return {{ ok: true }};
            }})()
        """)
        return result if isinstance(result, dict) else {"ok": False, "reason": "unknown"}

    def _apply_single_filter(self, filter_value: str):
        """Apply one filter option with hover-first strategy."""
        hover_reason = None
        if self._move_mouse and self._click_mouse:
            hover_ok, hover_reason = self._try_apply_filter_via_hover_mouse(filter_value)
            if hover_ok:
                self._sleep(1.2, minimum_seconds=0.4)
                self._wait_for_search_state()
                return

        js_result = self._apply_single_filter_js_fallback(filter_value)
        if js_result.get("ok"):
            self._sleep(1.2, minimum_seconds=0.4)
            self._wait_for_search_state()
            return

        reason = js_result.get("reason", "unknown")
        if hover_reason and hover_reason != reason:
            reason = f"{reason} (hover_attempt={hover_reason})"
        raise FeedExplorerError(
            f"Failed to apply search filter '{filter_value}': {reason}"
        )

    def _extract_search_feeds(self) -> list[dict[str, Any]]:
        """Extract feeds array from search initial state."""
        raw = self._evaluate(
            """
            (() => {
                if (
                    window.__INITIAL_STATE__ &&
                    window.__INITIAL_STATE__.search &&
                    window.__INITIAL_STATE__.search.feeds
                ) {
                    const feeds = window.__INITIAL_STATE__.search.feeds;
                    const data = feeds.value !== undefined ? feeds.value : feeds._value;
                    if (data) {
                        return JSON.stringify(data);
                    }
                }
                return "";
            })()
            """
        )

        if not raw:
            return []

        if not isinstance(raw, str):
            raise FeedExplorerError("Search feed payload is not a JSON string.")

        try:
            parsed = json.loads(raw)
        except json.JSONDecodeError as exc:
            raise FeedExplorerError(f"Failed to parse search feed JSON: {exc}") from exc

        if not isinstance(parsed, list):
            raise FeedExplorerError("Search feed payload is not a list.")
        return parsed

    def _extract_feed_detail(self, feed_id: str) -> dict[str, Any]:
        """Extract one feed detail object from noteDetailMap."""
        feed_literal = json.dumps(feed_id)
        raw = self._evaluate(f"""
            (() => {{
                const feedId = {feed_literal};
                const state = window.__INITIAL_STATE__;
                if (!state || !state.note || !state.note.noteDetailMap) {{
                    return "";
                }}

                const detailMap = state.note.noteDetailMap;
                if (detailMap[feedId]) {{
                    return JSON.stringify(detailMap[feedId]);
                }}

                const keys = Object.keys(detailMap || {{}});
                if (keys.length === 1 && detailMap[keys[0]]) {{
                    return JSON.stringify(detailMap[keys[0]]);
                }}
                return "";
            }})()
        """)

        if not raw:
            raise FeedExplorerError(
                f"Could not find feed detail for id '{feed_id}' in noteDetailMap."
            )

        if not isinstance(raw, str):
            raise FeedExplorerError("Feed detail payload is not a JSON string.")

        try:
            parsed = json.loads(raw)
        except json.JSONDecodeError as exc:
            raise FeedExplorerError(f"Failed to parse feed detail JSON: {exc}") from exc

        if not isinstance(parsed, dict):
            raise FeedExplorerError("Feed detail payload is not an object.")
        return parsed

    def search_feeds(
        self,
        keyword: str,
        filters: SearchFilters | None = None,
    ) -> list[dict[str, Any]]:
        """Extract feed list from the current search page and apply optional filters."""
        _ = keyword  # kept for future keyword-aware diagnostics
        self._wait_for_search_state()

        if filters:
            filters.validate()
            values = self._option_ordered_values(filters)

            # Prefer a single hover session so multiple options are selected
            # within the same filter panel without resetting previous states.
            if values and self._move_mouse and self._click_mouse:
                ok, reason = self._apply_filters_in_single_panel(values)
                if not ok:
                    # Fallback path for page variants where hover structure differs.
                    for value in values:
                        self._apply_single_filter(value)
                else:
                    self._sleep(1.2, minimum_seconds=0.4)
                    self._wait_for_search_state()
            else:
                for value in values:
                    self._apply_single_filter(value)

        feeds = self._extract_search_feeds()
        if feeds:
            return feeds

        # On some page variants `search.feeds` is initialized as [] first and
        # filled asynchronously. Retry briefly before returning empty results.
        deadline = time.time() + 8.0
        while time.time() < deadline:
            self._sleep(0.6, minimum_seconds=0.2)
            feeds = self._extract_search_feeds()
            if feeds:
                return feeds
        return feeds

    def get_feed_detail(self, feed_id: str) -> dict[str, Any]:
        """Extract feed detail from the current feed detail page."""
        feed_id = feed_id.strip()
        if not feed_id:
            raise FeedExplorerError("feed_id cannot be empty.")
        self._wait_for_detail_state()
        return self._extract_feed_detail(feed_id)


path: scripts/image_downloader.py
content: """
Media downloader for Xiaohongshu publishing.

Downloads images and videos from URLs to a local temp directory for upload,
and cleans up after publishing is complete.
"""

import os
import sys
import tempfile
import shutil
import uuid
from urllib.parse import urlparse, unquote

import requests

DEFAULT_TIMEOUT = 30  # seconds per download
TEMP_DIR_PREFIX = "xhs_images_"


class ImageDownloader:
    """Download images from URLs and manage a temporary directory for them."""

    def __init__(self, temp_dir: str | None = None):
        if temp_dir:
            self.temp_dir = temp_dir
            os.makedirs(self.temp_dir, exist_ok=True)
            self._owns_dir = False
        else:
            self.temp_dir = tempfile.mkdtemp(prefix=TEMP_DIR_PREFIX)
            self._owns_dir = True
        self.downloaded_files: list[str] = []

    def _guess_extension(self, url: str, content_type: str | None) -> str:
        """Guess file extension from URL path or Content-Type header."""
        # Try URL path first
        path = urlparse(url).path
        _, ext = os.path.splitext(unquote(path))
        if ext and ext.lower() in (".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"):
            return ext.lower()

        # Fall back to Content-Type
        ct_map = {
            "image/jpeg": ".jpg",
            "image/png": ".png",
            "image/gif": ".gif",
            "image/webp": ".webp",
            "image/bmp": ".bmp",
        }
        if content_type:
            for mime, ext in ct_map.items():
                if mime in content_type:
                    return ext

        return ".jpg"  # safe default

    def _guess_video_extension(self, url: str, content_type: str | None) -> str:
        """Guess video file extension from URL path or Content-Type header."""
        path = urlparse(url).path
        _, ext = os.path.splitext(unquote(path))
        if ext and ext.lower() in (".mp4", ".mov", ".avi", ".mkv", ".flv", ".wmv", ".webm"):
            return ext.lower()

        ct_map = {
            "video/mp4": ".mp4",
            "video/quicktime": ".mov",
            "video/x-msvideo": ".avi",
            "video/x-matroska": ".mkv",
            "video/x-flv": ".flv",
            "video/x-ms-wmv": ".wmv",
            "video/webm": ".webm",
        }
        if content_type:
            for mime, ext in ct_map.items():
                if mime in content_type:
                    return ext

        return ".mp4"  # safe default for video

    def download(self, url: str, referer: str | None = None) -> str:
        """
        Download a single image and return the local file path.

        Args:
            url: Image URL to download
            referer: Optional Referer header. If None, auto-generates from URL domain.

        Raises requests.RequestException on network errors.
        """
        # Build headers with Referer to bypass hotlink protection
        parsed = urlparse(url)
        if referer is None:
            referer = f"{parsed.scheme}://{parsed.netloc}/"

        headers = {
            "Referer": referer,
            "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
        }

        resp = requests.get(url, timeout=DEFAULT_TIMEOUT, stream=True, headers=headers)
        resp.raise_for_status()

        ext = self._guess_extension(url, resp.headers.get("Content-Type"))
        filename = f"{uuid.uuid4().hex[:12]}{ext}"
        filepath = os.path.join(self.temp_dir, filename)

        with open(filepath, "wb") as f:
            for chunk in resp.iter_content(chunk_size=8192):
                f.write(chunk)

        self.downloaded_files.append(filepath)
        print(f"[image_downloader] Downloaded: {url}")
        print(f"  -> {filepath} ({os.path.getsize(filepath)} bytes)")
        return filepath

    def download_video(self, url: str, referer: str | None = None) -> str:
        """
        Download a single video and return the local file path.

        Args:
            url: Video URL to download
            referer: Optional Referer header. If None, auto-generates from URL domain.

        Raises requests.RequestException on network errors.
        """
        parsed = urlparse(url)
        if referer is None:
            referer = f"{parsed.scheme}://{parsed.netloc}/"

        headers = {
            "Referer": referer,
            "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
        }

        resp = requests.get(url, timeout=DEFAULT_TIMEOUT * 4, stream=True, headers=headers)
        resp.raise_for_status()

        ext = self._guess_video_extension(url, resp.headers.get("Content-Type"))
        filename = f"{uuid.uuid4().hex[:12]}{ext}"
        filepath = os.path.join(self.temp_dir, filename)

        with open(filepath, "wb") as f:
            for chunk in resp.iter_content(chunk_size=65536):
                f.write(chunk)

        self.downloaded_files.append(filepath)
        size_mb = os.path.getsize(filepath) / (1024 * 1024)
        print(f"[image_downloader] Downloaded video: {url}")
        print(f"  -> {filepath} ({size_mb:.1f} MB)")
        return filepath

    def download_all(self, urls: list[str]) -> list[str]:
        """
        Download multiple images. Returns list of local file paths.

        Skips URLs that fail to download (logs the error, continues).
        """
        paths = []
        for url in urls:
            try:
                path = self.download(url)
                paths.append(path)
            except Exception as e:
                print(f"[image_downloader] Failed to download {url}: {e}", file=sys.stderr)
        return paths

    def cleanup(self):
        """Remove all downloaded files and the temp directory."""
        if self._owns_dir and os.path.isdir(self.temp_dir):
            shutil.rmtree(self.temp_dir, ignore_errors=True)
            print(f"[image_downloader] Cleaned up temp dir: {self.temp_dir}")
        else:
            for f in self.downloaded_files:
                try:
                    os.remove(f)
                except OSError:
                    pass
            print(f"[image_downloader] Cleaned up {len(self.downloaded_files)} files.")
        self.downloaded_files.clear()

    def __enter__(self):
        return self

    def __exit__(self, *_):
        self.cleanup()


if __name__ == "__main__":
    # Quick test: download URLs passed as command-line arguments
    if len(sys.argv) < 2:
        print("Usage: python image_downloader.py <url1> [url2] ...")
        sys.exit(1)

    dl = ImageDownloader()
    paths = dl.download_all(sys.argv[1:])
    print(f"\nDownloaded {len(paths)} image(s):")
    for p in paths:
        print(f"  {p}")
    print(f"Temp dir: {dl.temp_dir}")
    print("Files will remain until manually cleaned up.")


path: scripts/run_lock.py
content: """Single-instance lock helpers for publish scripts."""

from __future__ import annotations

import json
import os
import sys
import tempfile
import uuid
from contextlib import contextmanager
from datetime import datetime, timezone
from typing import Any


class SingleInstanceError(RuntimeError):
    """Raised when another publish process is already running."""


def _lock_path(lock_name: str) -> str:
    safe_name = "".join(ch if ch.isalnum() or ch in ("-", "_") else "_" for ch in lock_name)
    return os.path.join(tempfile.gettempdir(), f"{safe_name}.lock")


def _pid_running(pid: int) -> bool:
    if pid <= 0:
        return False
    try:
        os.kill(pid, 0)
    except ProcessLookupError:
        return False
    except PermissionError:
        return True
    except OSError:
        return False
    return True


def _read_lock_data(path: str) -> dict[str, Any]:
    try:
        with open(path, "r", encoding="utf-8") as file_handle:
            data = json.load(file_handle)
            if isinstance(data, dict):
                return data
    except Exception:
        pass
    return {}


def _write_lock_data(path: str, payload: dict[str, Any]) -> None:
    fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
    with os.fdopen(fd, "w", encoding="utf-8") as file_handle:
        json.dump(payload, file_handle, ensure_ascii=False)


def _cleanup_stale_lock(path: str) -> tuple[bool, dict[str, Any]]:
    lock_data = _read_lock_data(path)
    pid = lock_data.get("pid")

    if isinstance(pid, int) and _pid_running(pid):
        return False, lock_data

    try:
        os.remove(path)
    except FileNotFoundError:
        pass
    except OSError:
        return False, lock_data

    return True, lock_data


def _format_conflict_message(path: str, lock_data: dict[str, Any]) -> str:
    pid = lock_data.get("pid")
    started_at = lock_data.get("started_at")

    if isinstance(pid, int):
        msg = f"Another publish process is running (pid={pid})"
        if isinstance(started_at, str) and started_at:
            msg += f", started at {started_at}"
        return msg + ". Please wait or terminate it before retrying."

    return f"Another publish process is running (lock: {path}). Please wait before retrying."


@contextmanager
def single_instance(lock_name: str = "post_to_xhs_publish"):
    """Acquire a process-wide lock to prevent concurrent publish runs."""
    path = _lock_path(lock_name)
    [REDACTED_SECRET]).hex

    payload = {
        "pid": os.getpid(),
        "started_at": datetime.now(timezone.utc).isoformat(),
        "argv": sys.argv,
        "cwd": os.getcwd(),
        "token": token,
    }

    acquired = False
    for attempt in range(2):
        try:
            _write_lock_data(path, payload)
            acquired = True
            break
        except FileExistsError:
            removed, lock_data = _cleanup_stale_lock(path)
            if removed and attempt == 0:
                continue
            raise SingleInstanceError(_format_conflict_message(path, lock_data))

    if not acquired:
        raise SingleInstanceError(f"Could not acquire lock: {path}")

    try:
        yield
    finally:
        try:
            current = _read_lock_data(path)
            if current.get("token") == [REDACTED_SECRET])
        except FileNotFoundError:
            pass
        except OSError:
            pass


path: scripts/simple_test.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-

print("小红书发布测试")
print("标题: 每日正念科普")
print("内容: 已准备就绪")
print("图片: 使用示例图片")
print("测试完成！")

path: scripts/test_publish.py
content: #!/usr/bin/env python
# -*- coding: utf-8 -*-

import os
import sys
import subprocess

def test_publish():
    """测试发布功能"""
    print("开始测试小红书发布功能...")
    
    # 检查必要文件是否存在
    title_file = "title.txt"
    content_file = "content.txt"
    
    if not os.path.exists(title_file):
        print(f"错误：标题文件 {title_file} 不存在")
        return False
        
    if not os.path.exists(content_file):
        print(f"错误：内容文件 {content_file} 不存在")
        return False
    
    print("文件检查通过")
    
    # 准备发布命令
    cmd = [
        sys.executable, 
        "publish_pipeline.py",
        "--title-file", title_file,
        "--content-file", content_file,
        "--image-urls", "https://picsum.photos/600/400"
    ]
    
    print(f"执行命令: {' '.join(cmd)}")
    
    try:
        # 使用subprocess执行命令
        result = subprocess.run(
            cmd, 
            cwd=os.path.dirname(os.path.abspath(__file__)),
            capture_output=True, 
            text=True, 
            timeout=30
        )
        
        print("STDOUT:", result.stdout)
        if result.stderr:
            print("STDERR:", result.stderr)
        print("返回码:", result.returncode)
        
        if result.returncode == 0:
            print("✅ 发布测试成功完成")
            return True
        else:
            print("❌ 发布测试失败")
            return False
            
    except subprocess.TimeoutExpired:
        print("❌ 命令执行超时")
        return False
    except Exception as e:
        print(f"❌ 执行过程中出现错误: {e}")
        return False

if __name__ == "__main__":
    success = test_publish()
    sys.exit(0 if success else 1)

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