Skill: cp3d1455926-svg/redbook-skills (version 1.0.0)

--- name: RedBookSkills description: | 将图文/视频内容自动发布到小红书（XHS），并支持登录检查、内容检索与互动操作。 适用场景：发布图文、发布视频、仅启动测试浏览器、获取登录二维码、首页推荐抓取、搜索笔记、评论互动、抓取内容数据。 metadata: trigger: 发布内容到小红书 source: Angiin/Post-to-xhs --- # Post-to-xhs 你是“小红书发布助手”。目标是在用户确认后，调用本 Skill 的脚本完成发布或互动操作。 ## 输入判断 优先按以下顺序判断： 1. 用户明确要求"测试浏览器 / 启动浏览器 / 检查登录 / 获取登录二维码 / 只打开不发布"：进入测试浏览器流程。 2. 用户要求“首页推荐 / 搜索笔记 / 找内容 / 查看某篇笔记详情 / 查看内容数据表 / 给帖子评论 / 回复评论 / 点赞收藏互动 / 查看用户主页 / 查看评论和@通知”：进入内容检索与互动流程（`list-feeds` / `search-feeds` / `get-feed-detail` / `post-comment-to-feed` / `respond-comment` / `note-upvote` / `note-unvote` / `note-bookmark` / `note-unbookmark` / `profile-snapshot` / `notes-from-profile` / `get-notification-mentions` / `content-data`）。 3. 用户已提供 `标题 + 正文 + 视频(本地路径或 URL)`：直接进入视频发布流程。 4. 用户已提供 `标题 + 正文 + 图片(本地路径或 URL)`：直接进入图文发布流程。 5. 用户只提供网页 URL：先提取网页内容与图片/视频，再给出可发布草稿，等待用户确认。 6. 信息不全：先补齐缺失信息，不要直接发布。 ## 必做约束 - 发布前必须让用户确认最终标题、正文和图片/视频。 - 图文发布时，没有图片不得发布（小红书发图文必须有图片）。 - 视频发布时，没有视频不得发布。图片和视频不可混合使用（二选一）。 - 默认使用无头模式；若检测到未登录，切换有窗口模式登录。 - 标题长度不超过 38（中文/中文标点按 2，英文数字按 1）。 - 用户要求"仅测试浏览器"时，不得触发发布命令。 - 如使用文件路径，优先使用绝对路径；若用户给的是相对路径，先转换为绝对路径再执行命令。 - 若发布页结构异常，优先检查 `scripts/cdp_publish.py` 里的 `SELECTORS`、多图上传等待、正文编辑器与发布按钮点击逻辑；这些是最容易被小红书网页改版影响的区域。 ## 测试浏览器流程（不发布） 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. 若用户需要首页推荐流，执行 `list-feeds` 获取首页推荐笔记列表。 3. 若用户需要关键词搜索，执行 `search-feeds` 获取笔记列表（默认会先抓取搜索下拉推荐词，结果字段为 `recommended_keywords`）。 4. 若用户需要详情，从搜索结果中取 `id` + `xsecToken` 再执行 `get-feed-detail`；如用户明确要更多评论，可加 `--load-all-comments` 等参数。 5. 若用户需要发表评论，执行 `post-comment-to-feed`（一级评论；必填 `feed_id` / `xsec_token` / `content`）。 6. 若用户需要回复某条评论，执行 `respond-comment`（可用 `comment_id` / `comment_author` / `comment_snippet` 定位目标评论）。 7. 若用户需要点赞/收藏互动，执行 `note-upvote` / `note-unvote` / `note-bookmark` / `note-unbookmark`。 8. 若用户需要用户主页信息，执行 `profile-snapshot` 或 `notes-from-profile`。 9. 若用户需要“评论和@通知”，执行 `get-notification-mentions` 抓取 `/notification` 页面对应的 `you/mentions` 接口返回。 10. 若用户需要“笔记基础信息表”，执行 `content-data` 获取曝光/观看/点赞等指标。 11. 回传结构化结果（数量、核心字段、链接）。 ## 常用命令 ### 参数顺序提醒（`cdp_publish.py` / `publish_pipeline.py`） 请严格按下面顺序写命令，避免 `unrecognized arguments`： - 全局参数放在子命令前：`--host --port --headless --account --timing-jitter --reuse-existing-tab` - 子命令参数放在子命令后：如 `search-feeds` 的 `--keyword --sort-by --note-type` - 常见可选全局参数：`--host 10.0.0.12 --port 9222 --reuse-existing-tab --account NAME` 示例（正确）： ```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` 指向远程 Chrome。 ```bash # 启动测试浏览器（有窗口，推荐） python scripts/chrome_launcher.py # 可选：无头启动 python scripts/chrome_launcher.py --headless # 检查当前登录状态 python scripts/cdp_publish.py check-login # 常见变体：优先复用已有标签页 python scripts/cdp_publish.py --reuse-existing-tab check-login # 远程 CDP 检查登录 python scripts/cdp_publish.py --host 10.0.0.12 --port 9222 check-login # 获取登录二维码（返回 Base64，可供远程前端展示扫码） python scripts/cdp_publish.py get-login-qrcode # 重启 / 关闭测试浏览器 python scripts/chrome_launcher.py --restart python scripts/chrome_launcher.py --kill ``` ### 0.5) 首次登录 / 重新登录 ```bash # 本地 Chrome 登录 python scripts/cdp_publish.py login # 远程 CDP 登录（不会自动重启远程 Chrome） python scripts/cdp_publish.py --host 10.0.0.12 --port 9222 login ``` ### 1) 准备 title.txt / content.txt 若用户给的是标题和正文，可先写入临时文件再执行命令： ```bash printf '%s\n' '这里是标题' > /abs/path/title.txt printf '%s\n' '这里是正文' > /abs/path/content.txt ``` ### 2) 无头发布 or 有头预览 —— 使用图片 URL 发布 ```bash # 默认推荐：无头自动发布 python scripts/publish_pipeline.py --headless \ --title-file /abs/path/title.txt \ --content-file /abs/path/content.txt \ --image-urls "https://example.com/1.jpg" "https://example.com/2.jpg" # 仅预览：停留在发布页人工确认 python scripts/publish_pipeline.py \ --preview \ --title-file /abs/path/title.txt \ --content-file /abs/path/content.txt \ --image-urls "https://example.com/1.jpg" "https://example.com/2.jpg" # 常见变体：远程 CDP / 复用已有标签页 python scripts/publish_pipeline.py --host 10.0.0.12 --port 9222 --reuse-existing-tab \ --title-file /abs/path/title.txt \ --content-file /abs/path/content.txt \ --image-urls "https://example.com/1.jpg" ``` 说明：当 `--host` 不是 `127.0.0.1/localhost` 时，脚本会跳过本地 `chrome_launcher.py` 的自动启动/重启逻辑。 说明：`publish_pipeline.py` 默认自动点击发布；如需停留在发布页人工确认，请加 `--preview`。 ### 3) 无头发布 or 有头预览 —— 使用本地图片发布 ```bash # 本地图片发布 python scripts/publish_pipeline.py --headless \ --title-file /abs/path/title.txt \ --content-file /abs/path/content.txt \ --images "/abs/path/pic1.jpg" "/abs/path/pic2.jpg" # WSL/远程 CDP + Windows/UNC 路径：跳过本地文件预校验 python scripts/publish_pipeline.py --headless \ --title-file /abs/path/title.txt \ --content-file /abs/path/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()` 误判不存在。 说明：脚本会自动识别 `C:\...`、`\\wsl.localhost\...` 等 Windows/UNC 路径，并在传给 `DOM.setFileInputFiles` 时保留原始路径形态。 说明：若需要强制保留原始路径，也可显式加 `--preserve-upload-paths`。 ### 3.5) 视频发布（本地视频文件 / 视频 URL） ```bash # 本地视频文件 python scripts/publish_pipeline.py --headless \ --title-file /abs/path/title.txt \ --content-file /abs/path/content.txt \ --video "/abs/path/my_video.mp4" # 视频 URL python scripts/publish_pipeline.py --headless \ --title-file /abs/path/title.txt \ --content-file /abs/path/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 /abs/path/title.txt --content-file /abs/path/content.txt --image-urls "https://example.com/1.jpg" ``` ### 5) 搜索内容 / 获取笔记详情 ```bash # 首页推荐笔记 python scripts/cdp_publish.py list-feeds # 搜索笔记 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 # 可选：滚动加载更多一级评论，并尝试展开二级回复 python scripts/cdp_publish.py get-feed-detail \ --feed-id 67abc1234def567890123456 \ --xsec-token XSEC_TOKEN \ --load-all-comments \ --limit 20 \ --click-more-replies \ --reply-limit 10 \ --scroll-speed normal ``` 说明：`list-feeds` 返回首页推荐 feed 列表。 说明：`search-feeds` 输出中包含 `recommended_keywords_count` 与 `recommended_keywords`，表示回车前搜索框下拉推荐词。 说明：`get-feed-detail --load-all-comments` 会先滚动评论区，并可选点击“更多回复”后再提取详情，同时额外返回 `comment_loading`。 说明：`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 ``` ### 9) 评论回复 / 点赞收藏 / 用户主页信息 ```bash # 回复评论（支持按评论 ID / 作者 / 文本片段定位） python scripts/cdp_publish.py respond-comment \ --feed-id 67abc1234def567890123456 \ --xsec-token XSEC_TOKEN \ --comment-id COMMENT_ID \ --content "感谢反馈～" # 点赞 / 取消点赞 python scripts/cdp_publish.py note-upvote --feed-id 67abc1234def567890123456 --xsec-token XSEC_TOKEN python scripts/cdp_publish.py note-unvote --feed-id 67abc1234def567890123456 --xsec-token XSEC_TOKEN # 收藏 / 取消收藏 python scripts/cdp_publish.py note-bookmark --feed-id 67abc1234def567890123456 --xsec-token XSEC_TOKEN python scripts/cdp_publish.py note-unbookmark --feed-id 67abc1234def567890123456 --xsec-token XSEC_TOKEN # 用户主页快照 / 用户主页笔记 python scripts/cdp_publish.py profile-snapshot --user-id USER_ID python scripts/cdp_publish.py notes-from-profile --user-id USER_ID --limit 20 --max-scrolls 3 ``` 补充：更完整的背景说明、安装说明与面向人工阅读的示例可参考 `README.md`，但本文件中的命令样例应优先作为 agent 执行基线。 ## 失败处理 - 登录失败：提示用户重新扫码登录并重试；若用户需要远程展示二维码，可改用 `get-login-qrcode`。 - 图片/视频下载失败：提示更换 URL 或改用本地文件。 - 本地路径不可用：优先改用绝对路径；若为 WSL/远程 CDP 的 Windows/UNC 路径，可先尝试 `--skip-file-check`，必要时再加 `--preserve-upload-paths`。 - 评论/回复目标未定位成功：提示补充 `comment_id`，或改用 `comment_author` / `comment_snippet` 再试。 - 页面选择器失效：提示检查 `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: docs/code-review-2026-03-07.md
content: # RedBookSkills 代码审阅报告

审阅日期：2026-03-07  
审阅范围：`scripts/account_manager.py`、`scripts/chrome_launcher.py`、`scripts/feed_explorer.py`、`scripts/image_downloader.py`、`scripts/publish_pipeline.py`、`scripts/run_lock.py`、`scripts/cdp_publish.py`

## 审阅结论

整体结构是清晰的：账号管理、Chrome 生命周期、CDP 封装、下载器、发布流水线、锁机制已经做了模块拆分，`run_lock.py` 与 `feed_explorer.py` 的边界也相对干净。  
但当前实现里仍有几处**会直接影响正确性或稳定性**的问题，尤其是：

1. 定时发布校验存在**运行时异常**，会直接中断带 `--post-time` 的流程。
2. 内容数据抓取接口的分页/筛选参数**名义支持但实际未生效**。
3. 多账号能力与 Chrome 端口复用之间存在**会串号的设计缺陷**。
4. CDP 请求与正文填充逻辑存在**挂死/内容变形**风险。

## 审阅方式

- 静态通读全部脚本实现与 CLI 入口。
- 执行 `python3 -m py_compile scripts/*.py`，结果通过。
- 执行导入级检查与纯函数调用，确认 `scripts/cdp_publish.py` 在调用 `validate_schedule_post_time()` 时抛出 `NameError`。
- 执行 `python3 -Wall` 导入，确认 `scripts/cdp_publish.py` 存在多处 `SyntaxWarning: invalid escape sequence '\s'`。

## 做得好的地方

- 模块划分合理：`publish_pipeline` 负责编排，`cdp_publish` 负责页面自动化，职责大体明确。
- `run_lock.py` 的 stale lock 清理思路不错，能避免一部分僵尸锁问题。
- `feed_explorer.py` 的 `SearchFilters` + `FeedExplorer` 抽象可复用性较好。
- 大部分 CLI 参数有说明，类型注解与 docstring 覆盖率也不错。

## 主要问题

### 1. 定时发布参数校验存在运行时异常

- 严重级别：高
- 位置：`scripts/cdp_publish.py:54`、`scripts/cdp_publish.py:206`、`scripts/cdp_publish.py:3028`
- 现象：`validate_schedule_post_time()` 中使用了 `timedelta`，但文件顶部只导入了 `datetime`，没有导入 `timedelta`。
- 实际影响：只要调用带 `post_time` 的发布流程，就会在运行期触发 `NameError`，定时发布不可用。
- 复现结果：`from scripts.cdp_publish import validate_schedule_post_time; validate_schedule_post_time('2026-03-08 10:00')` 会报 `NameError: name 'timedelta' is not defined`。
- 建议：补充 `from datetime import datetime, timedelta`，并为该纯函数补最小单元测试。

### 2. 内容数据抓取接口的分页参数没有真正生效

- 严重级别：高
- 位置：`scripts/cdp_publish.py:2513`、`scripts/cdp_publish.py:2538`、`scripts/cdp_publish.py:2616`
- 现象：`get_content_data(page_num, page_size, note_type)` 接收分页与筛选参数，但方法内部只是打开固定页面 `XHS_CONTENT_DATA_URL`，然后被动监听页面自己发出的请求。
- 实际影响：调用方传入的 `page_num`、`page_size`、`note_type` 只是“记录下来”，并不驱动真实请求；最终返回的数据取决于页面默认行为，而不是 CLI 参数。
- 证据：代码在 `2616` 行以后还专门打印 “Requested pagination/filter differs from captured page request”，说明当前实现本身也知道参数可能失效。
- 建议：要么显式通过页面脚本触发目标分页请求，要么移除这几个 CLI 参数，避免形成错误契约。

### 3. 多账号隔离会被“端口已占用”逻辑绕过

- 严重级别：高
- 位置：`scripts/chrome_launcher.py:129`、`scripts/chrome_launcher.py:133`、`scripts/chrome_launcher.py:297`
- 现象：`launch_chrome()` / `ensure_chrome()` 只要发现调试端口已打开，就直接认为 Chrome 可复用，但并不会校验当前监听该端口的实例是否就是目标账号的 `user-data-dir`。
- 实际影响：在多账号场景下，如果端口 `9222` 已被另一个账号的 Chrome 占用，新流程会静默复用错误 Profile，导致“看起来指定了账号，实际上操作到了别的账号”。
- 建议：至少在连接前校验当前标签页所属 Profile/账号标识；更稳妥的方案是按账号分配独立 CDP 端口，或在本地保存端口到账号的映射关系。

### 4. 正文填充使用 `innerHTML`，会把普通文本当 HTML 解释

- 严重级别：中
- 位置：`scripts/cdp_publish.py:2886`、`scripts/cdp_publish.py:2899`
- 现象：`_fill_content()` 把用户正文拆成段落后直接拼接到 HTML，再赋值给 `el.innerHTML`。
- 实际影响：如果正文包含 `<`, `>`, `&` 或类似 HTML 片段，最终写入编辑器的内容可能被浏览器解析成标签，而不是原始文本；这会造成内容变形，甚至直接丢字。
- 建议：改为基于 `textContent` / `createTextNode` 逐段写入，或复用已有的逐字输入/粘贴模拟逻辑，避免 HTML 注入式赋值。

### 5. CDP 收包没有超时控制，流程可能无限挂起

- 严重级别：中
- 位置：`scripts/cdp_publish.py:548`、`scripts/cdp_publish.py:552`
- 现象：`_send()` 发送 CDP 命令后直接 `self.ws.recv()` 阻塞等待匹配响应，没有总超时，也没有连接中断恢复策略。
- 实际影响：一旦 Chrome 没返回响应、WebSocket 状态异常、或页面事件流干扰导致响应丢失，整个进程会无限等待；结合 `run_lock.py`，还可能把后续发布任务一并卡死。
- 建议：为 `_send()` 增加命令级超时和更清晰的异常包装，超时后主动断开并提示重连。

### 6. 账号配置损坏时会静默回退默认配置，存在数据覆盖风险

- 严重级别：中
- 位置：`scripts/account_manager.py:43`、`scripts/account_manager.py:47`、`scripts/account_manager.py:62`
- 现象：`_load_accounts()` 在 JSON 解析失败时直接 `pass`，然后返回默认结构；之后任意一次 `_save_accounts()` 都可能把原本损坏但可人工恢复的配置覆盖掉。
- 实际影响：配置文件一旦被部分写坏或手工编辑出错，工具不会显式报警，反而会悄悄“重置”为默认账号，造成难以察觉的数据丢失。
- 建议：对损坏配置直接报错并停止写入，或者先备份损坏文件再生成新文件。

### 7. 账号名未做约束，Profile 路径可逃逸基目录

- 严重级别：中
- 位置：`scripts/account_manager.py:138`、`scripts/account_manager.py:153`
- 现象：`add_account()` 直接把用户提供的 `name` 拼到 `PROFILES_BASE` 下，没有限制 `..`、路径分隔符或绝对路径片段。
- 实际影响：恶意或误操作输入如 `../../other-dir` 时，Profile 目录可能被创建到预期目录之外，破坏多账号目录边界。
- 建议：限制账号名字符集，例如仅允许字母、数字、`-`、`_`，并在保存前做规范化校验。

### 8. 图片批量下载会吞掉部分失败，发布结果可能“少图但继续发”

- 严重级别：中
- 位置：`scripts/image_downloader.py:152`、`scripts/image_downloader.py:163`、`scripts/publish_pipeline.py:547`、`scripts/publish_pipeline.py:550`
- 现象：`download_all()` 对单张下载失败只记录日志继续执行；流水线只检查“是否全部失败”，不检查“是否部分失败”。
- 实际影响：用户传 9 张图时，只要下载成功 1 张，流程就会继续发布，最终内容与调用者预期不一致，而且没有强失败信号。
- 建议：提供严格模式（默认严格），当任意下载失败时直接终止；或至少返回成功/失败明细并在流水线中强提示。

### 9. 多处内嵌 JS 字符串触发 `invalid escape sequence` 警告

- 严重级别：低
- 位置：`scripts/cdp_publish.py:775`、`scripts/cdp_publish.py:1243`
- 现象：Python 普通字符串里直接写了 JS 正则 `/\s+/` 的未转义版本，导入时触发 `SyntaxWarning`。
- 实际影响：当前版本通常仍可运行，但这类警告会增加维护噪音，也会掩盖真正的导入问题。
- 建议：把相关 JS 片段改成原始字符串，或统一使用 `\s` 转义。

## 次要观察

- `scripts/publish_pipeline.py:591` 直接调用 `publisher._click_publish()` 私有方法，接口层次有些泄漏；后续可考虑提供公开方法。
- `scripts/publish_pipeline.py:462` 提取话题标签后没有再次校验正文是否为空；如果正文只剩标签行，流程仍会继续。
- `scripts/chrome_launcher.py:155` 启动 Chrome 时把 `stdout/stderr` 全部重定向到 `DEVNULL`，排障成本较高。

## 优先修复建议

建议按下面顺序处理：

1. 先修 `timedelta` 缺失与 `get_content_data()` 参数失效。
2. 再修多账号端口复用问题，避免串号。
3. 然后处理 `_send()` 超时和 `_fill_content()` 文本写入方式。
4. 最后补账号名校验、配置损坏保护、下载失败策略。

## 总体评价

这个仓库已经具备“能跑通”的工程骨架，但当前更像是**高耦合的自动化脚本集合**，距离“稳定的技能包”还差一层可靠性治理。  
如果只做一次性个人使用，问题还可控；如果要长期运行、跨账号运行或接入更多自动化场景，以上高优问题建议尽快处理。


path: README.md
content: # RedBookSkills



自动发布内容到小红书（Xiaohongshu/RED）的命令行工具，也支持仅启动测试浏览器（不发布）。
通过 Chrome DevTools Protocol (CDP) 实现自动化发布，支持多账号管理、无头模式运行、自动搜索素材与内容数据抓取等功能。

## 功能特性
- **自动化发布**：自动填写标题、正文、上传图片
- **创作者中心兼容修复**：适配 2026 年 2-3 月发布页 DOM 变动（发布按钮、定时开关、日期输入、多图上传等待、正文编辑器）
- **话题标签自动写入**：识别正文最后一行 `#标签`，然后逐渐写入
- **多账号支持**：支持管理多个小红书账号，各账号 Cookie 隔离
- **无头模式**：支持后台运行，无需显示浏览器窗口
- **远程 CDP 支持**：可通过 `--host` / `--port` 连接远程 Chrome 调试端口
- **图片下载**：支持从 URL 自动下载图片，自动添加 Referer 绕过防盗链
- **登录检测**：自动检测登录状态，未登录时自动切换到有窗口模式扫码
- **登录二维码导出**：支持返回登录二维码 Base64 图片，便于远程前端展示扫码
- **登录状态缓存**：`check_login/check_home_login` 默认本地缓存 12 小时，减少重复跳转校验
- **首页推荐流抓取**：支持抓取首页推荐 feed 列表
- **内容检索与详情读取**：支持搜索笔记并获取指定笔记详情（含评论数据），详情可选滚动加载更多评论/回复
- **笔记评论**：支持按 `feed_id + xsec_token` 对指定笔记发表一级评论
- **评论回复**：支持按评论定位条件（评论 ID / 作者 / 文本片段）回复指定评论
- **互动动作控制**：支持对指定笔记执行点赞/取消点赞、收藏/取消收藏
- **用户页信息提取**：支持抓取用户主页快照与主页笔记列表
- **通知评论抓取**：支持在 `/notification` 页面抓取 `you/mentions` 接口返回
- **内容数据看板抓取**：支持抓取“笔记基础信息”表（曝光/观看/点赞等）并导出 CSV

## 安装

### 环境要求

- Python 3.10+
- Google Chrome 浏览器
- Windows 操作系统（目前仅测试 Windows）

### 安装依赖

```bash
pip install -r requirements.txt
```

## 快速开始

### 1. 首次登录

```bash
python scripts/cdp_publish.py login
```

在弹出的 Chrome 窗口中扫码登录小红书。

说明：当前发布链路已按 2026 年 2-3 月的小红书创作者中心改版调整过选择器与等待策略；如果后续再次改版，优先检查 `scripts/cdp_publish.py` 中的 `SELECTORS`、多图上传等待和发布按钮点击逻辑。

### 2. 启动/测试浏览器（不发布）

```bash
# 启动测试浏览器（有窗口，推荐）
python scripts/chrome_launcher.py

# 无头启动测试浏览器
python scripts/chrome_launcher.py --headless

# 检查当前登录状态
python scripts/cdp_publish.py check-login

# 获取登录二维码（返回 Base64，可供远程前端直接展示）
python scripts/cdp_publish.py get-login-qrcode

# 可选：优先复用已有标签页（减少有窗口模式下切到前台）
python scripts/cdp_publish.py check-login --reuse-existing-tab

# 连接远程 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 --kill
```

### 3. 发布内容

```bash
# 无头模式（推荐，默认自动发布）
python scripts/publish_pipeline.py --headless \
    --title "文章标题" \
    --content "文章正文" \
    --image-urls "https://example.com/image.jpg"

# 有窗口预览模式（仅填充，不自动点发布）
python scripts/publish_pipeline.py \
    --preview \
    --title "文章标题" \
    --content "文章正文" \
    --image-urls "https://example.com/image.jpg"

# 可选：优先复用已有标签页（减少有窗口模式下切到前台）
python scripts/publish_pipeline.py --reuse-existing-tab \
    --title "文章标题" \
    --content "文章正文" \
    --image-urls "https://example.com/image.jpg"

# 连接远程 CDP 并发布（远程 Chrome 需已开启调试端口）
python scripts/publish_pipeline.py --host 10.0.0.12 --port 9222 \
    --title "文章标题" \
    --content "文章正文" \
    --image-urls "https://example.com/image.jpg"

# 从文件读取内容
python scripts/publish_pipeline.py --headless \
    --title-file title.txt \
    --content-file content.txt \
    --image-urls "https://example.com/image.jpg"

# 正文最后一行可放话题标签（最多 10 个）
# 例如 content.txt 最后一行：
# #春招 #26届 #校招 #求职 #找工作

# 使用本地图片
python scripts/publish_pipeline.py --headless \
    --title "文章标题" \
    --content "文章正文" \
    --images "C:\path\to\image.jpg"

# WSL/远程 CDP + Windows/UNC 路径可跳过本地文件预校验
python scripts/publish_pipeline.py --headless \
    --title "文章标题" \
    --content "文章正文" \
    --images "\\wsl.localhost\Ubuntu\home\user\image.jpg" \
    --skip-file-check

```

### 4. 多账号管理

```bash
# 列出所有账号
python scripts/cdp_publish.py list-accounts

# 添加新账号
python scripts/cdp_publish.py add-account myaccount --alias "我的账号"

# 登录指定账号
python scripts/cdp_publish.py --account myaccount login

# 使用指定账号发布
python scripts/publish_pipeline.py --account myaccount --headless \
    --title "标题" --content "正文" --image-urls "URL"

# 设置默认账号
python scripts/cdp_publish.py set-default-account myaccount

# 切换账号（清除当前登录，重新扫码）
python scripts/cdp_publish.py switch-account
```

### 5. 搜索内容、查看笔记详情与互动操作

```bash
# 首页推荐列表
python scripts/cdp_publish.py list-feeds

# 搜索笔记（可选筛选）
python scripts/cdp_publish.py search-feeds --keyword "春招"
python scripts/cdp_publish.py search-feeds --keyword "春招" --sort-by 最新 --note-type 图文

# 获取笔记详情（feed_id 与 xsec_token 可从搜索结果中获取）
python scripts/cdp_publish.py get-feed-detail \
    --feed-id 67abc1234def567890123456 \
    --xsec-token YOUR_XSEC_TOKEN

# 可选：滚动加载更多一级评论，并尝试展开二级回复
python scripts/cdp_publish.py get-feed-detail \
    --feed-id 67abc1234def567890123456 \
    --xsec-token YOUR_XSEC_TOKEN \
    --load-all-comments \
    --limit 20 \
    --click-more-replies \
    --reply-limit 10 \
    --scroll-speed normal

# 给笔记发表评论（一级评论）
python scripts/cdp_publish.py post-comment-to-feed \
    --feed-id 67abc1234def567890123456 \
    --xsec-token YOUR_XSEC_TOKEN \
    --content "写得很实用，感谢分享！"

# 回复指定评论（可按评论ID / 作者 / 文本片段定位）
python scripts/cdp_publish.py respond-comment \
    --feed-id 67abc1234def567890123456 \
    --xsec-token YOUR_XSEC_TOKEN \
    --comment-id COMMENT_ID \
    --content "感谢你的反馈～"

# 点赞 / 取消点赞
python scripts/cdp_publish.py note-upvote --feed-id 67abc1234def567890123456 --xsec-token YOUR_XSEC_TOKEN
python scripts/cdp_publish.py note-unvote --feed-id 67abc1234def567890123456 --xsec-token YOUR_XSEC_TOKEN

# 收藏 / 取消收藏
python scripts/cdp_publish.py note-bookmark --feed-id 67abc1234def567890123456 --xsec-token YOUR_XSEC_TOKEN
python scripts/cdp_publish.py note-unbookmark --feed-id 67abc1234def567890123456 --xsec-token YOUR_XSEC_TOKEN

# 用户主页快照 / 用户主页笔记列表
python scripts/cdp_publish.py profile-snapshot --user-id USER_ID
python scripts/cdp_publish.py notes-from-profile --user-id USER_ID --limit 20 --max-scrolls 3

# 抓取“评论和@”通知接口（you/mentions）
python scripts/cdp_publish.py get-notification-mentions
```

说明：`list-feeds` 返回首页推荐 feed 列表；`search-feeds` 会先在搜索框输入关键词，抓取下拉推荐词（`recommended_keywords`），再回车拉取 feed 列表。
说明：`get-feed-detail --load-all-comments` 会在详情页滚动评论区，并可选点击“更多回复”后再提取 `window.__INITIAL_STATE__`。

### 6. 获取内容数据表（content_data）

```bash
# 抓取“笔记基础信息”数据表
python scripts/cdp_publish.py content-data

# 下划线别名
python scripts/cdp_publish.py content_data

# 导出 CSV
python scripts/cdp_publish.py content-data --csv-file "/abs/path/content_data.csv"
```

## 命令参考

### 话题标签（publish_pipeline.py）

- 从正文中提取规则：若“最后一个非空行”全部由 `#标签` 组成，则提取为话题标签并从正文移除。
- 标签输入策略：逐个输入 `#标签`，等待 `3` 秒，再发送 `Enter` 进行确认。
- 建议数量：`1-10` 个标签；超过平台限制时请手动精简。
- 示例（正文最后一行）：`#春招 #26届 #校招 #春招规划 #面试`

### publish_pipeline.py

统一发布入口，一条命令完成全部流程。

```bash
python scripts/publish_pipeline.py [选项]

选项:
  --title TEXT           文章标题
  --title-file FILE      从文件读取标题
  --content TEXT         文章正文
  --content-file FILE    从文件读取正文
  --image-urls URL...    图片 URL 列表
  --images FILE...       本地图片文件列表
  --skip-file-check      跳过本地媒体文件存在性检查（WSL/远程 CDP/UNC 路径可用）
  --preserve-upload-paths 强制保留原始上传路径，不将反斜杠转换为正斜杠
  --host HOST            CDP 主机地址（默认 127.0.0.1）
  --port PORT            CDP 端口（默认 9222）
  --headless             无头模式（无浏览器窗口）
  --reuse-existing-tab   优先复用已有标签页（默认关闭）
  --account NAME         指定账号
  --auto-publish         兼容参数：默认已自动发布（可省略）
  --preview              预览模式：仅填充内容，不点击发布
```

说明：启用 `--reuse-existing-tab` 后，发布流程仍会自动导航到发布页，因此会刷新到目标页面再继续执行。
说明：当 `--host` 非 `127.0.0.1/localhost` 时为远程模式，会跳过本地 `chrome_launcher.py` 的自动启动/重启逻辑，请确保远程 CDP 地址可达。
说明：当控制端运行在 WSL、但媒体路径使用 Windows/UNC（如 `\\wsl.localhost\...`）时，可加 `--skip-file-check` 跳过 Linux 侧 `isfile` 预校验。
说明：脚本现在会自动识别 `C:\...`、`\\wsl.localhost\...` 等 Windows/UNC 路径，并在传给 `DOM.setFileInputFiles` 时保留原始形态。
说明：若仍想强制关闭路径改写，可显式加 `--preserve-upload-paths`。
说明：`publish_pipeline.py` 默认会自动点击发布；如需人工确认，请显式加 `--preview`。

### cdp_publish.py

底层发布控制，支持分步操作。

```bash
# 检查登录状态
python scripts/cdp_publish.py check-login
python scripts/cdp_publish.py check-login --reuse-existing-tab
python scripts/cdp_publish.py --host 10.0.0.12 --port 9222 check-login

# 填写表单（不发布）
python scripts/cdp_publish.py fill --title "标题" --content "正文" --images img.jpg
python scripts/cdp_publish.py fill --title "标题" --content "正文" --images img.jpg --reuse-existing-tab
python scripts/cdp_publish.py --host 10.0.0.12 --port 9222 fill --title "标题" --content "正文" --images img.jpg

# 点击发布按钮
python scripts/cdp_publish.py click-publish

# 获取登录二维码（支持下划线别名：get_login_qrcode）
python scripts/cdp_publish.py get-login-qrcode

# 首页推荐列表（支持下划线别名：list_feeds）
python scripts/cdp_publish.py list-feeds

# 搜索笔记（支持下划线别名：search_feeds）
python scripts/cdp_publish.py search-feeds --keyword "春招"
python scripts/cdp_publish.py search-feeds --keyword "春招" --sort-by 最新 --note-type 图文

# 获取笔记详情（支持下划线别名：get_feed_detail）
python scripts/cdp_publish.py get-feed-detail --feed-id FEED_ID --xsec-token XSEC_TOKEN
python scripts/cdp_publish.py get-feed-detail --feed-id FEED_ID --xsec-token XSEC_TOKEN --load-all-comments --limit 20 --click-more-replies --reply-limit 10 --scroll-speed normal

# 发表评论（支持下划线别名：post_comment_to_feed）
python scripts/cdp_publish.py post-comment-to-feed --feed-id FEED_ID --xsec-token XSEC_TOKEN --content "评论内容"

# 回复评论（支持下划线别名：respond_comment）
python scripts/cdp_publish.py respond-comment --feed-id FEED_ID --xsec-token XSEC_TOKEN --content "回复内容" [--comment-id COMMENT_ID]

# 点赞/取消点赞（支持下划线别名：note_upvote / note_unvote）
python scripts/cdp_publish.py note-upvote --feed-id FEED_ID --xsec-token XSEC_TOKEN
python scripts/cdp_publish.py note-unvote --feed-id FEED_ID --xsec-token XSEC_TOKEN

# 收藏/取消收藏（支持下划线别名：note_bookmark / note_unbookmark）
python scripts/cdp_publish.py note-bookmark --feed-id FEED_ID --xsec-token XSEC_TOKEN
python scripts/cdp_publish.py note-unbookmark --feed-id FEED_ID --xsec-token XSEC_TOKEN

# 用户主页快照/主页笔记（支持下划线别名：profile_snapshot / notes_from_profile）
python scripts/cdp_publish.py profile-snapshot --profile-url "https://www.xiaohongshu.com/user/profile/USER_ID"
python scripts/cdp_publish.py notes-from-profile --user-id USER_ID --limit 20 --max-scrolls 3

# 抓取通知评论接口（支持下划线别名：get_notification_mentions）
python scripts/cdp_publish.py get-notification-mentions

# 获取内容数据表（支持下划线别名：content_data）
python scripts/cdp_publish.py content-data
python scripts/cdp_publish.py content-data --csv-file "/abs/path/content_data.csv"

# 账号管理
python scripts/cdp_publish.py login
python scripts/cdp_publish.py list-accounts
python scripts/cdp_publish.py add-account NAME [--alias ALIAS]
python scripts/cdp_publish.py remove-account NAME [--delete-profile]
python scripts/cdp_publish.py set-default-account NAME
python scripts/cdp_publish.py switch-account
```

说明：`list-feeds`、`search-feeds`、`get-feed-detail`、`post-comment-to-feed`、`respond-comment`、`note-upvote`、`note-unvote`、`note-bookmark`、`note-unbookmark`、`profile-snapshot`、`notes-from-profile` 与 `get-notification-mentions` 会校验 `xiaohongshu.com` 主页登录态（非创作者中心登录态）。
说明：登录态检查默认启用本地缓存（12 小时，仅缓存“已登录”结果），到期后自动重新走网页校验。
说明：`get-login-qrcode` 返回 `qrcode_base64` / `qrcode_data_url`，便于远程前端直接展示扫码。
说明：`search-feeds` 输出新增 `recommended_keywords_count` 与 `recommended_keywords` 字段，表示输入关键词后回车前的下拉推荐词。
说明：`get-feed-detail --load-all-comments` 额外返回 `comment_loading`，用于说明评论滚动加载结果。
说明：`content-data` 会校验创作者中心登录态，并抓取 `statistics/data-analysis` 页面中的笔记基础信息表。

### chrome_launcher.py

Chrome 浏览器管理。

```bash
# 启动 Chrome
python scripts/chrome_launcher.py
python scripts/chrome_launcher.py --headless

# 重启 Chrome
python scripts/chrome_launcher.py --restart

# 关闭 Chrome
python scripts/chrome_launcher.py --kill
```

## 支持各种 Skill 工具

本项目可作为 Claude Code、OpenCode 等支持 Skill 的工具使用，只需将项目复制到 `.claude/skills/post-to-xhs/` 目录，并添加 `SKILL.md` 文件即可。

详见 [docs/claude-code-integration.md](docs/claude-code-integration.md)

## 注意事项

1. **仅供学习研究**：请遵守小红书平台规则，不要用于违规内容发布
2. **登录安全**：Cookie 存储在本地 Chrome Profile 中，请勿泄露
3. **选择器更新**：如果小红书页面结构变化导致发布失败，需要更新 `cdp_publish.py` 中的选择器
4. feed 的图片类型
- WB_PRV：预览图（preview），通常更轻、更快，适合列表卡片。
  - WB_DFT：默认图（default），通常用于详情展示，质量/尺寸更完整。

## RoadMap
- [x] 支持更多账号管理功能
- [x] 支持发布功能
- [x] 增加后台笔记获取功能
- [x] 支持自动评论
- [x] 支持素材检索功能
- [x] 增加更多错误处理机制


## 许可证

MIT License

## 联系方式

微信号：`whitedewstory`

<img src="public/whitedew.jpg" alt="微信二维码" width="240" />

### 知识星球，分享最新的使用技巧
<img src="20260302-141029.jpg" alt="知识星球二维码" width="240" />

## Stars
[![Stargazers over time](https://starchart.cc/white0dew/XiaohongshuSkills.svg?variant=adaptive)](https://starchart.cc/white0dew/XiaohongshuSkills)

## 致谢
灵感来自：[Post-to-xhs](https://github.com/Angiin/Post-to-xhs)


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/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/content_calendar.py
content: # -*- coding: utf-8 -*-
# 小红书内容日历生成器
# 30 天内容规划、发布时间建议、选题库管理

import argparse
import json
from datetime import datetime, timedelta
from pathlib import Path

class ContentCalendar:
    """小红书内容日历生成器"""
    
    def __init__(self):
        # 内容类型分布建议
        self.content_distribution = {
            'skill': 0.6,    # 技能分享 60%
            'news': 0.3,     # 科技新闻 30%
            'study': 0.1     # 学习内容 10%
        }
        
        # 最佳发布时间
        self.best_posting_times = [
            '07:00-09:00',  # 通勤时间
            '12:00-14:00',  # 午休时间
            '19:00-22:00',  # 睡前时间（最佳）
        ]
        
        # 选题库
        self.topic_pool = {
            'skill': [
                '效率工具合集', '文件管理技巧', '时间管理方法',
                '软件推荐', '工作流程优化', '自动化脚本',
                '编程技巧', '学习资源', '知识管理'
            ],
            'news': [
                'AI 大模型更新', '科技产品发布', '行业动态',
                '新技术解读', '产品评测', '趋势分析'
            ],
            'study': [
                '读书分享', '学习笔记', '方法总结',
                '成长感悟', '项目记录', '经验复盘'
            ]
        }
    
    def generate_calendar(self, days=30, start_date=None, output_file='calendar.md'):
        """生成内容日历"""
        if not start_date:
            start_date = datetime.now()
        
        calendar = []
        calendar.append(f"# 小红书内容日历")
        calendar.append(f"生成时间：{datetime.now().strftime('%Y-%m-%d')}")
        calendar.append(f"周期：{days}天")
        calendar.append(f"开始日期：{start_date.strftime('%Y-%m-%d')}")
        calendar.append("")
        
        # 统计信息
        stats = {'skill': 0, 'news': 0, 'study': 0}
        
        # 按周分组
        current_date = start_date
        week_num = 1
        
        calendar.append(f"## 第{week_num}周")
        calendar.append("")
        
        for day in range(days):
            # 判断是否新的一周
            if day > 0 and day % 7 == 0:
                week_num += 1
                calendar.append("")
                calendar.append(f"## 第{week_num}周")
                calendar.append("")
            
            # 确定内容类型（根据分布）
            content_type = self._determine_content_type(day, stats)
            stats[content_type] += 1
            
            # 选择主题
            topic = self._select_topic(content_type, day)
            
            # 生成标题建议
            title = self._generate_title_suggestion(topic, content_type)
            
            # 选择发布时间
            post_time = self._select_posting_time(day)
            
            # 添加标签建议
            tags = self._suggest_tags(topic)
            
            # 添加笔记
            calendar.append(f"### {current_date.strftime('%Y-%m-%d')} ({self._get_weekday(current_date)})")
            calendar.append(f"**类型**：{self._get_type_name(content_type)}")
            calendar.append(f"**主题**：{topic}")
            calendar.append(f"**标题**：{title}")
            calendar.append(f"**时间**：{post_time}")
            calendar.append(f"**标签**：{tags}")
            calendar.append("")
            
            # 日期递增
            current_date += timedelta(days=1)
        
        # 总结
        calendar.append("## 📊 内容分布统计")
        calendar.append(f"- 技能分享：{stats['skill']}篇 ({stats['skill']/days*100:.1f}%)")
        calendar.append(f"- 科技新闻：{stats['news']}篇 ({stats['news']/days*100:.1f}%)")
        calendar.append(f"- 学习内容：{stats['study']}篇 ({stats['study']/days*100:.1f}%)")
        calendar.append("")
        
        calendar.append("## 💡 运营建议")
        calendar.append("1. 保持固定更新频率（建议每周 3-5 篇）")
        calendar.append("2. 选择最佳发布时间（19:00-22:00）")
        calendar.append("3. 关注热点话题，及时调整内容")
        calendar.append("4. 分析数据，优化内容方向")
        calendar.append("5. 积极互动，提高粉丝粘性")
        
        full_text = '\n'.join(calendar)
        
        # 保存到文件
        if output_file:
            with open(output_file, 'w', encoding='utf-8') as f:
                f.write(full_text)
            print(f"✅ 内容日历已保存到：{output_file}")
        
        return full_text
    
    def _determine_content_type(self, day, stats):
        """确定内容类型"""
        total = sum(stats.values())
        if total == 0:
            return 'skill'
        
        # 根据分布决定
        skill_ratio = stats['skill'] / total
        news_ratio = stats['news'] / total
        
        if skill_ratio < self.content_distribution['skill']:
            return 'skill'
        elif news_ratio < self.content_distribution['news']:
            return 'news'
        else:
            return 'study'
    
    def _select_topic(self, content_type, day):
        """选择主题"""
        topics = self.topic_pool.get(content_type, self.topic_pool['skill'])
        # 根据日期选择不同主题
        return topics[day % len(topics)]
    
    def _generate_title_suggestion(self, topic, content_type):
        """生成标题建议"""
        templates = {
            'skill': f'3 个{topic}技巧，效率提升 10 倍！',
            'news': f'{topic}新动态！这些变化你必须知道！',
            'study': f'{topic}学习笔记！干货满满！'
        }
        return templates.get(content_type, topic)
    
    def _select_posting_time(self, day):
        """选择发布时间"""
        # 工作日和周末不同
        return self.best_posting_times[day % 3]
    
    def _get_weekday(self, date):
        """获取星期几"""
        weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
        return weekdays[date.weekday()]
    
    def _get_type_name(self, content_type):
        """获取类型名称"""
        names = {
            'skill': '💻 技能分享',
            'news': '📰 科技新闻',
            'study': '📚 学习内容'
        }
        return names.get(content_type, '技能分享')
    
    def _suggest_tags(self, topic):
        """建议标签"""
        base_tags = ['#干货', '#分享', '#实用']
        return ' '.join(base_tags + [f'#{topic}'])
    
    def view_week(self, calendar_file='calendar.md'):
        """查看本周计划"""
        if not Path(calendar_file).exists():
            print(f"❌ 文件不存在：{calendar_file}")
            return
        
        # 读取并显示本周内容
        with open(calendar_file, 'r', encoding='utf-8') as f:
            content = f.read()
        
        # 简单实现：显示前 7 篇笔记
        lines = content.split('\n')
        week_count = 0
        note_count = 0
        
        print("📅 本周内容计划：")
        print("="*50)
        
        for line in lines:
            if line.startswith('### '):
                note_count += 1
                if note_count <= 7:
                    print(line)
            elif note_count <= 7 and line.startswith('**'):
                print(line)
        
        print("="*50)


def main():
    parser = argparse.ArgumentParser(description='小红书内容日历生成器')
    parser.add_argument('--days', type=int, default=30, help='天数')
    parser.add_argument('--start', type=str, help='开始日期（YYYY-MM-DD）')
    parser.add_argument('--output', type=str, default='calendar.md', help='输出文件')
    parser.add_argument('--view', type=str, choices=['week', 'month'], help='查看模式')
    
    args = parser.parse_args()
    
    calendar = ContentCalendar()
    
    if args.view:
        # 查看模式
        calendar.view_week(args.output)
    else:
        # 生成模式
        start_date = None
        if args.start:
            try:
                start_date = datetime.strptime(args.start, '%Y-%m-%d')
            except:
                print("⚠️ 日期格式错误，使用今天")
        
        full_calendar = calendar.generate_calendar(
            days=args.days,
            start_date=start_date,
            output_file=args.output
        )
        
        print("\n" + "="*50)
        print("✅ 内容日历生成完成！")
        print("="*50)
        print(f"📄 输出文件：{args.output}")
        print(f"📅 周期：{args.days}天")
        print("="*50)


if __name__ == '__main__':
    import sys
    sys.stdout.reconfigure(encoding='utf-8')
    main()


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/tag_recommender.py
content: # -*- coding: utf-8 -*-
# 小红书标签推荐系统
# 热门标签推荐、标签组合优化、竞品分析

import argparse
import random
import json
from datetime import datetime
from collections import Counter

class TagRecommender:
    """小红书标签推荐器"""
    
    def __init__(self):
        # 标签库
        self.tag_database = {
            # 大类目标签
            'category': {
                '科技': ['#科技', '#数码', '#互联网', '#科技创新', '#科技资讯'],
                '学习': ['#学习', '#教育', '#知识', '#校园', '#学习日常'],
                '生活': ['#生活', '#日常', '#记录生活', '#生活碎片', '#生活方式'],
                '职场': ['#职场', '#工作', '#打工人', '#职场日常', '#职场干货'],
                '效率': ['#效率', '#时间管理', '#自我管理', '#成长', '#提升'],
            },
            
            # 细分领域标签
            'niche': {
                '效率工具': ['#效率工具', '#软件推荐', '#神器', '#APP 推荐', '#工具分享'],
                'AI 技术': ['#AI', '#人工智能', '#机器学习', '#深度学习', '#AIGC'],
                '编程开发': ['#编程', '#代码', '#开发者', '#程序员', '#软件开发'],
                '数码评测': ['#数码评测', '#产品评测', '#开箱', '#测评', '#种草'],
                '学习心得': ['#学习心得', '#经验分享', '#干货分享', '#笔记', '#总结'],
            },
            
            # 热门标签（流量大）
            'hot': [
                '#小红书成长笔记', '#日常', '#分享', '#推荐', '#必看',
                '#收藏', '#实用', '#免费', '#平替', '#学生党',
                '#宝藏', '#安利', '#好物分享', '#干货', '#教程'
            ],
            
            # 长尾标签（竞争小）
            'longtail': {
                '效率工具': ['#打工人必备', '#办公神器', '#效率提升', '#工作技巧', '#职场技能'],
                'AI 技术': ['#AI 工具', '#AI 应用', '#AI 教程', '#AI 学习', '#AI 资源'],
                '编程开发': ['#编程学习', '#代码技巧', '#开发工具', '#技术分享', '#项目实战'],
            }
        }
    
    def recommend_tags(self, topic, content_type='skill', count=15, strategy='balanced'):
        """
        推荐标签
        
        Args:
            topic: 主题/话题
            content_type: 内容类型
            count: 标签数量
            strategy: 推荐策略 (balanced=平衡，aggressive=激进，conservative=保守)
        """
        tags = []
        
        # 1. 添加大类目标签（2-3 个）
        category_tags = self._match_category(topic)
        tags.extend(category_tags[:3])
        
        # 2. 添加细分领域标签（3-5 个）
        niche_tags = self._match_niche(topic)
        tags.extend(niche_tags[:5])
        
        # 3. 添加热门标签（根据策略调整数量）
        if strategy == 'aggressive':
            hot_count = 7  # 激进：多用热门标签
        elif strategy == 'conservative':
            hot_count = 3  # 保守：少用热门标签
        else:
            hot_count = 5  # 平衡
        
        tags.extend(random.sample(self.tag_database['hot'], min(hot_count, count - len(tags))))
        
        # 4. 添加长尾标签（2-3 个）
        longtail_tags = self._match_longtail(topic)
        tags.extend(longtail_tags[:3])
        
        # 5. 添加自定义话题标签
        if f'#{topic}' not in tags:
            tags.append(f'#{topic}')
        
        # 去重并限制数量
        tags = list(dict.fromkeys(tags))[:count]
        
        return tags
    
    def _match_category(self, topic):
        """匹配大类目标签"""
        for category, tag_list in self.tag_database['category'].items():
            if category in topic:
                return tag_list
        return ['#分享', '#日常', '#记录']
    
    def _match_niche(self, topic):
        """匹配细分领域标签"""
        for niche, tag_list in self.tag_database['niche'].items():
            if niche in topic:
                return tag_list
        return ['#干货', '#实用', '#推荐']
    
    def _match_longtail(self, topic):
        """匹配长尾标签"""
        for niche, tag_list in self.tag_database['longtail'].items():
            if niche in topic:
                return tag_list
        return ['#必备', '#小技巧', '#经验分享']
    
    def analyze_competitor_tags(self, competitor_notes):
        """分析竞品笔记标签"""
        all_tags = []
        
        for note in competitor_notes:
            tags = note.get('tags', [])
            all_tags.extend(tags)
        
        # 统计标签频率
        tag_counts = Counter(all_tags)
        
        # 返回最常用的标签
        return tag_counts.most_common(20)
    
    def generate_tag_combinations(self, topic, count=5):
        """生成标签组合方案"""
        combinations = []
        
        for i in range(count):
            tags = self.recommend_tags(topic, strategy=random.choice(['balanced', 'aggressive', 'conservative']))
            combinations.append({
                '方案': i + 1,
                '策略': random.choice(['平衡', '激进', '保守']),
                '标签': tags
            })
        
        return combinations
    
    def export_tags(self, tags, output_file='tags.txt'):
        """导出标签到文件"""
        with open(output_file, 'w', encoding='utf-8') as f:
            f.write(' '.join(tags))
        print(f"✅ 标签已导出到：{output_file}")


def main():
    parser = argparse.ArgumentParser(description='小红书标签推荐器')
    parser.add_argument('--topic', type=str, required=True, help='主题/话题')
    parser.add_argument('--type', type=str, default='skill', help='内容类型')
    parser.add_argument('--count', type=int, default=15, help='标签数量')
    parser.add_argument('--strategy', type=str, default='balanced',
                       choices=['balanced', 'aggressive', 'conservative'],
                       help='推荐策略')
    parser.add_argument('--output', type=str, help='导出文件')
    parser.add_argument('--analyze', type=str, help='分析模式（trending/competitor）')
    
    args = parser.parse_args()
    
    recommender = TagRecommender()
    
    if args.analyze:
        # 分析模式
        if args.analyze == 'trending':
            print("🔥 热门标签分析：")
            print(' '.join(random.sample(recommender.tag_database['hot'], 10)))
        else:
            print("分析功能需要竞品数据，暂不支持")
    else:
        # 推荐模式
        tags = recommender.recommend_tags(
            topic=args.topic,
            content_type=args.type,
            count=args.count,
            strategy=args.strategy
        )
        
        print("\n" + "="*50)
        print(f"🏷️ 标签推荐（主题：{args.topic}）")
        print("="*50)
        print(f"策略：{args.strategy}")
        print(f"数量：{len(tags)}个")
        print("\n推荐标签：")
        print(' '.join(tags))
        print("="*50)
        
        # 导出
        if args.output:
            recommender.export_tags(tags, args.output)


if __name__ == '__main__':
    main()


path: test_draft.md
content: # 小红书笔记草稿
主题：效率工具
类型：skill
生成时间：2026-03-21 22:42

## 📝 标题备选（5 选 1）
1. 3个效率工具神器，学生党必备！
2. 没想到，效率工具竟然可以这么简单！
3. 用了3 天年，今天才发现这个效率工具功能！
4. 效率工具全攻略！看完这篇就够了！
5. 0 基础学会效率工具，只要3 天！

## 📄 正文内容
最近很多人问我效率工具，今天统一回复！

✨ 核心功能/要点：

3️⃣ 功能1
   描述1...

4️⃣ 功能2
   描述2...

5️⃣ 功能3
   描述3...

⭐ 记得点赞收藏，不然找不到了！

## 🏷️ 推荐标签
#技能分享 #干货 #教程 #效率工具 #软件推荐 #神器 #打工人 #职场 #推荐 #日常 #分享 #收藏 #必看 #效率工具

## 🎨 封面设计建议
风格：渐变风
配色：粉色系
字体：艺术字
布局：左右分割
元素：主图（产品截图/场景图）, 大字标题（醒目）, 副标题/标签, 装饰元素（箭头/圆圈/线条）

## ⏰ 发布建议
最佳时间：19:00-22:00（睡前）
次佳时间：12:00-14:00（午休）
发布频率：每周 3-5 篇


path: todo.md
content:  xiaohongshu-mcp 有 get_login_qrcode（便于远程前端展示扫码）和独立 cookie 清理工具。
     我们只有 login/re-login/switch-account 流程命令，没有二维码数据接口。

path: week_calendar.md
content: # 小红书内容日历
生成时间：2026-03-21
周期：7天
开始日期：2026-03-21

## 第1周

### 2026-03-21 (周六)
**类型**：💻 技能分享
**主题**：效率工具合集
**标题**：3 个效率工具合集技巧，效率提升 10 倍！
**时间**：07:00-09:00
**标签**：#干货 #分享 #实用 #效率工具合集

### 2026-03-22 (周日)
**类型**：📰 科技新闻
**主题**：科技产品发布
**标题**：科技产品发布新动态！这些变化你必须知道！
**时间**：12:00-14:00
**标签**：#干货 #分享 #实用 #科技产品发布

### 2026-03-23 (周一)
**类型**：💻 技能分享
**主题**：时间管理方法
**标题**：3 个时间管理方法技巧，效率提升 10 倍！
**时间**：19:00-22:00
**标签**：#干货 #分享 #实用 #时间管理方法

### 2026-03-24 (周二)
**类型**：📚 学习内容
**主题**：成长感悟
**标题**：成长感悟学习笔记！干货满满！
**时间**：07:00-09:00
**标签**：#干货 #分享 #实用 #成长感悟

### 2026-03-25 (周三)
**类型**：💻 技能分享
**主题**：工作流程优化
**标题**：3 个工作流程优化技巧，效率提升 10 倍！
**时间**：12:00-14:00
**标签**：#干货 #分享 #实用 #工作流程优化

### 2026-03-26 (周四)
**类型**：📰 科技新闻
**主题**：趋势分析
**标题**：趋势分析新动态！这些变化你必须知道！
**时间**：19:00-22:00
**标签**：#干货 #分享 #实用 #趋势分析

### 2026-03-27 (周五)
**类型**：💻 技能分享
**主题**：编程技巧
**标题**：3 个编程技巧技巧，效率提升 10 倍！
**时间**：07:00-09:00
**标签**：#干货 #分享 #实用 #编程技巧

## 📊 内容分布统计
- 技能分享：4篇 (57.1%)
- 科技新闻：2篇 (28.6%)
- 学习内容：1篇 (14.3%)

## 💡 运营建议
1. 保持固定更新频率（建议每周 3-5 篇）
2. 选择最佳发布时间（19:00-22:00）
3. 关注热点话题，及时调整内容
4. 分析数据，优化内容方向
5. 积极互动，提高粉丝粘性

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