Skill: harveyzzzz/xhsredbook (version 1.0.1)

--- name: xiaohongshu-publisher version: 2.3.0 description: Automatically publish notes to Xiaohongshu (小红书) creator center. Generates cover images (PIL poster, multiple styles), writes content from templates, and publishes via Playwright browser context. Cross-platform (Windows/macOS/Linux). Login once, publish many times. Use when user wants to post to 小红书, XHS, or Red Note. --- # 小红书自动发布技能 自动发布笔记到小红书创作者中心。内容生成 → 封面图生成（多张） → 自动发布，全流程自动化。 ## 核心特性 - ✅ 跨平台（Windows / macOS / Linux） - ✅ 无硬编码路径，所有目录可通过环境变量或参数配置 - ✅ 浏览器会话默认不持久化到磁盘（安全），可通过 `--profile-dir` 或 `XHS_PROFILE_DIR` 显式开启 - ✅ 内容自动生成（6大话题模板库 + 通用模板） - ✅ 封面图自动生成（5种布局 × 7种纹理 × 10种配色，每张风格不同） - ✅ 多图上传（默认3张，可配置） - ✅ 一条命令完成全流程 ## 环境变量（可选） | 变量 | 说明 | 默认值 | |------|------|--------| | `SKILL_DATA_DIR` | 数据根目录（图片/内容/截图） | `<skill>/.local/` | | `XHS_PROFILE_DIR` | 浏览器 profile 目录（登录态持久化） | 临时目录（不持久化） | | `XHS_FONT_PATH` | 自定义字体路径 | 系统默认中文字体 | ## 快速开始 ### 首次登录（只需一次） ```bash python scripts/save_login.py # 默认保存到 .local/xhs_browser_profile/ python scripts/save_login.py --profile-dir ~/xhs_data # 自定义目录 ``` ### 一键发布 ```bash python scripts/auto_publish.py --topic 美食 # 临时 profile，不保留登录态 python scripts/auto_publish.py --topic 科技 --profile-dir .local/xhs_browser_profile # 复用已保存的登录态 python scripts/auto_publish.py --topic 穿搭 --count 5 python scripts/auto_publish.py --topic 生活 --image a.png b.png c.png ``` 也可以通过环境变量设置 profile 目录，避免每次传参： ```bash export XHS_PROFILE_DIR=.local/xhs_browser_profile # Linux/macOS set XHS_PROFILE_DIR=.local\xhs_browser_profile # Windows python scripts/auto_publish.py --topic 美食 ``` ### 参数说明 | 参数 | 说明 | 默认值 | |------|------|--------| | `--topic` | 话题（美食/旅游/穿搭/科技/生活/健身） | 科技 | | `--title` | 自定义标题（<20字） | 自动生成 | | `--content` | 自定义正文 | 自动生成 | | `--cover-text` | 封面图文字 | 用标题 | | `--image` | 已有图片路径（可多张） | 自动生成 | | `--profile-dir` | 浏览器 profile 目录（登录态持久化） | 临时目录 | | `--count` | 生成图片数量 | 3 | ## 话题模板 | 话题 | 标题数 | 正文数 | |------|--------|--------| | 美食 | 8 | 2 | | 旅游 | 7 | 2 | | 穿搭 | 6 | 2 | | 科技 | 8 | 3 | | 生活 | 6 | 2 | | 健身 | 5 | 1 | | 通用 | 7 | 2 | ## 封面图风格 - 布局：居中大字报 / 顶部标题 / 左对齐杂志风 / 高亮框 / 数字装饰 - 纹理：网格 / 圆点 / 圆形 / 条纹 / 方块 / 噪点 / 纯色 - 配色：10种小红书风格（含浅色系和深色系） - 尺寸：1080×1080（1:1） ## 文件结构 ``` xiaohongshu-publisher/ ├── SKILL.md ├── scripts/ │ ├── config.py # 跨平台路径配置 │ ├── auto_publish.py # 一键发布入口 │ ├── generate_content.py # 内容生成 │ ├── generate_cover.py # 封面图生成 │ ├── publish.py # 发布流程 │ └── save_login.py # 首次登录 └── references/ └── selectors.md # 页面选择器参考 ``` ## 依赖 ```bash pip install playwright pillow playwright install chromium ``` ## 发布流程 1. 打开 `creator.xiaohongshu.com/publish/publish` 2. 点击下拉箭头 → 选择"上传图文" 3. 通过 `accept` 属性定位图片 file input（区分视频/图片） 4. 逐张上传图片（每张间隔3秒） 5. 等待编辑器出现（轮询+滚动，最多30秒） 6. 填写正文（逐字输入） 7. 填写标题 8. 点击发布 ## 故障排查 | 问题 | 解决方案 | |------|----------| | 未登录 | `python scripts/save_login.py` | | 登录态失效 | 删除 profile 目录后重新运行 `save_login.py` | | 找不到编辑器 | 查看 screenshots/ 截图和 HTML dump | | 图片只上传1张 | 检查 file input 是否被正确重新定位 | | 标题超长 | 自动截断到20字 | ## 更新日志 ### v2.3 (2026-03-07) - 跨平台：去除所有硬编码 Windows 路径（`E:\`、`C:\Windows\Fonts`） - 安全：浏览器 profile 默认使用临时目录，不再自动持久化到磁盘 - 可配置：通过 `--profile-dir` 参数或 `XHS_PROFILE_DIR` / `SKILL_DATA_DIR` / `XHS_FONT_PATH` 环境变量自定义目录 - 字体：自动检测 Windows / macOS / Linux 系统中文字体 - 修复：`generate_cover.py` 噪点纹理在 RGBA 模式下的潜在 bug - 新增：`config.py` 统一路径配置模块 ### v2.2 (2026-03-06) - 多图上传：逐张上传，默认3张 - 封面图升级：5种布局 + 7种纹理 + 10种配色，大字报风格 - 内容丰富：科技话题去掉 AI 硬编码，新增数码好物等模板 - 图片 input 精确定位：通过 accept 属性区分视频/图片 input - 编辑器等待优化：轮询+滚动，最多30秒 ### v2.1 (2026-03-06) - persistent context 登录持久化 - Tab 切换修复（下拉箭头 → 上传图文） - 图片尺寸 1080×1080 ### v1.0 - 初始版本

path: references/selectors.md
content: # 小红书创作者中心选择器参考

> 最后更新: 2026-03-06

## 页面结构

- URL: `https://creator.xiaohongshu.com/publish/publish`
- 默认 tab: 上传视频
- 需要切换到: 上传图文

## 关键选择器

### Tab 切换
- 下拉箭头: `.dropdownBtn`
- 发布笔记按钮: `span.btn-text:has-text("发布笔记")`
- 上传图文 tab: `get_by_text("上传图文")` (多个元素，需通过 bounding_box 找可见的)
- creator-tab: `.creator-tab:has-text("上传图文")`

### 文件上传
- 视频 input: `input[type="file"][accept=".mp4,.mov,.flv,..."]`
- 图片 input: `input[type="file"][accept=".jpg,.jpeg,.png,.webp"]`
- 注意: 切换到图文 tab 后才会出现图片 input

### 内容编辑
- 正文编辑器: `[contenteditable="true"]`
- 标题输入: `input[placeholder*="标题"]`

### 发布
- 发布按钮: `button:has-text("发布")`
- 智能标题: `button:has-text("智能标题")` (可能不存在)

## 注意事项

1. "上传图文"元素有多个，部分被 CSS 隐藏 (`position: absolute; left: -9999px`)
2. 图片上传后需要等待编辑器出现（轮询 + 滚动）
3. 多张图片需要逐张上传，每张间隔等待
4. persistent context 可能记住上次的 tab 选择


path: scripts/auto_publish.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
auto_publish.py - 一键自动发布到小红书
流程：生成内容 → 生成封面图 → 发布
"""

import sys
import argparse
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent))

import generate_content
import generate_cover
from publish import publish


def main():
    parser = argparse.ArgumentParser(description="一键发布到小红书")
    parser.add_argument("--topic", default="科技", help="话题/主题（如：美食、旅游、穿搭、科技、生活、健身）")
    parser.add_argument("--title", default=None, help="自定义标题（<20字，不填则自动生成）")
    parser.add_argument("--content", default=None, help="自定义正文（不填则自动生成）")
    parser.add_argument("--cover-text", default=None, help="封面图文字（不填则用标题）")
    parser.add_argument("--image", default=None, nargs="*", help="使用已有图片路径（可多张，跳过图片生成）")
    parser.add_argument("--count", type=int, default=3, help="生成图片数量（默认3张）")
    parser.add_argument("--profile-dir", default=None, help="浏览器 profile 目录（不设则用临时目录，登录态不保留）")
    args = parser.parse_args()

    # Step 1: Generate content
    if args.title and args.content:
        title = args.title[:20]
        content = args.content
        print(f"[INFO] 使用自定义内容")
    else:
        print(f"[INFO] 自动生成内容，话题: {args.topic}")
        data = generate_content.generate_content(args.topic)
        title = args.title or data["title"]
        content = args.content or data["content"]

    title = title[:20]
    print(f"[标题] {title}")
    print(f"[正文] {content[:60]}...")

    # Step 2: Generate or use existing images
    if args.image:
        image_paths = [Path(p) for p in args.image]
        for p in image_paths:
            if not p.exists():
                print(f"[ERROR] 图片不存在: {p}")
                sys.exit(1)
        print(f"[INFO] 使用已有图片: {len(image_paths)} 张")
    else:
        cover_text = args.cover_text or title
        print(f"[INFO] 生成 {args.count} 张封面图，文字: {cover_text}")
        try:
            image_paths = [Path(p) for p in generate_cover.generate(text_fallback=cover_text, count=args.count)]
            print(f"[INFO] 封面图生成完成: {len(image_paths)} 张")
        except Exception as e:
            print(f"[ERROR] 封面图生成失败: {e}")
            sys.exit(1)

    # Step 3: Publish
    print("[INFO] 开始发布流程...")
    try:
        publish(content, image_paths, title, profile_dir=args.profile_dir)
        print("\n[SUCCESS] 发布完成！")
    except Exception as e:
        print(f"\n[ERROR] 发布失败: {e}")
        sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/config.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
config.py - Cross-platform path and runtime configuration helpers.

Goals:
- No hard-coded Windows drive letters
- Safe, configurable output directories
- Browser profile persistence is opt-in, not the default
"""

import os
import tempfile
from pathlib import Path
from typing import Optional

PROJECT_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_DATA_DIR = PROJECT_ROOT / ".local"


def get_data_dir() -> Path:
    """Return the base data directory used by the skill."""
    raw = os.environ.get("SKILL_DATA_DIR")
    base_dir = Path(raw).expanduser() if raw else DEFAULT_DATA_DIR
    base_dir.mkdir(parents=True, exist_ok=True)
    return base_dir


def get_subdir(name: str) -> Path:
    """Create and return a named subdirectory under the skill data dir."""
    path = get_data_dir() / name
    path.mkdir(parents=True, exist_ok=True)
    return path


def resolve_profile_dir(profile_dir: Optional[str] = None) -> tuple[Path, bool]:
    """
    Return (path, persistent).

    Priority:
    1. Explicit function argument
    2. XHS_PROFILE_DIR env var
    3. Temporary directory (non-persistent, default)
    """
    raw = profile_dir or os.environ.get("XHS_PROFILE_DIR")
    if raw:
        path = Path(raw).expanduser()
        if not path.is_absolute():
            path = PROJECT_ROOT / path
        path.mkdir(parents=True, exist_ok=True)
        return path, True

    temp_path = Path(tempfile.mkdtemp(prefix="xhs_profile_"))
    return temp_path, False


path: scripts/generate_content.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
generate_content.py - Generate title and content for Xiaohongshu posts
Supports template-based generation with rich topic coverage.
"""

import sys
import json
import random
from datetime import datetime

from config import get_subdir

OUTPUT_DIR = get_subdir("generated_content")

# ── 话题模板库 ──────────────────────────────────────────────────────────

TEMPLATES = {
    "美食": {
        "titles": [
            "这家店我能吃一辈子！",
            "人均50吃到撑的宝藏店",
            "闭眼入！好吃到舌盘子",
            "这个味道绝了！",
            "必打卡！本地人都爱的店",
            "今天的晚餐太满足了",
            "一口就爱上的味道",
            "吃货必看！这家真的绝",
        ],
        "contents": [
            "今天发现了一家超好吃的店！\n环境很舒服，服务也很好\n\n人均：50-80元\n营业时间：10:00-22:00\n\n强烈推荐他们家的招牌菜，\n味道真的绝了！\n下次还要带朋友来！\n\n#美食推荐 #探店 #好吃到飞起",
            "周末探店记录📝\n\n这家店藏在小巷子里\n但味道真的一绝！\n\n必点：\n🔥 招牌菜 - 入口即化\n🔥 特色甜品 - 甜而不腻\n🔥 手工饮品 - 清爽解腻\n\n环境也很出片📸\n适合约会/闺蜜聚餐\n\n#探店打卡 #美食分享 #周末去哪吃",
        ],
    },
    "旅游": {
        "titles": [
            "这个地方太美了！",
            "小众景点｜人少景美",
            "周末出游好去处！",
            "拍照超出片的地方",
            "这才是度假该有的样子",
            "私藏景点大公开！",
            "一个人也能玩的地方",
        ],
        "contents": [
            "这次旅行真的太棒了！\n风景超美，人也不多\n非常适合周末放松\n\n拍照tips：\n- 早上光线最好\n- 穿浅色衣服更出片\n- 记得带防晒！\n\n实用信息：\n门票：免费/50元\n交通：地铁/自驾\n建议游玩时间：2-3小时\n\n#旅游攻略 #周末去哪儿 #打卡",
            "终于去了心心念念的地方！\n\n📍 位置：市中心30分钟车程\n🎫 门票：免费\n⏰ 建议时间：半天\n\n亮点：\n✨ 自然风光超美\n✨ 人少不用排队\n✨ 拍照随便出大片\n\n小tips：\n带够水和零食\n穿舒适的鞋子\n\n#旅行日记 #小众景点 #出游推荐",
        ],
    },
    "穿搭": {
        "titles": [
            "这套穿搭绝了！",
            "显瘦10斤的穿搭公式",
            "小个子必看穿搭！",
            "通勤穿搭｜简约大方",
            "这样穿真的很显气质",
            "懒人穿搭一周不重样",
        ],
        "contents": [
            "今天的穿搭分享\n\n上衣：简约款\n下装：高腰阔腿裤\n鞋子：小白鞋\n\n穿搭 tips：\n- 高腰线拉长比例\n- 同色系更显高级\n- 配饰点睛很重要\n\n这套真的超好看！\n而且很日常，上班约会都 OK\n\n#穿搭分享 #OOTD #时尚穿搭",
            "一周通勤穿搭合集👔\n\n周一：西装+阔腿裤（干练）\n周二：针织+半裙（温柔）\n周三：衬衫+牛仔裤（休闲）\n周四：连衣裙（省心）\n周五：卫衣+短裙（轻松）\n\n每套都是基础款混搭\n不用花太多钱就能穿出质感\n\n#通勤穿搭 #职场穿搭 #一周穿搭",
        ],
    },
    "科技": {
        "titles": [
            "这个工具太好用了！",
            "效率翻倍的神器推荐",
            "后悔没早点知道！",
            "打工人必备工具合集",
            "这个APP改变了我的生活",
            "手机里必装的宝藏APP",
            "省时省力的效率神器",
            "数码好物推荐！",
        ],
        "contents": [
            "今天给大家分享几个超实用的效率工具！\n\n用了一段时间，工作效率真的提升很多\n\n推荐清单：\n📝 笔记工具 - 随时记录灵感\n📊 表格工具 - 数据一目了然\n🎨 设计工具 - 零基础也能出图\n📅 日程管理 - 再也不会忘事\n\n每个都亲测好用！\n关键是大部分都免费\n\n收藏起来慢慢用～\n\n#效率工具 #好物推荐 #科技分享",
            "手机里的宝藏APP分享📱\n\n用了3年的私藏清单\n每个都是精挑细选\n\n🔥 修图：轻松出大片\n🔥 记账：月底不再迷茫\n🔥 学习：碎片时间利用\n🔥 健康：运动饮食记录\n🔥 出行：省钱又省心\n\n都是免费或超便宜的\n学生党打工人都适用！\n\n#APP推荐 #手机必备 #实用工具",
            "数码好物开箱分享🎁\n\n最近入手了几个小玩意\n性价比真的超高！\n\n1️⃣ 无线充电器 - 告别线缆\n2️⃣ 蓝牙耳机 - 通勤必备\n3️⃣ 便携支架 - 解放双手\n4️⃣ 收纳包 - 出门不乱\n\n总花费不到200块\n但幸福感直线上升\n\n#数码好物 #开箱 #性价比之王",
        ],
    },
    "生活": {
        "titles": [
            "提升幸福感的小习惯",
            "独居生活也可以很精彩",
            "这些小事让生活更美好",
            "分享我的日常好物",
            "简单生活的快乐",
            "今天也是元气满满的一天",
        ],
        "contents": [
            "分享几个提升幸福感的小习惯✨\n\n1. 早起喝一杯温水\n2. 每天运动30分钟\n3. 睡前读书15分钟\n4. 定期整理房间\n5. 记录每天的小确幸\n\n坚持一个月\n你会发现生活真的不一样了\n\n#生活方式 #自律 #提升幸福感",
            "我的居家好物分享🏠\n\n🕯️ 香薰蜡烛 - 氛围感拉满\n🌿 绿植 - 看着心情好\n📖 好书 - 充实自己\n☕ 手冲咖啡 - 仪式感\n🎵 蓝牙音箱 - 随时有音乐\n\n不需要花很多钱\n就能让家变得温馨舒适\n\n#居家好物 #生活分享 #家居",
        ],
    },
    "健身": {
        "titles": [
            "坚持运动30天的变化",
            "新手友好的健身计划",
            "在家也能练出好身材",
            "这个动作瘦腿超有效",
            "健身小白必看！",
        ],
        "contents": [
            "分享我的健身日常💪\n\n今日训练：\n🔥 深蹲 x 15 x 3组\n🔥 平板支撑 60秒 x 3组\n🔥 开合跳 30个 x 3组\n🔥 拉伸 10分钟\n\n总用时：40分钟\n\n坚持一个月真的有变化！\n关键是要循序渐进\n不要一上来就猛练\n\n#健身打卡 #居家健身 #运动日常",
        ],
    },
}

# 通用模板（匹配不到具体话题时使用）
DEFAULT_TEMPLATE = {
    "titles": [
        "分享一个好东西！",
        "这个真的太实用了",
        "强烈推荐！",
        "宝藏发现！",
        "这个必须安利给你们",
        "今天的分享来了",
        "收藏起来慢慢看",
    ],
    "contents": [
        "今天给大家分享：{topic}\n\n真的超级好用/好看/好吃！\n自己用了一段时间，\n效果/体验都很不错\n\n优点：\n- 性价比高\n- 质量好\n- 值得入手\n\n有需要的姐妹可以冲了！\n\n#好物分享 #种草 #推荐",
        "关于{topic}的分享📝\n\n最近一直在研究这个\n总结了一些心得给大家\n\n重点：\n1️⃣ 入门简单\n2️⃣ 效果明显\n3️⃣ 坚持很重要\n\n希望对你们有帮助！\n有问题评论区聊～\n\n#经验分享 #干货 #实用",
    ],
}


def match_template(topic: str) -> dict:
    """Match topic to the best template."""
    topic_lower = topic.lower()
    for key, tmpl in TEMPLATES.items():
        if key in topic_lower:
            return tmpl
    return DEFAULT_TEMPLATE


def generate_content(topic: str) -> dict:
    """
    Generate title (<20 chars) and content based on topic.
    Returns: {"title": "...", "content": "...", "topic": "..."}
    """
    template = match_template(topic)
    title = random.choice(template["titles"])
    content = random.choice(template["contents"]).format(topic=topic)
    return {"title": title, "content": content.strip(), "topic": topic}


def save_content(topic: str) -> str:
    """Generate and save content to JSON. Returns file path."""
    data = generate_content(topic)
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    output_path = OUTPUT_DIR / f"{timestamp}.json"

    with open(output_path, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)

    print(f"[OK] Content saved: {output_path}")
    print(f"[Title] {data['title']}")
    print(f"[Content preview] {data['content'][:80]}...")
    return str(output_path)


if __name__ == "__main__":
    if len(sys.argv) < 2:
        topics = list(TEMPLATES.keys())
        print(f"Usage: python generate_content.py <topic>")
        print(f"Available topics: {', '.join(topics)}")
        print(f"Or use any custom topic text.")
        sys.exit(1)

    topic = " ".join(sys.argv[1:])
    try:
        save_content(topic)
    except Exception as e:
        print(f"[ERROR] {e}")
        sys.exit(1)


path: scripts/generate_cover.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
generate_cover.py - Generate Xiaohongshu-style cover images (大字报风格)
Features:
- Textured backgrounds (geometric patterns, noise, shapes)
- Bold eye-catching title text with decorative elements
- Multiple layout styles mimicking popular XHS covers
"""

import sys
import os
import random
import math
import platform
from datetime import datetime
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont, ImageFilter

from config import get_subdir

OUTPUT_DIR = get_subdir("generated_images")

# ── 配色方案 ────────────────────────────────────────────────────────────

PALETTES = [
    # 奶油白 + 黑字（极简大字报）
    {"bg": (245, 240, 230), "accent": (255, 90, 95), "text": (30, 30, 30), "sub": (120, 120, 120)},
    # 暖黄 + 棕字
    {"bg": (255, 243, 220), "accent": (255, 140, 50), "text": (80, 50, 20), "sub": (160, 120, 70)},
    # 浅粉 + 深红字
    {"bg": (255, 235, 235), "accent": (255, 80, 100), "text": (180, 40, 60), "sub": (200, 120, 130)},
    # 薄荷绿 + 深绿字
    {"bg": (230, 250, 240), "accent": (60, 200, 140), "text": (30, 80, 60), "sub": (100, 160, 130)},
    # 浅蓝 + 深蓝字
    {"bg": (230, 240, 255), "accent": (80, 130, 255), "text": (30, 50, 100), "sub": (100, 130, 180)},
    # 浅紫 + 紫字
    {"bg": (245, 235, 255), "accent": (160, 100, 240), "text": (80, 40, 130), "sub": (140, 110, 180)},
    # 纯白 + 红色强调
    {"bg": (255, 255, 255), "accent": (255, 60, 60), "text": (20, 20, 20), "sub": (150, 150, 150)},
    # 深色系 - 深灰 + 白字
    {"bg": (45, 45, 55), "accent": (255, 200, 80), "text": (255, 255, 255), "sub": (180, 180, 190)},
    # 深色系 - 墨绿 + 金字
    {"bg": (30, 50, 45), "accent": (220, 180, 100), "text": (240, 235, 220), "sub": (160, 170, 160)},
    # 莫兰迪粉 + 深色字
    {"bg": (225, 200, 195), "accent": (200, 100, 90), "text": (60, 40, 40), "sub": (130, 100, 95)},
]

# ── 字体 ────────────────────────────────────────────────────────────────

def _font_search_paths(bold: bool = True) -> list[str]:
    """Return candidate font paths for the current OS."""
    system = platform.system()
    env_font = os.environ.get("XHS_FONT_PATH")
    if env_font:
        return [env_font]

    if system == "Windows":
        fonts_dir = Path(os.environ.get("WINDIR", "C:/Windows")) / "Fonts"
        if bold:
            return [str(fonts_dir / n) for n in ("msyhbd.ttc", "simhei.ttf", "msyh.ttc")]
        return [str(fonts_dir / n) for n in ("msyh.ttc", "simsun.ttc")]

    if system == "Darwin":  # macOS
        candidates = [
            "/System/Library/Fonts/PingFang.ttc",
            "/System/Library/Fonts/STHeiti Medium.ttc",
            "/Library/Fonts/Arial Unicode.ttf",
        ]
    else:  # Linux / other
        candidates = [
            "/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc",
            "/usr/share/fonts/noto-cjk/NotoSansCJK-Bold.ttc",
            "/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf",
            "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
        ]
    return candidates


def load_font(size: int, bold: bool = True):
    for fp in _font_search_paths(bold):
        try:
            return ImageFont.truetype(fp, size)
        except Exception:
            continue
    return ImageFont.load_default()


# ── 背景纹理 ────────────────────────────────────────────────────────────

def bg_noise(img, intensity=15):
    """Add subtle noise texture."""
    w, h = img.size
    pixels = img.load()
    for y in range(0, h, 2):
        for x in range(0, w, 2):
            pixel = pixels[x, y]
            r, g, b = pixel[:3]
            a = pixel[3] if len(pixel) > 3 else 255
            n = random.randint(-intensity, intensity)
            pixels[x, y] = (
                max(0, min(255, r + n)),
                max(0, min(255, g + n)),
                max(0, min(255, b + n)),
                a,
            )


def bg_grid(draw, w, h, color, spacing=80):
    """Draw subtle grid lines."""
    line_color = color + (25,)
    for x in range(0, w, spacing):
        draw.line([(x, 0), (x, h)], fill=line_color, width=1)
    for y in range(0, h, spacing):
        draw.line([(0, y), (w, y)], fill=line_color, width=1)


def bg_dots(draw, w, h, color, spacing=60):
    """Draw dot pattern."""
    dot_color = color + (35,)
    for x in range(spacing//2, w, spacing):
        for y in range(spacing//2, h, spacing):
            r = 3
            draw.ellipse([(x-r, y-r), (x+r, y+r)], fill=dot_color)


def bg_circles(draw, w, h, accent):
    """Draw large decorative circles."""
    for _ in range(random.randint(2, 5)):
        cx = random.randint(-100, w+100)
        cy = random.randint(-100, h+100)
        radius = random.randint(100, 350)
        circle_color = accent + (20,)
        draw.ellipse([(cx-radius, cy-radius), (cx+radius, cy+radius)], fill=circle_color)


def bg_stripes(draw, w, h, color, angle=45):
    """Draw diagonal stripes."""
    stripe_color = color + (18,)
    spacing = 50
    for offset in range(-h, w+h, spacing):
        x1 = offset
        y1 = 0
        x2 = offset - h
        y2 = h
        draw.line([(x1, y1), (x2, y2)], fill=stripe_color, width=2)


def bg_blocks(draw, w, h, accent):
    """Draw random rectangular blocks."""
    for _ in range(random.randint(3, 8)):
        x1 = random.randint(0, w)
        y1 = random.randint(0, h)
        bw = random.randint(40, 200)
        bh = random.randint(40, 200)
        block_color = accent + (15,)
        draw.rectangle([(x1, y1), (x1+bw, y1+bh)], fill=block_color)


# ── 布局样式 ────────────────────────────────────────────────────────────

def layout_centered(draw, w, h, title, palette):
    """经典居中大字报"""
    lines = wrap_text(title, 6)
    font_size = 120 if len(lines) <= 2 else 95 if len(lines) <= 3 else 75
    font = load_font(font_size, bold=True)

    line_h = font_size + 35
    total_h = len(lines) * line_h
    start_y = (h - total_h) // 2

    for i, line in enumerate(lines):
        bbox = draw.textbbox((0, 0), line, font=font)
        tw = bbox[2] - bbox[0]
        x = (w - tw) // 2
        y = start_y + i * line_h
        # Text shadow
        draw.text((x+3, y+3), line, fill=(0, 0, 0, 40), font=font)
        draw.text((x, y), line, fill=palette["text"], font=font)

    # Accent line under text
    line_y = start_y + total_h + 20
    line_w = min(400, w - 200)
    draw.rectangle(
        [((w - line_w)//2, line_y), ((w + line_w)//2, line_y + 6)],
        fill=palette["accent"]
    )


def layout_top_heavy(draw, w, h, title, palette):
    """标题在上方，下方留白"""
    lines = wrap_text(title, 7)
    font_size = 105 if len(lines) <= 2 else 85
    font = load_font(font_size, bold=True)

    line_h = font_size + 30
    start_y = 180

    for i, line in enumerate(lines):
        bbox = draw.textbbox((0, 0), line, font=font)
        tw = bbox[2] - bbox[0]
        x = (w - tw) // 2
        y = start_y + i * line_h
        draw.text((x+3, y+3), line, fill=(0, 0, 0, 35), font=font)
        draw.text((x, y), line, fill=palette["text"], font=font)

    # Decorative emoji/icon area at bottom
    sub_font = load_font(32, bold=False)
    sub_text = random.choice([
        "👇 点击查看详情", "✨ 收藏不迷路", "📌 建议收藏",
        "💡 实用干货", "🔥 强烈推荐", "❤️ 喜欢就点赞"
    ])
    bbox = draw.textbbox((0, 0), sub_text, font=sub_font)
    tw = bbox[2] - bbox[0]
    draw.text(((w-tw)//2, h - 150), sub_text, fill=palette["sub"], font=sub_font)


def layout_left_aligned(draw, w, h, title, palette):
    """左对齐，杂志风"""
    lines = wrap_text(title, 6)
    font_size = 110 if len(lines) <= 2 else 88
    font = load_font(font_size, bold=True)

    line_h = font_size + 35
    start_x = 100
    start_y = (h - len(lines) * line_h) // 2

    # Accent bar on the left
    bar_y1 = start_y - 20
    bar_y2 = start_y + len(lines) * line_h + 20
    draw.rectangle([(60, bar_y1), (72, bar_y2)], fill=palette["accent"])

    for i, line in enumerate(lines):
        y = start_y + i * line_h
        draw.text((start_x+3, y+3), line, fill=(0, 0, 0, 35), font=font)
        draw.text((start_x, y), line, fill=palette["text"], font=font)


def layout_highlight_box(draw, w, h, title, palette):
    """文字带高亮背景框"""
    lines = wrap_text(title, 7)
    font_size = 100 if len(lines) <= 2 else 80
    font = load_font(font_size, bold=True)

    line_h = font_size + 45
    total_h = len(lines) * line_h
    start_y = (h - total_h) // 2

    for i, line in enumerate(lines):
        bbox = draw.textbbox((0, 0), line, font=font)
        tw = bbox[2] - bbox[0]
        th = bbox[3] - bbox[1]
        x = (w - tw) // 2
        y = start_y + i * line_h

        # Highlight box behind text
        pad_x, pad_y = 25, 12
        draw.rectangle(
            [(x - pad_x, y - pad_y), (x + tw + pad_x, y + th + pad_y)],
            fill=palette["accent"]
        )
        # White text on accent background
        draw.text((x, y), line, fill=(255, 255, 255), font=font)


def layout_number_style(draw, w, h, title, palette):
    """带数字/序号装饰的风格"""
    lines = wrap_text(title, 7)
    font_size = 95 if len(lines) <= 2 else 78
    font = load_font(font_size, bold=True)

    # Big decorative number in background
    num_font = load_font(350, bold=True)
    num = random.choice(["01", "!", "★", "♥", "?!"])
    bbox = draw.textbbox((0, 0), num, font=num_font)
    nw = bbox[2] - bbox[0]
    nh = bbox[3] - bbox[1]
    draw.text(((w-nw)//2, (h-nh)//2 - 50), num, fill=palette["accent"] + (30,), font=num_font)

    # Title text
    line_h = font_size + 35
    total_h = len(lines) * line_h
    start_y = (h - total_h) // 2

    for i, line in enumerate(lines):
        bbox = draw.textbbox((0, 0), line, font=font)
        tw = bbox[2] - bbox[0]
        x = (w - tw) // 2
        y = start_y + i * line_h
        draw.text((x+3, y+3), line, fill=(0, 0, 0, 40), font=font)
        draw.text((x, y), line, fill=palette["text"], font=font)


# ── 工具函数 ────────────────────────────────────────────────────────────

def wrap_text(text: str, max_chars: int = 7) -> list:
    lines = []
    for i in range(0, len(text), max_chars):
        lines.append(text[i:i + max_chars])
    return lines


# ── 主生成函数 ──────────────────────────────────────────────────────────

def generate(prompt: str = "", text_fallback: str = "", count: int = 1) -> list:
    """
    Generate cover image(s).
    Returns list of file paths.
    """
    display_text = text_fallback or prompt[:20] or "好物分享"
    paths = []
    for idx in range(count):
        path = _generate_one(display_text, idx)
        paths.append(path)
    return paths


def _generate_one(display_text: str, idx: int = 0) -> str:
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    output_path = OUTPUT_DIR / f"{timestamp}_{idx}.png"

    print(f"[封面生成] 图片 {idx+1}: {display_text}")

    w, h = 1080, 1080

    palette = random.choice(PALETTES)

    # Create RGBA image
    img = Image.new('RGBA', (w, h), palette["bg"] + (255,))
    draw = ImageDraw.Draw(img, 'RGBA')

    # Apply random background texture
    texture = random.choice(["noise", "grid", "dots", "circles", "stripes", "blocks", "clean"])
    accent_rgb = palette["accent"]

    if texture == "noise":
        bg_noise(img, intensity=10)
        draw = ImageDraw.Draw(img, 'RGBA')  # refresh draw after pixel manipulation
    elif texture == "grid":
        bg_grid(draw, w, h, accent_rgb)
    elif texture == "dots":
        bg_dots(draw, w, h, accent_rgb)
    elif texture == "circles":
        bg_circles(draw, w, h, accent_rgb)
    elif texture == "stripes":
        bg_stripes(draw, w, h, accent_rgb)
    elif texture == "blocks":
        bg_blocks(draw, w, h, accent_rgb)
    # "clean" = no texture

    # Apply random layout
    layout = random.choice([
        layout_centered,
        layout_top_heavy,
        layout_left_aligned,
        layout_highlight_box,
        layout_number_style,
    ])

    layout(draw, w, h, display_text, palette)

    # Convert to RGB and save
    img_rgb = img.convert('RGB')
    img_rgb.save(str(output_path), quality=95)
    print(f"[封面生成] 风格: {texture} + {layout.__name__}")
    print(f"[封面生成] 保存: {output_path}")
    return str(output_path)


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument("--prompt", default="")
    parser.add_argument("--text", default="好物分享")
    args = parser.parse_args()

    try:
        paths = generate(args.prompt, args.text, count=3)
        for p in paths:
            print(f"[SUCCESS] {p}")
    except Exception as e:
        print(f"\n[FAIL] {e}")
        sys.exit(1)


path: scripts/publish.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
publish.py - Publish to Xiaohongshu creator center
Uses persistent browser context to maintain login state across sessions.
"""

import sys
import time
from pathlib import Path
from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout

from config import get_subdir, resolve_profile_dir

# Screenshot directory
SCREENSHOT_DIR = get_subdir("screenshots")

# Default content
FALLBACK_TITLE = "分享一个好东西！"
CONTENT = "今天给大家分享一个超级实用的工具！\n\n#好物分享 #种草 #推荐"


def save_shot(page, name: str) -> str:
    """Save screenshot"""
    timestamp = time.strftime("%Y%m%d_%H%M%S")
    path = SCREENSHOT_DIR / f"{timestamp}_{name}.png"
    page.screenshot(path=str(path))
    print(f"[Screenshot] {path}")
    return str(path)


def publish(content: str, image_paths, title: str = FALLBACK_TITLE, profile_dir: str | None = None):
    """
    Publish to Xiaohongshu using browser context.

    Args:
        content: Post content text
        image_paths: Single path (str/Path) or list of paths
        title: Title (<20 chars)
        profile_dir: Optional browser profile directory. If omitted, a temporary
            directory is used and login state will not persist after the run.
    """
    # Normalize to list
    if isinstance(image_paths, (str, Path)):
        image_paths = [str(image_paths)]
    else:
        image_paths = [str(p) for p in image_paths]

    # Ensure title is within 20 chars
    if len(title) > 20:
        title = title[:20]
        print(f"[Warning] Title truncated to 20 chars: {title}")

    print(f"\nStarting publish to Xiaohongshu...")
    print(f"[Images] {len(image_paths)} files")
    for ip in image_paths:
        print(f"  - {ip}")
    print(f"[Title] {title}")
    print(f"[Content] {content[:50]}...")

    user_data_dir, persistent = resolve_profile_dir(profile_dir)
    if persistent:
        print(f"[Profile] Using persistent browser profile: {user_data_dir}")
    else:
        print(f"[Profile] Using temporary browser profile: {user_data_dir}")
        print("[Profile] Login/session data will not be kept after this run unless XHS_PROFILE_DIR is set.")

    with sync_playwright() as p:
        # Launch browser context. Persistence is opt-in via profile_dir / XHS_PROFILE_DIR.
        context = p.chromium.launch_persistent_context(
            user_data_dir=str(user_data_dir),
            headless=False,
            viewport={"width": 1920, "height": 1080},
            args=["--start-maximized"],
            user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
        )

        page = context.new_page()

        try:
            # Step 1: Open creator center
            print("\n[1/8] Opening creator center...")
            page.goto("https://creator.xiaohongshu.com/publish/publish", timeout=30000)
            page.wait_for_timeout(3000)
            save_shot(page, "01_landing")

            # Check if we need to login
            try:
                if "login" in page.url.lower() or page.locator('text=扫码登录').first.is_visible(timeout=3000):
                    print("\n[ERROR] Not logged in! Please run save_login.py first to login.")
                    save_shot(page, "01_need_login")
                    raise Exception("Not logged in. Run: python scripts/save_login.py")
            except Exception as e:
                if "Not logged in" in str(e):
                    raise
                # Timeout on checking login means we're probably logged in
                pass

            # Step 1.5: Switch to image/text tab (上传图文)
            # -------------------------------------------------------
            # 小红书创作者中心默认打开"上传视频"tab。
            # 页面上的 file input accept=".mp4,.mov,..." 是视频的。
            # 必须点击"发布笔记"下拉箭头 → "上传图文" 才能切换。
            # 切换后会出现接受图片格式的 file input。
            # 不能靠"点击上传"文字判断，因为视频tab也有这个文字。
            # -------------------------------------------------------
            print("[1.5/8] Switching to image/text upload tab...")
            page.wait_for_timeout(2000)

            # Check: is there already an image-accepting file input?
            has_image_input = False
            try:
                img_inputs = page.locator('input[type="file"]')
                for i in range(img_inputs.count()):
                    accept = img_inputs.nth(i).get_attribute("accept") or ""
                    if any(ext in accept.lower() for ext in [".jpg", ".jpeg", ".png", ".gif", ".webp", "image"]):
                        has_image_input = True
                        print("[OK] Already on image/text tab (image file input found)")
                        break
            except Exception:
                pass

            if not has_image_input:
                # Must switch to image/text tab
                # Step A: Click dropdown arrow
                try:
                    dropdown = page.locator('.dropdownBtn').first
                    if dropdown.is_visible(timeout=3000):
                        dropdown.click()
                        print("[OK] Clicked dropdown arrow")
                        page.wait_for_timeout(1500)
                except Exception:
                    try:
                        page.locator('span.btn-text:has-text("发布笔记")').first.click(timeout=3000)
                        print("[OK] Clicked '发布笔记' button")
                        page.wait_for_timeout(1500)
                    except Exception:
                        print("[Debug] Could not click publish menu trigger")

                # Step B: Click visible "上传图文"
                clicked = False
                try:
                    candidates = page.get_by_text("上传图文")
                    for i in range(candidates.count()):
                        el = candidates.nth(i)
                        box = el.bounding_box()
                        if box and box['x'] > 0 and box['y'] > 0:
                            el.click()
                            print(f"[OK] Clicked '上传图文' (element {i}, pos {box['x']:.0f},{box['y']:.0f})")
                            clicked = True
                            page.wait_for_timeout(3000)
                            break
                except Exception as e:
                    print(f"[Debug] get_by_text failed: {e}")

                if not clicked:
                    try:
                        tab = page.locator('.creator-tab:has-text("上传图文")').first
                        tab.click(force=True)
                        print("[OK] Force-clicked creator-tab '上传图文'")
                        page.wait_for_timeout(3000)
                    except Exception:
                        print("[WARNING] Could not click '上传图文' tab")

            # Verify we now have an image file input
            page.wait_for_timeout(2000)
            save_shot(page, "01c_after_tab_switch")

            # Step 2: Find the correct image file input
            print("[2/8] Looking for image upload input...")
            image_input = None
            try:
                all_inputs = page.locator('input[type="file"]')
                count = all_inputs.count()
                print(f"[Debug] Found {count} file inputs")
                for i in range(count):
                    inp = all_inputs.nth(i)
                    accept = inp.get_attribute("accept") or ""
                    print(f"[Debug] Input {i}: accept='{accept}'")
                    if any(ext in accept.lower() for ext in [".jpg", ".jpeg", ".png", ".gif", ".webp", "image"]):
                        image_input = inp
                        print(f"[OK] Found image file input (index {i})")
                        break
            except Exception as e:
                print(f"[Debug] File input search error: {e}")

            # If no image-specific input found, try .input-file elements
            if image_input is None:
                try:
                    hidden_inputs = page.locator('.input-file')
                    count = hidden_inputs.count()
                    print(f"[Debug] Found {count} .input-file elements")
                    for i in range(count):
                        inp = hidden_inputs.nth(i)
                        accept = inp.get_attribute("accept") or ""
                        if "mp4" not in accept.lower():
                            image_input = inp
                            print(f"[OK] Found .input-file (index {i}, accept='{accept}')")
                            break
                except Exception:
                    pass

            if image_input is None:
                # Last resort: use any file input that's NOT the video one
                try:
                    all_inputs = page.locator('input[type="file"]')
                    for i in range(all_inputs.count()):
                        accept = all_inputs.nth(i).get_attribute("accept") or ""
                        if "mp4" not in accept:
                            image_input = all_inputs.nth(i)
                            print(f"[OK] Using non-video file input (index {i})")
                            break
                except Exception:
                    pass

            # Step 3: Upload images one by one
            print(f"[3/8] Uploading {len(image_paths)} image(s)...")
            for img_idx, img_path in enumerate(image_paths):
                print(f"  Uploading image {img_idx+1}/{len(image_paths)}: {Path(img_path).name}")
                if image_input:
                    image_input.set_input_files(img_path)
                else:
                    page.locator('input[type="file"]').first.set_input_files(img_path)
                page.wait_for_timeout(3000)

                # After first upload, the file input may change; re-locate for subsequent uploads
                if img_idx < len(image_paths) - 1:
                    try:
                        # Look for the "add more" upload button that appears after first image
                        add_inputs = page.locator('input[type="file"]')
                        count = add_inputs.count()
                        for i in range(count):
                            accept = add_inputs.nth(i).get_attribute("accept") or ""
                            if any(ext in accept.lower() for ext in [".jpg", ".jpeg", ".png", ".webp"]):
                                image_input = add_inputs.nth(i)
                                break
                    except Exception:
                        pass

            print(f"[OK] {len(image_paths)} image(s) upload attempted")

            # Wait for image processing and editor to appear
            print("[3.5/8] Waiting for image to process...")
            page.wait_for_timeout(5000)
            save_shot(page, "03_after_image_upload")

            # Try clicking any overlay/confirm/next buttons
            for btn_text in ["下一步", "完成", "确定", "继续"]:
                try:
                    next_btn = page.locator(f'button:has-text("{btn_text}")').first
                    if next_btn.is_visible(timeout=2000):
                        next_btn.click()
                        print(f"[OK] Clicked '{btn_text}' button")
                        page.wait_for_timeout(2000)
                        break
                except Exception:
                    continue

            save_shot(page, "03b_after_next_button")

            # Wait for editor to appear (poll up to 30 seconds)
            print("[3.6/8] Waiting for content editor to appear...")
            editor_selectors = [
                '#post-textarea',
                '.ql-editor',
                '[contenteditable="true"]',
                'div[data-placeholder]',
                'textarea[placeholder*="正文"]',
                'textarea[placeholder*="内容"]',
                'div[data-slate-editor="true"]',
                '.content-editor',
                'div[role="textbox"]',
                'p[data-placeholder]',
            ]

            editor_ready = False
            for wait_round in range(6):  # 6 rounds x 5 sec = 30 sec max
                for selector in editor_selectors:
                    try:
                        el = page.locator(selector).first
                        if el.is_visible(timeout=1000):
                            print(f"[OK] Editor found via {selector} (after {(wait_round+1)*5}s)")
                            editor_ready = True
                            break
                    except Exception:
                        continue
                if editor_ready:
                    break
                # Scroll down to reveal editor if hidden below fold
                page.mouse.wheel(0, 300)
                page.wait_for_timeout(4000)

            if not editor_ready:
                save_shot(page, "03c_editor_not_ready")
                # Dump HTML for debugging
                try:
                    html = page.content()
                    debug_path = SCREENSHOT_DIR / f"{time.strftime('%Y%m%d_%H%M%S')}_page_debug.html"
                    debug_path.write_text(html, encoding="utf-8")
                    print(f"[Debug] Page HTML saved to: {debug_path}")
                except Exception:
                    pass

            # Step 4: Fill content
            print("[4/8] Filling content...")
            save_shot(page, "04_before_content_fill")
            editor_found = False
            for selector in editor_selectors:
                try:
                    editor = page.locator(selector).first
                    if editor.is_visible(timeout=3000):
                        editor.click()
                        page.wait_for_timeout(500)
                        for char in content:
                            page.keyboard.type(char, delay=10)
                        print(f"[OK] Content filled via {selector}")
                        editor_found = True
                        break
                except Exception as e:
                    print(f"[Debug] Selector {selector} failed: {e}")
                    continue

            if not editor_found:
                print("[ERROR] Content editor not found!")
                save_shot(page, "04_editor_not_found")
                # Dump page HTML for debugging
                try:
                    html = page.content()
                    debug_path = SCREENSHOT_DIR / f"{time.strftime('%Y%m%d_%H%M%S')}_page_debug.html"
                    debug_path.write_text(html, encoding="utf-8")
                    print(f"[Debug] Page HTML saved to: {debug_path}")
                except Exception:
                    pass
                raise Exception("Content editor not found")

            page.wait_for_timeout(2000)
            save_shot(page, "04_after_content")

            # Step 5: Try smart title
            print("[5/8] Trying smart title...")
            try:
                smart_title_btn = page.locator('button:has-text("智能标题")').first
                smart_title_btn.click(timeout=3000)
                print("[OK] Smart title button clicked")
                page.wait_for_timeout(3000)
            except Exception as e:
                print(f"[Warning] Smart title button not found: {e}")

            # Step 6: Check title, fill manually if empty
            print("[6/8] Checking and filling title...")
            try:
                title_input = page.locator('input[placeholder*="标题"]').first
                current_title = title_input.input_value()
                if not current_title or len(current_title.strip()) == 0:
                    print("[Warning] Smart title not generated, filling manually")
                    title_input.fill(title)
                    print(f"[OK] Title filled: {title}")
                else:
                    print(f"[OK] Smart title generated: {current_title}")
            except Exception as e:
                print(f"[Warning] Title handling failed: {e}")

            page.wait_for_timeout(1000)
            save_shot(page, "05_before_publish")

            # Step 7: Click publish
            print("[7/8] Clicking publish...")
            publish_btn = page.locator('button:has-text("发布")').first
            publish_btn.click()

            # Step 8: Wait for completion
            print("[8/8] Waiting for completion...")
            page.wait_for_timeout(5000)
            save_shot(page, "06_after_publish")

            print("\n[OK] Publish completed!")

        except Exception as e:
            print(f"\n[ERROR] Publish failed: {e}")
            save_shot(page, "error")
            raise

        finally:
            print("\nBrowser will close in 5 seconds...")
            page.wait_for_timeout(5000)
            context.close()


# Legacy entry point
def publish_to_xiaohongshu(image_path: str, title: str, content: str, profile_dir: str | None = None):
    publish(content, [image_path], title, profile_dir=profile_dir)


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser(description="Publish to Xiaohongshu")
    parser.add_argument("image_path", help="Image file path")
    parser.add_argument("title", help="Post title (<20 chars)")
    parser.add_argument("content", help="Post content")
    parser.add_argument("--profile-dir", default=None, help="Browser profile directory")
    args = parser.parse_args()

    if not Path(args.image_path).exists():
        print(f"[ERROR] Image not found: {args.image_path}")
        sys.exit(1)

    try:
        publish(args.content, args.image_path, args.title[:20], profile_dir=args.profile_dir)
    except Exception as e:
        print(f"\n[ERROR] Publish failed: {e}")
        sys.exit(1)


path: scripts/save_login.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
save_login.py - Login to Xiaohongshu and persist session via browser profile.
Run once. After login, publish.py reuses the same profile — no re-login needed.
"""

import os
import time
from pathlib import Path
from playwright.sync_api import sync_playwright

from config import resolve_profile_dir


def save_login(profile_dir: str | None = None):
    user_data_dir, persistent = resolve_profile_dir(profile_dir)
    if not persistent:
        # For save_login, persistence is the whole point — default to .local/xhs_browser_profile
        user_data_dir = Path(__file__).resolve().parent.parent / ".local" / "xhs_browser_profile"
        user_data_dir.mkdir(parents=True, exist_ok=True)
        persistent = True

    print("\n=== Xiaohongshu Login (Persistent Profile) ===\n")
    print(f"Profile directory: {user_data_dir}")
    print("1. Browser will open Xiaohongshu creator center")
    print("2. Scan QR code to login")
    print("3. After login succeeds, press ENTER here to close browser")
    print("   (All login state is auto-saved to browser profile)\n")

    with sync_playwright() as p:
        context = p.chromium.launch_persistent_context(
            user_data_dir=str(user_data_dir),
            headless=False,
            viewport={"width": 1920, "height": 1080},
            args=["--start-maximized"],
            user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
        )

        page = context.new_page()

        try:
            print("[INFO] Opening Xiaohongshu creator center...")
            page.goto("https://creator.xiaohongshu.com/", timeout=30000)
            page.wait_for_timeout(3000)

            print("\n" + "=" * 50)
            print("Please scan QR code to login now.")
            print("=" * 50 + "\n")

            # Wait for user confirmation
            import threading
            done = threading.Event()

            def wait_enter():
                input(">>> Press ENTER after login is complete: ")
                done.set()

            t = threading.Thread(target=wait_enter, daemon=True)
            t.start()

            # Also auto-detect login (max 3 min)
            start = time.time()
            while time.time() - start < 180:
                if done.is_set():
                    print("\n[OK] Confirmed by user.")
                    break
                url = page.url
                if "creator.xiaohongshu.com" in url and "login" not in url.lower():
                    print(f"\n[OK] Login detected! URL: {url}")
                    break
                time.sleep(2)
            else:
                print("\n[WARN] Timeout — closing anyway. If you logged in, state is still saved.")

            # Let cookies/storage settle
            page.wait_for_timeout(2000)

            print(f"\n[OK] Browser profile saved to: {user_data_dir}")
            print("You can now run publish.py — no login needed.\n")

        except Exception as e:
            print(f"\n[ERROR] {e}")

        finally:
            context.close()


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser(description="Login to Xiaohongshu and persist session")
    parser.add_argument("--profile-dir", default=None, help="Browser profile directory (default: .local/xhs_browser_profile)")
    args = parser.parse_args()
    save_login(profile_dir=args.profile_dir)


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