Skill: punkin6/wechat-tietu-draft (version 1.0.0)

--- name: wechat-tietu-draft description: 创建微信公众号贴图类型草稿的自动化工具。专门用于创建简化界面的贴图草稿（只有标题和正文），适合图片为主的内容。使用触发词"发布贴图草稿"、"创建贴图草稿"、"贴图草稿"、"微信公众号贴图"或"贴图类型草稿"激活。提供统一入口脚本(skill_main.py)进行状态感知的全流程管理。 --- # wechat-tietu-draft Skill 微信公众号贴图类型草稿创建自动化工具。 ## 🎯 如何激活本技能 在OpenClaw对话中使用以下任一触发词： ### 主要触发词 - **创建贴图草稿** - 最常用的触发方式 - **发布贴图草稿** - 常用触发方式 - **贴图草稿** - 简洁触发 - **微信公众号贴图** - 描述性触发 - **贴图类型草稿** - 明确类型触发 ### 使用示例 ``` 用户：创建贴图草稿 助手：好的，我将为您创建微信公众号贴图类型草稿。请确保Chrome浏览器已打开并已登录微信公众号... ``` ## 技能概述 本技能专门用于创建微信公众号的**贴图类型草稿**，这是一种简化界面的草稿类型，特别适合： ### 贴图草稿的特点 1. **界面简化** - 只有标题和正文两个输入框，没有作者、摘要等复杂字段 2. **操作快速** - 填写简单，保存迅速 3. **适合图文** - 特别适合图片为主、文字为辅的内容 ### 什么时候使用本技能？ - ✅ **创建图片为主的公众号内容**（配图文章、图片集等） - ✅ **需要快速创建简单草稿**（不想填写太多信息） - ✅ **制作图文混排内容**（图片+简短文字说明） - ❌ **创建正式的长篇文章**（请使用普通草稿技能） - ❌ **需要作者、摘要等完整信息**（请使用普通草稿技能） ## 核心功能 ### 版本说明 本技能提供统一的文件输入版本，通过 `skill_main.py` 统一入口进行管理。 ### 输入文件格式说明 - **文件类型**：仅接受 `.txt` 文本文件，UTF-8 编码。 - **内容规则**： - **第 1 行**：作为标题（可选）。若为空或过长，则整篇视为正文。 - **第 2 行及以后**：全部视为正文（含空行、作者行、日期行等）。标题与正文之间可有任意空行或说明行，**无需**改成「严格第一行标题 + 其余行正文」。 - **符合要求的文件**：满足「.txt」「UTF-8」「正文非空」即可；第 1 行后有空行或作者信息等均属正常，技能会照常处理。 ### 主要工作流程 #### 统一入口版本工作流程 (`skill_main.py`)： 1. **自动环境检测** - 检查Chrome状态和登录状态 2. **智能流程选择** - 根据当前状态选择最合适的执行路径 3. **状态感知执行** - 环境就绪时直接创建，未就绪时引导用户 4. **完整结果反馈** - 显示保存成功或提供问题解决方案 ### 技术特点 - **快速保存验证** - 采用方案C优化，保存后800毫秒即开始检查 - **智能错误处理** - 自动检测常见问题并提供明确指导 - **用户友好交互** - 清晰的提示和进度显示 - **严格文件验证** - 只接受.txt文件，不做格式转换 - **严格环境检查** - 必须使用CDP模式，检测失败时明确提示用户 ### 严格的环境要求 本技能**严格使用CDP模式**，不会在检测失败时静默切换到其他方式： #### 检查条件： 1. ✅ **Chrome 必须在技能使用的 CDP 端口运行（默认 `19222`，与常见教程 `9222` 错开；可用 `--port` 改）** 2. ✅ **必须已登录微信公众号** 3. ✅ **必须在微信公众号页面** #### 检测失败时的行为： - ❌ **不会静默切换到其他浏览器工具** - ✅ **会明确提示用户问题所在** - ✅ **会提供详细的解决步骤** - ✅ **会优雅地终止任务** ## 使用方式 ### 推荐：统一入口 `skill_main.py` `skill_main.py` 是本技能**唯一推荐入口**（创建草稿勿直接运行 `wechat_tietu_draft.py`）。 **macOS / Linux** - **从 `.txt` 创建贴图草稿：** ```bash python3 skill_main.py --file 文章.txt 2>&1 | tee skill_main.log ``` - **指定 Chrome 调试端口**（默认 `19222`；若需与他人共用 9222 可显式 `--port 9222`）： ```bash python3 skill_main.py --file 文章.txt --port 19222 2>&1 | tee skill_main.log ``` **Windows（PowerShell，同上）** ```powershell python skill_main.py --file 文章.txt 2>&1 | Tee-Object -FilePath skill_main.log ``` （若使用 **Git Bash**，可与 macOS 相同，用 `python3` 与 `tee`。） 说明：子进程写草稿时的详细日志仍会落在同目录下的 **`skill.log`**；`skill_main.log` 记录的是**入口脚本自身**在终端上的输出（含等待登录等提示）。 #### 统一入口脚本的特点： - ✅ **自动环境检测** - 检查Chrome状态和登录状态 - ✅ **状态感知流程** - 根据当前状态选择最合适的执行路径 - ✅ **统一错误处理** - 提供明确的错误信息和解决建议 - ✅ **用户友好交互** - 清晰的提示和进度显示 - ✅ **完整工作流** - 从环境启动到草稿创建的全流程管理 ## 辅助说明 **Chrome 启动已内置在 `skill_main.py --file`**：环境不足时会自动释放当前默认端口（默认 `19222`，若需）并拉起带 `--remote-debugging-port=…` 的 Chrome；环境检测在创建草稿流程中自动完成，无需单独自检命令。 **登录前截图**：进入「等待登录 / 就绪」后、自动轮询前，会对当前调试 Chrome 里优先选中的公众号相关标签页截一张 **PNG**，默认写入技能目录 **`mp_login_screenshot.png`**（已 `.gitignore`）。CDP WebSocket 一律 **`proxy=None` 直连本机**，避免 VPN 的 SOCKS 误伤本地调试口。 ## 使用前准备 ### 必要条件 - ✅ **Chrome浏览器** - 必须使用Google Chrome（不能使用其他浏览器） - ✅ **Python环境** - Python 3.7或更高版本 - ✅ **微信公众号账号** - 需要有可登录的公众号 ### 快速检查 运行以下命令检查环境是否就绪： ```bash # 检查Python版本 python3 --version # 检查Chrome是否安装 which google-chrome # Linux/Mac where chrome # Windows # 安装必要依赖（只需一次） pip install websockets ``` ## 常见问题与解决 ### 问题1：显示"代理错误"或"SOCKS错误" **可能原因**：VPN或网络代理干扰 **解决方法**： 1. **完全关闭VPN软件** 2. 检查系统网络设置，确保没有代理 3. 重新运行技能 ⚠️ **不要尝试安装python-socks等库**，这无法解决问题 ### 问题2：技能没有反应 **可能原因**：Chrome浏览器未打开或未登录 **解决方法**： 1. 手动打开Chrome浏览器 2. 访问 https://mp.weixin.qq.com 并扫码登录 3. 重新使用触发词激活技能 ### 问题3：保存失败或超时 **可能原因**：网络不稳定或页面未加载完成 **解决方法**： 1. 检查网络连接是否稳定 2. 手动刷新微信公众号页面 3. 等待页面完全加载后再试 ## 典型失败场景与建议 - **报错含 SOCKS / python-socks / proxy** 可能原因：系统开了 VPN 或全局代理。 建议：关闭 VPN/代理后重试，不要改技能代码或装 python-socks。 - **Chrome 已开但检测不到公众号页** 可能原因：未用本技能指定的调试端口、或未打开 mp.weixin.qq.com。 建议：用 `--port` 指定端口，或在技能拉起的 Chrome 里打开公众号并扫码。 - **等待超时（长时间未检测到登录）** 可能原因：未扫码、或扫码后会话被踢出/过期。 建议：在技能使用的 Chrome 窗口内重新扫码，保持页面在公众号首页或草稿相关页。 - **草稿创建失败 / 退出码非 0** 可能原因：页面结构变化、网络中断、或公众号风控。 建议：查看 `skill.log` 最后几行；刷新公众号页后重试；必要时检查页面元素是否变更。 ### 快速检查步骤 如果遇到问题，按顺序检查： 1. ✅ Chrome是否已打开？ 2. ✅ 是否已登录微信公众号？ 3. ✅ VPN是否已关闭？ 4. ✅ 网络连接是否正常？ 5. ✅ 微信公众号页面是否能正常访问？ ## 文件结构 ``` wechat-tietu-draft/ ├── skill_main.py # 统一入口（含 Chrome 启动 + 环境检测 + 调子进程） ├── wechat_tietu_draft.py # 由 skill_main 子进程调用，勿直接运行 ├── examples/ # 示例目录 │ └── sample_tietu.txt # 贴图内容示例（纯文本） ├── requirements.txt # Python 依赖 ├── SKILL.md # 本技能说明 ├── skill.log # 运行日志（执行时生成，可忽略） ``` ## 许可证 MIT License ## 支持 如有问题，请查看： 1. 本文件中的「常见问题与解决」「故障排除」章节 2. `examples/sample_tietu.txt` 示例文件 3. 运行日志 `skill.log`（执行技能后生成） --- ## 维护者说明 - **默认 CDP 端口**：`19222`（与常见教程 `9222` 错开，可用 `--port` 覆盖）。等待登录最长秒数可通过环境变量 `TIETU_MAX_WAIT` 覆盖（默认 300）。 - **入口与子进程**：用户仅需运行 `skill_main.py --file <文章.txt>`；`skill_main.py` 负责环境检测、Chrome 启动、等待就绪，再以子进程调用 `wechat_tietu_draft.py` 执行实际草稿写入。勿直接运行 `wechat_tietu_draft.py`。 - **OpenClaw 集成**：推荐调用方式为传入 `.txt` 文件路径并执行 `python3 skill_main.py --file <路径>`；技能会输出可解析的 `STAGE:` 行（如 `CHROME_STARTED`、`LOGIN_CONFIRMED`、`DRAFT_CREATED_OK`、`DRAFT_FAILED`、`LOGIN_TIMEOUT`）便于上游汇总状态。 ## 未来计划 - 多账号 / 多 Chrome Profile 支持（当前为单端口单实例）。 - 微信公众号页面结构若改版，可能需更新元素选择器。 --- **提示**: 微信公众号页面结构可能变化，如遇到问题请检查元素选择器是否需要更新。

path: skill_main.py
content: #!/usr/bin/env python3
"""
wechat-tietu-draft 技能主入口脚本
统一的环境检查、状态管理和草稿创建入口
"""

import os
import sys
import json
import base64
import subprocess
import time
import argparse
import asyncio
import concurrent.futures
import urllib.request
import urllib.error
from pathlib import Path
from datetime import datetime

import websockets

# 清除代理环境变量，避免websockets的SOCKS代理错误
proxy_vars = [
    'http_proxy', 'https_proxy', 'all_proxy',
    'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY',
    'no_proxy', 'NO_PROXY'
]

for var in proxy_vars:
    os.environ.pop(var, None)


def _looks_like_proxy_error(text):
    if not text:
        return False
    t = text.lower()
    if "python-socks" in t or "socks proxy" in t:
        return True
    if "connecting through" in t and "proxy" in t:
        return True
    if "socks" in t and "requires" in t:
        return True
    return False


def print_proxy_vpn_hint():
    """出现代理/WebSocket 相关错误时：提醒关 VPN，勿改代码凑修复。"""
    print("\n【代理/VPN 导致连接失败】请关闭 VPN 或系统代理后重试；勿改技能代码或装 python-socks。")
    print("  执行: python3 skill_main.py --file <文章.txt>\n")


# 环境状态定义
class EnvStatus:
    """环境状态常量"""
    UNKNOWN = "unknown"
    READY = "ready"                     # Chrome运行且已登录微信公众号
    CHROME_RUNNING_NOT_LOGGED = "chrome_running_not_logged"  # Chrome运行但未登录
    CHROME_RUNNING_LOGIN_STATUS_UNKNOWN = "chrome_running_login_status_unknown"  # Chrome运行，登录状态不确定
    CHROME_NOT_RUNNING = "chrome_not_running"                # Chrome未运行
    NO_WECHAT_PAGE = "no_wechat_page"   # Chrome运行但没有微信公众号页面
    PORT_OCCUPIED_NO_PAGES = "port_occupied_no_pages"  # 端口被占用但没有页面
    ERROR = "error"                     # 检测过程中出错

# 与 cdp 轮询、环境判定共用（已登录 URL 特征）
WECHAT_MP_HOME = "https://mp.weixin.qq.com/"
# 本技能默认 CDP 端口（避免与常见教程 9222 冲突；可用 --port 覆盖）
DEFAULT_CDP_PORT = 19222
# 等待登录/就绪：最长秒数、轮询间隔（可通过环境变量 TIETU_MAX_WAIT 覆盖，仅整数）
DEFAULT_MAX_WAIT_SECONDS = int(os.environ.get("TIETU_MAX_WAIT", "300"))
DEFAULT_CHECK_INTERVAL = 5
MP_LOGGED_IN_URL_MARKERS = (
    "token=",
    "cgi-bin/home",
    "cgi-bin/masssend",
    "cgi-bin/material",
    "cgi-bin/draft",
    "cgi-bin/appmsg",
)


def print_chrome_help():
    """Chrome / 微信公众平台 设置说明（cdp 脚本可复用）"""
    print(f"\n📋 CDP 模式：Chrome 需在端口 {DEFAULT_CDP_PORT} 运行并已登录公众号；由 skill_main.py --file 自动启动 Chrome。")
    print("   创建草稿: python3 skill_main.py --file 文章内容.txt\n")


def open_url_in_browser(url):
    """本机打开 URL（无 CDP 时打开 mp 首页等）"""
    try:
        if sys.platform == "darwin":
            subprocess.run(["open", url], check=False, timeout=10)
        elif sys.platform == "win32":
            os.startfile(url)  # noqa: S606
        else:
            subprocess.run(["xdg-open", url], check=False, timeout=10)
    except Exception:
        pass


def validate_and_read_txt_file(file_path):
    """
    验证并读取 .txt：扩展名、存在、可读、UTF-8；
    第一行可选标题（≤100 字），其余为正文。
    返回 (title, content) 或 (None, None)。
    """
    if not str(file_path).lower().endswith(".txt"):
        print("❌ 错误：只接受.txt文本文件")
        print(f"   当前文件: {file_path}")
        return None, None
    if not os.path.exists(file_path):
        print("❌ 错误：文件不存在")
        print(f"   路径: {file_path}")
        return None, None
    if not os.access(file_path, os.R_OK):
        print("❌ 错误：文件不可读")
        print(f"   路径: {file_path}")
        return None, None
    try:
        with open(file_path, "r", encoding="utf-8") as f:
            lines = f.readlines()
        if not lines:
            print("❌ 错误：文件为空")
            return None, None
        title_line = lines[0].strip()
        if title_line and len(title_line) <= 100:
            title = title_line
            content_lines = lines[1:]
        else:
            title = ""
            content_lines = lines
        content = "".join(content_lines).strip()
        if not content:
            print("❌ 错误：正文内容为空")
            return None, None
        print(f"✅ 文件验证通过 标题: {title or '(无标题)'} 正文: {len(content)} 字")
        return title, content
    except UnicodeDecodeError:
        print("❌ 错误：文件编码不是UTF-8")
        return None, None
    except Exception as e:
        print(f"❌ 错误：读取文件失败: {e}")
        return None, None


# ---------------------------------------------------------------------------
# CDP / 会话过期 / 统一等待（与 wechat_tietu_draft 共用一套逻辑）
# ---------------------------------------------------------------------------


async def _cdp_session_expired_need_login(ws_url):
    """页面存在 a#jumpUrl 文案「登录」→ 会话过期。"""
    expr = r"""(function(){var a=document.querySelector('a#jumpUrl');if(!a)return false;return (a.textContent||'').trim()==='\u767b\u5f55';})()"""
    msg_id = 99
    try:
        async with websockets.connect(ws_url, proxy=None) as ws:
            await ws.send(
                json.dumps(
                    {
                        "id": msg_id,
                        "method": "Runtime.evaluate",
                        "params": {"expression": expr, "returnByValue": True},
                    }
                )
            )
            while True:
                raw = await asyncio.wait_for(ws.recv(), timeout=10)
                data = json.loads(raw)
                if data.get("id") != msg_id:
                    continue
                if data.get("error"):
                    return None
                val = data.get("result", {}).get("result", {}).get("value")
                return bool(val)
    except Exception:
        return None


async def _cdp_capture_screenshot_png(ws_url):
    """
    CDP Page.captureScreenshot → PNG bytes。需可连 WebSocket 的调试页。
    """
    cap_id = 77
    try:
        async with websockets.connect(
            ws_url, max_size=50 * 1024 * 1024, proxy=None
        ) as ws:
            await ws.send(json.dumps({"id": 1, "method": "Page.enable"}))
            await ws.send(
                json.dumps(
                    {
                        "id": cap_id,
                        "method": "Page.captureScreenshot",
                        "params": {"format": "png", "captureBeyondViewport": True},
                    }
                )
            )
            while True:
                raw = await asyncio.wait_for(ws.recv(), timeout=30)
                data = json.loads(raw)
                if data.get("method") == "Page.loadEventFired":
                    continue
                if data.get("id") == 1:
                    continue
                if data.get("id") != cap_id:
                    continue
                if data.get("error"):
                    return None
                b64 = data.get("result", {}).get("data")
                if not b64:
                    return None
                return base64.b64decode(b64)
    except Exception:
        return None
    return None


def _pick_page_for_screenshot(pages):
    """优先 mp 登录/首页，否则任意 page。"""
    if not pages:
        return None
    candidates = [p for p in pages if p.get("type") == "page" and p.get("webSocketDebuggerUrl")]
    if not candidates:
        return None

    def score(p):
        u = (p.get("url") or "").lower()
        t = p.get("title") or ""
        s = 0
        if "mp.weixin.qq.com" in u:
            s += 10
        if "扫码" in t or "login" in u or "login" in t.lower():
            s += 5
        return s

    candidates.sort(key=score, reverse=True)
    return candidates[0]


def capture_wechat_mp_screenshot(port=DEFAULT_CDP_PORT, output_path=None):
    """
    对当前调试实例中优先选取的公众号相关标签页截图（PNG）。
    成功返回绝对路径字符串，失败返回 None。
    """
    try:
        r = subprocess.run(
            ["curl", "-s", f"http://127.0.0.1:{port}/json/list"],
            capture_output=True,
            text=True,
            timeout=8,
        )
        if r.returncode != 0:
            return None
        pages = json.loads(r.stdout)
        page = _pick_page_for_screenshot(pages)
        if not page:
            return None
        ws_url = page.get("webSocketDebuggerUrl")
        if not ws_url:
            return None
        script_dir = Path(__file__).resolve().parent
        if output_path is None:
            output_path = script_dir / "mp_login_screenshot.png"
        else:
            output_path = Path(output_path).expanduser().resolve()

        def _run():
            return asyncio.run(_cdp_capture_screenshot_png(ws_url))

        with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex:
            png = ex.submit(_run).result(timeout=45)
        if not png:
            return None
        output_path.parent.mkdir(parents=True, exist_ok=True)
        output_path.write_bytes(png)
        return str(output_path)
    except Exception:
        return None


def check_wechat_session_expired_via_cdp(ws_url):
    """True=过期；False=未判定过期；None=无法检测。线程内 asyncio.run 避免嵌套 loop。"""
    if not ws_url:
        return None

    def _run():
        return asyncio.run(_cdp_session_expired_need_login(ws_url))

    try:
        with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex:
            return ex.submit(_run).result(timeout=20)
    except Exception:
        return None


async def _cdp_navigate_url(ws_url, url):
    nav_id = 88
    try:
        async with websockets.connect(ws_url, proxy=None) as ws:
            await ws.send(
                json.dumps(
                    {
                        "id": nav_id,
                        "method": "Page.navigate",
                        "params": {"url": url},
                    }
                )
            )
            while True:
                raw = await asyncio.wait_for(ws.recv(), timeout=20)
                data = json.loads(raw)
                if data.get("id") == nav_id:
                    return not bool(data.get("error"))
    except Exception:
        return False
    return False


def _mp_page_logged_in_ready(page):
    """可发草稿：mp + URL 特征 + 非扫码页 + 非会话过期(CDP)。"""
    if not page or "mp.weixin.qq.com" not in (page.get("url") or ""):
        return False
    url = (page.get("url") or "").lower()
    title = page.get("title") or ""
    if "扫码登录" in title or "Login" in title:
        return False
    if not any(m in url for m in MP_LOGGED_IN_URL_MARKERS):
        return False
    if check_wechat_session_expired_via_cdp(page.get("webSocketDebuggerUrl")) is True:
        return False
    return True


def wait_until_ready_for_draft(port=DEFAULT_CDP_PORT, max_seconds=None, interval=DEFAULT_CHECK_INTERVAL):
    """
    统一等待：轮询直到存在「可发草稿」的 mp 标签页（含 CDP 会话未过期）。
    交互 / 非交互 / skill_main / cdp 子进程前均用同一套标准。
    """
    if max_seconds is None:
        max_seconds = DEFAULT_MAX_WAIT_SECONDS
    print("\n⏳ 等待微信公众平台就绪（自动检测）…")
    checks = max(1, max_seconds // interval)
    for i in range(checks):
        elapsed = i * interval
        try:
            r = subprocess.run(
                ["curl", "-s", f"http://127.0.0.1:{port}/json/list"],
                capture_output=True,
                text=True,
                timeout=5,
            )
            if r.returncode != 0:
                time.sleep(interval)
                continue
            pages = json.loads(r.stdout)
            for p in pages:
                if _mp_page_logged_in_ready(p):
                    print("STAGE: LOGIN_CONFIRMED")
                    print(f"✅ 已就绪（约 {elapsed} 秒），可创建草稿\n")
                    return True
        except Exception:
            pass
        if elapsed > 0 and elapsed % 60 == 0:
            print(f"   … 等待中 {elapsed}/{max_seconds}s")
        time.sleep(interval)
    print("STAGE: LOGIN_TIMEOUT")
    print(f"\n❌ 等待超时（{max_seconds} 秒）。长时间未检测到登录，可能未扫码或会话被踢出。")
    print_chrome_help()
    return False


def refetch_best_wechat_page(port=DEFAULT_CDP_PORT):
    try:
        r = subprocess.run(
            ["curl", "-s", f"http://127.0.0.1:{port}/json/list"],
            capture_output=True,
            text=True,
            timeout=5,
        )
        if r.returncode != 0:
            return None
        pages = json.loads(r.stdout)
        best = None
        for p in pages:
            if not _mp_page_logged_in_ready(p):
                continue
            u = p.get("url") or ""
            if "token=" in u.lower():
                return p
            if best is None:
                best = p
        return best
    except Exception:
        return None


def prompt_session_expired_scan_login(ws_url=None, will_auto_continue=True):
    """会话过期：3 秒 → CDP/系统浏览器打开 mp 首页 → 再进入 wait_until_ready_for_draft。"""
    print("\n⚠️ 会话已过期，请重新扫码登录。正在打开首页…")
    time.sleep(3)
    opened = False
    if ws_url:
        try:

            def _nav():
                return asyncio.run(_cdp_navigate_url(ws_url, WECHAT_MP_HOME))

            with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex:
                opened = bool(ex.submit(_nav).result(timeout=25))
        except Exception:
            pass
    if not opened:
        open_url_in_browser(WECHAT_MP_HOME)
    print("  请在浏览器内扫码登录，将自动检测就绪。\n")
    print_chrome_help()


def ensure_chrome_ready_for_draft(port=DEFAULT_CDP_PORT):
    """
    创建草稿前统一闸门：端口、mp 页、扫码页、会话过期 → 必要时提示并 wait_until_ready_for_draft。
    """
    print("\n🔍 检查 Chrome / 公众号…")
    try:
        r = subprocess.run(
            ["curl", "-s", f"http://127.0.0.1:{port}/json/list"],
            capture_output=True,
            text=True,
            timeout=5,
        )
        if r.returncode != 0:
            print("❌ Chrome 调试端口不可用")
            print_chrome_help()
            return False
        pages = json.loads(r.stdout)
        if not pages:
            print("❌ 无页面")
            print_chrome_help()
            return False
        wechat = None
        for p in pages:
            if "mp.weixin.qq.com" in (p.get("url") or ""):
                wechat = p
                break
        if not wechat:
            print("❌ 未找到 mp.weixin.qq.com 标签页，正在打开首页…")
            open_url_in_browser(WECHAT_MP_HOME)
            return wait_until_ready_for_draft(port=port, max_seconds=DEFAULT_MAX_WAIT_SECONDS, interval=DEFAULT_CHECK_INTERVAL)
        t = wechat.get("title") or ""
        if "扫码登录" in t or "Login" in t:
            print("❌ 当前为扫码登录页，等待登录完成…")
            return wait_until_ready_for_draft(port=port, max_seconds=DEFAULT_MAX_WAIT_SECONDS, interval=DEFAULT_CHECK_INTERVAL)
        ws = wechat.get("webSocketDebuggerUrl")
        if check_wechat_session_expired_via_cdp(ws) is True:
            print("❌ 会话已过期（a#jumpUrl「登录」）")
            prompt_session_expired_scan_login(ws, will_auto_continue=True)
            return wait_until_ready_for_draft(port=port, max_seconds=DEFAULT_MAX_WAIT_SECONDS, interval=DEFAULT_CHECK_INTERVAL)
        if _mp_page_logged_in_ready(wechat):
            print("✅ 已登录且会话有效")
            return True
        for p in pages:
            if _mp_page_logged_in_ready(p):
                print("✅ 已登录且会话有效")
                return True
        print("⚠️ 登录状态未确认，等待就绪…")
        return wait_until_ready_for_draft(port=port, max_seconds=DEFAULT_MAX_WAIT_SECONDS, interval=DEFAULT_CHECK_INTERVAL)
    except subprocess.TimeoutExpired:
        print("❌ 连接 Chrome 超时")
        print_chrome_help()
        return False
    except json.JSONDecodeError:
        print("❌ Chrome 返回异常")
        print_chrome_help()
        return False
    except Exception as e:
        print(f"❌ 检查失败: {e}")
        print_chrome_help()
        return False


def print_header():
    """打印技能标题"""
    print("wechat-tietu-draft v1.0.0")

def check_environment_status(port=DEFAULT_CDP_PORT):
    """
    检查当前环境状态
    返回: (状态, 详细信息)
    """
    try:
        # 1. 检查调试端口是否响应
        try:
            result = subprocess.run(
                ["curl", "-s", f"http://127.0.0.1:{port}/json/version"],
                capture_output=True,
                text=True,
                timeout=3
            )
            port_responding = (result.returncode == 0)
        except Exception:
            port_responding = False
        
        if not port_responding:
            print("   ❌ Chrome调试端口未响应")
            return EnvStatus.CHROME_NOT_RUNNING, {"port": port, "reason": "port_not_responding"}
        
        # 2. 获取页面列表
        try:
            result = subprocess.run(
                ["curl", "-s", f"http://127.0.0.1:{port}/json/list"],
                capture_output=True,
                text=True,
                timeout=3
            )
            
            if result.returncode != 0:
                print("   ❌ 无法获取页面列表")
                return EnvStatus.PORT_OCCUPIED_NO_PAGES, {"port": port, "reason": "cannot_get_pages"}
            
            pages = json.loads(result.stdout)
            
            if not pages:
                print("   ⚠️ 端口被占用但没有页面")
                return EnvStatus.PORT_OCCUPIED_NO_PAGES, {"port": port, "pages": 0}
            
            # 3. 检查微信公众号页面和登录状态
            wechat_page_found = False
            wechat_logged_in = False
            wechat_page_info = None
            
            for page in pages:
                if page.get("type") == "page":
                    url = page.get("url", "")
                    title = page.get("title", "")
                    
                    if "mp.weixin.qq.com" in url:
                        wechat_page_found = True
                        wechat_page_info = page
                        
                        # 智能登录状态判断
                        # 1. 检查是否为登录页面
                        login_keywords = ["login", "扫码", "二维码", "qrcode", "登录"]
                        is_login_page = any(keyword in url.lower() or keyword in title.lower() 
                                          for keyword in login_keywords)
                        
                        # 2. 检查是否为已登录状态（包含token参数）
                        # 微信公众号登录后URL通常包含token参数
                        is_logged_in = any(
                            indicator in url.lower()
                            for indicator in MP_LOGGED_IN_URL_MARKERS
                        )
                        
                        # 3. 综合判断
                        if is_login_page:
                            wechat_logged_in = False
                        elif is_logged_in:
                            wechat_logged_in = True
                        else:
                            wechat_logged_in = False  # 不确定状态
                        break
            
            if not wechat_page_found:
                print("   ⚠️ 没有找到微信公众号页面")
                return EnvStatus.NO_WECHAT_PAGE, {"pages": len(pages), "first_page": pages[0] if pages else None}
            
            if wechat_logged_in:
                print("   ✅ 环境就绪")
                return EnvStatus.READY, {"page": wechat_page_info, "port": port}
            elif is_login_page:
                print("   ⚠️ 需要登录公众号")
                return EnvStatus.CHROME_RUNNING_NOT_LOGGED, {"page": wechat_page_info, "port": port}
            else:
                print("   ⚠️ 登录状态不确定")
                return EnvStatus.CHROME_RUNNING_LOGIN_STATUS_UNKNOWN, {"page": wechat_page_info, "port": port}
                
        except json.JSONDecodeError:
            print("   ❌ 无法解析页面数据")
            return EnvStatus.ERROR, {"reason": "json_decode_error"}
        except Exception as e:
            print(f"   ❌ 检查页面时出错: {e}")
            return EnvStatus.ERROR, {"reason": str(e)}
            
    except Exception as e:
        print(f"❌ 环境检查失败: {e}")
        return EnvStatus.ERROR, {"reason": str(e)}

# ---------- 内置原 start_real_chrome.py（已删除独立文件）----------


def _chrome_kill_port_pids(port=DEFAULT_CDP_PORT):
    """释放调试端口（macOS/Linux：lsof + kill）。"""
    try:
        r = subprocess.run(
            ["lsof", "-ti", f":{port}"],
            capture_output=True,
            text=True,
        )
        if r.returncode != 0 or not r.stdout.strip():
            return False
        for pid in r.stdout.strip().split():
            try:
                subprocess.run(["kill", "-9", pid], capture_output=True, timeout=2)
            except Exception:
                pass
        time.sleep(2)
        return True
    except Exception as e:
        print(f"  释放端口时: {e}")
        return False


def _chrome_launch_debug(port=DEFAULT_CDP_PORT):
    """启动带 remote-debugging-port 的 Chrome，返回 info 字典或 None。"""
    _chrome_kill_port_pids(port)
    chrome_paths = [
        "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
        "/usr/bin/google-chrome",
    ]
    chrome_cmd = None
    for p in chrome_paths:
        if os.path.exists(p):
            chrome_cmd = p
            break
    if not chrome_cmd:
        r = subprocess.run(["which", "google-chrome"], capture_output=True, text=True)
        chrome_cmd = r.stdout.strip() if r.returncode == 0 else "google-chrome"
    ts = int(time.time())
    user_data = os.path.expanduser(f"~/.chrome-real-wechat-{ts}")
    os.makedirs(user_data, exist_ok=True)
    cmd = [
        chrome_cmd,
        f"--remote-debugging-port={port}",
        f"--user-data-dir={user_data}",
        "--no-first-run",
        "--no-default-browser-check",
        "--new-window",
        "--window-size=1200,800",
        "--window-position=100,100",
        WECHAT_MP_HOME,
    ]
    try:
        subprocess.Popen(
            cmd,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            start_new_session=True,
        )
        time.sleep(3)
        for i in range(15):
            try:
                with urllib.request.urlopen(
                    f"http://127.0.0.1:{port}/json/version", timeout=3
                ) as resp:
                    if resp.status == 200:
                        return {"port": port, "user_data_dir": user_data}
            except Exception:
                pass
            time.sleep(2)
        print("  调试端口未响应，请查看是否弹出 Chrome 窗口")
        return {"port": port, "user_data_dir": user_data}
    except Exception as e:
        print(f"❌ 启动失败: {e}")
        return None


def _sr_list_has_page(port):
    try:
        r = subprocess.run(
            ["curl", "-s", f"http://127.0.0.1:{port}/json/list"],
            capture_output=True,
            text=True,
            timeout=3,
        )
        if r.returncode != 0:
            return False
        for p in json.loads(r.stdout):
            if p.get("type") == "page":
                return True
        return False
    except Exception:
        return False


def _sr_find_wechat_page(port):
    try:
        r = subprocess.run(
            ["curl", "-s", f"http://127.0.0.1:{port}/json/list"],
            capture_output=True,
            text=True,
            timeout=3,
        )
        if r.returncode != 0:
            return False, None
        for p in json.loads(r.stdout):
            if p.get("type") == "page" and "mp.weixin.qq.com" in (p.get("url") or ""):
                return True, p
        return False, None
    except Exception:
        return False, None


def _sr_navigate_first_tab_to_mp(port):
    try:
        r = subprocess.run(
            ["curl", "-s", f"http://127.0.0.1:{port}/json/list"],
            capture_output=True,
            text=True,
            timeout=3,
        )
        if r.returncode != 0:
            return False
        first = None
        for p in json.loads(r.stdout):
            if p.get("type") == "page":
                first = p
                break
        if not first or not first.get("id"):
            return False
        pid = first["id"]
        body = json.dumps(
            {"id": 1, "method": "Page.navigate", "params": {"url": WECHAT_MP_HOME}}
        ).encode()
        req = urllib.request.Request(
            f"http://127.0.0.1:{port}/json/session/{pid}",
            data=body,
            headers={"Content-Type": "application/json"},
            method="POST",
        )
        with urllib.request.urlopen(req, timeout=5) as resp:
            return resp.status == 200
    except Exception:
        return False


def _sr_print_targets(port):
    """仅用于调试；为减噪默认不打印目标列表。"""
    pass


def _sr_after_launch_hints():
    """Chrome 启动后统一提示：扫码登录后由自动检测就绪，交互/非交互一致。"""
    print("\n📱 请在 Chrome 内扫码登录公众号；将自动检测就绪。")


def run_start_chrome_flow(port=DEFAULT_CDP_PORT):
    """
    原 start_real_chrome.py main：尽量复用已有调试实例；否则杀端口再起新 Chrome。
    返回是否已尽力拉起可用调试端口（Popen 成功即 True）。
    """
    debug_ok = False
    try:
        r = subprocess.run(
            ["curl", "-s", f"http://127.0.0.1:{port}/json/version"],
            capture_output=True,
            text=True,
            timeout=2,
        )
        debug_ok = r.returncode == 0
    except Exception:
        pass

    if debug_ok and _sr_list_has_page(port):
        ok, _ = _sr_find_wechat_page(port)
        if ok:
            print("STAGE: CHROME_STARTED")
            print("✅ 已有调试 Chrome 且含公众号页")
            _sr_after_launch_hints()
            return True
        print("⚠️ 有页面但无公众号页 → 尝试导航到 mp…")
        if _sr_navigate_first_tab_to_mp(port):
            time.sleep(3)
        _sr_after_launch_hints()
        print("STAGE: CHROME_STARTED")
        return True

    if debug_ok:
        print("⚠️ 调试端口可用但无可用页 → 释放端口并启动新 Chrome…")
    else:
        print("⚠️ 调试端口不可用 → 启动 Chrome…")

    info = _chrome_launch_debug(port)
    if not info:
        print("❌ 无法启动 Chrome；可手动:")
        print(
            f"   open -a 'Google Chrome' --args --remote-debugging-port={port} {WECHAT_MP_HOME}"
        )
        return False
    _sr_after_launch_hints()
    print("STAGE: CHROME_STARTED")
    return True


def start_chrome_environment(port=DEFAULT_CDP_PORT):
    """兼容旧名：等同 run_start_chrome_flow。"""
    return run_start_chrome_flow(port)

def wait_for_login_confirmation(port=DEFAULT_CDP_PORT, max_wait_seconds=None, check_interval=DEFAULT_CHECK_INTERVAL):
    """
    统一等待登录/就绪。交互与非交互均走自动检测，不再等待回车。
    """
    if max_wait_seconds is None:
        max_wait_seconds = DEFAULT_MAX_WAIT_SECONDS
    print("\n⏳ 等待登录 / 就绪（自动检测）…")
    shot = capture_wechat_mp_screenshot(port=port)
    if shot:
        print(f"📷 截图: {shot}")
    return wait_until_ready_for_draft(
        port=port,
        max_seconds=max_wait_seconds,
        interval=check_interval,
    )

def create_draft_from_file(file_path, port=None):
    """从文件创建草稿"""
    if port is None:
        port = DEFAULT_CDP_PORT
    print("\n📝 创建贴图草稿…")
    script_dir = Path(__file__).parent
    log_file = script_dir / "skill.log"
    
    # 标准文件输入版本 (唯一推荐版本)
    draft_script = script_dir / "wechat_tietu_draft.py"
    if not draft_script.exists():
        print(f"❌ 找不到文件输入脚本: {draft_script}")
        return False
    
    title, content = validate_and_read_txt_file(str(file_path))
    if title is None or content is None:
        print_chrome_help()
        return False
    
    if not ensure_chrome_ready_for_draft(port):
        return False
    try:
        child_env = os.environ.copy()
        child_env["CDP_TIETU_CHILD"] = "1"
        child_env["CDP_TIETU_PORT"] = str(port)
        with open(log_file, "w", encoding="utf-8") as f:
            f.write(f"# Run at {datetime.now().isoformat(timespec='seconds')} file={Path(file_path).name}\n")
            f.flush()
            result = subprocess.run(
                [sys.executable, str(draft_script), str(file_path)],
                stdout=f,
                stderr=subprocess.STDOUT,
                text=True,
                timeout=120,
                env=child_env,
            )
        if result.returncode == 0:
            print("STAGE: DRAFT_CREATED_OK")
            print("✅ 草稿创建完成")
            return True
        else:
            print("STAGE: DRAFT_FAILED")
            print(f"❌ 草稿创建失败 (退出码: {result.returncode})")
            try:
                with open(log_file, "r", encoding="utf-8", errors="replace") as lf:
                    tail = lf.read(8000)
                if _looks_like_proxy_error(tail):
                    print_proxy_vpn_hint()
            except Exception:
                pass
            return False
            
    except subprocess.TimeoutExpired:
        print("❌ 草稿创建超时")
        return False
    except Exception as e:
        print(f"❌ 创建草稿时出错: {e}")
        return False

def main():
    """主函数"""
    parser = argparse.ArgumentParser(description='微信公众号贴图草稿创建工具')
    parser.add_argument('--file', type=str, help='从.txt文件创建草稿')
    parser.add_argument(
        '--port',
        type=int,
        default=DEFAULT_CDP_PORT,
        help=f'Chrome 调试端口 (默认: {DEFAULT_CDP_PORT}，避免与常见 9222 冲突)',
    )
    
    args = parser.parse_args()
    
    # 打印标题
    print_header()
    
    # 验证参数
    if not args.file:
        print("\n❌ 请指定 --file 参数:")
        print("   python3 skill_main.py --file 文章.txt")
        return
    
    success = False
    try:
        status, details = check_environment_status(args.port)
        blocked = False

        # STAGE: ENV_* 用于 OpenClaw/上游做稳定状态汇总（不影响人类可读输出）
        env_stage = {
            EnvStatus.READY: "ENV_READY",
            EnvStatus.CHROME_RUNNING_NOT_LOGGED: "ENV_CHROME_RUNNING_NOT_LOGGED",
            EnvStatus.CHROME_RUNNING_LOGIN_STATUS_UNKNOWN: "ENV_CHROME_RUNNING_LOGIN_STATUS_UNKNOWN",
            EnvStatus.CHROME_NOT_RUNNING: "ENV_CHROME_NOT_RUNNING",
            EnvStatus.NO_WECHAT_PAGE: "ENV_NO_WECHAT_PAGE",
            EnvStatus.PORT_OCCUPIED_NO_PAGES: "ENV_PORT_OCCUPIED_NO_PAGES",
            EnvStatus.ERROR: "ENV_ERROR",
            EnvStatus.UNKNOWN: "ENV_UNKNOWN",
        }.get(status, "ENV_UNKNOWN")
        print(f"STAGE: {env_stage}")

        # 步骤 2：任何「需拉起调试 Chrome」的状态都走同一套内置启动（原 start_real_chrome）
        need_launch = status in (
            EnvStatus.CHROME_NOT_RUNNING,
            EnvStatus.PORT_OCCUPIED_NO_PAGES,
            EnvStatus.ERROR,
        )
        if need_launch:
            if not run_start_chrome_flow(args.port):
                blocked = True
            else:
                status = None  # 刚启动或已复用实例，后面统一等待

        if not blocked:
            if status == EnvStatus.NO_WECHAT_PAGE:
                open_url_in_browser(WECHAT_MP_HOME)

            # 统一等待就绪，再创建草稿
            if not wait_for_login_confirmation(
                args.port,
                max_wait_seconds=DEFAULT_MAX_WAIT_SECONDS,
                check_interval=DEFAULT_CHECK_INTERVAL,
            ):
                success = False
            else:
                success = create_draft_from_file(args.file, port=args.port)
    except KeyboardInterrupt:
        print("\n👋 已中断")
        raise
    except Exception as e:
        err = str(e)
        print(f"\n❌ 执行异常: {err}")
        if _looks_like_proxy_error(err):
            print_proxy_vpn_hint()
        else:
            import traceback
            traceback.print_exc()
        success = False

    if success:
        print("STAGE: RESULT_OK")
        print("🎉 贴图草稿创建完成")
    else:
        print("STAGE: RESULT_FAILED")
        print("❌ 贴图草稿创建失败")

if __name__ == "__main__":
    main()

path: requirements.txt
content: websockets>=10.0

path: wechat_tietu_draft.py
content: #!/usr/bin/env python3
"""
wechat-tietu-draft: 微信公众号贴图类型草稿创建脚本
专门用于从.txt文件创建贴图类型草稿，修复段落丢失问题。

重要规则：
1. 只接受.txt文本文件
2. 将纯文本转换为HTML格式以保留段落结构
3. 标题和正文只允许纯文本及emoji
4. 文件第一行作为标题（可选），其余作为正文

段落处理规则（修复版）：
- 双换行符(\n\n) → 段落分隔 <br><br>
- 单换行符(\n) → 段落内换行 <br>

版本: v1.0.0
"""

import os
import sys

# 清除所有代理环境变量，避免websockets的SOCKS代理错误
proxy_vars = [
    'http_proxy', 'https_proxy', 'all_proxy',
    'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY',
    'no_proxy', 'NO_PROXY'
]

for var in proxy_vars:
    os.environ.pop(var, None)

import json
import time
import asyncio
import re
import websockets
import subprocess
import urllib.parse

# 与 skill_main 共用（skill_main 不 import 本模块，无循环依赖）
from skill_main import (
    DEFAULT_CDP_PORT,
    check_wechat_session_expired_via_cdp,
    ensure_chrome_ready_for_draft,
    print_chrome_help,
    prompt_session_expired_scan_login,
    refetch_best_wechat_page,
    validate_and_read_txt_file,
    wait_until_ready_for_draft,
)

CDP_PORT = int(os.environ.get("CDP_TIETU_PORT", str(DEFAULT_CDP_PORT)))


def _js_json_literal(s):
    """将 Python 字符串转为可在 JS 中 JSON.parse('...') 的安全字面量，避免 ` \\ ${ 等破坏脚本。"""
    return json.dumps(s, ensure_ascii=False).replace('\\', '\\\\').replace("'", "\\'")


def convert_text_to_prosemirror_html(text):
    """
    将纯文本转换为ProseMirror兼容的HTML
    修复段落丢失问题：使用<br>标签而不是<p>标签
    
    规则:
    1. 双换行符(\n\n) -> <br><br> (段落分隔)
    2. 单换行符(\n) -> <br> (段落内换行)
    3. 转义文本中的HTML标签，但保留文本中的<br>字样
    """
    if not text or not text.strip():
        return ""
    
    # 第一步：先处理换行符转换，避免干扰
    # 标准化换行符
    text = text.replace('\r\n', '\n').replace('\r', '\n')
    
    # 第二步：转换换行符为临时标记
    # 使用临时标记，避免与文本中的<br>混淆
    # 1. 将双换行符替换为 [DOUBLE_BR]
    temp_text = text.replace('\n\n', '[DOUBLE_BR]')
    # 2. 将剩余的单换行符替换为 [SINGLE_BR]
    temp_text = temp_text.replace('\n', '[SINGLE_BR]')
    
    # 第三步：转义HTML标签
    import html
    escaped_text = html.escape(temp_text)
    
    # 第四步：将临时标记恢复为真正的<br>标签
    # 注意：文本中的<br>字样已经被转义为&lt;br&gt;
    result = escaped_text.replace('[DOUBLE_BR]', '<br><br>')
    result = result.replace('[SINGLE_BR]', '<br>')
    
    return result

async def create_tietu_draft_from_file(file_path):
    """从.txt文件创建贴图类型草稿"""
    print("微信公众号贴图草稿运行日志（文件输入版）")
    print(time.strftime("%Y-%m-%d %H:%M:%S"))
    print("-" * 3)
    
    print("\n📋 重要规则:")
    print("1. ✅ 只接受.txt文本文件")
    print("2. ✅ 不做任何HTML或格式转换")
    print("3. ✅ 标题和正文只允许纯文本及emoji")
    print("4. ✅ 文件第一行作为标题（可选），其余作为正文")
    
    # 验证并读取文件
    title, content = validate_and_read_txt_file(file_path)
    if title is None or content is None:
        print("\n❌ 文件验证失败，任务终止")
        return
    
    if not ensure_chrome_ready_for_draft(CDP_PORT):
        print("\n❌ Chrome 未就绪，任务终止")
        return
    
    print("\n📋 前提条件:")
    print(f"1. Chrome已启动 (端口 {CDP_PORT})")
    print("2. 已扫码登录微信公众号")
    print("3. 在微信公众号页面")
    
    # 检查Chrome状态
    print("\n🔍 检查Chrome状态...")
    try:
        result = subprocess.run(
            ["curl", "-s", f"http://127.0.0.1:{CDP_PORT}/json/list"],
            capture_output=True,
            text=True,
            timeout=5
        )
        
        if result.returncode != 0:
            print("❌ 无法连接到Chrome调试端口")
            print("请确保Chrome已启动并启用远程调试:")
            print("  Chrome启动命令:")
            print("  /Applications/Google\\ Chrome.app/Contents/MacOS/Google\\ Chrome \\")
            print(f"    --remote-debugging-port={CDP_PORT} \\")
            print("    --user-data-dir=/tmp/wechat-debug \\")
            print("    https://mp.weixin.qq.com/")
            return
        
        pages = json.loads(result.stdout)
        if not pages:
            print("❌ 没有找到Chrome页面")
            return
        
        print(f"✅ 找到 {len(pages)} 个页面")
        
        # 查找微信公众号页面
        wechat_page = None
        for page in pages:
            url = page.get('url', '')
            title_text = page.get('title', '')
            
            # 检查是否是微信公众号页面
            if 'mp.weixin.qq.com' in url:
                wechat_page = page
                print(f"✅ 找到微信公众号页面: {url[:80]}...")
                break
        
        if not wechat_page:
            print("❌ 未找到微信公众号页面")
            print("请确保已登录微信公众号")
            return

        # 会话过期单独检测（扫码登录页已由 check_chrome_running_strict 按标题处理）
        ws_dbg = wechat_page.get("webSocketDebuggerUrl")
        if check_wechat_session_expired_via_cdp(ws_dbg) is True:
            print("❌ 微信公众号会话已过期（a#jumpUrl「登录」）")
            prompt_session_expired_scan_login(ws_dbg, will_auto_continue=True)
            if not wait_until_ready_for_draft(port=CDP_PORT, max_seconds=300, interval=5):
                return
            wechat_page = refetch_best_wechat_page(CDP_PORT)
            if not wechat_page:
                print("❌ 登录后仍无法获取有效的微信公众号页面")
                return
            print(f"✅ 已切换到登录后的页面: {(wechat_page.get('url') or '')[:80]}...")
            ws_dbg = wechat_page.get("webSocketDebuggerUrl")

        # 提取token
        url = wechat_page['url']
        parsed_url = urllib.parse.urlparse(url)
        query_params = urllib.parse.parse_qs(parsed_url.query)
        [REDACTED_SECRET]'token', [''])[0]
        
        if not [REDACTED_SECRET]"❌ 无法从URL提取token")
            print(f"URL: {url[:100]}...")
            return
        
        print(f"✅ 提取到[REDACTED_SECRET]")
        
        # 总是创建新的贴图类型草稿，避免覆盖现有草稿
        # 即使当前页面已经是贴图类型草稿编辑器，也创建新的
        tietu_url = f"https://mp.weixin.qq.com/cgi-bin/appmsg?t=media/appmsg_edit_v2&action=edit&isNew=1&type=77&createType=8&[REDACTED_SECRET]"
        print(f"\n🌐 创建新的贴图类型草稿...")
        print(f"目标URL: {tietu_url[:80]}...")
        
        # 记录原始URL用于后续比较
        original_url = wechat_page['url']
        original_appmsgid = None
        if 'appmsgid=' in original_url:
            import re
            match = re.search(r'appmsgid=(\d+)', original_url)
            if match:
                original_appmsgid = match.group(1)
                print(f"原始草稿ID: {original_appmsgid}")
        
        should_navigate = True  # 总是导航到新草稿页面
        
        # 保存原始信息供后续使用
        url_info = {
            'original_url': original_url,
            'original_appmsgid': original_appmsgid
        }
        
        # 获取WebSocket URL
        ws_url = wechat_page['webSocketDebuggerUrl']
        
        # 连接到Chrome DevTools
        print("\n🔗 连接到Chrome DevTools...")
        async with websockets.connect(ws_url, proxy=None) as websocket:
            print("✅ 连接成功")
            print(f"   连接URL: {ws_url}")
            
            # 调试：获取页面信息
            debug_cmd = {
                "id": 0,
                "method": "Runtime.evaluate",
                "params": {
                    "expression": "JSON.stringify({title: document.title, url: window.location.href, readyState: document.readyState})",
                    "returnByValue": True
                }
            }
            await websocket.send(json.dumps(debug_cmd))
            debug_response = await websocket.recv()
            debug_result = json.loads(debug_response)
            print(f"🔍 初始调试: {debug_result}")
            
            if should_navigate:
                # 导航到贴图类型草稿页面
                navigate_cmd = {
                    "id": 1,
                    "method": "Page.navigate",
                    "params": {"url": tietu_url}
                }
                
                await websocket.send(json.dumps(navigate_cmd))
                response = await websocket.recv()
                print("✅ 页面导航完成")
                
                # 等待页面加载：先等 load 事件，再给 SPA 渲染时间
                print("⏳ 等待页面加载...")
                await asyncio.sleep(3)
            else:
                print("✅ 使用现有页面，跳过导航")
                await asyncio.sleep(3)
            
            # 检查页面元素（带重试：SPA 可能延迟渲染）
            elements = {}
            max_check_attempts = 3
            check_interval = 2
            print("\n🔍 检查页面元素...")
            check_elements_js = """
            (function() {
                try {
                    const elements = {};
                    
                    // 基本页面信息
                    elements.pageTitle = document.title;
                    elements.url = window.location.href;
                    elements.readyState = document.readyState;
                    
                    // 简化：检查标题输入框
                    const titleInput = document.querySelector('textarea#title');
                    elements.hasTitleInput = !!titleInput;
                    if (titleInput) {
                        elements.titleValue = titleInput.value;
                        elements.titleId = titleInput.id;
                    }
                    
                    // 正文编辑器：页面上常有多个 ProseMirror（标题区/摘要/正文），
                    // querySelector 只取到第一个会把正文写进标题区 → 标题像正文、正文空。
                    const pmAll = Array.from(document.querySelectorAll('div.ProseMirror[contenteditable="true"]'));
                    function pmArea(el) {
                        if (!el) return 0;
                        const r = el.getBoundingClientRect();
                        return Math.max(0, r.width) * Math.max(0, r.height);
                    }
                    let contentEditor = null;
                    if (pmAll.length === 1) {
                        contentEditor = pmAll[0];
                    } else if (pmAll.length > 1) {
                        contentEditor = pmAll.reduce(function(a, b) { return pmArea(a) >= pmArea(b) ? a : b; });
                    }
                    elements.proseMirrorCount = pmAll.length;
                    elements.hasContentEditor = !!contentEditor;
                    if (contentEditor) {
                        elements.contentText = contentEditor.textContent.substring(0, 100);
                    }
                    
                    // 简化：检查保存按钮
                    const buttons = Array.from(document.querySelectorAll('button'));
                    const saveButtons = buttons.filter(btn => 
                        btn.textContent.includes('保存为草稿') || btn.textContent.includes('保存')
                    );
                    elements.hasSaveButton = saveButtons.length > 0;
                    elements.saveButtonCount = saveButtons.length;
                    
                    // 简单调试信息
                    elements.debug = {
                        totalButtons: buttons.length,
                        bodyChildren: document.body.children.length,
                        hasForm: document.querySelector('form') !== null,
                        hasTextarea: document.querySelector('textarea') !== null
                    };
                    
                    return elements;
                } catch (error) {
                    return {error: error.message, stack: error.stack};
                }
            })()
            """
            
            # 必须使用 returnByValue: True，否则 CDP 只返回对象引用，拿不到 pageTitle/url 等序列化值，会显示「未知」且元素全未找到
            check_cmd = {
                "id": 2,
                "method": "Runtime.evaluate",
                "params": {
                    "expression": check_elements_js,
                    "returnByValue": True
                }
            }
            
            for attempt in range(max_check_attempts):
                if attempt > 0:
                    print(f"   重试 ({attempt + 1}/{max_check_attempts})，等待 {check_interval} 秒...")
                    await asyncio.sleep(check_interval)
                await websocket.send(json.dumps(check_cmd))
                response = await websocket.recv()
                result = json.loads(response)
                if 'result' not in result or 'result' not in result.get('result', {}):
                    continue
                elements = result['result']['result'].get('value', {})
                if not isinstance(elements, dict):
                    elements = {}
                if elements.get('error'):
                    print(f"❌ JavaScript执行错误: {elements.get('error')}")
                    print(f"   堆栈: {elements.get('stack', '无')}")
                    return
                # 三个关键元素都找到则不再重试
                if (elements.get('hasTitleInput') and elements.get('hasContentEditor') and elements.get('hasSaveButton')):
                    break

            # 使用最后一次检查得到的 elements 输出（returnByValue 已保证拿到序列化值）
            print(f"🔍 页面检查结果:")
            print(f"   页面标题: {elements.get('pageTitle', '未知')}")
            url_str = elements.get('url') or '未知'
            print(f"   页面URL: {(url_str[:80] + '...') if len(str(url_str)) > 80 else url_str}")
            print(f"   文档状态: {elements.get('readyState', '未知')}")
            
            print(f"\n📝 元素检查:")
            print(f"   标题输入框: {'✅ 找到' if elements.get('hasTitleInput') else '❌ 未找到'}")
            if elements.get('hasTitleInput'):
                title_val = elements.get('titleValue') or '空'
                print(f"   当前标题值: {str(title_val)[:50]}...")
            
            print(f"   正文编辑器: {'✅ 找到' if elements.get('hasContentEditor') else '❌ 未找到'}")
            if elements.get('proseMirrorCount'):
                print(f"   ProseMirror 数量: {elements.get('proseMirrorCount')}（正文取面积最大者，避免写进标题区）")
            if elements.get('hasContentEditor'):
                content_preview = elements.get('contentText') or '空'
                print(f"   内容预览: {str(content_preview)[:50]}...")
            
            print(f"   保存按钮: {'✅ 找到' if elements.get('hasSaveButton') else '❌ 未找到'}")
            print(f"   保存按钮数量: {elements.get('saveButtonCount', 0)}")
            
            debug_info = elements.get('debug', {}) or {}
            print(f"\n📊 调试信息:")
            print(f"   总按钮数: {debug_info.get('totalButtons', 0)}")
            print(f"   Body子元素: {debug_info.get('bodyChildren', 0)}个")
            print(f"   有表单: {'✅ 是' if debug_info.get('hasForm') else '❌ 否'}")
            print(f"   有文本域: {'✅ 是' if debug_info.get('hasTextarea') else '❌ 否'}")
            
            if not elements.get('hasTitleInput'):
                print("\n⚠️  警告: 未找到标题输入框")
                print("   可能原因:")
                print("   1. 页面未完全加载")
                print("   2. 微信公众号界面已更新")
                print("   3. 不是贴图类型草稿页面")
            
            if not elements.get('hasContentEditor'):
                print("\n⚠️  警告: 未找到正文编辑器")
                print("   可能原因:")
                print("   1. 页面未完全加载")
                print("   2. 微信公众号界面已更新")
                print("   3. 不是贴图类型草稿页面")
            
            if not elements.get('hasSaveButton'):
                print("\n⚠️  警告: 未找到保存按钮")
                print("   可能原因:")
                print("   1. 页面未完全加载")
                print("   2. 微信公众号界面已更新")
                print("   3. 不是贴图类型草稿页面")
            
            if not (elements.get('hasTitleInput') and elements.get('hasContentEditor') and elements.get('hasSaveButton')):
                print("\n❌ 页面关键元素未找全，无法继续填写。请确认已打开贴图类型草稿页面并重试。")
                return
            
            # 填写标题（如果有）
            if title:
                print(f"\n📝 填写标题: {title}")
                title_payload = _js_json_literal(title)
                fill_title_js = f"""
                (function() {{
                    var titleStr = JSON.parse('{title_payload}');
                    var titleInput = document.querySelector('textarea#title');
                    if (titleInput) {{
                        titleInput.value = titleStr;
                        titleInput.dispatchEvent(new Event('input', {{ bubbles: true }}));
                        return {{ success: true, value: titleInput.value }};
                    }}
                    return {{ success: false }};
                }})()
                """
                eval_cmd = {
                    "id": 3,
                    "method": "Runtime.evaluate",
                    "params": {
                        "expression": fill_title_js,
                        "awaitPromise": True,
                        "returnByValue": True
                    }
                }
                await websocket.send(json.dumps(eval_cmd))
                response = await websocket.recv()
                result = json.loads(response)
                if 'result' in result and 'result' in result['result']:
                    fill_result = result['result']['result'].get('value') or {}
                    if fill_result.get('success'):
                        print("✅ 标题填写成功")
                    else:
                        print("❌ 标题填写失败")
            
            # 填写正文（HTML 通过 JSON 传入，避免 ` \ ${ 等破坏脚本）
            print(f"\n📄 填写正文 ({len(content)} 字符)...")
            html_content = convert_text_to_prosemirror_html(content)
            print(f"   🔄 转换为HTML: {len(html_content)} 字符")
            print(f"   📋 HTML预览: {html_content[:100]}...")
            html_payload = _js_json_literal(html_content)
            fill_content_js = f"""
            (function() {{
                var htmlStr = JSON.parse('{html_payload}');
                var pmAll = Array.from(document.querySelectorAll('div.ProseMirror[contenteditable="true"]'));
                function pmArea(el) {{
                    if (!el) return 0;
                    var r = el.getBoundingClientRect();
                    return Math.max(0, r.width) * Math.max(0, r.height);
                }}
                var contentEditor = null;
                if (pmAll.length === 1) contentEditor = pmAll[0];
                else if (pmAll.length > 1)
                    contentEditor = pmAll.reduce(function(a, b) {{ return pmArea(a) >= pmArea(b) ? a : b; }});
                if (contentEditor) {{
                    contentEditor.innerHTML = '';
                    contentEditor.innerHTML = htmlStr;
                    contentEditor.dispatchEvent(new Event('input', {{ bubbles: true }}));
                    contentEditor.dispatchEvent(new Event('change', {{ bubbles: true }}));
                    return {{
                        success: true,
                        charCount: contentEditor.textContent.length,
                        htmlLength: contentEditor.innerHTML.length,
                        htmlPreview: contentEditor.innerHTML.substring(0, 100),
                        pickedIndex: pmAll.indexOf(contentEditor),
                        proseMirrorCount: pmAll.length
                    }};
                }}
                return {{ success: false, proseMirrorCount: pmAll.length }};
            }})()
            """
            eval_cmd = {
                "id": 4,
                "method": "Runtime.evaluate",
                "params": {
                    "expression": fill_content_js,
                    "awaitPromise": True,
                    "returnByValue": True
                }
            }
            await websocket.send(json.dumps(eval_cmd))
            response = await websocket.recv()
            result = json.loads(response)
            if 'result' in result and 'result' in result['result']:
                fill_result = result['result']['result'].get('value') or {}
                if fill_result.get('success'):
                    print(f"✅ 正文填写成功")
                    print(f"   字符数: {fill_result.get('charCount', '未知')}")
                    if fill_result.get('proseMirrorCount', 0) > 1:
                        print(f"   使用第 {fill_result.get('pickedIndex', '?') + 1}/{fill_result.get('proseMirrorCount')} 个 ProseMirror（按面积大小区分正文）")
                else:
                    print("❌ 正文填写失败")
                    if fill_result.get('proseMirrorCount') is not None:
                        print(f"   ProseMirror 数量: {fill_result.get('proseMirrorCount')}")
            
            # 保存草稿
            print("\n💾 保存草稿...")
            save_js = """
            (function() {
                var saveButtons = Array.from(document.querySelectorAll('button')).filter(function(btn) {
                    return btn.textContent.indexOf('保存为草稿') !== -1 || btn.textContent.indexOf('保存') !== -1;
                });
                if (saveButtons.length > 0) {
                    saveButtons[0].click();
                    return { success: true, buttonText: saveButtons[0].textContent };
                }
                return { success: false };
            })()
            """
            eval_cmd = {
                "id": 5,
                "method": "Runtime.evaluate",
                "params": {
                    "expression": save_js,
                    "awaitPromise": True,
                    "returnByValue": True
                }
            }
            await websocket.send(json.dumps(eval_cmd))
            response = await websocket.recv()
            result = json.loads(response)
            if 'result' in result and 'result' in result['result']:
                save_result = result['result']['result'].get('value') or {}
                if save_result.get('success'):
                    print(f"✅ 已点击保存按钮: {save_result.get('buttonText', '未知')}")
                else:
                    print("❌ 保存按钮点击失败")
            
            # 方案C优化：等待800毫秒后开始检查保存状态
            print("\n⏳ 等待保存完成（方案C优化: 800ms）...")
            await asyncio.sleep(0.8)
            
            # 重要：给页面更多时间完成跳转和保存
            print("⏳ 等待页面完成跳转和保存（额外2秒）...")
            await asyncio.sleep(2)
            
            # 检查保存状态
            print("🔍 检查保存状态...")
            check_save_js = """
            (function() {
                const status = {};
                
                // 检查Toast提示
                const toasts = Array.from(document.querySelectorAll('.weui-toast, .weui-toptips, .el-message'));
                status.hasToast = toasts.length > 0;
                status.toastText = toasts.length > 0 ? toasts[0].textContent : '';
                
                // 检查页面标题变化
                status.pageTitle = document.title;
                status.isSavedPage = document.title.includes('草稿') || document.title.includes('Draft');
                
                // 检查URL变化
                status.currentUrl = window.location.href;
                status.hasToken = status.currentUrl.includes('token=');
                status.isTietuType = status.currentUrl.includes('type=77') && status.currentUrl.includes('createType=8');
                
                // 新增：检查草稿是否已存在
                status.hasAppMsgId = status.currentUrl.includes('appmsgid=');
                status.hasReprintConfirm = status.currentUrl.includes('reprint_confirm=0');
                status.isExistingDraft = status.hasAppMsgId && status.hasReprintConfirm && status.currentUrl.includes('type=77');
                
                // 检查按钮状态变化
                const buttons = Array.from(document.querySelectorAll('button'));
                const saveButtons = buttons.filter(btn => 
                    btn.textContent.includes('保存为草稿') || btn.textContent.includes('保存')
                );
                const editButtons = buttons.filter(btn => 
                    btn.textContent.includes('继续编辑') || btn.textContent.includes('编辑')
                );
                
                status.saveButtonDisabled = saveButtons.length > 0 ? saveButtons[0].disabled : false;
                status.hasEditButton = editButtons.length > 0;
                
                return status;
            })()
            """
            
            eval_cmd = {
                "id": 6,
                "method": "Runtime.evaluate",
                "params": {
                    "expression": check_save_js,
                    "awaitPromise": True
                }
            }
            
            await websocket.send(json.dumps(eval_cmd))
            response = await websocket.recv()
            result = json.loads(response)
            
            if 'result' in result and 'result' in result['result']:
                status = result['result']['result'].get('value', {})
                
                print("\n📊 保存状态报告:")
                
                # 多维度检查保存状态
                checks = []
                
                # 1. Toast提示检查
                if status.get('hasToast'):
                    toast_text = status.get('toastText', '')
                    if '成功' in toast_text or '保存' in toast_text:
                        checks.append(("✅ Toast提示", "保存成功提示"))
                    else:
                        checks.append(("⚠️  Toast提示", f"有提示但内容: {toast_text[:30]}..."))
                else:
                    checks.append(("❓ Toast提示", "无提示"))
                
                # 2. 页面标题检查
                page_title = status.get('pageTitle', '')
                if '草稿' in page_title or 'Draft' in page_title:
                    checks.append(("✅ 页面标题", "包含草稿相关字样"))
                else:
                    checks.append(("❓ 页面标题", f"当前: {page_title[:30]}..."))
                
                # 3. URL检查
                current_url = status.get('currentUrl', '')
                has_token = status.get('hasToken', False)
                is_tietu_type = status.get('isTietuType', False)
                has_appmsgid = status.get('hasAppMsgId', False)
                has_reprint_confirm = status.get('hasReprintConfirm', False)
                is_existing_draft = status.get('isExistingDraft', False)
                
                if is_existing_draft:
                    # 这是已存在的草稿编辑器页面
                    checks.append(("✅ URL参数", "已有草稿页面(appmsgid+reprint_confirm+type=77)"))
                elif has_token and is_tietu_type:
                    checks.append(("✅ URL参数", "包含token和贴图类型参数"))
                elif has_token:
                    checks.append(("⚠️  URL参数", "有token但不是贴图类型"))
                else:
                    checks.append(("❓ URL参数", "无token参数"))
                
                # 4. 按钮状态检查
                save_disabled = status.get('saveButtonDisabled', False)
                has_edit_button = status.get('hasEditButton', False)
                
                if save_disabled:
                    checks.append(("✅ 按钮状态", "保存按钮已禁用"))
                else:
                    checks.append(("❓ 按钮状态", "保存按钮未禁用"))
                
                if has_edit_button:
                    checks.append(("✅ 编辑按钮", "出现继续编辑按钮"))
                else:
                    checks.append(("❓ 编辑按钮", "无继续编辑按钮"))
                
                # 显示检查结果
                for check, desc in checks:
                    print(f"   {check}: {desc}")
                
                # 综合判断
                success_checks = sum(1 for check, _ in checks if check.startswith('✅'))
                warning_checks = sum(1 for check, _ in checks if check.startswith('⚠️'))
                
                print(f"\n📈 检查统计: ✅ {success_checks} | ⚠️  {warning_checks} | ❓ {len(checks) - success_checks - warning_checks}")
                
                # 添加URL变化验证
                print(f"\n🔗 URL变化验证:")
                final_url_check = """
                (function() {
                    return {
                        url: window.location.href,
                        hasAppMsgId: window.location.href.includes('appmsgid='),
                        appmsgid: (function() {
                            const match = window.location.href.match(/appmsgid=(\\d+)/);
                            return match ? match[1] : null;
                        })()
                    };
                })()
                """
                
                final_check_cmd = {
                    "id": 10,
                    "method": "Runtime.evaluate",
                    "params": {"expression": final_url_check, "returnByValue": True}
                }
                await websocket.send(json.dumps(final_check_cmd))
                final_response = await websocket.recv()
                final_result = json.loads(final_response)
                
                final_url = None
                final_appmsgid = None
                if 'result' in final_result and 'result' in final_result['result']:
                    url_info = final_result['result']['result'].get('value', {})
                    final_url = url_info.get('url', '')
                    final_appmsgid = url_info.get('appmsgid')
                
                print(f"   最终URL: {final_url[:80] if final_url else '未知'}...")
                print(f"   最终草稿ID: {final_appmsgid if final_appmsgid else '无'}")
                print(f"   原始草稿ID: {url_info.get('original_appmsgid', '无')}")
                
                # 基于URL变化的成功判断
                url_based_success = False
                original_appmsgid = url_info.get('original_appmsgid')
                
                if final_appmsgid and original_appmsgid:
                    if final_appmsgid != original_appmsgid:
                        print(f"   ✅ 草稿ID变化: {original_appmsgid} → {final_appmsgid} (创建了新草稿)")
                        url_based_success = True
                    else:
                        print(f"   ⚠️  草稿ID未变: 可能是覆盖或导航失败")
                elif final_appmsgid and not original_appmsgid:
                    print(f"   ✅ 创建了草稿 (新ID: {final_appmsgid})")
                    url_based_success = True
                elif final_url and 'isNew=1' in final_url and 'appmsgid=' in final_url:
                    print(f"   ✅ 从isNew=1页面变成了有appmsgid的编辑页面")
                    url_based_success = True
                
                # 最终成功判断：JavaScript检查 OR URL变化验证
                final_success = (success_checks >= 3) or is_existing_draft or url_based_success
                
                if final_success:
                    print("\n🎉 保存成功！贴图草稿已创建。")
                    print(f"   标题: {title if title else '(无标题)'}")
                    print(f"   正文长度: {len(content)} 字符")
                    print(f"   保存时间: {time.strftime('%Y-%m-%d %H:%M:%S')}")
                    
                    if is_existing_draft:
                        print(f"   📝 这是已有草稿的编辑器页面")
                    elif url_based_success:
                        print(f"   🔗 通过URL变化验证成功")
                        if final_appmsgid:
                            print(f"   草稿ID: {final_appmsgid}")
                else:
                    print("\n⚠️  保存状态不确定，请手动检查:")
                    print("   1. 查看微信公众号草稿箱")
                    print("   2. 检查页面是否有错误提示")
                    print("   3. 如果需要，手动保存草稿")
                    print("   ℹ️  注意: 即使脚本报告失败，操作也可能已成功")
            
            print("\n") 
            print("任务完成")
            print("=" * 3)
            
    except websockets.exceptions.InvalidURI:
        print("❌ 无效的WebSocket URL")
    except websockets.exceptions.ConnectionClosedError:
        print("❌ WebSocket连接已关闭")
    except ConnectionRefusedError:
        print("❌ 无法连接到Chrome调试端口")
        print("请确保Chrome已启动并启用远程调试")
    except Exception as e:
        print(f"❌ 发生错误: {str(e)}")
        import traceback
        traceback.print_exc()

def main():
    """仅允许由 skill_main 通过子进程调用（环境变量 CDP_TIETU_CHILD=1）。"""
    if os.environ.get("CDP_TIETU_CHILD") != "1":
        d = os.path.dirname(os.path.abspath(__file__))
        print("请勿直接运行本脚本。")
        print("请使用技能入口创建贴图草稿：")
        print()
        print(f"  cd {d}")
        print("  python3 skill_main.py --file <你的文章.txt>")
        print()
        print("—" * 56)
        print("【给 OpenClaw / 自动化】")
        print("  不要为「直接运行本脚本」去改代码（例如去掉 CDP_TIETU_CHILD 或改入口）。")
        print("  正确用法：始终通过 skill_main.py --file <文章.txt> 创建草稿；")
        print("  本文件仅作 skill_main 的子进程，故意不可单独执行。")
        print("—" * 56)
        print()
        sys.exit(2)
    if len(sys.argv) != 2:
        print("内部调用参数错误：需要唯一参数 .txt 路径", file=sys.stderr)
        sys.exit(1)
    asyncio.run(create_tietu_draft_from_file(sys.argv[1]))


if __name__ == "__main__":
    main()

path: examples/sample_tietu.txt
content: 春日花海摄影集 🌸📷

拍摄于2026年3月，记录春日花海美景。

这次拍摄选择了几个不同的地点：
1. 樱花公园 - 粉色的樱花如云似霞
2. 郁金香花田 - 五彩斑斓的郁金香海洋
3. 油菜花海 - 金黄色的油菜花一望无际

拍摄技巧分享：
- 使用广角镜头捕捉大场景
- 早晨和傍晚的光线最柔和
- 尝试不同的角度和构图

后期处理建议：
- 适当调整色彩饱和度
- 保持自然真实的色调
- 突出花朵的细节纹理

摄影不仅是记录，更是感受自然之美。希望通过这些照片，能让你感受到春日的温暖和生机。

#春日摄影 #花海 #自然风光 📸✨

—— 摄影爱好者小明

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