
Toutiao Publisher
- 1k installs
- 28 repo stars
- Updated July 16, 2026
- guanyang/super-publisher
toutiao-publisher is an agent skill that publishes articles to Toutiao (Today's Headlines) with one-time browser QR login and persistent session management for developers distributing Chinese-language content.
About
toutiao-publisher is an agent skill in the guanyang/super-publisher repository that posts articles to Toutiao (Today's Headlines, 头条号) for developers automating Chinese-language content distribution. The skill opens a browser for a one-time QR-code login, then persists the session so later publishing runs skip re-authentication. When triggered by mentions of toutiao, 头条号, or Today's Headlines, the agent manages account login state and pushes article drafts through Toutiao's interactive publishing flow. Developers reach for toutiao-publisher when they need repeatable, agent-driven posting to ByteDance's Toutiao platform without rebuilding browser automation or session storage themselves. The workflow covers authentication setup, session maintenance, and article submission in a single skill rather than a separate CMS integration.
- One-time browser-based login with persistent session management
- Automatically opens authenticated browser session at the publish page
- Title optimization to fit Toutiao's 2-30 character requirement
- Handles manual publishing workflow while maintaining login state
- Trigger-based activation on mentions of toutiao or 头条号
Toutiao Publisher by the numbers
- 1,021 all-time installs (skills.sh)
- +9 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #447 of 1,879 Marketing & SEO skills by installs in the Skillselion catalog
- Security screen: HIGH risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/guanyang/super-publisher --skill toutiao-publisherAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1k |
|---|---|
| repo stars | ★ 28 |
| Security audit | 1 / 3 scanners passed |
| Last updated | July 16, 2026 |
| Repository | guanyang/super-publisher ↗ |
How do you publish articles to Toutiao programmatically?
Publish articles directly to Toutiao (Today's Headlines) with persistent authentication.
Who is it for?
Developers automating Chinese article distribution to Toutiao who want persistent browser sessions without writing custom scrapers.
Skip if: Teams publishing only to Western platforms or requiring fully headless CI publishing with no interactive browser login.
When should I use this skill?
User asks to publish to Toutiao, manage 头条号 login, or mentions toutiao or Today's Headlines.
What you get
Live Toutiao articles plus a persisted browser login session for repeat publishing.
- published Toutiao articles
- persisted login session
Files
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.
# Browser will open for manual login (scan QR code)
python scripts/run.py auth_manager.py setupInstructions: 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
# Opens browser with authenticated session at publish page
python scripts/run.py publisher.pyInstructions: 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 (Draft Automation)
You can fill a draft automatically by providing arguments. By default, the tool stops before final publishing so the user can inspect the article and click Publish manually.
# Fill title, Markdown content, inline images, and cover image
python scripts/run.py publisher.py --title "AI Trends 2025" --content "article.md" --cover "assets/cover.jpg"Markdown images such as  are inserted into the article body at their original positions. Local relative image paths are resolved from the Markdown file directory.
If the article body contains images, the tool skips explicit cover handling and does not open Toutiao's cover picker. --cover is only uploaded when the Markdown body has no inline images.
Debug screenshots are disabled by default. If troubleshooting is needed, add --debug-screenshots; screenshots will be saved under output/toutiao-publisher-debug/ instead of the working directory.
Automated Final Publish
Only use this when the user explicitly asks to publish without manual review:
python scripts/run.py publisher.py --title "AI Trends 2025" --content "article.md" --cover "assets/cover.jpg" --auto-publishWithout --auto-publish, never click Toutiao's final publish buttons automatically.
Management
# Check authentication status
python scripts/run.py auth_manager.py status
# Clear authentication data (logout)
python scripts/run.py auth_manager.py clearTechnical Details
- Persistent Auth: Uses
patchrightto launch a persistent browser context. Cookies and storage state are saved todata/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.
Toutiao Publisher Skill 架构与最佳实践指南
1. 架构原理 (Architecture)
Toutiao Publisher 是一个基于 Playwright 的自动化发布工具,旨在解决头条号及其复杂的富文本编辑器交互问题。其核心设计理念是 "模拟真实用户行为" (User Simulation) 而非简单的 API 调用。
1.1 核心组件
- `publisher.py` (核心执行器)
- 作为主入口,负责编排整个发布流程。
- 智能填充:针对 ProseMirror 编辑器,采用多级降级策略(
execCommand>ClipboardEvent)确保内容注入成功。 - 正文图片插入:将 Markdown 正文图片先转换为占位符,再通过系统剪贴板在编辑器中逐张粘贴到原位置。
- 封面自动化:实现了文件上传控件(
input[type=file])的精准定位与交互,支持本地图片上传。 - 人工发布确认:默认只填充草稿并停留在浏览器中,最终发布由用户检查后手动点击;只有显式传入
--auto-publish才会点击发布按钮。 - 即时登录 (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 Draft Filling)
最推荐的使用方式是通过命令行自动填充草稿,然后人工检查并手动发布。
# 标准草稿填充命令(推荐)
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。Markdown 正文图片会按原位置插入,推荐使用本地图片路径。 -
--cover: 图片绝对路径。建议 16:9 比例,PNG/JPG 格式。若正文 Markdown 已包含图片,脚本不会额外处理封面;只有正文没有图片时才上传该封面。 -
--auto-publish: 显式启用最终发布按钮点击。默认不传,保留人工检查。 -
--headless: 加上此参数可在后台运行(需确保已登录)。人工发布检查需要可见浏览器,因此默认流程不建议加。 -
--debug-screenshots: 默认关闭。排查问题时开启,截图会保存到output/toutiao-publisher-debug/,不会散落在当前目录。
若确实需要全自动发布,必须显式传入:
python scripts/run.py publisher.py \
--title "你的标题(2-30字)" \
--content "/absolute/path/to/article.md" \
--cover "/absolute/path/to/cover.png" \
--auto-publish2.2 登录与状态管理
- 首次使用:直接运行发布命令(不带
--headless)。脚本会自动弹出浏览器,请扫码登录。登录后脚本会自动保存状态。 - 状态失效:如果遇到
No valid authentication且自动重试无效,可手动清理状态:
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 定义文件patchright==1.55.2
python-dotenv==1.0.0
#!/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()
"""
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)
"""
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
import re
from dataclasses import dataclass
from pathlib import Path
IMAGE_PATTERN = re.compile(r"!\[([^\]]*)\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)")
@dataclass
class ConvertedMarkdown:
html: str
images: list[dict[str, str]]
def _resolve_image_path(raw_path, base_dir):
cleaned = raw_path.strip().strip("<>").strip("\"'")
if cleaned.startswith(("http://", "https://", "data:")):
return cleaned
base = Path(base_dir or ".")
image_path = Path(cleaned)
if not image_path.is_absolute():
image_path = base / image_path
return str(image_path.resolve())
def _replace_markdown_images(line, images, base_dir):
def replace(match):
placeholder = f"TTIMGPH_{len(images)}"
images.append(
{
"placeholder": placeholder,
"path": _resolve_image_path(match.group(2), base_dir),
"alt": match.group(1).strip(),
}
)
return placeholder
return IMAGE_PATTERN.sub(replace, line)
def convert_with_images(text, base_dir=None):
"""
Simple Markdown to HTML converter for Toutiao.
Handles headers, code blocks, lists, and basic formatting.
"""
lines = text.split("\n")
html = []
images = []
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
line = _replace_markdown_images(line, images, base_dir)
stripped = line.strip()
# 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 ConvertedMarkdown(html="\n".join(html), images=images)
def convert(text):
return convert_with_images(text).html
if __name__ == "__main__":
# Test
sample = """# Title
Introduction **bold**.
* Item 1
* Item 2
```python
print("Code")
```
"""
print(convert(sample))
#!/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
import platform
import shutil
import subprocess
import tempfile
# 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_with_images
def _run_command(command, args, input_data=None):
result = subprocess.run(
[command, *args],
input=input_data,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
return result.returncode == 0
def copy_image_to_clipboard(image_path):
system = platform.system()
image_path = str(Path(image_path).resolve())
if system == "Darwin":
swift_source = """
import AppKit
import Foundation
let inputPath = CommandLine.arguments[1]
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
guard let image = NSImage(contentsOfFile: inputPath) else {
FileHandle.standardError.write("Failed to load image\\n".data(using: .utf8)!)
exit(1)
}
if !pasteboard.writeObjects([image]) {
FileHandle.standardError.write("Failed to write image to clipboard\\n".data(using: .utf8)!)
exit(1)
}
"""
with tempfile.TemporaryDirectory(prefix="toutiao-clipboard-") as temp_dir:
swift_path = Path(temp_dir) / "clipboard.swift"
swift_path.write_text(swift_source, encoding="utf-8")
return _run_command("swift", [str(swift_path), image_path])
if system == "Linux":
mime = "image/png"
ext = Path(image_path).suffix.lower()
if ext in {".jpg", ".jpeg"}:
mime = "image/jpeg"
elif ext == ".gif":
mime = "image/gif"
elif ext == ".webp":
mime = "image/webp"
if shutil.which("wl-copy"):
with open(image_path, "rb") as image_file:
return _run_command("wl-copy", ["--type", mime], image_file.read())
if shutil.which("xclip"):
return _run_command("xclip", ["-selection", "clipboard", "-t", mime, "-i", image_path])
print(" ⚠️ No clipboard tool found. Install wl-copy or xclip.")
return False
if system == "Windows":
escaped = image_path.replace("'", "''")
ps = (
"Add-Type -AssemblyName System.Windows.Forms;"
"Add-Type -AssemblyName System.Drawing;"
f"$img = [System.Drawing.Image]::FromFile('{escaped}');"
"[System.Windows.Forms.Clipboard]::SetImage($img);"
"$img.Dispose()"
)
return _run_command("powershell.exe", ["-NoProfile", "-Sta", "-Command", ps])
print(f" ⚠️ Unsupported clipboard platform: {system}")
return False
def paste_from_clipboard(page):
system = platform.system()
if system == "Darwin":
if _run_command(
"osascript",
[
"-e",
'tell application "Google Chrome" to activate',
"-e",
'tell application "System Events" to keystroke "v" using command down',
],
):
return True
elif system == "Linux":
if shutil.which("xdotool") and _run_command("xdotool", ["key", "ctrl+v"]):
return True
if shutil.which("ydotool") and _run_command("ydotool", ["key", "29:1", "47:1", "47:0", "29:0"]):
return True
elif system == "Windows":
ps = "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait('^v')"
if _run_command("powershell.exe", ["-NoProfile", "-Command", ps]):
return True
try:
modifier = "Meta" if system == "Darwin" else "Control"
page.keyboard.press(f"{modifier}+V")
return True
except Exception as e:
print(f" ⚠️ Failed to send paste keystroke: {e}")
return False
def select_placeholder(page, placeholder, retries=3):
for attempt in range(1, retries + 1):
selected = page.evaluate(
"""(placeholder) => {
const editor = document.querySelector('.ProseMirror');
if (!editor) return false;
const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT);
let node;
while ((node = walker.nextNode())) {
const text = node.textContent || '';
let searchStart = 0;
let idx;
while ((idx = text.indexOf(placeholder, searchStart)) !== -1) {
const afterIdx = idx + placeholder.length;
const charAfter = text[afterIdx];
if (charAfter === undefined || !/\\d/.test(charAfter)) {
node.parentElement?.scrollIntoView({ block: 'center' });
const range = document.createRange();
range.setStart(node, idx);
range.setEnd(node, afterIdx);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
editor.focus();
return true;
}
searchStart = afterIdx;
}
}
return false;
}""",
placeholder,
)
if selected:
selected_text = page.evaluate("window.getSelection()?.toString() || ''")
if selected_text.strip() == placeholder:
return True
if attempt < retries:
time.sleep(0.5)
return False
def insert_content_images(page, images):
if not images:
return True
print(f"🖼️ Inserting {len(images)} inline image(s)...")
success = True
for index, image in enumerate(images, start=1):
placeholder = image["placeholder"]
image_path = image["path"]
print(f" [{index}/{len(images)}] Replacing {placeholder}...")
if image_path.startswith(("http://", "https://", "data:")):
print(f" ⚠️ Skipping non-local image: {image_path}")
success = False
continue
if not os.path.exists(image_path):
print(f" ⚠️ Image file not found: {image_path}")
success = False
continue
if not copy_image_to_clipboard(image_path):
print(" ⚠️ Failed to copy image to clipboard.")
success = False
continue
if not select_placeholder(page, placeholder):
print(f" ⚠️ Could not select placeholder: {placeholder}")
success = False
continue
before_count = page.locator(".ProseMirror img").count()
page.keyboard.press("Backspace")
time.sleep(0.3)
if not paste_from_clipboard(page):
print(" ⚠️ Failed to paste image.")
success = False
continue
inserted = False
start = time.time()
while time.time() - start < 20:
if page.locator(".ProseMirror img").count() > before_count:
inserted = True
break
time.sleep(1)
if inserted:
print(" ✅ Image inserted.")
else:
print(" ⚠️ Image insertion was not detected.")
success = False
remaining = page.evaluate(
"""() => {
const text = document.querySelector('.ProseMirror')?.innerText || '';
return Array.from(text.matchAll(/TTIMGPH_\\d+/g)).map(match => match[0]);
}"""
)
if remaining:
print(f" ⚠️ Remaining image placeholders: {', '.join(remaining)}")
success = False
return success
def click_locator_with_fallback(locator, description):
try:
locator.click(timeout=3000)
return True
except Exception as e:
print(f" ⚠️ Normal click failed for {description}: {e}")
try:
locator.click(force=True, timeout=3000)
return True
except Exception as e:
print(f" ⚠️ Force click failed for {description}: {e}")
try:
locator.evaluate("(el) => el.click()")
return True
except Exception as e:
print(f" ⚠️ JS click failed for {description}: {e}")
return False
def click_button_by_text(page, texts, description, timeout_ms=5000):
end_time = time.time() + timeout_ms / 1000
while time.time() < end_time:
result = page.evaluate(
"""(texts) => {
const buttons = Array.from(document.querySelectorAll('button, .byte-btn'));
for (const button of buttons) {
const text = (button.textContent || '').trim();
if (texts.some(item => text.includes(item))) {
button.scrollIntoView({ block: 'center' });
button.click();
return text || 'clicked';
}
}
return '';
}""",
texts,
)
if result:
print(f" Clicked {description}: {result}")
return True
time.sleep(0.5)
print(f" ⚠️ Could not find {description}.")
return False
def should_upload_cover(cover_image_path=None, content_images=None):
return bool(cover_image_path) and not bool(content_images)
def make_screenshot_taker(page, enabled=False, debug_dir=None):
if not enabled:
return lambda name: None
target_dir = Path(debug_dir or "output/toutiao-publisher-debug") / str(int(time.time()))
def take_screenshot(name):
try:
target_dir.mkdir(parents=True, exist_ok=True)
ts = int(time.time())
filename = target_dir / f"{ts}_{name}.png"
page.screenshot(path=str(filename))
print(f" 📸 Saved screenshot: {filename}")
except Exception as e:
print(f" ⚠️ Screenshot failed: {e}")
return take_screenshot
def publish(
title=None,
content_html=None,
content_base_dir=None,
cover_image_path=None,
dry_run=False,
headless=False,
no_cover=False,
raw=False,
auto_publish=False,
debug_screenshots=False,
debug_dir=None,
):
"""
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 = ""
content_images = []
if content_html and not raw:
print("🔄 Converting Markdown to HTML...")
try:
converted = convert_with_images(content_html, base_dir=content_base_dir)
final_html = converted.html
content_images = converted.images
print(f" HTML preview: {final_html[:100]}...")
if content_images:
print(f" Found {len(content_images)} inline image(s).")
except Exception as e:
print(f"⚠️ Conversion failed, using raw text: {e}")
final_html = content_html
elif content_html:
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()
take_screenshot = make_screenshot_taker(
page,
enabled=debug_screenshots,
debug_dir=debug_dir,
)
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")
if content_images:
try:
insert_content_images(page, content_images)
except Exception as e:
print(f"⚠️ Failed to insert inline images: {e}")
take_screenshot("after_inline_images")
# 3. Cover Image Processing
if cover_image_path and content_images:
print("🖼️ Article has inline images; skipping explicit cover upload.")
if should_upload_cover(cover_image_path, content_images):
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():
click_locator_with_fallback(upload_tab, "Upload Local button")
else:
# Fallback text search
upload_text = page.locator("div, span").filter(
has_text="本地上传"
).last
click_locator_with_fallback(upload_text, "本地上传 button")
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)
if not click_locator_with_fallback(confirm_btn, "upload confirm button"):
raise RuntimeError("upload confirm click failed")
print(" ✅ Cover image uploaded and confirmed.")
except Exception as e:
print(f" ⚠️ Confirm button issue: {e}")
if not click_button_by_text(page, ["确定", "确认"], "fallback upload confirm button"):
raise
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 auto_publish and 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("🛑 Manual review mode: skipping final publish click.")
print(" Please review the article in Chrome and click Publish manually.")
if headless:
print(" Headless mode cannot stay open for manual publishing.")
else:
print(" Press Ctrl+C in this terminal after you are done.")
try:
while True:
time.sleep(5)
except KeyboardInterrupt:
print("\n✅ Manual review finished.")
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 and auto_publish:
print("browser open for inspection. Closing in 60s...")
time.sleep(60)
if context:
try:
context.close()
except Exception as e:
print(f"⚠️ Browser context close warning: {e}")
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)",
)
parser.add_argument(
"--auto-publish",
action="store_true",
help="Click the final publish buttons automatically after filling the article",
)
parser.add_argument(
"--debug-screenshots",
action="store_true",
help="Save step-by-step debug screenshots under output/toutiao-publisher-debug/",
)
parser.add_argument(
"--debug-dir",
help="Directory for debug screenshots when --debug-screenshots is enabled",
)
args = parser.parse_args()
content = args.content
content_base_dir = Path.cwd()
if content and os.path.exists(content):
content_path = Path(content).resolve()
content_base_dir = content_path.parent
with open(content_path, "r", encoding="utf-8") as f:
content = f.read()
publish(
title=args.title,
content_html=content,
content_base_dir=content_base_dir,
cover_image_path=args.cover,
dry_run=args.dry_run,
headless=args.headless,
no_cover=args.no_cover,
raw=args.raw,
auto_publish=args.auto_publish,
debug_screenshots=args.debug_screenshots,
debug_dir=args.debug_dir,
)
if __name__ == "__main__":
main()
#!/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()
#!/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)
import sys
import unittest
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parents[1] / "scripts"
sys.path.insert(0, str(SCRIPT_DIR))
from md2html import convert_with_images
class MarkdownImageConversionTest(unittest.TestCase):
def test_replaces_local_images_with_placeholders_and_returns_metadata(self):
markdown = "Intro\n\n\n\nOutro"
result = convert_with_images(markdown, base_dir=Path("/tmp/article"))
self.assertIn("<p>Intro</p>", result.html)
self.assertIn("<p>TTIMGPH_0</p>", result.html)
self.assertIn("<p>Outro</p>", result.html)
self.assertEqual(
result.images,
[
{
"placeholder": "TTIMGPH_0",
"path": str(Path("/tmp/article/assets/chart.png").resolve()),
"alt": "Chart",
}
],
)
if __name__ == "__main__":
unittest.main()
import sys
import unittest
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parents[1] / "scripts"
sys.path.insert(0, str(SCRIPT_DIR))
from publisher import should_upload_cover
class CoverUploadPolicyTest(unittest.TestCase):
def test_skips_cover_upload_when_article_has_inline_images(self):
self.assertFalse(
should_upload_cover(
cover_image_path="cover.png",
content_images=[{"placeholder": "TTIMGPH_0"}],
)
)
def test_uploads_cover_when_article_has_no_inline_images(self):
self.assertTrue(
should_upload_cover(
cover_image_path="cover.png",
content_images=[],
)
)
if __name__ == "__main__":
unittest.main()
import sys
import tempfile
import unittest
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parents[1] / "scripts"
sys.path.insert(0, str(SCRIPT_DIR))
from publisher import make_screenshot_taker
class FakePage:
def __init__(self):
self.paths = []
def screenshot(self, path):
self.paths.append(path)
class PublisherDebugScreenshotTest(unittest.TestCase):
def test_screenshot_taker_is_noop_when_disabled(self):
with tempfile.TemporaryDirectory() as temp_dir:
page = FakePage()
take_screenshot = make_screenshot_taker(
page,
enabled=False,
debug_dir=Path(temp_dir) / "debug",
)
take_screenshot("after_title")
self.assertEqual(page.paths, [])
self.assertFalse((Path(temp_dir) / "debug").exists())
def test_screenshot_taker_writes_to_debug_dir_when_enabled(self):
with tempfile.TemporaryDirectory() as temp_dir:
page = FakePage()
take_screenshot = make_screenshot_taker(
page,
enabled=True,
debug_dir=Path(temp_dir) / "debug",
)
take_screenshot("after_title")
self.assertEqual(len(page.paths), 1)
self.assertTrue(page.paths[0].startswith(str(Path(temp_dir) / "debug")))
self.assertTrue(page.paths[0].endswith("_after_title.png"))
if __name__ == "__main__":
unittest.main()
Related skills
How it compares
Choose toutiao-publisher over generic browser-automation skills when the goal is Toutiao-specific publishing with built-in session persistence rather than one-off page scraping.
FAQ
How does toutiao-publisher handle Toutiao login?
toutiao-publisher opens a browser for a one-time manual login, typically via QR code scan. The skill persists the session so later publishing runs reuse stored credentials without repeating authentication.
When should an agent invoke toutiao-publisher?
toutiao-publisher should run when a developer asks to publish to Toutiao or 头条号, manage Toutiao login, or mentions Today's Headlines. The skill handles session setup and article submission to that platform.
Does toutiao-publisher require login every publish?
No. toutiao-publisher requires interactive browser login only once during initial setup. Subsequent article publishes reuse the persisted session managed by the skill across agent runs.
Is Toutiao Publisher safe to install?
skills.sh reports 1 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.