Skill: guanyang/toutiao-publisher (version 1.0.1)

--- name: toutiao-publisher description: Publish articles to Toutiao (Today's Headlines). Handles persistent authentication (login once) and session management. Opens browser for interactive publishing. --- # Toutiao Publisher Skill Manage Toutiao (Today's Headlines) account, maintain persistent login session, and publish articles. ## When to Use This Skill Trigger when user: - Asks to publish to Toutiao/Today's Headlines - Wants to manage Toutiao login - Mentions "toutiao" or "头条号" ## Core Workflow ### Step 1: Authentication (One-Time Setup) The skill requires a one-time login. The session is persisted for subsequent uses. ```bash # Browser will open for manual login (scan QR code) python scripts/run.py auth_manager.py setup ``` **Instructions:** 1. Run the setup command. 2. A browser window will open loading the Toutiao login page. 3. Log in manually (e.g., scan QR code). 4. Once logged in (redirected to dashboard), the script will save the session and close. ### Step 2: Publish Article ```bash # Opens browser with authenticated session at publish page python scripts/run.py publisher.py ``` **Instructions:** 1. Run the publisher command. 2. Browser opens directly to the "Publish Article" page. 3. Write and publish the article manually. 4. Press `Ctrl+C` in the terminal when done. > **Note:** Toutiao requires titles to be **2-30 characters**. This tool automatically optimizes titles to fit this constraint (truncating if >30, padding if <2). #### Advanced Usage (Automated) You can fully automate the publishing process by providing arguments: ```bash # Publish with title, content file, and cover image python scripts/run.py publisher.py --title "AI Trends 2025" --content "article.md" --cover "assets/cover.jpg" --headless ``` ### Management ```bash # Check authentication status python scripts/run.py auth_manager.py status # Clear authentication data (logout) python scripts/run.py auth_manager.py clear ``` ## Technical Details - **Persistent Auth**: Uses `patchright` to launch a persistent browser context. Cookies and storage state are saved to `data/browser_state/state.json`. - **Anti-Detection**: Uses `patchright`'s stealth features to avoid bot detection. - **Environment**: Automatically manages a virtual environment (`.venv`) with required dependencies. ## Script Reference - `scripts/auth_manager.py`: Handles login, session validation, and state persistence. - `scripts/publisher.py`: Launches authenticated browser for publishing. - `scripts/run.py`: Wrapper ensuring execution in the correct virtual environment.

path: requirements.txt
content: patchright==1.55.2
python-dotenv==1.0.0


path: README.md
content: # Toutiao Publisher Skill 架构与最佳实践指南

## 1. 架构原理 (Architecture)

Toutiao Publisher 是一个基于 **Playwright** 的自动化发布工具，旨在解决头条号及其复杂的富文本编辑器交互问题。其核心设计理念是 **"模拟真实用户行为" (User Simulation)** 而非简单的 API 调用。

### 1.1 核心组件

*   **`publisher.py` (核心执行器)**
    *   作为主入口，负责编排整个发布流程。
    *   **智能填充**：针对 ProseMirror 编辑器，采用多级降级策略（`execCommand` > `ClipboardEvent`）确保内容注入成功。
    *   **封面自动化**：实现了文件上传控件（`input[type=file]`）的精准定位与交互，支持本地图片上传。
    *   **双重发布确认**：模拟“点击发布 -> 等待弹窗 -> 点击确认”的完整链路。
    *   **即时登录 (Login-on-the-fly)**：不再依赖分离的登录脚本，发布时若检测到未登录，自动暂停等待用户扫码，实现无缝衔接。

*   **`auth_manager.py` (凭证管理)**
    *   负责 Cookie 和 LocalStorage 的持久化。
    *   通过 `state.json` 实现免登录复用。
    *   包含自动检测 Cookie 有效性的逻辑。

*   **`browser_utils.py` (环境工厂)**
    *   配置反爬虫策略（Anti-detection）。
    *   管理持久化浏览器上下文（Persistent Context），确保 UserDataDir 的正确复用。

### 1.2 关键技术点

*   **混合输入模式**：对于标题使用标准 `fill`，对于正文使用 JS 注入，对于封面使用 `setInputFiles`。
*   **鲁棒性设计**：
    *   **Autosave 触发器**：注入内容后自动输入空格触发编辑器保存机制。
    *   **动态等待**：从不使用固定 `sleep`，而是基于 `wait_for_selector` 和状态轮询。
*   **调试友好**：关键步骤自动截图（Debug Screenshots），便于排查无头模式下的问题。

---

## 2. 最佳实践 (Best Practices)

### 2.1 自动化发布 (Automated Publishing)

最推荐的使用方式是通过命令行进行全自动发布。

```bash
# 标准发布命令（推荐）
python scripts/run.py publisher.py \
  --title "你的标题（2-30字）" \
  --content "/absolute/path/to/article.md" \
  --cover "/absolute/path/to/cover.png"
```

*   **参数说明**：
    *   `--title`: 必填。脚本会自动截断超长标题。
    *   `--content`: 支持 Markdown 文件路径或直接文本串。自动转换为 HTML。
    *   `--cover`: 图片绝对路径。建议 16:9 比例，PNG/JPG 格式。
    *   `--headless`: 加上此参数可在后台静默运行（需确保已登录）。**初次使用建议不加，以便观察流程。**

### 2.2 登录与状态管理

*   **首次使用**：直接运行发布命令（不带 `--headless`）。脚本会自动弹出浏览器，请扫码登录。登录后脚本会自动保存状态。
*   **状态失效**：如果遇到 `No valid authentication` 且自动重试无效，可手动清理状态：
    ```bash
    rm -rf data/browser_state
    ```
    然后重新运行发布命令。

### 2.3 故障排查

1.  **正文为空？**
    *   这是 ProseMirror 编辑器的常见防御机制。最新版脚本已使用 `document.execCommand('insertHTML')` 解决此问题。请确保脚本是最新的。
2.  **保存失败警告？**
    *   通常是因为网络延迟。脚本会自动尝试输入空格来触发重试。只要最终能点击“发布”，通常说明保存已成功。
3.  **进程锁定 (TargetClosedError)？**
    *   这是因为上一次运行异常退出，导致 Chrome 锁定了 `UserDataDir`。
    *   **解决**：运行 `pkill -f "Chrome"` 或重启终端。

### 2.4 通过自然语言调用 (Natural Language Interaction)

作为 Agent Skill，最强大的用法是直接通过自然语言指令调用，Agent 会自动解析参数并执行脚本。

**场景一：发布本地 Markdown 文件**
> "帮我把 `docs/guide.md` 发布到头条，标题设为 'AI 开发指南'，封面用这张图 `assets/cover.png`。"

**场景二：生成并发布**
> "写一篇关于 Python 并发编程的文章，重点介绍 Asyncio，写完后直接发布到头条，标题自拟，并生成一张这风格的封面图一起发布。"

**场景三：仅发布正文（无封面）**
> "发布这篇文章：[粘贴文本内容]，标题是 '今日随笔'。"

**交互式登录**
> 当 Agent 提示需要登录时，不需要记忆复杂的命令，直接回复：
> "已经扫码登录了，继续吧。"

---

## 3. 目录结构

```
toutiao-publisher/
├── scripts/
│   ├── publisher.py            # 发布脚本
│   ├── setup_environment.py    # 环境配置
│   ├── config.py               # 配置文件
│   ├── auth_manager.py         # 认证模块
│   ├── browser_utils.py        # 浏览器配置
│   ├── md2html.py              # Markdown 转换器
│   └── run.py                  # 运行入口
├── data/                       # 数据目录，运行时存本地
│   └── browser_state/          # 存储 Cookie 和 Profile（自动生成）
├── README.md                   # 本文档
├── requirements.txt            # 依赖包
└── SKILL.md                    # Skill 定义文件
```


path: scripts/run.py
content: #!/usr/bin/env python3
"""
Universal runner for Toutiao Publisher skill scripts
Ensures all scripts run with the correct virtual environment
"""

import os
import sys
import subprocess
from pathlib import Path


def get_venv_python():
    """Get the virtual environment Python executable"""
    skill_dir = Path(__file__).parent.parent
    venv_dir = skill_dir / ".venv"

    if os.name == "nt":  # Windows
        venv_python = venv_dir / "Scripts" / "python.exe"
    else:  # Unix/Linux/Mac
        venv_python = venv_dir / "bin" / "python"

    return venv_python


def ensure_venv():
    """Ensure virtual environment exists"""
    skill_dir = Path(__file__).parent.parent
    venv_dir = skill_dir / ".venv"
    setup_script = skill_dir / "scripts" / "setup_environment.py"

    # Check if venv exists
    if not venv_dir.exists():
        print("🔧 First-time setup: Creating virtual environment...")
        print("   This may take a minute...")

        # Run setup with system Python
        result = subprocess.run([sys.executable, str(setup_script)])
        if result.returncode != 0:
            print("❌ Failed to set up environment")
            sys.exit(1)

        print("✅ Environment ready!")

    return get_venv_python()


def main():
    """Main runner"""
    if len(sys.argv) < 2:
        print("Usage: python run.py <script_name> [args...]")
        print("\nAvailable scripts:")
        print("  auth_manager.py     - Handle authentication")
        print("  publisher.py        - Publish article")
        print("  cleanup_manager.py  - Clean up skill data")
        sys.exit(1)

    script_name = sys.argv[1]
    script_args = sys.argv[2:]

    # Handle both "scripts/script.py" and "script.py" formats
    if script_name.startswith("scripts/"):
        # Remove the scripts/ prefix if provided
        script_name = script_name[8:]  # len('scripts/') = 8

    # Ensure .py extension
    if not script_name.endswith(".py"):
        script_name += ".py"

    # Get script path
    skill_dir = Path(__file__).parent.parent
    script_path = skill_dir / "scripts" / script_name

    if not script_path.exists():
        print(f"❌ Script not found: {script_name}")
        print(f"   Working directory: {Path.cwd()}")
        print(f"   Skill directory: {skill_dir}")
        print(f"   Looked for: {script_path}")
        sys.exit(1)

    # Ensure venv exists and get Python executable
    venv_python = ensure_venv()

    # Build command
    cmd = [str(venv_python), str(script_path)] + script_args

    # Run the script
    try:
        result = subprocess.run(cmd)
        sys.exit(result.returncode)
    except KeyboardInterrupt:
        print("\n⚠️ Interrupted by user")
        sys.exit(130)
    except Exception as e:
        print(f"❌ Error: {e}")
        sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/config.py
content: """
Configuration for Toutiao Publisher Skill
Centralizes constants, selectors, and paths
"""

from pathlib import Path

# Paths
SKILL_DIR = Path(__file__).parent.parent
DATA_DIR = SKILL_DIR / "data"
BROWSER_STATE_DIR = DATA_DIR / "browser_state"
BROWSER_PROFILE_DIR = BROWSER_STATE_DIR / "browser_profile"
STATE_FILE = BROWSER_STATE_DIR / "state.json"
AUTH_INFO_FILE = DATA_DIR / "auth_info.json"

# URLs
LOGIN_URL = "https://mp.toutiao.com/auth/page/login"
PUBLISH_URL = "https://mp.toutiao.com/profile_v4/graphic/publish"
HOME_URL = "https://mp.toutiao.com/"

# Browser Configuration
BROWSER_ARGS = [
    "--disable-blink-features=AutomationControlled",  # Patches navigator.webdriver
    "--disable-dev-shm-usage",
    "--no-sandbox",
    "--no-first-run",
    "--no-default-browser-check",
]

USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"

# Timeouts
LOGIN_TIMEOUT_MINUTES = 10
PAGE_LOAD_TIMEOUT = 30000
DEFAULT_TIMEOUT = 30000


path: scripts/setup_environment.py
content: #!/usr/bin/env python3
"""
Environment Setup for Toutiao Publisher Skill
Manages virtual environment and dependencies automatically
"""

import os
import sys
import subprocess
import venv
from pathlib import Path


class SkillEnvironment:
    """Manages skill-specific virtual environment"""

    def __init__(self):
        # Skill directory paths
        self.skill_dir = Path(__file__).parent.parent
        self.venv_dir = self.skill_dir / ".venv"
        self.requirements_file = self.skill_dir / "requirements.txt"

        # Python executable in venv
        if os.name == "nt":  # Windows
            self.venv_python = self.venv_dir / "Scripts" / "python.exe"
            self.venv_pip = self.venv_dir / "Scripts" / "pip.exe"
        else:  # Unix/Linux/Mac
            self.venv_python = self.venv_dir / "bin" / "python"
            self.venv_pip = self.venv_dir / "bin" / "pip"

    def ensure_venv(self) -> bool:
        """Ensure virtual environment exists and is set up"""

        # Check if we're already in the correct venv
        if self.is_in_skill_venv():
            print("✅ Already running in skill virtual environment")
            return True

        # Create venv if it doesn't exist
        if not self.venv_dir.exists():
            print(f"🔧 Creating virtual environment in {self.venv_dir.name}/")
            try:
                venv.create(self.venv_dir, with_pip=True)
                print("✅ Virtual environment created")
            except Exception as e:
                print(f"❌ Failed to create venv: {e}")
                return False

        # Install/update dependencies
        if self.requirements_file.exists():
            print("📦 Installing dependencies...")
            try:
                # Upgrade pip first
                subprocess.run(
                    [str(self.venv_pip), "install", "--upgrade", "pip"],
                    check=True,
                    capture_output=True,
                    text=True,
                )

                # Install requirements
                result = subprocess.run(
                    [str(self.venv_pip), "install", "-r", str(self.requirements_file)],
                    check=True,
                    capture_output=True,
                    text=True,
                )
                print("✅ Dependencies installed")

                # Install Chrome for Patchright (not Chromium!)
                print("🌐 Installing Google Chrome for Patchright...")
                try:
                    subprocess.run(
                        [
                            str(self.venv_python),
                            "-m",
                            "patchright",
                            "install",
                            "chrome",
                        ],
                        check=True,
                        capture_output=True,
                        text=True,
                    )
                    print("✅ Chrome installed")
                except subprocess.CalledProcessError as e:
                    print(f"⚠️ Warning: Failed to install Chrome: {e}")
                    print(
                        "   You may need to run manually: python -m patchright install chrome"
                    )

                return True
            except subprocess.CalledProcessError as e:
                print(f"❌ Failed to install dependencies: {e}")
                print(f"   Output: {e.output if hasattr(e, 'output') else 'No output'}")
                return False
        else:
            print("⚠️ No requirements.txt found, skipping dependency installation")
            return True

    def is_in_skill_venv(self) -> bool:
        """Check if we're already running in the skill's venv"""
        if hasattr(sys, "real_prefix") or (
            hasattr(sys, "base_prefix") and sys.base_prefix != sys.prefix
        ):
            # We're in a venv, check if it's ours
            venv_path = Path(sys.prefix)
            return venv_path == self.venv_dir
        return False

    def get_python_executable(self) -> str:
        """Get the correct Python executable to use"""
        if self.venv_python.exists():
            return str(self.venv_python)
        return sys.executable

    def run_script(self, script_name: str, args: list = None) -> int:
        """Run a script with the virtual environment"""
        script_path = self.skill_dir / "scripts" / script_name

        if not script_path.exists():
            print(f"❌ Script not found: {script_path}")
            return 1

        # Ensure venv is set up
        if not self.ensure_venv():
            print("❌ Failed to set up environment")
            return 1

        # Build command
        cmd = [str(self.venv_python), str(script_path)]
        if args:
            cmd.extend(args)

        print(f"🚀 Running: {script_name} with venv Python")

        try:
            # Run the script with venv Python
            result = subprocess.run(cmd)
            return result.returncode
        except Exception as e:
            print(f"❌ Failed to run script: {e}")
            return 1

    def activate_instructions(self) -> str:
        """Get instructions for manual activation"""
        if os.name == "nt":
            activate = self.venv_dir / "Scripts" / "activate.bat"
            return f"Run: {activate}"
        else:
            activate = self.venv_dir / "bin" / "activate"
            return f"Run: source {activate}"


def main():
    """Main entry point for environment setup"""
    import argparse

    parser = argparse.ArgumentParser(
        description="Setup Toutiao Publisher skill environment"
    )

    parser.add_argument(
        "--check", action="store_true", help="Check if environment is set up"
    )

    parser.add_argument(
        "--run", help="Run a script with the venv (e.g., --run ask_question.py)"
    )

    parser.add_argument("args", nargs="*", help="Arguments to pass to the script")

    args = parser.parse_args()

    env = SkillEnvironment()

    if args.check:
        if env.venv_dir.exists():
            print(f"✅ Virtual environment exists: {env.venv_dir}")
            print(f"   Python: {env.get_python_executable()}")
            print(f"   To activate manually: {env.activate_instructions()}")
        else:
            print(f"❌ No virtual environment found")
            print(f"   Run setup_environment.py to create it")
        return

    if args.run:
        # Run a script with venv
        return env.run_script(args.run, args.args)

    # Default: ensure environment is set up
    if env.ensure_venv():
        print("\n✅ Environment ready!")
        print(f"   Virtual env: {env.venv_dir}")
        print(f"   Python: {env.get_python_executable()}")
        print(f"\nTo activate manually: {env.activate_instructions()}")
        print(
            f"Or run scripts directly: python setup_environment.py --run script_name.py"
        )
    else:
        print("\n❌ Environment setup failed")
        return 1


if __name__ == "__main__":
    sys.exit(main() or 0)


path: scripts/browser_utils.py
content: """
Browser Utilities for Toutiao Publisher Skill
Handles browser launching, stealth features, and common interactions
"""

import json
import time
import random
from typing import Optional, List

from patchright.sync_api import Playwright, BrowserContext, Page
from config import BROWSER_PROFILE_DIR, STATE_FILE, BROWSER_ARGS, USER_AGENT


class BrowserFactory:
    """Factory for creating configured browser contexts"""

    @staticmethod
    def launch_persistent_context(
        playwright: Playwright,
        headless: bool = True,
        user_data_dir: str = str(BROWSER_PROFILE_DIR),
    ) -> BrowserContext:
        """
        Launch a persistent browser context with anti-detection features
        and cookie workaround.
        """
        # Launch persistent context
        context = playwright.chromium.launch_persistent_context(
            user_data_dir=user_data_dir,
            channel="chrome",  # Use real Chrome
            headless=headless,
            no_viewport=True,
            ignore_default_args=["--enable-automation"],
            user_agent=USER_AGENT,
            args=BROWSER_ARGS,
        )

        # Cookie Workaround for Playwright bug #36139
        # Session cookies (expires=-1) don't persist in user_data_dir automatically
        BrowserFactory._inject_cookies(context)

        return context

    @staticmethod
    def _inject_cookies(context: BrowserContext):
        """Inject cookies from state.json if available"""
        if STATE_FILE.exists():
            try:
                with open(STATE_FILE, "r") as f:
                    state = json.load(f)
                    if "cookies" in state and len(state["cookies"]) > 0:
                        context.add_cookies(state["cookies"])
                        # print(f"  🔧 Injected {len(state['cookies'])} cookies from state.json")
            except Exception as e:
                print(f"  ⚠️  Could not load state.json: {e}")


class StealthUtils:
    """Human-like interaction utilities"""

    @staticmethod
    def random_delay(min_ms: int = 100, max_ms: int = 500):
        """Add random delay"""
        time.sleep(random.uniform(min_ms / 1000, max_ms / 1000))

    @staticmethod
    def human_type(
        page: Page, selector: str, text: str, wpm_min: int = 320, wpm_max: int = 480
    ):
        """Type with human-like speed"""
        element = page.query_selector(selector)
        if not element:
            # Try waiting if not immediately found
            try:
                element = page.wait_for_selector(selector, timeout=2000)
            except:
                pass

        if not element:
            print(f"⚠️ Element not found for typing: {selector}")
            return

        # Click to focus
        element.click()

        # Type
        for char in text:
            element.type(char, delay=random.uniform(25, 75))
            if random.random() < 0.05:
                time.sleep(random.uniform(0.15, 0.4))

    @staticmethod
    def realistic_click(page: Page, selector: str):
        """Click with realistic movement"""
        element = page.query_selector(selector)
        if not element:
            return

        # Optional: Move mouse to element (simplified)
        box = element.bounding_box()
        if box:
            x = box["x"] + box["width"] / 2
            y = box["y"] + box["height"] / 2
            page.mouse.move(x, y, steps=5)

        StealthUtils.random_delay(100, 300)
        element.click()
        StealthUtils.random_delay(100, 300)


path: scripts/publisher.py
content: #!/usr/bin/env python3
"""
Publisher script for Toutiao
Navigates to the publish page with authenticated session.
"""

import sys
import argparse
import time
from pathlib import Path
import os

# Add parent directory to path
sys.path.insert(0, str(Path(__file__).parent))

from config import PUBLISH_URL
from patchright.sync_api import sync_playwright
from auth_manager import AuthManager
from browser_utils import BrowserFactory


from md2html import convert as md_to_html


def publish(
    title=None,
    content_html=None,
    cover_image_path=None,
    dry_run=False,
    headless=False,
    no_cover=False,
    raw=False,
):
    """
    Launches a browser to the Toutiao publishing page and automates the posting process.
    """
    # Optimize title to meet Toutiao constraints (2-30 chars)
    if title:
        original_title = title
        if len(title) > 30:
            title = title[:30]
            print(
                f"⚠️ Title optimized (truncated to 30 chars): '{original_title}' -> '{title}'"
            )
        elif len(title) < 2:
            title = f"{title}..."
            print(
                f"⚠️ Title optimized (extended to min 2 chars): '{original_title}' -> '{title}'"
            )

    # Check if we have valid auth
    auth_manager = AuthManager()
    # Auto-login feature integrated, skipping strict pre-check
    # if not auth_manager.is_authenticated():
    #     print(
    #         "❌ No valid authentication found. Please run 'auth_manager.py setup' first."
    #     )
    #     return False

    # Convert Markdown to HTML if content is provided
    final_html = ""
    if content_html and not raw:
        print("🔄 Converting Markdown to HTML...")
        try:
            final_html = md_to_html(content_html)
            print(f"  HTML preview: {final_html[:100]}...")
        except Exception as e:
            print(f"⚠️ Conversion failed, using raw text: {e}")
            final_html = content_html

    print(f"🚀 Launching Toutiao Publisher (Headless: {headless})...")

    with sync_playwright() as p:
        context = BrowserFactory.launch_persistent_context(p, headless=headless)

        # Get the page (persistent context usually has one page open or we create one)
        page = context.pages[0] if context.pages else context.new_page()

        # Helper for screenshot
        def take_screenshot(name):
            try:
                # Use timestamp to avoid overwrites
                ts = int(time.time())
                filename = f"debug_{name}_{ts}.png"
                # Save in current directory
                page.screenshot(path=filename)
                print(f"  📸 Saved screenshot: {filename}")
            except Exception as e:
                print(f"  ⚠️ Screenshot failed: {e}")

        try:
            # Navigate to publishing page
            print(f"🌐 Navigating to {PUBLISH_URL}...")
            try:
                page.goto(PUBLISH_URL, timeout=60000)
                # Relaxed wait condition as networkidle is too strict for Toutiao
                page.wait_for_load_state("domcontentloaded")
            except Exception as e:
                print(f"⚠️ Navigation warning (proceeding anyway): {e}")

            # Check if we were redirected to login
            if "auth/page/login" in page.url or "sso.toutiao.com" in page.url:
                print("⚠️ Redirected to login page.")
                if headless:
                    print(
                        "❌ Cannot login in headless mode. Please run without --headless."
                    )
                    return False

                print("⏳ Waiting for user login (5 mins)...")
                print("   Please scan QR code in the browser window.")

                start_time = time.time()
                logged_in = False
                while time.time() - start_time < 300:
                    try:
                        # Check indicators
                        if (
                            "profile_v4" in page.url
                            or "mp.toutiao.com/graphic/publish" in page.url
                        ):
                            print("✅ Detected login! Saving state...")
                            # Save state for future use
                            try:
                                state_path = Path("data/browser_state/state.json")
                                state_path.parent.mkdir(parents=True, exist_ok=True)
                                context.storage_state(path=str(state_path))
                                print("   State saved.")
                            except Exception as e:
                                print(f"   Warning: Could not save state: {e}")

                            logged_in = True
                            break

                        # Also check if we are back on publish page
                        if PUBLISH_URL in page.url:
                            logged_in = True
                            break
                    except:
                        pass
                    time.sleep(1)

                if not logged_in:
                    print("❌ Login timeout.")
                    return False

                # If we logged in but are not on publish page, go there
                if PUBLISH_URL not in page.url:
                    print(f"🔄 Redirecting to publish page: {PUBLISH_URL}")
                    page.goto(PUBLISH_URL)
                    page.wait_for_load_state("networkidle")

            print("✅ Publishing page loaded.")
            time.sleep(3)  # Wait a bit for dynamic content

            # Handle potential overlays (e.g. AI assistant drawer)
            print("  Checking for obstructing overlays...")
            try:
                # Common overlay selectors
                overlays = [
                    ".byte-drawer-mask",
                    ".ai-assistant-drawer",
                    ".byte-modal-mask",
                ]
                for sel in overlays:
                    if page.locator(sel).is_visible():
                        print(f"  ⚠️ Found overlay: {sel}. Attempting to close/hide...")
                        # Try clicking it to dismiss
                        page.locator(sel).click(force=True, position={"x": 10, "y": 10})
                        # Or execute JS to remove
                        page.evaluate(f"document.querySelector('{sel}')?.remove()")
                        time.sleep(1)
            except Exception as e:
                print(f"  ⚠️ Error handling overlays: {e}")

            # 1. Fill Title
            if title:
                print(f"✍️ Filling title: {title[:20]}...")
                try:
                    title_filled = False

                    # Method A: Placeholder Contains "标题"
                    print("  Attempting to fill title...")
                    title_input = page.locator("textarea").first
                    if title_input.count() > 0:
                        title_input.fill(title)
                        title_filled = True
                        print("  Filled first textarea with title.")
                    else:
                        # Fallback
                        print("  Falling back to placeholder search...")
                        title_input_ph = page.get_by_placeholder("标题", exact=False)
                        if title_input_ph.count() > 0:
                            title_input_ph.first.fill(title)
                            title_filled = True
                            print("  Filled by placeholder.")

                    if not title_filled:
                        print("❌ Could not identify title input.")

                except Exception as e:
                    print(f"⚠️ Failed to fill title: {e}")

                take_screenshot("after_title")

            # 2. Fill Content
            if content_html:
                print("📝 Filling article content with HTML paste...")
                try:
                    # Toutiao uses ProseMirror
                    # Wait for it to appear
                    try:
                        page.wait_for_selector(".ProseMirror", timeout=5000)
                    except Exception:
                        print("  ⚠️ Timeout waiting for .ProseMirror")

                    editor = page.locator(".ProseMirror").first
                    if editor.count() > 0:
                        editor.click()
                        editor.clear()

                        # Prepare plain text version (original markdown or stripped)
                        # We pass 'content_html' (which is actually the raw text/markdown passed to func if conversion failed,
                        # but in our flow 'content_html' arg to publish() IS the markdown if we called it right)
                        # Wait, let's look at the arguments.
                        # publish(content_html=...) receives the raw file content.
                        # Then we convert it to 'final_html'.
                        # So 'content_html' is the plain text source.

                        # Use robust argument passing to avoid JS parsing errors
                        print("  Attempting content fill via execCommand...")

                        # Pass data safely to JS environment
                        eval_args = {"html": final_html}

                        filled = page.evaluate(
                            """(data) => {
                            const editor = document.querySelector('.ProseMirror');
                            if (editor) {
                                editor.focus();
                                // Try insertHTML first - usually most reliable for WYSIWYG
                                const success = document.execCommand('insertHTML', false, data.html);
                                if (!success) {
                                    // Fallback to clipboard event
                                    console.log('execCommand failed, trying clipboard event');
                                    const clipboardData = new DataTransfer();
                                    clipboardData.setData('text/html', data.html);
                                    // Create paste event
                                    const pasteEvent = new ClipboardEvent('paste', {
                                        bubbles: true,
                                        cancelable: true,
                                        clipboardData: clipboardData
                                    });
                                    editor.dispatchEvent(pasteEvent);
                                }
                                return true;
                            }
                            return false;
                        }""",
                            eval_args,
                        )

                        time.sleep(3)
                        print("✅ Content pasted via JS event.")

                        # Verify Draft Saved Status
                        print("  Checking save status...")
                        saved_successfully = False
                        for _ in range(10):
                            if page.get_by_text("保存失败").is_visible():
                                print("❌ Alert: 'Save Failed' detected!")
                                take_screenshot("save_failed")

                                # Attempt retrieval: Click "Save Draft" button if exists
                                save_btn = page.get_by_text("保存草稿")
                                if save_btn.is_visible():
                                    print("  Clicking 'Save Draft' manually...")
                                    save_btn.click()
                                else:
                                    # Try typing a space
                                    print("  Typing space to trigger autosave...")
                                    editor.type(" ")
                                time.sleep(3)

                            if page.get_by_text("草稿已保存").is_visible():
                                print("✅ Draft saved successfully.")
                                saved_successfully = True
                                break
                            time.sleep(1)

                        if not saved_successfully:
                            print(
                                "⚠️ Warning: content might not be saved. Publishing might fail."
                            )

                    else:
                        print("⚠️ ProseMirror editor not found.")
                except Exception as e:
                    print(f"⚠️ Failed to fill content: {e}")

                take_screenshot("after_content")

            # 3. Cover Image Processing
            if cover_image_path:
                print(f"🖼️ Uploading cover image: {cover_image_path}...")
                try:
                    # check if file exists
                    if not os.path.exists(cover_image_path):
                        print(f"  ❌ Cover image not found at: {cover_image_path}")
                    else:
                        # 3.1 Click "Add Cover" area
                        print("  Clicking 'Add Cover' area...")
                        add_cover_btn = page.locator("div.article-cover-add").first
                        if add_cover_btn.is_visible():
                            add_cover_btn.click()
                        else:
                            # Try finding by text if class selector fails
                            page.locator("div, span").filter(
                                has_text="添加封面"
                            ).last.click()
                        time.sleep(1)

                        # 3.2 Select "Upload Local Image" tab/button
                        print("  Clicking 'Upload Local' button...")
                        # Try the specific class from reference
                        upload_tab = page.locator(
                            "div.btn-upload-handle.upload-handler"
                        ).first
                        if upload_tab.is_visible():
                            upload_tab.click()
                        else:
                            # Fallback text search
                            page.locator("div, span").filter(
                                has_text="本地上传"
                            ).last.click()
                        time.sleep(1)

                        # 3.3 Upload File
                        print("  Setting file input...")
                        # Playwright handles file uploads gracefully with set_input_files
                        # We look for the file input inside the upload handler or globally
                        file_input = page.locator("input[type='file']").first
                        file_input.set_input_files(cover_image_path)
                        print("  File sent to input.")

                        # 3.4 Confirm Upload
                        print("  Waiting for confirm button...")
                        # Reference script used: button[data-e2e='imageUploadConfirm-btn']
                        confirm_btn = page.locator(
                            "button[data-e2e='imageUploadConfirm-btn']"
                        )

                        # Wait for it to be clickable (upload processing)
                        try:
                            confirm_btn.wait_for(state="visible", timeout=30000)
                            # Sometimes button is disabled while processing
                            time.sleep(2)
                            confirm_btn.click()
                            print("  ✅ Cover image uploaded and confirmed.")
                        except Exception as e:
                            print(f"  ⚠️ Confirm button issue: {e}")
                            # Try fallback confirm button
                            page.locator("button.byte-btn-primary").filter(
                                has_text="确定"
                            ).last.click()

                        time.sleep(2)
                        take_screenshot("cover_uploaded")

                except Exception as e:
                    print(f"⚠️ Failed to upload cover: {e}")

            elif no_cover:
                print("🖼️ Selecting 'No Cover' (无封面) mode...")
                try:
                    # Robust selection for No Cover
                    no_cover_loc = (
                        page.locator("div, span, label").filter(has_text="无封面").last
                    )
                    if no_cover_loc.is_visible():
                        no_cover_loc.click()
                        print("  Clicked '无封面' option.")
                    else:
                        # Fallback: try checking if a radio exists
                        page.locator("input[type='radio'][value='0']").click()

                    time.sleep(2)
                    take_screenshot("cover_mode_selected")
                except Exception as e:
                    print(f"⚠️ Failed to select no cover: {e}")

            # 4. Final Publish Step (Optimized Two-Step Flow)
            if not dry_run:
                print("🚀 Submitting article (Final Step)...")
                try:
                    take_screenshot("before_publish_click")

                    # Step 4.1: Click "Preview & Publish" or "Publish"
                    print("  Step 1: Clicking initial Publish/Preview button...")

                    # Strategy: Try specific text locators first
                    # "预览并发布" (Preview & Publish) is preferred
                    initial_btn = (
                        page.locator("button").filter(has_text="预览并发布").last
                    )
                    if not initial_btn.is_visible():
                        print(
                            "  'Preview & Publish' not found, trying generic 'Publish'..."
                        )
                        # Exclude modal buttons logic can be complex in generic selectors,
                        # but usually the main publish button is prominent
                        initial_btn = (
                            page.locator("button").filter(has_text="发布").last
                        )

                    if initial_btn.is_visible() and initial_btn.is_enabled():
                        initial_btn.click()
                        print("  ✅ Initial button clicked.")
                    else:
                        print(
                            "  ⚠️ Could not find initial publish button! Attempting blind JS click on .publish-btn..."
                        )
                        page.evaluate("document.querySelector('.publish-btn')?.click()")

                    # Step 4.2: Wait for potential preview/modal
                    print("  Waiting for interface response (10s)...")
                    time.sleep(10)

                    # Step 4.3: Final Confirmation Button
                    print("  Step 2: Looking for Final Confirm button...")
                    # Reference script indicates class: .publish-btn-last
                    final_btn = page.locator(".publish-btn-last").first

                    if final_btn.is_visible():
                        print("  Found .publish-btn-last. Clicking...")
                        final_btn.click()
                    else:
                        # Fallback: Look for the primary button in a modal
                        print(
                            "  Main locator failed. Checking for modal confirmation..."
                        )
                        modal_confirm = (
                            page.locator(".byte-modal .byte-btn-primary")
                            .filter(has_text="确定")
                            .or_(
                                page.locator(".byte-modal .byte-btn-primary").filter(
                                    has_text="确认发布"
                                )
                            )
                            .last
                        )

                        if modal_confirm.is_visible():
                            print("  Found modal confirm button. Clicking...")
                            modal_confirm.click()
                        else:
                            print(
                                "  ❌ Critical: Could not find final confirmation button!"
                            )
                            return False

                    # Success Check
                    print("  Checking for success indicators...")
                    time.sleep(5)
                    take_screenshot("final_result")

                    # Common success texts
                    success_texts = ["发布成功", "主页查看", "已发布"]
                    for text in success_texts:
                        if page.get_by_text(text).is_visible():
                            print(f"✨ Publish Successful! Found text: {text}")
                            return True

                    return (
                        True  # Assume success if we clicked final button without error
                    )

                except Exception as e:
                    print(f"❌ Failed during publish sequence: {e}")
                    import traceback

                    traceback.print_exc()
                    return False
            else:
                print("🚧 Dry run: Skipping final publish click.")
                time.sleep(5)

            print("✨ Operation completed.")
            return True

        except Exception as e:
            print(f"❌ Error during publishing: {e}")
            import traceback

            traceback.print_exc()
            return False
        finally:
            if not headless:
                print("browser open for inspection. Closing in 60s...")
                time.sleep(60)
            if context:
                context.close()


def main():
    parser = argparse.ArgumentParser(description="Toutiao Article Publisher")
    parser.add_argument("--title", help="Article title")
    parser.add_argument("--content", help="Article content (string or file path)")
    parser.add_argument("--cover", help="Path to cover image")
    parser.add_argument(
        "--dry-run", action="store_true", help="Fill fields but do not publish"
    )
    # Add headless and no-cover arguments
    parser.add_argument(
        "--headless", action="store_true", help="Run in headless mode (no UI)"
    )
    parser.add_argument(
        "--no-cover", action="store_true", help="Select 'No Cover' option"
    )
    parser.add_argument(
        "--raw",
        action="store_true",
        help="Paste content as raw text (no HTML conversion)",
    )

    args = parser.parse_args()

    content = args.content
    if content and os.path.exists(content):
        with open(content, "r", encoding="utf-8") as f:
            content = f.read()

    publish(
        title=args.title,
        content_html=content,
        cover_image_path=args.cover,
        dry_run=args.dry_run,
        headless=args.headless,
        no_cover=args.no_cover,
        raw=args.raw,
    )


if __name__ == "__main__":
    main()


path: scripts/auth_manager.py
content: #!/usr/bin/env python3
"""
Authentication Manager for Toutiao Publisher
Handles Toutiao login and browser state persistence
"""

import json
import time
import argparse
import shutil
import re
import sys
from pathlib import Path
from typing import Optional, Dict, Any

from patchright.sync_api import sync_playwright, BrowserContext

# Add parent directory to path
sys.path.insert(0, str(Path(__file__).parent))

from config import (
    BROWSER_STATE_DIR,
    STATE_FILE,
    AUTH_INFO_FILE,
    DATA_DIR,
    LOGIN_URL,
    HOME_URL,
)
from browser_utils import BrowserFactory


class AuthManager:
    """
    Manages authentication and browser state for Toutiao
    """

    def __init__(self):
        """Initialize the authentication manager"""
        # Ensure directories exist
        DATA_DIR.mkdir(parents=True, exist_ok=True)
        BROWSER_STATE_DIR.mkdir(parents=True, exist_ok=True)

        self.state_file = STATE_FILE
        self.auth_info_file = AUTH_INFO_FILE
        self.browser_state_dir = BROWSER_STATE_DIR

    def is_authenticated(self) -> bool:
        """Check if valid authentication exists"""
        if not self.state_file.exists():
            return False

        # Check if state file is not too old (7 days)
        age_days = (time.time() - self.state_file.stat().st_mtime) / 86400
        if age_days > 7:
            print(
                f"⚠️ Browser state is {age_days:.1f} days old, may need re-authentication"
            )

        return True

    def get_auth_info(self) -> Dict[str, Any]:
        """Get authentication information"""
        info = {
            "authenticated": self.is_authenticated(),
            "state_file": str(self.state_file),
            "state_exists": self.state_file.exists(),
        }

        if self.auth_info_file.exists():
            try:
                with open(self.auth_info_file, "r") as f:
                    saved_info = json.load(f)
                    info.update(saved_info)
            except Exception:
                pass

        if info["state_exists"]:
            age_hours = (time.time() - self.state_file.stat().st_mtime) / 3600
            info["state_age_hours"] = age_hours

        return info

    def setup_auth(self, headless: bool = False, timeout_minutes: int = 10) -> bool:
        """
        Perform interactive authentication setup

        Args:
            headless: Run browser in headless mode (False for login)
            timeout_minutes: Maximum time to wait for login

        Returns:
            True if authentication successful
        """
        print("🔐 Starting authentication setup...")
        print(f"  Timeout: {timeout_minutes} minutes")

        playwright = None
        context = None

        try:
            playwright = sync_playwright().start()

            # Launch using factory
            context = BrowserFactory.launch_persistent_context(
                playwright, headless=headless
            )

            # Navigate to Toutiao Login
            page = context.new_page()
            page.goto(LOGIN_URL, wait_until="domcontentloaded")

            # Check if already authenticated (redirected to home)
            if "mp.toutiao.com" in page.url and "auth/page/login" not in page.url:
                print("  ✅ Already authenticated!")
                self._save_browser_state(context)
                self._save_auth_info()
                return True

            # Wait for manual login
            print("\n  ⏳ Please log in to your Toutiao account...")
            print(f"  ⏱️  Waiting up to {timeout_minutes} minutes for login...")
            print("  (Please scan the QR code or login with password)")

            try:
                # Wait for URL to be the home page or dashboard, implying login success
                # Often it redirects to https://mp.toutiao.com/profile_v4/index or similar
                # timeout_ms = int(timeout_minutes * 60 * 1000)

                start_time = time.time()
                while time.time() - start_time < (timeout_minutes * 60):
                    # Check all pages in the context
                    login_detected = False
                    for p in context.pages:
                        try:
                            current_url = p.url

                            # Check for success indicators
                            is_login_page = "auth/page/login" in current_url
                            is_toutiao_domain = "mp.toutiao.com" in current_url
                            is_profile_page = "profile_v4" in current_url

                            # Check URL match
                            if (
                                not is_login_page and is_toutiao_domain
                            ) or is_profile_page:
                                print(
                                    f"  ✅ Login successful! (Detected in tab: {current_url})"
                                )
                                login_detected = True
                                break
                        except Exception:
                            continue

                    if login_detected:
                        # Wait a bit for cookies to settle
                        time.sleep(3)

                        # Save authentication state
                        self._save_browser_state(context)
                        self._save_auth_info()
                        return True

                    time.sleep(1)

                print("  ❌ Timeout waiting for login redirect")
                return False

            except Exception as e:
                print(f"  ❌ Authentication error: {e}")
                return False

        except Exception as e:
            print(f"  ❌ Error: {e}")
            return False

        finally:
            # Clean up browser resources
            if context:
                try:
                    context.close()
                except Exception:
                    pass

            if playwright:
                try:
                    playwright.stop()
                except Exception:
                    pass

    def _save_browser_state(self, context: BrowserContext):
        """Save browser state to disk"""
        try:
            # Save storage state (cookies, localStorage)
            context.storage_state(path=str(self.state_file))
            print(f"  💾 Saved browser state to: {self.state_file}")
        except Exception as e:
            print(f"  ❌ Failed to save browser state: {e}")
            raise

    def _save_auth_info(self):
        """Save authentication metadata"""
        try:
            info = {
                "authenticated_at": time.time(),
                "authenticated_at_iso": time.strftime("%Y-%m-%d %H:%M:%S"),
            }
            with open(self.auth_info_file, "w") as f:
                json.dump(info, f, indent=2)
        except Exception:
            pass  # Non-critical

    def clear_auth(self) -> bool:
        """
        Clear all authentication data

        Returns:
            True if cleared successfully
        """
        print("🗑️ Clearing authentication data...")

        try:
            # Remove browser state
            if self.state_file.exists():
                self.state_file.unlink()
                print("  ✅ Removed browser state")

            # Remove auth info
            if self.auth_info_file.exists():
                self.auth_info_file.unlink()
                print("  ✅ Removed auth info")

            # Clear entire browser state directory
            if self.browser_state_dir.exists():
                shutil.rmtree(self.browser_state_dir)
                self.browser_state_dir.mkdir(parents=True, exist_ok=True)
                print("  ✅ Cleared browser data")

            return True

        except Exception as e:
            print(f"  ❌ Error clearing auth: {e}")
            return False

    def re_auth(self, headless: bool = False, timeout_minutes: int = 10) -> bool:
        """
        Perform re-authentication (clear and setup)

        Args:
            headless: Run browser in headless mode
            timeout_minutes: Login timeout in minutes

        Returns:
            True if successful
        """
        print("🔄 Starting re-authentication...")

        # Clear existing auth
        self.clear_auth()

        # Setup new auth
        return self.setup_auth(headless, timeout_minutes)

    def validate_auth(self) -> bool:
        """
        Validate that stored authentication works
        """
        if not self.is_authenticated():
            return False

        print("🔍 Validating authentication...")

        playwright = None
        context = None

        try:
            playwright = sync_playwright().start()

            # Launch using factory
            context = BrowserFactory.launch_persistent_context(
                playwright, headless=True
            )

            # Try to access HOME_URL
            page = context.new_page()
            page.goto(HOME_URL, wait_until="domcontentloaded", timeout=30000)

            # Check if we are redirected to login
            if "auth/page/login" in page.url:
                print("  ❌ Authentication is invalid (redirected to login)")
                return False
            else:
                print("  ✅ Authentication is valid")
                return True

        except Exception as e:
            print(f"  ❌ Validation failed: {e}")
            return False

        finally:
            if context:
                try:
                    context.close()
                except Exception:
                    pass
            if playwright:
                try:
                    playwright.stop()
                except Exception:
                    pass


def main():
    """Command-line interface for authentication management"""
    parser = argparse.ArgumentParser(description="Manage Toutiao authentication")

    subparsers = parser.add_subparsers(dest="command", help="Commands")

    # Setup command
    setup_parser = subparsers.add_parser("setup", help="Setup authentication")
    setup_parser.add_argument(
        "--headless", action="store_true", help="Run in headless mode"
    )
    setup_parser.add_argument(
        "--timeout",
        type=float,
        default=10,
        help="Login timeout in minutes (default: 10)",
    )

    # Status command
    subparsers.add_parser("status", help="Check authentication status")

    # Validate command
    subparsers.add_parser("validate", help="Validate authentication")

    # Clear command
    subparsers.add_parser("clear", help="Clear authentication")

    # Re-auth command
    reauth_parser = subparsers.add_parser(
        "reauth", help="Re-authenticate (clear + setup)"
    )
    reauth_parser.add_argument(
        "--timeout",
        type=float,
        default=10,
        help="Login timeout in minutes (default: 10)",
    )

    args = parser.parse_args()

    # Initialize manager
    auth = AuthManager()

    # Execute command
    if args.command == "setup":
        if auth.setup_auth(headless=args.headless, timeout_minutes=args.timeout):
            print("\n✅ Authentication setup complete!")
        else:
            print("\n❌ Authentication setup failed")
            exit(1)

    elif args.command == "status":
        info = auth.get_auth_info()
        print("\n🔐 Authentication Status:")
        print(f"  Authenticated: {'Yes' if info['authenticated'] else 'No'}")
        if info.get("state_age_hours"):
            print(f"  State age: {info['state_age_hours']:.1f} hours")
        if info.get("authenticated_at_iso"):
            print(f"  Last auth: {info['authenticated_at_iso']}")
        print(f"  State file: {info['state_file']}")

    elif args.command == "validate":
        if auth.validate_auth():
            print("Authentication is valid and working")
        else:
            print("Authentication is invalid or expired")
            print("Run: auth_manager.py setup")

    elif args.command == "clear":
        if auth.clear_auth():
            print("Authentication cleared")

    elif args.command == "reauth":
        if auth.re_auth(timeout_minutes=args.timeout):
            print("\n✅ Re-authentication complete!")
        else:
            print("\n❌ Re-authentication failed")
            exit(1)

    else:
        parser.print_help()


if __name__ == "__main__":
    main()


path: scripts/md2html.py
content: import re


def convert(text):
    """
    Simple Markdown to HTML converter for Toutiao.
    Handles headers, code blocks, lists, and basic formatting.
    """
    lines = text.split("\n")
    html = []
    in_code_block = False
    in_list = False

    for line in lines:
        stripped = line.strip()

        # Code blocks
        if stripped.startswith("```"):
            if in_code_block:
                html.append("</code></pre>")
                in_code_block = False
            else:
                html.append("<pre><code>")
                in_code_block = True
            continue

        if in_code_block:
            import html as html_lib

            # Escape code content
            safe_line = html_lib.escape(line)
            html.append(
                f"{safe_line}<br>"
            )  # Use br for newlines in code for some editors
            continue

        # List handling logic (exit list if empty line or header)
        if in_list and (not stripped or stripped.startswith("#")):
            html.append("</ul>")
            in_list = False

        # Headers
        if line.startswith("#"):
            # Close list if open
            if in_list:
                html.append("</ul>")
                in_list = False

            level = len(line.split()[0])
            # Max h6
            if level > 6:
                level = 6
            content = line[level:].strip()
            html.append(f"<h{level}>{content}</h{level}>")
            continue

        # Lists ( * or - or 1.)
        # Simplified: treat all as ul for now or simple lists
        is_list_item = stripped.startswith("* ") or stripped.startswith("- ")

        if is_list_item:
            if not in_list:
                html.append("<ul>")
                in_list = True
            content = stripped[2:]
            # Bold formatting inside list
            content = re.sub(r"\*\*(.*?)\*\*", r"<b>\1</b>", content)
            html.append(f"<li>{content}</li>")
            continue

        # Paragraphs
        if stripped:
            # If we're not in a list or code block, it's a paragraph
            if not in_list:
                # Bold formatting
                line_content = re.sub(r"\*\*(.*?)\*\*", r"<b>\1</b>", stripped)
                html.append(f"<p>{line_content}</p>")
            else:
                # Continuation of list? Or close it?
                # For simplicity, if we hit non-list text line, close list
                html.append("</ul>")
                in_list = False
                line_content = re.sub(r"\*\*(.*?)\*\*", r"<b>\1</b>", stripped)
                html.append(f"<p>{line_content}</p>")

    if in_list:
        html.append("</ul>")

    return "\n".join(html)


if __name__ == "__main__":
    # Test
    sample = """# Title
    
    Introduction **bold**.
    
    * Item 1
    * Item 2
    
    ```python
    print("Code")
    ```
    """
    print(convert(sample))


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