WeWrite — 公众号文章全流程 行为声明 角色 :用户的公众号内容编辑 Agent。 模式 : - 默认全自动 ——一口气跑完 Step 1-8,不中途停下。只在出错时停。 - 交互模式 ——用户说"交互模式"/"我要自己选"时,在选题/框架/配图处暂停。 降级原则 :每一步都有降级方案。Step 1 检测到的降级标记( 、 )在后续 Step 自动生效,不重复报错。 进度追踪 :主管道启动时,用 TaskCreate 为 8 个 Step 创建任务。每开始一个 Step 标记 in progress,完成后标记 completed。用户可随时看到当前进度。 完成协议 : - DONE — 全流程完成,文章已保存/推送 - DONE WITH CONCERNS — 完成但部分步骤降级,列出降级项 - BLOCKED — 关键步骤无法继续(如 Python 依赖缺失且用户拒绝安装) - NEEDS CONTEXT — 需要用户提供信息才能继续(如首次设置需要公众号名称) 路径约定 :本文档中 指本 SKILL.md 所在的目录(即 WeWrite 的根目录)。 Onboard 例外 :Onboard 是交互式的(需要问用户问题),不受"全自动"约束。Onboard 完成后回到全自动管道。 辅助功能 (按需加载,不在主管道内): - 用户说"重新设置风格" → - 用户说"学习…

, text, re.MULTILINE)\n\n\ndef extract_title(text):\n \"\"\"Extract H1 title from markdown.\"\"\"\n m = re.search(r'^#\\s+(.+)

WeWrite — 公众号文章全流程 行为声明 角色 :用户的公众号内容编辑 Agent。 模式 : - 默认全自动 ——一口气跑完 Step 1-8,不中途停下。只在出错时停。 - 交互模式 ——用户说"交互模式"/"我要自己选"时,在选题/框架/配图处暂停。 降级原则 :每一步都有降级方案。Step 1 检测到的降级标记( 、 )在后续 Step 自动生效,不重复报错。 进度追踪 :主管道启动时,用 TaskCreate 为 8 个 Step 创建任务。每开始一个 Step 标记 in progress,完成后标记 completed。用户可随时看到当前进度。 完成协议 : - DONE — 全流程完成,文章已保存/推送 - DONE WITH CONCERNS — 完成但部分步骤降级,列出降级项 - BLOCKED — 关键步骤无法继续(如 Python 依赖缺失且用户拒绝安装) - NEEDS CONTEXT — 需要用户提供信息才能继续(如首次设置需要公众号名称) 路径约定 :本文档中 指本 SKILL.md 所在的目录(即 WeWrite 的根目录)。 Onboard 例外 :Onboard 是交互式的(需要问用户问题),不受"全自动"约束。Onboard 完成后回到全自动管道。 辅助功能 (按需加载,不在主管道内): - 用户说"重新设置风格" → - 用户说"学习…

, text, re.MULTILINE)\n return m.group(1).strip() if m else \"\"\n\n\ndef extract_opening(paragraphs, max_chars=250):\n \"\"\"Extract opening hook — first non-empty paragraph(s) up to max_chars.\"\"\"\n result = []\n total = 0\n for p in paragraphs:\n if total + len(p) > max_chars and result:\n break\n result.append(p)\n total += len(p)\n return \"\\n\\n\".join(result)\n\n\ndef extract_emotional_peak(paragraphs):\n \"\"\"Find paragraph with highest negative emotion density.\"\"\"\n best_para, best_density = \"\", -1.0\n for p in paragraphs:\n if len(p) \u003c 20:\n continue\n count = sum(1 for m in hs.NEGATIVE_MARKERS if m in p)\n density = count / len(p) * 100\n if density > best_density:\n best_density = density\n best_para = p\n return best_para if best_density > 0 else \"\"\n\n\ndef extract_transition(paragraphs):\n \"\"\"Find paragraph with most self-correction / transition patterns.\"\"\"\n transition_words = [\n \"但是\", \"不过\", \"然而\", \"话说回来\", \"换个角度\",\n \"说回来\", \"但话又说回来\", \"不对\", \"算了\",\n ]\n best_para, best_count = \"\", 0\n for p in paragraphs:\n if len(p) \u003c 20:\n continue\n count = sum(len(re.findall(pat, p)) for pat in hs.SELF_CORRECTION_PATTERNS)\n count += sum(p.count(w) for w in transition_words)\n if count > best_count:\n best_count = count\n best_para = p\n return best_para if best_count > 0 else \"\"\n\n\ndef extract_closing(paragraphs, max_chars=250):\n \"\"\"Extract closing paragraph(s), reading backwards.\"\"\"\n result = []\n total = 0\n for p in reversed(paragraphs):\n if total + len(p) > max_chars and result:\n break\n result.insert(0, p)\n total += len(p)\n return \"\\n\\n\".join(result)\n\n\n# ============================================================\n# Category Detection\n# ============================================================\n\ndef detect_category(text, paragraphs, headings):\n \"\"\"Auto-detect article category from content features.\"\"\"\n data_count = sum(len(re.findall(p, text)) for p in hs.REAL_SOURCE_PATTERNS)\n story_count = sum(text.count(m) for m in STORY_MARKERS)\n h2_count = len(headings)\n neg_count = sum(1 for m in hs.NEGATIVE_MARKERS if m in text)\n\n scores = {\n \"tech-opinion\": data_count * 2,\n \"story-emotional\": story_count * 1.5,\n \"list-practical\": h2_count * 3 if h2_count >= 5 else 0,\n \"hot-take\": neg_count * 2 + data_count if len(text) \u003c 2000 else 0,\n \"general\": 5,\n }\n return max(scores, key=scores.get)\n\n\n# ============================================================\n# Statistical Fingerprint\n# ============================================================\n\ndef compute_vocab_temperature(text):\n \"\"\"Compute vocabulary temperature band distribution.\"\"\"\n counts = {\n \"cold\": sum(text.count(w) for w in hs.COLD_WORDS),\n \"warm\": sum(text.count(w) for w in hs.WARM_WORDS),\n \"hot\": sum(text.count(w) for w in hs.HOT_WORDS),\n \"wild\": sum(text.count(w) for w in hs.WILD_WORDS),\n }\n total = sum(counts.values())\n if total == 0:\n return {k: 0.25 for k in counts}\n return {k: round(v / total, 2) for k, v in counts.items()}\n\n\ndef compute_paragraph_cv(paragraphs):\n \"\"\"Coefficient of variation for paragraph lengths.\"\"\"\n if len(paragraphs) \u003c 3:\n return 0.0\n lengths = [len(p) for p in paragraphs]\n mean = sum(lengths) / len(lengths)\n if mean == 0:\n return 0.0\n variance = sum((l - mean) ** 2 for l in lengths) / len(lengths)\n return round((variance ** 0.5) / mean, 2)\n\n\ndef count_short_paragraphs(text):\n \"\"\"Count single-sentence short paragraphs (1-10 chars, non-heading).\"\"\"\n return sum(1 for l in text.split('\\n')\n if l.strip() and 1 \u003c= len(l.strip()) \u003c= 10\n and not l.strip().startswith('#'))\n\n\n# ============================================================\n# Main Extraction\n# ============================================================\n\ndef extract_exemplar(text, category=None, source=None):\n \"\"\"Analyze article and return structured exemplar dict.\"\"\"\n clean = re.sub(r'^#+\\s+.*

WeWrite — 公众号文章全流程 行为声明 角色 :用户的公众号内容编辑 Agent。 模式 : - 默认全自动 ——一口气跑完 Step 1-8,不中途停下。只在出错时停。 - 交互模式 ——用户说"交互模式"/"我要自己选"时,在选题/框架/配图处暂停。 降级原则 :每一步都有降级方案。Step 1 检测到的降级标记( 、 )在后续 Step 自动生效,不重复报错。 进度追踪 :主管道启动时,用 TaskCreate 为 8 个 Step 创建任务。每开始一个 Step 标记 in progress,完成后标记 completed。用户可随时看到当前进度。 完成协议 : - DONE — 全流程完成,文章已保存/推送 - DONE WITH CONCERNS — 完成但部分步骤降级,列出降级项 - BLOCKED — 关键步骤无法继续(如 Python 依赖缺失且用户拒绝安装) - NEEDS CONTEXT — 需要用户提供信息才能继续(如首次设置需要公众号名称) 路径约定 :本文档中 指本 SKILL.md 所在的目录(即 WeWrite 的根目录)。 Onboard 例外 :Onboard 是交互式的(需要问用户问题),不受"全自动"约束。Onboard 完成后回到全自动管道。 辅助功能 (按需加载,不在主管道内): - 用户说"重新设置风格" → - 用户说"学习…

, '', text, flags=re.MULTILINE).strip()\n paragraphs = hs._split_paragraphs(text)\n sentences = hs._split_sentences(clean)\n headings = extract_headings(text)\n title = extract_title(text) or source or \"\"\n\n if not category:\n category = detect_category(clean, paragraphs, headings)\n\n score_result = hs.score_article(text)\n\n # Sentence length stats\n lengths = [len(s) for s in sentences]\n if len(lengths) >= 2:\n mean = sum(lengths) / len(lengths)\n variance = sum((l - mean) ** 2 for l in lengths) / len(lengths)\n sentence_stddev = round(variance ** 0.5, 1)\n else:\n sentence_stddev = 0.0\n\n neg_count = sum(1 for s in sentences if any(m in s for m in hs.NEGATIVE_MARKERS))\n negative_ratio = round(neg_count / len(sentences), 2) if sentences else 0.0\n\n return {\n \"title\": title,\n \"source\": source or title,\n \"category\": category,\n \"humanness_score\": score_result[\"composite_score\"],\n \"fingerprint\": {\n \"sentence_stddev\": sentence_stddev,\n \"vocab_temperature\": compute_vocab_temperature(clean),\n \"negative_ratio\": negative_ratio,\n \"paragraph_cv\": compute_paragraph_cv(paragraphs),\n \"short_paragraphs\": count_short_paragraphs(text),\n },\n \"segments\": {\n \"opening\": extract_opening(paragraphs),\n \"emotional_peak\": extract_emotional_peak(paragraphs),\n \"transition\": extract_transition(paragraphs),\n \"closing\": extract_closing(paragraphs),\n },\n \"extracted_at\": datetime.now().strftime(\"%Y-%m-%d\"),\n \"char_count\": len(clean),\n }\n\n\n# ============================================================\n# Persistence\n# ============================================================\n\ndef save_exemplar(exemplar):\n \"\"\"Save exemplar to markdown file and update index.yaml. Returns filepath.\"\"\"\n EXEMPLARS_DIR.mkdir(parents=True, exist_ok=True)\n\n category = exemplar[\"category\"]\n num = 1\n while (EXEMPLARS_DIR / f\"{category}-{num:03d}.md\").exists():\n num += 1\n filename = f\"{category}-{num:03d}.md\"\n filepath = EXEMPLARS_DIR / filename\n\n fp = exemplar[\"fingerprint\"]\n seg = exemplar[\"segments\"]\n\n frontmatter = {\n \"source\": exemplar[\"source\"],\n \"category\": category,\n \"humanness_score\": exemplar[\"humanness_score\"],\n \"sentence_stddev\": fp[\"sentence_stddev\"],\n \"vocab_temperature\": fp[\"vocab_temperature\"],\n \"negative_ratio\": fp[\"negative_ratio\"],\n \"paragraph_cv\": fp[\"paragraph_cv\"],\n \"short_paragraphs\": fp[\"short_paragraphs\"],\n \"extracted_at\": exemplar[\"extracted_at\"],\n }\n\n content = \"---\\n\"\n content += yaml.dump(frontmatter, allow_unicode=True, default_flow_style=False)\n content += \"---\\n\\n\"\n\n section_map = [\n (\"opening\", \"开头钩子\"),\n (\"emotional_peak\", \"情绪高峰\"),\n (\"transition\", \"转折/自纠\"),\n (\"closing\", \"收尾\"),\n ]\n for key, label in section_map:\n if seg.get(key):\n content += f\"## {label}\\n\\n{seg[key]}\\n\\n\"\n\n filepath.write_text(content, encoding=\"utf-8\")\n _update_index(filename, exemplar)\n return filepath\n\n\ndef _update_index(filename, exemplar):\n \"\"\"Add or update entry in index.yaml.\"\"\"\n index = []\n if INDEX_FILE.exists():\n with open(INDEX_FILE, \"r\", encoding=\"utf-8\") as f:\n index = yaml.safe_load(f) or []\n\n entry = {\n \"file\": filename,\n \"source\": exemplar[\"source\"],\n \"category\": exemplar[\"category\"],\n \"humanness_score\": exemplar[\"humanness_score\"],\n \"extracted_at\": exemplar[\"extracted_at\"],\n }\n index = [e for e in index if e.get(\"file\") != filename]\n index.append(entry)\n index.sort(key=lambda x: (x[\"category\"], x[\"humanness_score\"]))\n\n with open(INDEX_FILE, \"w\", encoding=\"utf-8\") as f:\n yaml.dump(index, f, allow_unicode=True, default_flow_style=False)\n\n\n# ============================================================\n# List / CLI\n# ============================================================\n\ndef list_exemplars():\n \"\"\"Print all exemplars in the library.\"\"\"\n if not INDEX_FILE.exists():\n print(\"范文库为空。用法: python3 scripts/extract_exemplar.py article.md\")\n return\n\n with open(INDEX_FILE, \"r\", encoding=\"utf-8\") as f:\n index = yaml.safe_load(f) or []\n\n if not index:\n print(\"范文库为空。\")\n return\n\n print(f\"\\n{'=' * 60}\")\n print(f\"范文库 ({len(index)} 篇)\")\n print(f\"{'=' * 60}\")\n\n by_cat = {}\n for e in index:\n by_cat.setdefault(e[\"category\"], []).append(e)\n\n for cat, entries in sorted(by_cat.items()):\n print(f\"\\n [{cat}] ({len(entries)} 篇)\")\n for e in entries:\n score = e[\"humanness_score\"]\n bar = \"█\" * int((100 - score) / 10) + \"░\" * (10 - int((100 - score) / 10))\n print(f\" {bar} {score:5.1f} {e['source'][:40]}\")\n\n\ndef main():\n parser = argparse.ArgumentParser(description=\"Extract style exemplars from articles\")\n parser.add_argument(\"inputs\", nargs=\"*\", help=\"Markdown article file(s)\")\n parser.add_argument(\"--category\", \"-c\", choices=CATEGORIES,\n help=\"Article category (auto-detected if omitted)\")\n parser.add_argument(\"--source\", \"-s\", help=\"Source name (e.g. account name)\")\n parser.add_argument(\"--list\", \"-l\", action=\"store_true\", help=\"List all exemplars\")\n parser.add_argument(\"--json\", action=\"store_true\", help=\"JSON output\")\n args = parser.parse_args()\n\n if args.list:\n list_exemplars()\n return\n\n if not args.inputs:\n parser.print_help()\n sys.exit(1)\n\n for input_path in args.inputs:\n path = Path(input_path)\n if not path.exists():\n print(f\"Error: {input_path} not found\", file=sys.stderr)\n continue\n\n text = path.read_text(encoding=\"utf-8\")\n source = args.source or path.stem # fallback to filename without extension\n exemplar = extract_exemplar(text, category=args.category, source=source)\n filepath = save_exemplar(exemplar)\n\n if args.json:\n print(json.dumps(exemplar, ensure_ascii=False, indent=2))\n else:\n print(f\"✓ {path.name}\")\n print(f\" Category: {exemplar['category']}\")\n print(f\" Score: {exemplar['humanness_score']:.1f}/100\")\n print(f\" Segments: {sum(1 for v in exemplar['segments'].values() if v)}/4\")\n fp = exemplar[\"fingerprint\"]\n print(f\" Stddev: {fp['sentence_stddev']}\")\n print(f\" Neg ratio: {fp['negative_ratio']:.0%}\")\n print(f\" Para CV: {fp['paragraph_cv']}\")\n temp = fp[\"vocab_temperature\"]\n print(f\" Temp: cold={temp['cold']} warm={temp['warm']} hot={temp['hot']} wild={temp['wild']}\")\n print(f\" Saved to: {filepath}\")\n print()\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":12967,"content_sha256":"f08849d794d6f55e8a4548453def75d70316410ff8130fb3e350b4a551bf9680"},{"filename":"scripts/fetch_article.py","content":"#!/usr/bin/env python3\n\"\"\"fetch_article.py — extract WeChat article content as Markdown.\n\nFour-level fetching strategy:\n Level 1: requests (fast, zero overhead, works for most articles)\n Level 2: Camoufox anti-detection browser (bypasses WeChat bot verification)\n Level 3: Playwright headless Chrome (fallback)\n Level 4: Prompt user to save HTML manually and pass via --file\n\nUsage:\n python3 scripts/fetch_article.py \u003curl> # auto fetch\n python3 scripts/fetch_article.py \u003curl> -o article.md # save to file\n python3 scripts/fetch_article.py --file saved.html # from local HTML\n python3 scripts/fetch_article.py \u003curl> --json # JSON output for agent\n\"\"\"\n\nimport argparse\nimport json\nimport re\nimport sys\nfrom pathlib import Path\n\nimport requests\nfrom bs4 import BeautifulSoup, NavigableString\n\n_BROWSER_UA = (\n \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \"\n \"AppleWebKit/537.36 (KHTML, like Gecko) \"\n \"Chrome/124.0.0.0 Safari/537.36\"\n)\n\n\n# ---------------------------------------------------------------------------\n# Fetching: three-level strategy\n# ---------------------------------------------------------------------------\n\ndef _fetch_requests(url: str, timeout: int = 20) -> str | None:\n \"\"\"Level 1: plain requests. Returns HTML string or None on failure.\"\"\"\n try:\n resp = requests.get(url, headers={\"User-Agent\": _BROWSER_UA}, timeout=timeout)\n resp.raise_for_status()\n resp.encoding = \"utf-8\"\n return resp.text\n except requests.exceptions.RequestException:\n return None\n\n\ndef _fetch_camoufox(url: str) -> str | None:\n \"\"\"Level 2: Camoufox anti-detection browser. Returns HTML or None.\"\"\"\n try:\n from camoufox.sync_api import Camoufox\n except ImportError:\n return None\n\n try:\n with Camoufox(headless=True) as browser:\n page = browser.new_page()\n page.goto(url, wait_until=\"domcontentloaded\", timeout=30000)\n try:\n page.wait_for_selector(\"#js_content\", timeout=10000)\n except Exception:\n pass # timeout — still try to parse\n import time\n time.sleep(2) # let JS finish rendering\n html = page.content()\n return html\n except Exception:\n return None\n\n\ndef _fetch_playwright(url: str, timeout: int = 30000) -> str | None:\n \"\"\"Level 3: Playwright headless Chrome. Returns HTML or None.\"\"\"\n try:\n from playwright.sync_api import sync_playwright\n except ImportError:\n return None\n\n try:\n with sync_playwright() as p:\n browser = p.chromium.launch(headless=True)\n page = browser.new_page(user_agent=_BROWSER_UA)\n page.goto(url, wait_until=\"networkidle\", timeout=timeout)\n # Wait for WeChat content to render\n page.wait_for_selector(\"#js_content\", timeout=10000)\n html = page.content()\n browser.close()\n return html\n except Exception:\n return None\n\n\ndef fetch_html(url: str) -> str:\n \"\"\"Fetch article HTML with automatic fallback.\n\n Returns HTML string. Exits with error if all levels fail.\n \"\"\"\n # Level 1: plain requests\n html = _fetch_requests(url)\n if html and _has_content(html):\n return html\n\n # Level 2: Camoufox anti-detection browser\n print(\"requests 未获取到正文,尝试 Camoufox...\", file=sys.stderr)\n html = _fetch_camoufox(url)\n if html and _has_content(html):\n return html\n\n # Level 3: Playwright fallback\n print(\"Camoufox 未获取到正文,尝试 Playwright...\", file=sys.stderr)\n html = _fetch_playwright(url)\n if html and _has_content(html):\n return html\n\n # Level 4: manual\n print(\n \"Error: 无法获取文章内容。请在浏览器中打开文章 → 右键另存为 HTML → 使用 --file 参数传入。\",\n file=sys.stderr,\n )\n sys.exit(1)\n\n\ndef _has_content(html: str) -> bool:\n \"\"\"Check if HTML contains non-empty #js_content.\"\"\"\n soup = BeautifulSoup(html, \"html.parser\")\n content = soup.find(id=\"js_content\")\n if content is None:\n return False\n text = content.get_text(strip=True)\n return len(text) > 50 # must have real content, not just whitespace\n\n\n# ---------------------------------------------------------------------------\n# HTML → Markdown conversion\n# ---------------------------------------------------------------------------\n\ndef _extract_metadata(soup: BeautifulSoup) -> dict:\n \"\"\"Extract article metadata from WeChat page.\"\"\"\n title_tag = soup.find(\"h1\", class_=\"rich_media_title\") or soup.find(\n \"h1\", id=\"activity-name\"\n )\n title = title_tag.get_text(strip=True) if title_tag else \"\"\n\n author_tag = soup.find(\"a\", id=\"js_name\") or soup.find(\n \"span\", class_=\"rich_media_meta_nickname\"\n )\n author = author_tag.get_text(strip=True) if author_tag else \"\"\n\n # Publish time\n pub_tag = soup.find(\"em\", id=\"publish_time\")\n pub_time = pub_tag.get_text(strip=True) if pub_tag else \"\"\n\n return {\"title\": title, \"author\": author, \"publish_time\": pub_time}\n\n\ndef _elem_to_md(elem, depth: int = 0) -> str:\n \"\"\"Convert a single HTML element to Markdown.\"\"\"\n tag = elem.name if hasattr(elem, \"name\") else None\n\n if isinstance(elem, NavigableString):\n text = str(elem).strip()\n return text if text else \"\"\n\n if tag is None:\n return \"\"\n\n # Skip hidden/empty elements\n style = elem.get(\"style\", \"\")\n if \"display:none\" in style.replace(\" \", \"\").lower():\n return \"\"\n if \"visibility:hidden\" in style.replace(\" \", \"\").lower():\n return \"\"\n\n # Get inner content recursively\n inner = \"\"\n for child in elem.children:\n inner += _elem_to_md(child, depth + 1)\n\n inner = inner.strip()\n if not inner:\n return \"\"\n\n # Headings\n if tag in (\"h1\", \"h2\", \"h3\", \"h4\"):\n level = int(tag[1])\n return f\"\\n\\n{'#' * level} {inner}\\n\\n\"\n\n # Paragraphs\n if tag == \"p\":\n return f\"\\n\\n{inner}\\n\\n\"\n\n # Line breaks\n if tag == \"br\":\n return \"\\n\"\n\n # Bold\n if tag in (\"strong\", \"b\"):\n return f\"**{inner}**\"\n\n # Italic\n if tag in (\"em\", \"i\"):\n return f\"*{inner}*\"\n\n # Links\n if tag == \"a\":\n href = elem.get(\"href\", \"\")\n if href and not href.startswith(\"javascript:\"):\n return f\"[{inner}]({href})\"\n return inner\n\n # Images\n if tag == \"img\":\n src = elem.get(\"data-src\") or elem.get(\"src\") or \"\"\n alt = elem.get(\"alt\", \"\")\n if src:\n return f\"\\n\\n![{alt}]({src})\\n\\n\"\n return \"\"\n\n # Blockquotes\n if tag == \"blockquote\":\n lines = inner.split(\"\\n\")\n quoted = \"\\n\".join(f\"> {line}\" for line in lines if line.strip())\n return f\"\\n\\n{quoted}\\n\\n\"\n\n # Lists\n if tag in (\"ul\", \"ol\"):\n return f\"\\n\\n{inner}\\n\\n\"\n if tag == \"li\":\n parent = elem.parent\n if parent and parent.name == \"ol\":\n # Ordered list — position tracking is imperfect but functional\n return f\"1. {inner}\\n\"\n return f\"- {inner}\\n\"\n\n # Code\n if tag == \"code\":\n if elem.parent and elem.parent.name == \"pre\":\n return inner\n return f\"`{inner}`\"\n if tag == \"pre\":\n return f\"\\n\\n```\\n{inner}\\n```\\n\\n\"\n\n # Horizontal rule\n if tag == \"hr\":\n return \"\\n\\n---\\n\\n\"\n\n # Section / div / span — pass through\n if tag in (\"section\", \"div\", \"span\", \"article\", \"main\", \"figure\",\n \"figcaption\", \"table\", \"thead\", \"tbody\", \"tr\"):\n return inner\n\n # Table cells\n if tag in (\"td\", \"th\"):\n return f\" {inner} |\"\n\n return inner\n\n\ndef html_to_markdown(soup: BeautifulSoup) -> str:\n \"\"\"Convert WeChat article HTML to clean Markdown.\"\"\"\n content = soup.find(id=\"js_content\")\n if content is None:\n return \"\"\n\n # WeChat lazy-loads #js_content with visibility:hidden; JS removes it later.\n # Strip the style so _elem_to_md doesn't skip the entire container.\n if content.get(\"style\"):\n del content[\"style\"]\n\n raw = _elem_to_md(content)\n\n # Clean up excessive whitespace\n md = re.sub(r\"\\n{3,}\", \"\\n\\n\", raw)\n md = md.strip()\n return md\n\n\n# ---------------------------------------------------------------------------\n# Public API\n# ---------------------------------------------------------------------------\n\ndef fetch_article(url: str = None, file_path: str = None) -> dict:\n \"\"\"Fetch and parse a WeChat article.\n\n Args:\n url: WeChat article URL.\n file_path: Path to saved HTML file (alternative to URL).\n\n Returns:\n dict with keys: title, author, publish_time, markdown, url\n \"\"\"\n if file_path:\n html = Path(file_path).read_text(encoding=\"utf-8\")\n elif url:\n html = fetch_html(url)\n else:\n raise ValueError(\"Either url or file_path must be provided\")\n\n soup = BeautifulSoup(html, \"html.parser\")\n meta = _extract_metadata(soup)\n md = html_to_markdown(soup)\n\n return {\n \"title\": meta[\"title\"],\n \"author\": meta[\"author\"],\n \"publish_time\": meta[\"publish_time\"],\n \"markdown\": md,\n \"url\": url or \"\",\n }\n\n\n# ---------------------------------------------------------------------------\n# CLI\n# ---------------------------------------------------------------------------\n\ndef main():\n ap = argparse.ArgumentParser(\n description=\"Extract WeChat article content as Markdown.\"\n )\n ap.add_argument(\"url\", nargs=\"?\", help=\"WeChat article URL\")\n ap.add_argument(\"--file\", dest=\"file_path\",\n help=\"Local HTML file instead of URL\")\n ap.add_argument(\"-o\", \"--output\", help=\"Save Markdown to file\")\n ap.add_argument(\"--json\", dest=\"as_json\", action=\"store_true\",\n help=\"Output as JSON (for agent use)\")\n args = ap.parse_args()\n\n if not args.url and not args.file_path:\n ap.error(\"Provide a URL or --file path\")\n\n result = fetch_article(url=args.url, file_path=args.file_path)\n\n if args.as_json:\n print(json.dumps(result, ensure_ascii=False, indent=2))\n elif args.output:\n # Write Markdown with YAML frontmatter\n out = Path(args.output)\n frontmatter = f\"---\\ntitle: \\\"{result['title']}\\\"\\nauthor: \\\"{result['author']}\\\"\\n\"\n if result[\"publish_time\"]:\n frontmatter += f\"date: \\\"{result['publish_time']}\\\"\\n\"\n if result[\"url\"]:\n frontmatter += f\"source: \\\"{result['url']}\\\"\\n\"\n frontmatter += \"---\\n\\n\"\n out.write_text(frontmatter + result[\"markdown\"], encoding=\"utf-8\")\n print(f\"Saved: {out}\")\n else:\n if result[\"title\"]:\n print(f\"# {result['title']}\\n\")\n if result[\"author\"]:\n print(f\"> {result['author']}\")\n if result[\"publish_time\"]:\n print(f\"> {result['publish_time']}\")\n if result[\"author\"] or result[\"publish_time\"]:\n print()\n print(result[\"markdown\"])\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":11112,"content_sha256":"9fedbc952ef2f990a4a79ebabe8e5fd03dc2d5567837e1b51078345ea9295035"},{"filename":"scripts/fetch_hotspots.py","content":"#!/usr/bin/env python3\n\"\"\"\nFetch trending topics from multiple Chinese platforms.\n\nSources (all attempted in parallel, results merged and deduplicated):\n 1. Weibo hot search (weibo.com/ajax/side/hotSearch)\n 2. Toutiao hot board (toutiao.com/hot-event/hot-board)\n 3. Baidu hot search (top.baidu.com/api/board)\n\nUsage:\n python3 fetch_hotspots.py --limit 20\n\"\"\"\n\nimport argparse\nimport json\nimport sys\nfrom datetime import datetime, timezone, timedelta\n\nimport requests\n\nTIMEOUT = 10\nHEADERS = {\n \"User-Agent\": \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \"\n \"AppleWebKit/537.36 (KHTML, like Gecko) \"\n \"Chrome/120.0.0.0 Safari/537.36\",\n \"Accept\": \"application/json, text/plain, */*\",\n}\n\n\ndef fetch_weibo() -> list[dict]:\n \"\"\"Fetch Weibo hot search.\"\"\"\n try:\n resp = requests.get(\n \"https://weibo.com/ajax/side/hotSearch\",\n headers={**HEADERS, \"Referer\": \"https://weibo.com/\"},\n timeout=TIMEOUT,\n )\n data = resp.json()\n items = []\n for entry in data.get(\"data\", {}).get(\"realtime\", []):\n note = entry.get(\"note\", \"\")\n if not note:\n continue\n items.append({\n \"title\": note,\n \"source\": \"微博\",\n \"hot\": entry.get(\"num\", 0),\n \"url\": f\"https://s.weibo.com/weibo?q=%23{note}%23\",\n \"description\": entry.get(\"label_name\", \"\"),\n })\n return items\n except Exception as e:\n print(f\"[warn] weibo failed: {e}\", file=sys.stderr)\n return []\n\n\ndef fetch_toutiao() -> list[dict]:\n \"\"\"Fetch Toutiao hot board.\"\"\"\n try:\n resp = requests.get(\n \"https://www.toutiao.com/hot-event/hot-board/?origin=toutiao_pc\",\n headers=HEADERS,\n timeout=TIMEOUT,\n )\n data = resp.json()\n items = []\n for entry in data.get(\"data\", []):\n title = entry.get(\"Title\", \"\")\n if not title:\n continue\n items.append({\n \"title\": title,\n \"source\": \"今日头条\",\n \"hot\": int(entry.get(\"HotValue\", 0) or 0),\n \"url\": entry.get(\"Url\", \"\"),\n \"description\": \"\",\n })\n return items\n except Exception as e:\n print(f\"[warn] toutiao failed: {e}\", file=sys.stderr)\n return []\n\n\ndef fetch_baidu() -> list[dict]:\n \"\"\"Fetch Baidu hot search.\"\"\"\n try:\n resp = requests.get(\n \"https://top.baidu.com/api/board?platform=wise&tab=realtime\",\n headers=HEADERS,\n timeout=TIMEOUT,\n )\n data = resp.json()\n items = []\n # Baidu nests items inside cards[0].content[0].content\n for card in data.get(\"data\", {}).get(\"cards\", []):\n top_content = card.get(\"content\", [])\n if not top_content:\n continue\n entries = top_content[0].get(\"content\", []) if isinstance(top_content[0], dict) else top_content\n for entry in entries:\n word = entry.get(\"word\", \"\")\n if not word:\n continue\n items.append({\n \"title\": word,\n \"source\": \"百度\",\n \"hot\": int(entry.get(\"hotScore\", 0) or 0),\n \"url\": entry.get(\"url\", \"\"),\n \"description\": \"\",\n })\n return items\n except Exception as e:\n print(f\"[warn] baidu failed: {e}\", file=sys.stderr)\n return []\n\n\ndef deduplicate(items: list[dict]) -> list[dict]:\n \"\"\"Remove duplicates by exact title match.\"\"\"\n seen = set()\n result = []\n for item in items:\n title = item[\"title\"].strip()\n if title and title not in seen:\n seen.add(title)\n result.append(item)\n return result\n\n\ndef main():\n parser = argparse.ArgumentParser(description=\"Fetch trending topics\")\n parser.add_argument(\"--limit\", type=int, default=20, help=\"Max items to return\")\n args = parser.parse_args()\n\n all_items = []\n sources_ok = []\n sources_fail = []\n\n for name, fetcher in [(\"weibo\", fetch_weibo), (\"toutiao\", fetch_toutiao), (\"baidu\", fetch_baidu)]:\n items = fetcher()\n if items:\n sources_ok.append(name)\n all_items.extend(items)\n else:\n sources_fail.append(name)\n\n all_items = deduplicate(all_items)\n\n # Normalize hot values across platforms (different scales: toutiao ~10M, weibo ~1M, baidu ~100K)\n # Strategy: within each source, rank-based score 0-100, so cross-platform sorting is fair\n by_source: dict[str, list[dict]] = {}\n for item in all_items:\n by_source.setdefault(item[\"source\"], []).append(item)\n\n for source, items in by_source.items():\n items.sort(key=lambda x: int(x.get(\"hot\", 0) or 0), reverse=True)\n n = len(items)\n for rank, item in enumerate(items):\n # Top item = 100, linear decay to ~1 for last item\n item[\"hot_normalized\"] = round(100 * (n - rank) / n, 1) if n > 0 else 0\n\n all_items.sort(key=lambda x: x.get(\"hot_normalized\", 0), reverse=True)\n all_items = all_items[:args.limit]\n\n tz = timezone(timedelta(hours=8))\n output = {\n \"timestamp\": datetime.now(tz).isoformat(),\n \"sources\": sources_ok,\n \"sources_failed\": sources_fail,\n \"count\": len(all_items),\n \"items\": all_items,\n }\n\n if not all_items:\n output[\"error\"] = \"All sources failed. SKILL.md should fall back to WebSearch.\"\n\n json.dump(output, sys.stdout, ensure_ascii=False, indent=2)\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":5727,"content_sha256":"b27e66cd5240855fc7446f5bfc8ff379373de47545dbe8fabfb5e76997a85f00"},{"filename":"scripts/fetch_stats.py","content":"#!/usr/bin/env python3\n\"\"\"\nFetch WeChat article statistics and update history.yaml.\n\nUses WeChat Data Analytics API to pull article performance:\n - /datacube/getarticlesummary (daily summary)\n - /datacube/getarticletotal (cumulative)\n\nUsage:\n python3 fetch_stats.py\n python3 fetch_stats.py --days 7\n\nRequires: wechat appid/secret in config.yaml (skill root or toolkit dir)\n\"\"\"\n\nimport argparse\nimport json\nimport sys\nfrom datetime import datetime, timedelta\nfrom pathlib import Path\n\nimport requests\nimport yaml\n\nSKILL_DIR = Path(__file__).parent.parent\nTOOLKIT_CONFIG_PATHS = [\n SKILL_DIR / \"config.yaml\", # skill root\n SKILL_DIR / \"toolkit\" / \"config.yaml\", # toolkit dir\n Path.home() / \".config\" / \"wewrite\" / \"config.yaml\",\n Path.cwd() / \"config.yaml\",\n]\n\n\ndef _load_toolkit_config() -> dict:\n for p in TOOLKIT_CONFIG_PATHS:\n if p.exists():\n with open(p, \"r\", encoding=\"utf-8\") as f:\n return yaml.safe_load(f) or {}\n return {}\n\n\ndef _get_access_token(appid: str, secret: str) -> str:\n resp = requests.get(\n \"https://api.weixin.qq.com/cgi-bin/token\",\n params={\"grant_type\": \"client_credential\", \"appid\": appid, \"secret\": secret},\n )\n data = resp.json()\n if \"access_token\" not in data:\n raise ValueError(f\"Token error: {data}\")\n return data[\"access_token\"]\n\n\ndef fetch_article_summary(token: str, date: str) -> list[dict]:\n \"\"\"\n Fetch daily article summary.\n API: POST /datacube/getarticlesummary\n date format: \"2026-03-23\"\n \"\"\"\n resp = requests.post(\n \"https://api.weixin.qq.com/datacube/getarticlesummary\",\n params={\"access_token\": token},\n json={\"begin_date\": date, \"end_date\": date},\n )\n data = resp.json()\n if \"list\" not in data:\n errcode = data.get(\"errcode\", \"unknown\")\n errmsg = data.get(\"errmsg\", \"\")\n if errcode == 61500:\n # No data for this date (article not yet published or no reads)\n return []\n print(f\"[warn] getarticlesummary error: {errcode} {errmsg}\", file=sys.stderr)\n return []\n return data[\"list\"]\n\n\ndef fetch_article_total(token: str, date: str) -> list[dict]:\n \"\"\"\n Fetch cumulative article stats.\n API: POST /datacube/getarticletotal\n \"\"\"\n resp = requests.post(\n \"https://api.weixin.qq.com/datacube/getarticletotal\",\n params={\"access_token\": token},\n json={\"begin_date\": date, \"end_date\": date},\n )\n data = resp.json()\n if \"list\" not in data:\n return []\n return data[\"list\"]\n\n\ndef update_history(stats_list: list[dict]):\n \"\"\"Match stats to history.yaml entries and update.\"\"\"\n history_path = SKILL_DIR / \"history.yaml\"\n if not history_path.exists():\n print(\"No history.yaml found.\")\n return\n\n with open(history_path, \"r\", encoding=\"utf-8\") as f:\n history = yaml.safe_load(f) or {}\n\n articles = history.get(\"articles\", [])\n if not articles:\n print(\"No articles in history to update.\")\n return\n\n # Build a lookup by title for matching\n title_to_idx = {}\n for i, article in enumerate(articles):\n title_to_idx[article.get(\"title\", \"\")] = i\n\n updated = 0\n for stat in stats_list:\n title = stat.get(\"title\", \"\")\n if title in title_to_idx:\n idx = title_to_idx[title]\n articles[idx][\"stats\"] = {\n \"read_count\": stat.get(\"int_page_read_count\", 0),\n \"share_count\": stat.get(\"share_count\", 0),\n \"like_count\": stat.get(\"old_like_count\", 0) + stat.get(\"like_count\", 0),\n \"read_rate\": round(\n stat.get(\"int_page_read_count\", 0)\n / max(stat.get(\"target_user\", 1), 1)\n * 100,\n 1,\n ),\n }\n updated += 1\n\n if updated > 0:\n history[\"articles\"] = articles\n with open(history_path, \"w\", encoding=\"utf-8\") as f:\n yaml.dump(history, f, allow_unicode=True, default_flow_style=False)\n print(f\"Updated stats for {updated} article(s).\")\n else:\n print(\"No matching articles found in stats data.\")\n\n\ndef main():\n parser = argparse.ArgumentParser(description=\"Fetch WeChat article stats\")\n parser.add_argument(\"--days\", type=int, default=3, help=\"Days to look back\")\n args = parser.parse_args()\n\n cfg = _load_toolkit_config()\n wechat_cfg = cfg.get(\"wechat\", {})\n appid = wechat_cfg.get(\"appid\")\n secret = wechat_cfg.get(\"secret\")\n\n if not appid or not secret:\n print(\"Error: wechat appid/secret not found in config.yaml\", file=sys.stderr)\n sys.exit(1)\n\n token = _get_access_token(appid, secret)\n print(f\"Fetching stats for last {args.days} days...\")\n\n all_stats = []\n for i in range(args.days):\n date = (datetime.now() - timedelta(days=i + 1)).strftime(\"%Y-%m-%d\")\n stats = fetch_article_summary(token, date)\n if stats:\n print(f\" {date}: {len(stats)} article(s)\")\n all_stats.extend(stats)\n\n if all_stats:\n update_history(all_stats)\n else:\n print(\"No stats data found for the specified period.\")\n\n # Also print summary\n print(f\"\\nTotal data points: {len(all_stats)}\")\n for s in all_stats:\n title = s.get(\"title\", \"unknown\")\n reads = s.get(\"int_page_read_count\", 0)\n shares = s.get(\"share_count\", 0)\n print(f\" [{reads} reads, {shares} shares] {title}\")\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":5556,"content_sha256":"ccd0d483dfeab205f4ff609550d407fd7c636b537a7906e2a48640bffba1cf8d"},{"filename":"scripts/humanness_score.py","content":"#!/usr/bin/env python3\n\"\"\"\nHumanness scoring for WeWrite articles.\n\nThree-tier evaluation aligned with writing-guide.md's anti-AI checklist:\n\n Tier 1 (Statistical, 50%): 6 checks measuring statistical properties\n that AI detectors analyze (burstiness, distribution, variance).\n Tier 2 (Pattern, 30%): 5 checks for specific linguistic patterns\n (banned words, broken sentences, real sources).\n Tier 3 (LLM, 20%): Semantic analysis done by the agent in SKILL.md\n (style drift, density waves, coherence). Passed via --tier3 flag.\n\nEach check outputs a continuous 0-1 score and maps to a writing-config\nparameter, so the optimization loop knows which knob to turn.\n\nStandalone mode (no --tier3): weights redistribute to T1=62.5%, T2=37.5%.\n\nUsage:\n python3 humanness_score.py article.md # single score\n python3 humanness_score.py article.md --verbose # detailed report\n python3 humanness_score.py article.md --json # full JSON\n python3 humanness_score.py article.md --json --tier3 0.7 # with agent score\n\"\"\"\n\nimport argparse\nimport json\nimport re\nimport sys\nfrom pathlib import Path\n\n\n# ============================================================\n# Constants\n# ============================================================\n\nBANNED_WORDS = [\n \"首先\", \"其次\", \"再者\", \"最后\", \"总之\", \"综上所述\", \"总而言之\",\n \"此外\", \"另外\", \"与此同时\", \"不仅如此\", \"更重要的是\", \"在此基础上\",\n \"作为一个\", \"让我们\", \"值得注意的是\", \"需要指出的是\", \"不可否认\",\n \"毋庸置疑\", \"众所周知\", \"事实上\", \"显而易见\", \"可以说\", \"从某种意义上说\",\n \"非常重要\", \"至关重要\", \"不言而喻\", \"具有重要意义\", \"发挥着重要作用\",\n \"意义深远\", \"影响深远\", \"引发了广泛关注\", \"引起了热烈讨论\",\n \"总的来说\", \"综合来看\", \"由此可见\", \"不难发现\", \"通过以上分析\",\n \"正如我们所看到的\",\n]\n\nREAL_SOURCE_PATTERNS = [\n r'[A-Z][a-z]+\\s+[A-Z][a-z]+',\n r'[\\u4e00-\\u9fff]{2,4}(?:表示|指出|认为|写道|提到|说过)',\n r'(?:据|根据|来自)\\s*[\\u4e00-\\u9fff]+(?:报告|数据|研究|调查)',\n r'20[12]\\d\\s*年',\n r'\\d+(?:\\.\\d+)?%',\n r'(?:亿|万)\\s*(?:美元|元|人民币)',\n]\n\nNEGATIVE_MARKERS = [\n # 直接负面情绪\n \"失望\", \"糟糕\", \"扯\", \"坑\", \"烂\", \"差劲\", \"崩溃\", \"吐槽\", \"骂\",\n \"怒\", \"烦\", \"焦虑\", \"担忧\", \"不满\", \"恶心\", \"可怕\", \"可悲\", \"可笑\",\n \"离谱\", \"尴尬\", \"无语\", \"蠢\", \"惨\", \"亏\", \"危\",\n # 绝望/迷茫\n \"绝望\", \"迷茫\", \"心累\", \"丧\", \"后悔\", \"后怕\", \"心寒\",\n # 欺骗/操控(隐性负面)\n \"骗\", \"忽悠\", \"割韭菜\", \"套路\", \"画大饼\", \"洗脑\",\n # 失败/徒劳\n \"白费\", \"白搭\", \"没戏\", \"黄了\", \"凉了\", \"废了\",\n # 自嘲/自贬\n \"傻\", \"天真\", \"吃亏\", \"自嗨\", \"打脸\",\n # 讽刺/反语\n \"呵呵\", \"好吧\", \"行吧\", \"真服了\",\n # 短语\n \"太扯了\", \"说实话我很失望\", \"搞什么\", \"不靠谱\", \"受不了\",\n \"受够了\", \"想哭\", \"伤心\", \"苦哈哈\", \"得过且过\",\n]\n\nCOMMON_ADVERBS = [\n \"非常\", \"十分\", \"极其\", \"特别\", \"相当\", \"尤其\", \"格外\",\n \"更加\", \"越来越\", \"逐渐\", \"不断\", \"始终\", \"一直\",\n \"已经\", \"正在\", \"将要\", \"可能\", \"大概\", \"或许\",\n \"似乎\", \"显然\", \"明显\", \"确实\", \"果然\", \"居然\",\n \"竟然\", \"简直\", \"几乎\", \"完全\", \"绝对\", \"必然\",\n]\n\nCOLD_WORDS = [\n \"边际\", \"认知负荷\", \"信息不对称\", \"路径依赖\", \"商业模式\", \"生态系统\", \"增量\",\n \"技术栈\", \"标准化\", \"结构性\", \"规模化\", \"护城河\", \"飞轮\", \"闭环\",\n \"赛道\", \"壁垒\", \"方法论\", \"底层逻辑\", \"第一性原理\", \"杠杆\", \"复利\",\n \"ROI\", \"PMF\", \"代运营\", \"供给侧\", \"需求侧\",\n]\nWARM_WORDS = [\n \"说白了\", \"其实吧\", \"讲真\", \"说实话\", \"坦白讲\", \"懂的都懂\", \"怎么说呢\",\n \"老实说\", \"这么说吧\", \"你想啊\", \"别急\", \"慢慢来\",\n \"有意思的是\", \"好玩的是\", \"巧的是\", \"说来话长\", \"话说回来\",\n]\nHOT_WORDS = [\n \"DNA动了\", \"格局打开\", \"遥遥领先\", \"卷\", \"内卷\", \"炸了\", \"杀疯了\", \"吃灰\",\n \"凡尔赛\", \"标题党\", \"躺平\", \"摆烂\", \"破防\", \"上头\", \"内耗\",\n \"蒸发\", \"出圈\", \"降维打击\", \"弯道超车\",\n]\nWILD_WORDS = [\n \"整挺好\", \"不靠谱\", \"瞎折腾\", \"搁这儿\", \"糊弄\", \"扯\", \"嗯\",\n \"苦哈哈\", \"傻乎乎\", \"稀里糊涂\", \"得了吧\", \"算了吧\",\n \"摔了跤\", \"交学费\", \"踩坑\", \"翻车\", \"栽了\",\n]\n\nSELF_CORRECTION_PATTERNS = [\n r'不对[,,]', r'准确说', r'算了', r'说错了',\n r'其实不是', r'我记混了', r'应该说', r'更准确地说',\n r'([^)]{4,})', # Chinese parenthetical insertion (≥4 chars)\n]\n\nBROKEN_SENTENCE_PATTERNS = [\n r'——(?!.*[,。!?])',\n r'\\.{3,}|…',\n r'不对[,,]',\n r'算了',\n]\n\n\n# ============================================================\n# Helpers\n# ============================================================\n\ndef _split_sentences(text):\n \"\"\"Split text by Chinese sentence-ending punctuation.\"\"\"\n sentences = re.split(r'[。!?\\n]', text)\n return [s.strip() for s in sentences if s.strip() and len(s.strip()) > 1]\n\n\ndef _split_paragraphs(text):\n \"\"\"Split text into paragraphs, excluding headings.\"\"\"\n return [p.strip() for p in text.split('\\n\\n')\n if p.strip() and not p.strip().startswith('#')]\n\n\ndef _make_result(score, detail, param=None):\n \"\"\"Create a check result dict.\"\"\"\n r = {\"score\": round(max(0.0, min(1.0, score)), 4), \"detail\": detail}\n if param is not None:\n r[\"param\"] = param\n else:\n r[\"param\"] = None\n return r\n\n\n# ============================================================\n# Tier 1: Statistical Checks (weight 50%)\n# ============================================================\n\ndef score_sentence_length_stddev(text):\n \"\"\"[1.1] Sentence length standard deviation. → sentence_variance\"\"\"\n sentences = _split_sentences(text)\n if len(sentences) \u003c 5:\n return _make_result(0.5, \"too few sentences to measure\", \"sentence_variance\")\n lengths = [len(s) for s in sentences]\n mean = sum(lengths) / len(lengths)\n variance = sum((l - mean) ** 2 for l in lengths) / len(lengths)\n stddev = variance ** 0.5\n score = min(1.0, stddev / 25.0)\n return _make_result(score, f\"stddev={stddev:.1f} (target ≥15)\", \"sentence_variance\")\n\n\ndef score_sentence_length_range(text):\n \"\"\"[1.1] Sentence length range (max - min). → sentence_variance\"\"\"\n sentences = _split_sentences(text)\n if len(sentences) \u003c 5:\n return _make_result(0.5, \"too few sentences\", \"sentence_variance\")\n lengths = [len(s) for s in sentences]\n rng = max(lengths) - min(lengths)\n range_score = min(1.0, rng / 40.0)\n # Check for single-sentence short paragraphs\n lines = text.split('\\n')\n short_paras = sum(1 for l in lines if l.strip() and 1 \u003c= len(l.strip()) \u003c= 5\n and not l.strip().startswith('#'))\n expected = max(1, len(text) / 500)\n short_score = min(1.0, short_paras / expected)\n score = range_score * 0.6 + short_score * 0.4\n return _make_result(score, f\"range={rng} (target ≥30), short_paras={short_paras}\", \"sentence_variance\")\n\n\ndef score_paragraph_length_variance(text):\n \"\"\"[1.3] Paragraph length variance. → paragraph_rhythm\"\"\"\n paragraphs = _split_paragraphs(text)\n if len(paragraphs) \u003c 3:\n return _make_result(0.5, \"too few paragraphs\", \"paragraph_rhythm\")\n total_pairs = len(paragraphs) - 1\n similar = sum(1 for i in range(total_pairs)\n if abs(len(paragraphs[i]) - len(paragraphs[i + 1])) \u003c= 20)\n score = 1.0 - (similar / total_pairs) if total_pairs > 0 else 0.5\n return _make_result(score, f\"{similar}/{total_pairs} consecutive similar-length pairs\", \"paragraph_rhythm\")\n\n\ndef score_vocabulary_richness(text):\n \"\"\"[1.2] CJK bigram type-token ratio + temperature mix. → word_temperature_bias\"\"\"\n cjk_chars = re.findall(r'[\\u4e00-\\u9fff]', text)\n if len(cjk_chars) \u003c 20:\n return _make_result(0.5, \"too few CJK characters\", \"word_temperature_bias\")\n bigrams = [cjk_chars[i] + cjk_chars[i + 1] for i in range(len(cjk_chars) - 1)]\n ttr = len(set(bigrams)) / len(bigrams) if bigrams else 0\n ttr_score = min(1.0, ttr / 0.7)\n # Temperature mix bonus\n found_temps = sum([\n any(w in text for w in COLD_WORDS),\n any(w in text for w in WARM_WORDS),\n any(w in text for w in HOT_WORDS),\n any(w in text for w in WILD_WORDS),\n ])\n temp_bonus = found_temps / 4.0 * 0.3\n score = min(1.0, ttr_score * 0.7 + temp_bonus)\n return _make_result(score, f\"bigram_ttr={ttr:.3f}, temps={found_temps}/4\", \"word_temperature_bias\")\n\n\ndef score_negative_emotion_ratio(text):\n \"\"\"[1.4] Negative emotion ratio. → emotional_arc\"\"\"\n sentences = _split_sentences(text)\n if not sentences:\n return _make_result(0.5, \"no sentences\", \"emotional_arc\")\n negative_count = sum(1 for s in sentences\n if any(m in s for m in NEGATIVE_MARKERS))\n ratio = negative_count / len(sentences)\n score = min(1.0, ratio / 0.25)\n return _make_result(score, f\"negative={negative_count}/{len(sentences)} ({ratio:.0%}, target ≥20%)\", \"emotional_arc\")\n\n\ndef score_adverb_density(text):\n \"\"\"[1.5] Adverb density control. → adverb_max_per_100\"\"\"\n char_count = len(text)\n if char_count \u003c 50:\n return _make_result(0.5, \"text too short\", \"adverb_max_per_100\")\n # Count adverb occurrences\n total_adverbs = sum(text.count(adv) for adv in COMMON_ADVERBS)\n density = total_adverbs / char_count * 100\n # Check consecutive sentences starting with adverbs\n sentences = _split_sentences(text)\n consecutive_adverb_starts = 0\n for i in range(len(sentences) - 1):\n a_starts = any(sentences[i].startswith(adv) for adv in COMMON_ADVERBS)\n b_starts = any(sentences[i + 1].startswith(adv) for adv in COMMON_ADVERBS)\n if a_starts and b_starts:\n consecutive_adverb_starts += 1\n score = 1.0\n if density > 3.0:\n score -= min(0.5, (density - 3.0) * 0.1)\n score -= consecutive_adverb_starts * 0.3\n return _make_result(score, f\"density={density:.1f}/100chars, consecutive_starts={consecutive_adverb_starts}\", \"adverb_max_per_100\")\n\n\n# ============================================================\n# Tier 2: Pattern Checks (weight 30%)\n# ============================================================\n\ndef score_banned_words(text):\n \"\"\"[2.1] Banned word check. → null (hard rule, no config param)\"\"\"\n found = [w for w in BANNED_WORDS if w in text]\n score = max(0.0, 1.0 - len(found) * 0.2)\n detail = \"0 banned words\" if not found else f\"{len(found)} found: {found[:5]}\"\n return _make_result(score, detail, None)\n\n\ndef score_broken_sentences(text):\n \"\"\"[2.2] Broken/incomplete sentence patterns. → broken_sentence_rate\"\"\"\n count = 0\n lines = text.split('\\n')\n for line in lines:\n line = line.strip()\n if not line:\n continue\n for p in BROKEN_SENTENCE_PATTERNS:\n count += len(re.findall(p, line))\n if 1 \u003c= len(line) \u003c= 10 and not line.startswith('#'):\n count += 1\n char_count = len(text)\n expected = max(3, char_count / 500 * 3)\n score = min(1.0, count / expected)\n return _make_result(score, f\"{count} broken structures (expected ≥{expected:.0f})\", \"broken_sentence_rate\")\n\n\ndef score_real_sources(text):\n \"\"\"[3.1] Real external source indicators. → real_data_density\"\"\"\n count = 0\n for pattern in REAL_SOURCE_PATTERNS:\n count += len(re.findall(pattern, text))\n score = min(1.0, count / 5.0)\n return _make_result(score, f\"{count} real-source indicators (target ≥5)\", \"real_data_density\")\n\n\ndef score_word_temperature_mix(text):\n \"\"\"[1.2] Word temperature band coverage. → word_temperature_bias\"\"\"\n found_temps = sum([\n any(w in text for w in COLD_WORDS),\n any(w in text for w in WARM_WORDS),\n any(w in text for w in HOT_WORDS),\n any(w in text for w in WILD_WORDS),\n ])\n score = max(0.0, (found_temps - 1) / 3.0)\n return _make_result(score, f\"{found_temps}/4 temperature bands\", \"word_temperature_bias\")\n\n\ndef score_self_correction(text):\n \"\"\"[2.2] Self-correction and parenthetical patterns. → self_correction_rate\"\"\"\n count = 0\n for pattern in SELF_CORRECTION_PATTERNS:\n count += len(re.findall(pattern, text))\n score = min(1.0, count / 3.0)\n return _make_result(score, f\"{count} self-corrections/insertions (target ≥3)\", \"self_correction_rate\")\n\n\n# ============================================================\n# Tier Runners\n# ============================================================\n\nTIER1_CHECKS = [\n (\"sentence_length_stddev\", score_sentence_length_stddev),\n (\"sentence_length_range\", score_sentence_length_range),\n (\"paragraph_length_variance\", score_paragraph_length_variance),\n (\"vocabulary_richness\", score_vocabulary_richness),\n (\"negative_emotion_ratio\", score_negative_emotion_ratio),\n (\"adverb_density\", score_adverb_density),\n]\n\nTIER2_CHECKS = [\n (\"banned_words\", score_banned_words),\n (\"broken_sentences\", score_broken_sentences),\n (\"real_sources\", score_real_sources),\n (\"word_temperature_mix\", score_word_temperature_mix),\n (\"self_correction\", score_self_correction),\n]\n\n\ndef run_tier(checks, text):\n \"\"\"Run a tier of checks. Returns dict keyed by check name + _summary.\"\"\"\n results = {}\n scores = []\n for name, fn in checks:\n r = fn(text)\n results[name] = r\n scores.append(r[\"score\"])\n results[\"_summary\"] = {\n \"count\": len(checks),\n \"mean_score\": round(sum(scores) / len(scores), 4) if scores else 0,\n \"scores\": [round(s, 4) for s in scores],\n }\n return results\n\n\n# ============================================================\n# Calibration (bell-curve + over-optimization penalty)\n# ============================================================\n\n# Human article baselines (from 15 example articles, 2026-03-30)\n# Dimensions where AI over-optimizes: bell-curve scoring penalizes\n# both \"too low\" AND \"too high\" relative to human average.\n_BELL_CURVE_CHECKS = {\n \"broken_sentences\": 0.39,\n \"self_correction\": 0.20,\n \"sentence_length_range\": 0.71,\n \"paragraph_length_variance\": 0.52,\n \"banned_words\": 0.73,\n}\n\n\ndef _bell_curve(raw_score, center):\n \"\"\"Score peaks at center (human avg), penalizes over-optimization.\n\n Below center: linear rise (as before).\n Above center: quadratic penalty — too much is suspicious.\n \"\"\"\n if center \u003c= 0:\n return raw_score\n if raw_score \u003c= center:\n return raw_score / center\n else:\n overshoot = (raw_score - center) / (1.0 - center) if center \u003c 1 else 0\n return max(0.0, 1.0 - overshoot * overshoot)\n\n\ndef calibrate_tiers(tier1, tier2):\n \"\"\"Apply bell-curve calibration and over-optimization penalty in-place.\"\"\"\n # 1. Bell-curve adjustment for over-optimizable dimensions\n for tier in [tier1, tier2]:\n for name, data in tier.items():\n if name.startswith(\"_\"):\n continue\n if name in _BELL_CURVE_CHECKS:\n raw = data[\"score\"]\n center = _BELL_CURVE_CHECKS[name]\n calibrated = round(max(0.0, min(1.0, _bell_curve(raw, center))), 4)\n data[\"raw_score\"] = raw\n data[\"score\"] = calibrated\n data[\"detail\"] += f\" [calibrated from {raw:.2f}, center={center}]\"\n\n # 2. Over-optimization penalty: if 60%+ of checks score > 0.8,\n # the article is suspiciously \"perfect\" — apply global penalty.\n all_scores = []\n for tier in [tier1, tier2]:\n for name, data in tier.items():\n if not name.startswith(\"_\"):\n all_scores.append(data[\"score\"])\n\n high_count = sum(1 for s in all_scores if s > 0.8)\n over_opt_ratio = high_count / len(all_scores) if all_scores else 0\n penalty = 1.0\n if over_opt_ratio >= 0.6:\n penalty = 0.85 # 15% penalty for suspiciously perfect articles\n\n if penalty \u003c 1.0:\n for tier in [tier1, tier2]:\n for name, data in tier.items():\n if not name.startswith(\"_\"):\n data[\"score\"] = round(data[\"score\"] * penalty, 4)\n\n # 3. Recalculate tier summaries\n for tier in [tier1, tier2]:\n scores = [data[\"score\"] for name, data in tier.items() if not name.startswith(\"_\")]\n tier[\"_summary\"][\"mean_score\"] = round(sum(scores) / len(scores), 4) if scores else 0\n tier[\"_summary\"][\"scores\"] = [round(s, 4) for s in scores]\n\n return penalty\n\n\n# ============================================================\n# Composite Score\n# ============================================================\n\ndef compute_composite(tier1, tier2, tier3_score=None):\n \"\"\"Compute composite score (0=human, 100=AI).\n\n With tier3: T1=50%, T2=30%, T3=20%\n Without: T1=62.5%, T2=37.5%\n \"\"\"\n t1_mean = tier1[\"_summary\"][\"mean_score\"]\n t2_mean = tier2[\"_summary\"][\"mean_score\"]\n\n if tier3_score is not None:\n humanness = t1_mean * 0.50 + t2_mean * 0.30 + tier3_score * 0.20\n weights = {\"tier1\": 0.50, \"tier2\": 0.30, \"tier3\": 0.20}\n else:\n humanness = t1_mean * 0.625 + t2_mean * 0.375\n weights = {\"tier1\": 0.625, \"tier2\": 0.375}\n\n composite = round((1 - humanness) * 100, 2)\n return composite, weights\n\n\ndef build_param_scores(tier1, tier2):\n \"\"\"Build flat param→score map for optimization. Averages if multiple checks map to same param.\"\"\"\n param_map = {}\n for tier in [tier1, tier2]:\n for name, data in tier.items():\n if name.startswith(\"_\"):\n continue\n param = data.get(\"param\")\n if param is None:\n continue\n if param not in param_map:\n param_map[param] = []\n param_map[param].append(data[\"score\"])\n return {p: round(sum(scores) / len(scores), 4) for p, scores in param_map.items()}\n\n\n# ============================================================\n# Main API\n# ============================================================\n\ndef score_article(text, verbose=False, tier3_score=None):\n \"\"\"Score an article. Returns full results dict.\"\"\"\n clean = re.sub(r'^#+\\s+.*

WeWrite — 公众号文章全流程 行为声明 角色 :用户的公众号内容编辑 Agent。 模式 : - 默认全自动 ——一口气跑完 Step 1-8,不中途停下。只在出错时停。 - 交互模式 ——用户说"交互模式"/"我要自己选"时,在选题/框架/配图处暂停。 降级原则 :每一步都有降级方案。Step 1 检测到的降级标记( 、 )在后续 Step 自动生效,不重复报错。 进度追踪 :主管道启动时,用 TaskCreate 为 8 个 Step 创建任务。每开始一个 Step 标记 in progress,完成后标记 completed。用户可随时看到当前进度。 完成协议 : - DONE — 全流程完成,文章已保存/推送 - DONE WITH CONCERNS — 完成但部分步骤降级,列出降级项 - BLOCKED — 关键步骤无法继续(如 Python 依赖缺失且用户拒绝安装) - NEEDS CONTEXT — 需要用户提供信息才能继续(如首次设置需要公众号名称) 路径约定 :本文档中 指本 SKILL.md 所在的目录(即 WeWrite 的根目录)。 Onboard 例外 :Onboard 是交互式的(需要问用户问题),不受"全自动"约束。Onboard 完成后回到全自动管道。 辅助功能 (按需加载,不在主管道内): - 用户说"重新设置风格" → - 用户说"学习…

, '', text, flags=re.MULTILINE).strip()\n\n tier1 = run_tier(TIER1_CHECKS, clean)\n tier2 = run_tier(TIER2_CHECKS, clean)\n over_opt_penalty = calibrate_tiers(tier1, tier2)\n composite, weights = compute_composite(tier1, tier2, tier3_score)\n param_scores = build_param_scores(tier1, tier2)\n\n result = {\n \"composite_score\": composite,\n \"tier1\": tier1,\n \"tier2\": tier2,\n \"tier3\": {\n \"score\": tier3_score,\n \"source\": \"agent\" if tier3_score is not None else \"not_available\",\n },\n \"weights\": weights,\n \"param_scores\": param_scores,\n \"over_optimization_penalty\": over_opt_penalty,\n \"char_count\": len(clean),\n }\n\n if verbose:\n _print_verbose(result)\n\n return result\n\n\ndef _print_verbose(result):\n \"\"\"Print a human-readable report.\"\"\"\n composite = result[\"composite_score\"]\n print(f\"\\n{'=' * 60}\")\n print(f\"HUMANNESS SCORE: {composite:.1f}/100 (lower = more human)\")\n print(f\"{'=' * 60}\")\n\n for tier_name, tier_label, weight in [\n (\"tier1\", \"Tier 1 — Statistical\", result[\"weights\"].get(\"tier1\", 0)),\n (\"tier2\", \"Tier 2 — Pattern\", result[\"weights\"].get(\"tier2\", 0)),\n ]:\n tier = result[tier_name]\n summary = tier[\"_summary\"]\n print(f\"\\n{tier_label} (weight {weight:.0%}, mean {summary['mean_score']:.2f})\")\n for name, data in tier.items():\n if name.startswith(\"_\"):\n continue\n bar = \"█\" * int(data[\"score\"] * 10) + \"░\" * (10 - int(data[\"score\"] * 10))\n param_tag = f\" [{data['param']}]\" if data.get(\"param\") else \"\"\n print(f\" {bar} {data['score']:.2f} {name}{param_tag}\")\n print(f\" {data['detail']}\")\n\n t3 = result[\"tier3\"]\n if t3[\"score\"] is not None:\n t3_weight = result[\"weights\"].get(\"tier3\", 0)\n print(f\"\\nTier 3 — LLM (weight {t3_weight:.0%})\")\n print(f\" Score: {t3['score']:.2f} (source: {t3['source']})\")\n else:\n print(f\"\\nTier 3 — LLM: not available (standalone mode)\")\n\n print(f\"\\nComposite: {composite:.1f} (0=完美人类, 100=明显AI)\")\n print(f\"Weights: {result['weights']}\")\n\n param_scores = result[\"param_scores\"]\n if param_scores:\n sorted_params = sorted(param_scores.items(), key=lambda x: x[1])\n print(f\"\\nLowest-scoring parameters (optimize these first):\")\n for param, score in sorted_params[:3]:\n print(f\" {param}: {score:.2f}\")\n\n\ndef main():\n parser = argparse.ArgumentParser(description=\"Score article humanness (0=human, 100=AI)\")\n parser.add_argument(\"input\", help=\"Markdown article file\")\n parser.add_argument(\"--verbose\", \"-v\", action=\"store_true\", help=\"Detailed report\")\n parser.add_argument(\"--json\", action=\"store_true\", help=\"JSON output\")\n parser.add_argument(\"--tier3\", type=float, default=None,\n help=\"Tier 3 LLM score (0-1), passed by agent from SKILL.md\")\n args = parser.parse_args()\n\n text = Path(args.input).read_text(encoding=\"utf-8\")\n result = score_article(text, verbose=args.verbose, tier3_score=args.tier3)\n\n if args.json:\n print(json.dumps(result, ensure_ascii=False, indent=2))\n elif not args.verbose:\n print(f\"{result['composite_score']:.1f}\")\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":21913,"content_sha256":"de2d9a7df1be4c9ef0c1043a5eddc6e6fdeea063fce5c595bc978fa640ebfea0"},{"filename":"scripts/learn_edits.py","content":"#!/usr/bin/env python3\n\"\"\"\nLearn from human edits by diffing AI draft vs published final.\n\nCompares the original AI-generated article with the human-edited version,\ncomputes structured diffs, and saves typed lessons to lessons/.\n\nEach lesson has:\n - type: word_sub / para_delete / para_add / structure / title / tone\n - occurrences: how many times this pattern has been seen across all lessons\n - first_seen / last_seen: timestamps for confidence decay\n - confidence: auto-computed from occurrences + recency\n\nWhen summarizing, outputs all patterns with aggregated confidence scores.\nThe Agent uses this to write structured playbook.md rules.\n\nUsage:\n python3 learn_edits.py --draft path/to/draft.md --final path/to/final.md\n python3 learn_edits.py --from-wechat # auto-sync from WeChat draft box\n python3 learn_edits.py --summarize # all lessons with confidence\n python3 learn_edits.py --summarize --json # JSON output for agent\n\"\"\"\n\nimport argparse\nimport difflib\nimport json\nimport re\nimport sys\nfrom datetime import datetime, timedelta\nfrom pathlib import Path\n\nimport yaml\n\nSKILL_DIR = Path(__file__).parent.parent\n\n# Pattern types with descriptions\nPATTERN_TYPES = {\n \"word_sub\": \"用词替换\",\n \"para_delete\": \"段落删除\",\n \"para_add\": \"段落新增\",\n \"structure\": \"结构调整\",\n \"title\": \"标题修改\",\n \"tone\": \"语气调整\",\n \"expression\": \"表达偏好\",\n}\n\n\ndef load_text(path: str) -> str:\n return Path(path).read_text(encoding=\"utf-8\")\n\n\ndef markdown_to_plaintext(md: str) -> str:\n \"\"\"Strip markdown formatting to plain text for diff comparison.\"\"\"\n text = md\n # Remove HTML comments (editing anchors etc.)\n text = re.sub(r\"\u003c!--.*?-->\", \"\", text, flags=re.DOTALL)\n # Remove markdown headers markers\n text = re.sub(r\"^#{1,6}\\s+\", \"\", text, flags=re.MULTILINE)\n # Remove bold/italic markers\n text = re.sub(r\"\\*{1,3}(.*?)\\*{1,3}\", r\"\\1\", text)\n # Remove inline code\n text = re.sub(r\"`([^`]+)`\", r\"\\1\", text)\n # Remove link syntax [text](url) → text\n text = re.sub(r\"\\[([^\\]]+)\\]\\([^)]+\\)\", r\"\\1\", text)\n # Remove image syntax\n text = re.sub(r\"!\\[([^\\]]*)\\]\\([^)]+\\)\", r\"\\1\", text)\n # Collapse whitespace\n text = re.sub(r\"[ \\t]+\", \" \", text)\n text = re.sub(r\"\\n{3,}\", \"\\n\\n\", text)\n return text.strip()\n\n\ndef fetch_wechat_draft() -> tuple[str, str, str]:\n \"\"\"\n Fetch the latest draft from WeChat and find the corresponding local file.\n Returns (draft_plaintext, final_plaintext, draft_path).\n \"\"\"\n # Load config\n config_path = SKILL_DIR / \"config.yaml\"\n if not config_path.exists():\n raise FileNotFoundError(\"config.yaml not found — need WeChat API credentials\")\n\n with open(config_path) as f:\n config = yaml.safe_load(f)\n\n wechat = config.get(\"wechat\", {})\n appid = wechat.get(\"appid\", \"\")\n secret = wechat.get(\"secret\", \"\")\n if not appid or not secret:\n raise ValueError(\"config.yaml missing wechat.appid or wechat.secret\")\n\n # Load history to find latest article with media_id\n history_path = SKILL_DIR / \"history.yaml\"\n if not history_path.exists():\n raise FileNotFoundError(\"history.yaml not found — no articles to compare\")\n\n with open(history_path) as f:\n history = yaml.safe_load(f) or []\n\n # Find most recent article with media_id\n latest = None\n for article in reversed(history):\n if article.get(\"media_id\"):\n latest = article\n break\n\n if not latest:\n raise ValueError(\"No article with media_id found in history.yaml\")\n\n media_id = latest[\"media_id\"]\n title = latest.get(\"title\", \"\")\n\n # Find the local draft file\n # Priority: output_file field → title slug match → largest file\n date = latest.get(\"date\", \"\")\n output_dir = SKILL_DIR / \"output\"\n draft_path = None\n\n # First try: exact path from history\n output_file = latest.get(\"output_file\", \"\")\n if output_file:\n candidate = SKILL_DIR / output_file if not Path(output_file).is_absolute() else Path(output_file)\n if candidate.exists():\n draft_path = candidate\n\n if not draft_path and date:\n candidates = sorted(output_dir.glob(f\"{date}-*.md\"))\n if len(candidates) == 1:\n draft_path = candidates[0]\n elif len(candidates) > 1:\n # Multiple files on same date — try to match by title keywords\n title_lower = title.lower()\n for c in candidates:\n slug = c.stem.replace(date + \"-\", \"\").replace(\"-\", \" \")\n # Check if slug words appear in title\n if any(w in title_lower for w in slug.split() if len(w) > 1):\n draft_path = c\n break\n if not draft_path:\n # Fallback: use the largest file (likely the final version)\n draft_path = max(candidates, key=lambda p: p.stat().st_size)\n\n if not draft_path or not draft_path.exists():\n raise FileNotFoundError(\n f\"Cannot find local draft for '{title}' (date={date}) in output/\"\n )\n\n # Get access token and fetch draft from WeChat\n sys.path.insert(0, str(SKILL_DIR / \"toolkit\"))\n from wechat_api import get_access_token\n from publisher import get_draft, html_to_plaintext\n\n token = get_access_token(appid, secret)\n html = get_draft(token, media_id)\n wechat_text = html_to_plaintext(html)\n\n # Convert local draft to plaintext\n local_md = load_text(str(draft_path))\n local_text = markdown_to_plaintext(local_md)\n\n print(f\"本地文件: {draft_path}\")\n print(f\"微信草稿: media_id={media_id}\")\n print(f\"文章标题: {title}\")\n print(f\"本地字数: {len(local_text)}, 微信字数: {len(wechat_text)}\")\n\n return local_text, wechat_text, str(draft_path)\n\n\ndef split_sections(text: str) -> list[dict]:\n \"\"\"Split markdown into sections by H2 headers.\"\"\"\n sections = []\n current = {\"header\": \"(intro)\", \"lines\": []}\n for line in text.split(\"\\n\"):\n if line.strip().startswith(\"## \"):\n if current[\"lines\"] or current[\"header\"] != \"(intro)\":\n sections.append(current)\n current = {\"header\": line.strip(), \"lines\": []}\n else:\n current[\"lines\"].append(line)\n sections.append(current)\n return sections\n\n\ndef extract_title(text: str) -> str:\n for line in text.split(\"\\n\"):\n if line.strip().startswith(\"# \") and not line.strip().startswith(\"## \"):\n return line.strip()[2:].strip()\n return \"\"\n\n\ndef compute_diff(draft: str, final: str) -> dict:\n \"\"\"Compute structured diff between draft and final.\"\"\"\n draft_lines = draft.split(\"\\n\")\n final_lines = final.split(\"\\n\")\n\n differ = difflib.unified_diff(draft_lines, final_lines, lineterm=\"\")\n diff_lines = list(differ)\n\n additions = [l[1:].strip() for l in diff_lines\n if l.startswith(\"+\") and not l.startswith(\"+++\") and l[1:].strip()]\n deletions = [l[1:].strip() for l in diff_lines\n if l.startswith(\"-\") and not l.startswith(\"---\") and l[1:].strip()]\n\n draft_title = extract_title(draft)\n final_title = extract_title(final)\n\n draft_sections = split_sections(draft)\n final_sections = split_sections(final)\n draft_h2s = [s[\"header\"] for s in draft_sections if s[\"header\"] != \"(intro)\"]\n final_h2s = [s[\"header\"] for s in final_sections if s[\"header\"] != \"(intro)\"]\n\n draft_chars = len(draft.replace(\"\\n\", \"\").replace(\" \", \"\"))\n final_chars = len(final.replace(\"\\n\", \"\").replace(\" \", \"\"))\n\n return {\n \"title_changed\": draft_title != final_title,\n \"draft_title\": draft_title,\n \"final_title\": final_title,\n \"structure_changed\": draft_h2s != final_h2s,\n \"draft_h2s\": draft_h2s,\n \"final_h2s\": final_h2s,\n \"lines_added\": len(additions),\n \"lines_deleted\": len(deletions),\n \"draft_chars\": draft_chars,\n \"final_chars\": final_chars,\n \"char_diff\": final_chars - draft_chars,\n \"additions_sample\": additions[:20],\n \"deletions_sample\": deletions[:20],\n }\n\n\ndef save_lesson(diff_result: dict, draft_path: str, final_path: str) -> Path:\n \"\"\"Save structured lesson data for Agent to analyze.\"\"\"\n lessons_dir = SKILL_DIR / \"lessons\"\n lessons_dir.mkdir(parents=True, exist_ok=True)\n\n date_str = datetime.now().strftime(\"%Y-%m-%d\")\n lesson_file = lessons_dir / f\"{date_str}-diff.yaml\"\n\n counter = 1\n while lesson_file.exists():\n lesson_file = lessons_dir / f\"{date_str}-diff-{counter}.yaml\"\n counter += 1\n\n data = {\n \"date\": date_str,\n \"timestamp\": datetime.now().isoformat(),\n \"draft_file\": str(draft_path),\n \"final_file\": str(final_path),\n \"diff_summary\": {\n \"title_changed\": diff_result[\"title_changed\"],\n \"draft_title\": diff_result[\"draft_title\"],\n \"final_title\": diff_result[\"final_title\"],\n \"structure_changed\": diff_result[\"structure_changed\"],\n \"lines_added\": diff_result[\"lines_added\"],\n \"lines_deleted\": diff_result[\"lines_deleted\"],\n \"char_diff\": diff_result[\"char_diff\"],\n },\n # Agent fills these after analyzing the draft and final:\n \"patterns\": [],\n # Pattern format (Agent writes):\n # - type: \"word_sub\" # one of PATTERN_TYPES keys\n # key: \"avoid_jiangzhen\" # short unique identifier\n # description: \"把'讲真'替换为'坦白说'\"\n # rule: \"不要使用'讲真',用'坦白说'代替\" # imperative, executable\n }\n\n with open(lesson_file, \"w\", encoding=\"utf-8\") as f:\n yaml.dump(data, f, allow_unicode=True, default_flow_style=False)\n\n return lesson_file\n\n\ndef load_all_lessons() -> list[dict]:\n \"\"\"Load all lesson files.\"\"\"\n lessons_dir = SKILL_DIR / \"lessons\"\n if not lessons_dir.exists():\n return []\n lessons = []\n for f in sorted(lessons_dir.glob(\"*-diff*.yaml\")):\n with open(f, \"r\", encoding=\"utf-8\") as fh:\n data = yaml.safe_load(fh)\n if data:\n lessons.append(data)\n return lessons\n\n\ndef compute_confidence(occurrences: int, first_seen: str, last_seen: str) -> float:\n \"\"\"Compute confidence score from frequency and recency.\n\n Confidence = base_from_occurrences + recency_bonus - age_decay.\n\n - 1 occurrence = 3 (low, might be one-off)\n - 2 occurrences = 5 (moderate, likely a preference)\n - 3+ occurrences = 7+ (high, confirmed preference)\n - Recency bonus: +1 if last_seen within 7 days\n - Age decay: -1 per 30 days since last_seen (user style evolves)\n - Clamped to 1-10\n \"\"\"\n base = min(8, 2 + occurrences * 2)\n\n try:\n last = datetime.fromisoformat(last_seen)\n days_since = (datetime.now() - last).days\n except (ValueError, TypeError):\n days_since = 0\n\n recency_bonus = 1.0 if days_since \u003c= 7 else 0.0\n age_decay = max(0, days_since // 30)\n\n return max(1.0, min(10.0, base + recency_bonus - age_decay))\n\n\ndef aggregate_patterns(lessons: list[dict]) -> list[dict]:\n \"\"\"Aggregate patterns across all lessons. Returns sorted by confidence.\"\"\"\n pattern_map = {} # key → aggregated data\n\n for lesson in lessons:\n date = lesson.get(\"date\", \"\")\n timestamp = lesson.get(\"timestamp\", date)\n for p in lesson.get(\"patterns\", []):\n key = p.get(\"key\", \"\")\n if not key:\n continue\n if key not in pattern_map:\n pattern_map[key] = {\n \"key\": key,\n \"type\": p.get(\"type\", \"expression\"),\n \"description\": p.get(\"description\", \"\"),\n \"rule\": p.get(\"rule\", \"\"),\n \"occurrences\": 0,\n \"first_seen\": timestamp,\n \"last_seen\": timestamp,\n }\n entry = pattern_map[key]\n entry[\"occurrences\"] += 1\n # Keep the most recent description/rule (may evolve)\n if p.get(\"description\"):\n entry[\"description\"] = p[\"description\"]\n if p.get(\"rule\"):\n entry[\"rule\"] = p[\"rule\"]\n # Update timestamps\n if timestamp \u003c entry[\"first_seen\"]:\n entry[\"first_seen\"] = timestamp\n if timestamp > entry[\"last_seen\"]:\n entry[\"last_seen\"] = timestamp\n\n # Compute confidence for each\n results = []\n for entry in pattern_map.values():\n entry[\"confidence\"] = round(compute_confidence(\n entry[\"occurrences\"], entry[\"first_seen\"], entry[\"last_seen\"]\n ), 1)\n results.append(entry)\n\n # Sort by confidence descending\n results.sort(key=lambda x: x[\"confidence\"], reverse=True)\n return results\n\n\ndef summarize_lessons(as_json: bool = False):\n \"\"\"Load all lessons, aggregate patterns, output with confidence scores.\"\"\"\n lessons = load_all_lessons()\n if not lessons:\n print(\"No lessons found.\")\n return\n\n patterns = aggregate_patterns(lessons)\n\n if as_json:\n print(json.dumps({\n \"total_lessons\": len(lessons),\n \"total_patterns\": len(patterns),\n \"patterns\": patterns,\n }, ensure_ascii=False, indent=2))\n return\n\n print(f\"Total lessons: {len(lessons)}\")\n print(f\"Unique patterns: {len(patterns)}\")\n print()\n\n for p in patterns:\n type_label = PATTERN_TYPES.get(p[\"type\"], p[\"type\"])\n conf_bar = \"█\" * int(p[\"confidence\"]) + \"░\" * (10 - int(p[\"confidence\"]))\n print(f\" {conf_bar} {p['confidence']:4.1f} [{type_label}] {p['key']}\")\n print(f\" {p['description']}\")\n if p[\"rule\"]:\n print(f\" → {p['rule']}\")\n print(f\" seen {p['occurrences']}x, first {p['first_seen'][:10]}, last {p['last_seen'][:10]}\")\n print()\n\n\ndef main():\n parser = argparse.ArgumentParser(description=\"Learn from human edits\")\n parser.add_argument(\"--draft\", help=\"Path to AI draft\")\n parser.add_argument(\"--final\", help=\"Path to human-edited final\")\n parser.add_argument(\"--from-wechat\", action=\"store_true\",\n help=\"Auto-fetch edited version from WeChat draft box\")\n parser.add_argument(\"--summarize\", action=\"store_true\", help=\"Summarize all lessons\")\n parser.add_argument(\"--json\", action=\"store_true\", help=\"JSON output (with --summarize)\")\n args = parser.parse_args()\n\n if args.summarize:\n summarize_lessons(as_json=args.json)\n return\n\n if args.from_wechat:\n local_text, wechat_text, draft_path = fetch_wechat_draft()\n if local_text == wechat_text:\n print(\"\\n微信草稿与本地文件内容一致,没有修改。\")\n return\n diff_result = compute_diff(local_text, wechat_text)\n # Save with special marker for wechat source\n lesson_file = save_lesson(diff_result, draft_path, f\"wechat:{draft_path}\")\n print(f\"\\nLesson saved to: {lesson_file}\")\n print(f\"\\n检测到 {diff_result['lines_added']} 处新增, {diff_result['lines_deleted']} 处删除\")\n print(f\"字数变化: {diff_result['char_diff']:+d}\")\n print(f\"\\nAgent 接下来读取 {draft_path} 和微信草稿内容,分析修改模式并写入 {lesson_file}\")\n return\n\n if not args.draft or not args.final:\n print(\"Error: --draft and --final required (or use --from-wechat)\", file=sys.stderr)\n sys.exit(1)\n\n draft = load_text(args.draft)\n final = load_text(args.final)\n diff_result = compute_diff(draft, final)\n\n # Print summary\n print(\"=\" * 60)\n print(\"EDIT ANALYSIS\")\n print(\"=\" * 60)\n\n if diff_result[\"title_changed\"]:\n print(f\"\\n标题修改:\")\n print(f\" AI: {diff_result['draft_title']}\")\n print(f\" 人工: {diff_result['final_title']}\")\n\n if diff_result[\"structure_changed\"]:\n print(f\"\\n结构修改:\")\n print(f\" AI H2: {diff_result['draft_h2s']}\")\n print(f\" 人工 H2: {diff_result['final_h2s']}\")\n\n print(f\"\\n数量变化:\")\n print(f\" 新增 {diff_result['lines_added']} 行, 删除 {diff_result['lines_deleted']} 行\")\n print(f\" 字数变化: {diff_result['char_diff']:+d} ({diff_result['draft_chars']} → {diff_result['final_chars']})\")\n\n if diff_result[\"deletions_sample\"]:\n print(f\"\\n被删除的内容(采样):\")\n for line in diff_result[\"deletions_sample\"][:10]:\n print(f\" - {line[:80]}\")\n\n if diff_result[\"additions_sample\"]:\n print(f\"\\n新增的内容(采样):\")\n for line in diff_result[\"additions_sample\"][:10]:\n print(f\" + {line[:80]}\")\n\n # Save lesson\n lesson_file = save_lesson(diff_result, args.draft, args.final)\n print(f\"\\nLesson saved to: {lesson_file}\")\n\n # Auto-grow exemplar library from edited finals\n final_title = extract_title(final)\n try:\n import extract_exemplar\n exemplar = extract_exemplar.extract_exemplar(final, source=final_title or \"user-edited\")\n if exemplar[\"humanness_score\"] \u003c= 50:\n exemplar_path = extract_exemplar.save_exemplar(exemplar)\n print(f\"\\n✓ 终稿已加入范文库: {exemplar_path}\")\n print(f\" Score: {exemplar['humanness_score']:.1f}/100, Category: {exemplar['category']}\")\n else:\n print(f\"\\n⚠ 终稿 humanness_score={exemplar['humanness_score']:.1f} > 50,未加入范文库\")\n except Exception as e:\n print(f\"\\n⚠ 范文提取跳过: {e}\")\n\n lesson_count = len(load_all_lessons())\n print(f\"Total lessons: {lesson_count}\")\n\n if lesson_count >= 5 and lesson_count % 5 == 0:\n print(f\"\\n{'=' * 60}\")\n print(\"PLAYBOOK UPDATE TRIGGERED\")\n print(f\"{'=' * 60}\")\n print(f\"{lesson_count} lessons. Agent should run:\")\n print(f\" python3 scripts/learn_edits.py --summarize --json\")\n print(f\"Then update playbook.md with high-confidence patterns.\")\n\n # Instructions for Agent\n print(f\"\"\"\n{'=' * 60}\nINSTRUCTIONS FOR AGENT\n{'=' * 60}\n\nRead the draft and final versions, then for each meaningful edit:\n\n1. Read: {args.draft}\n2. Read: {args.final}\n3. For each edit, add a pattern entry to {lesson_file}:\n\n patterns:\n - type: \"word_sub\" # {' / '.join(PATTERN_TYPES.keys())}\n key: \"short_unique_id\" # e.g. \"avoid_jiangzhen\", \"shorter_paragraphs\"\n description: \"把'讲真'替换为'坦白说'\"\n rule: \"不要使用'讲真',用'坦白说'代替\" # imperative, executable\n\n4. Rules must be imperative (可执行的指令), not descriptive.\n BAD: \"用户偏好简短段落\"\n GOOD: \"段落不超过 80 字,长段必须在 3 句内换行\"\n\n5. If pattern already exists in previous lessons (same key),\n confidence will auto-increase on next --summarize.\n\"\"\")\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":18936,"content_sha256":"bef0b6ea9241ffe0d64ce1cea8c03eb1424c3fb1ddcf317e9aea88559fc920e4"},{"filename":"scripts/learn_theme.py","content":"\"\"\"learn_theme.py — extract a WeWrite-compatible theme from a WeChat article URL.\n\nUsage:\n python3 scripts/learn_theme.py \u003curl> # fetch + analyse live article\n python3 scripts/learn_theme.py --file \u003cpath> # analyse a saved HTML file\n\"\"\"\n\nimport argparse\nimport colorsys\nimport re\nimport sys\nfrom collections import Counter\nfrom pathlib import Path\n\nimport yaml\nfrom bs4 import BeautifulSoup\n\n# ---------------------------------------------------------------------------\n# 1. Color utilities\n# ---------------------------------------------------------------------------\n\ndef rgb_to_hex(rgb_str: str) -> str:\n \"\"\"Convert ``rgb(r,g,b)`` or ``rgba(r,g,b,a)`` to ``#rrggbb``.\n\n Pass-through for values that already look like hex (lowercased).\n Return the original string unchanged if no pattern matches.\n \"\"\"\n if not isinstance(rgb_str, str):\n return rgb_str\n s = rgb_str.strip()\n # Already hex\n if re.match(r\"^#[0-9a-fA-F]{3,8}$\", s):\n return s.lower()\n m = re.match(\n r\"rgba?\\(\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)(?:\\s*,\\s*[\\d.]+)?\\s*\\)\",\n s,\n re.IGNORECASE,\n )\n if m:\n r, g, b = int(m.group(1)), int(m.group(2)), int(m.group(3))\n return \"#{:02x}{:02x}{:02x}\".format(r, g, b)\n return s\n\n\ndef lightness(hex_color: str) -> float:\n \"\"\"Return HLS lightness (0.0–1.0) for a hex colour string.\n\n Returns 0.5 for any invalid / non-hex input.\n \"\"\"\n s = hex_color.strip().lstrip(\"#\")\n if len(s) == 3:\n s = \"\".join(c * 2 for c in s)\n if len(s) != 6:\n return 0.5\n try:\n r = int(s[0:2], 16) / 255.0\n g = int(s[2:4], 16) / 255.0\n b = int(s[4:6], 16) / 255.0\n except ValueError:\n return 0.5\n _h, l, _s = colorsys.rgb_to_hls(r, g, b)\n return l\n\n\ndef is_gray(hex_color: str, threshold: int = 30) -> bool:\n \"\"\"Return True if R, G, B values are all within *threshold* of each other.\"\"\"\n s = hex_color.strip().lstrip(\"#\")\n if len(s) == 3:\n s = \"\".join(c * 2 for c in s)\n if len(s) != 6:\n return False\n try:\n r = int(s[0:2], 16)\n g = int(s[2:4], 16)\n b = int(s[4:6], 16)\n except ValueError:\n return False\n return max(r, g, b) - min(r, g, b) \u003c= threshold\n\n\ndef adjust_lightness(hex_color: str, target_l: float) -> str:\n \"\"\"Return a new hex colour with lightness set to *target_l* (0.0–1.0).\"\"\"\n s = hex_color.strip().lstrip(\"#\")\n if len(s) == 3:\n s = \"\".join(c * 2 for c in s)\n if len(s) != 6:\n return hex_color\n try:\n r = int(s[0:2], 16) / 255.0\n g = int(s[2:4], 16) / 255.0\n b = int(s[4:6], 16) / 255.0\n except ValueError:\n return hex_color\n h, _l, sat = colorsys.rgb_to_hls(r, g, b)\n nr, ng, nb = colorsys.hls_to_rgb(h, max(0.0, min(1.0, target_l)), sat)\n return \"#{:02x}{:02x}{:02x}\".format(\n int(nr * 255), int(ng * 255), int(nb * 255)\n )\n\n\ndef derive_darkmode(colors: dict) -> dict:\n \"\"\"Derive a dark-mode colour dict from a light-mode *colors* dict.\n\n Rules\n -----\n background → #1e1e1e\n text → lightness set to 0.80\n text_light → lightness set to 0.60\n primary → lightness + 0.15, capped at 0.85\n code_bg → #2d2d2d\n code_color → #d4d4d4\n quote_bg → #252525\n quote_border → dark-mode primary\n \"\"\"\n primary = colors.get(\"primary\", \"#2563eb\")\n primary_l = lightness(primary)\n dm_primary = adjust_lightness(primary, min(primary_l + 0.15, 0.85))\n\n dm = {\n \"background\": \"#1e1e1e\",\n \"text\": adjust_lightness(colors.get(\"text\", \"#333333\"), 0.80),\n \"text_light\": adjust_lightness(colors.get(\"text_light\", \"#666666\"), 0.60),\n \"primary\": dm_primary,\n \"code_bg\": \"#2d2d2d\",\n \"code_color\": \"#d4d4d4\",\n \"quote_bg\": \"#252525\",\n \"quote_border\": dm_primary,\n }\n return dm\n\n\n# ---------------------------------------------------------------------------\n# 2. HTML fetch and style extraction\n# ---------------------------------------------------------------------------\n\ndef parse_inline_style(style_str: str) -> dict:\n \"\"\"Parse ``\"color: red; font-size: 16px\"`` into ``{\"color\": \"red\", \"font-size\": \"16px\"}``.\"\"\"\n result = {}\n if not style_str:\n return result\n for declaration in style_str.split(\";\"):\n declaration = declaration.strip()\n if \":\" not in declaration:\n continue\n prop, _, val = declaration.partition(\":\")\n result[prop.strip().lower()] = val.strip()\n return result\n\n\n_TARGET_TAGS = {\n \"p\", \"section\", \"span\", \"strong\", \"em\",\n \"h1\", \"h2\", \"h3\", \"h4\",\n \"blockquote\", \"code\", \"pre\", \"img\", \"a\",\n}\n\nTEMPLATE_THEME = \"professional-clean\"\nTHEMES_DIR = Path(__file__).resolve().parent.parent / \"toolkit\" / \"themes\"\n\n\ndef _attach_title(soup, content) -> None:\n \"\"\"Find the article title in *soup* and stash it on *content*.\"\"\"\n title_tag = soup.find(\"h1\", class_=\"rich_media_title\") or soup.find(\n \"h1\", id=\"activity-name\"\n )\n content._wewrite_title = title_tag.get_text(strip=True) if title_tag else \"\"\n\n\ndef fetch_article(url: str, timeout: int = 20) -> \"BeautifulSoup tag\":\n \"\"\"Fetch a WeChat article, return the ``#js_content`` element.\n\n Delegates to fetch_article.fetch_html() for three-level fetching\n (requests → Playwright → manual fallback).\n\n The article title is attached as ``content._wewrite_title`` (empty string\n if not found).\n \"\"\"\n from scripts.fetch_article import fetch_html\n\n html = fetch_html(url)\n soup = BeautifulSoup(html, \"html.parser\")\n\n content = soup.find(id=\"js_content\")\n if content is None:\n print(\"Error: #js_content not found.\", file=sys.stderr)\n sys.exit(1)\n\n _attach_title(soup, content)\n return content\n\n\ndef extract_styles(content) -> dict:\n \"\"\"Iterate all elements in *content*, group inline styles by tag name.\n\n Returns ``{tag_name: [style_dict, ...], ...}`` for the target tags.\n Only elements that have a non-empty ``style`` attribute are included.\n \"\"\"\n grouped: dict[str, list[dict]] = {tag: [] for tag in _TARGET_TAGS}\n for elem in content.find_all(True):\n tag = elem.name\n if tag not in _TARGET_TAGS:\n continue\n raw_style = elem.get(\"style\", \"\")\n if not raw_style:\n continue\n parsed = parse_inline_style(raw_style)\n if parsed:\n grouped[tag].append(parsed)\n return grouped\n\n\n# ---------------------------------------------------------------------------\n# 3. Style analysis\n# ---------------------------------------------------------------------------\n\nDEFAULTS = {\n \"primary\": \"#2563eb\",\n \"secondary\": \"#3b82f6\",\n \"text\": \"#333333\",\n \"text_light\": \"#666666\",\n \"background\": \"#ffffff\",\n \"code_bg\": \"#1e293b\",\n \"code_color\": \"#e2e8f0\",\n \"quote_border\": \"#2563eb\",\n \"quote_bg\": \"#eff6ff\",\n \"border_radius\": \"8px\",\n \"font_size\": \"16px\",\n \"line_height\": \"1.8\",\n \"letter_spacing\": \"0px\",\n \"font_family\": (\n '-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, '\n '\"Helvetica Neue\", Arial, \"PingFang SC\", \"Hiragino Sans GB\", '\n '\"Microsoft YaHei\", sans-serif'\n ),\n \"p_margin\": \"0 0 16px 0\",\n}\n\n\ndef most_common_value(style_list: list, prop: str):\n \"\"\"Return the most common value of CSS *prop* across *style_list*.\n\n Returns ``None`` if the property does not appear in any dict.\n \"\"\"\n values = [d[prop] for d in style_list if prop in d and d[prop]]\n if not values:\n return None\n return Counter(values).most_common(1)[0][0]\n\n\ndef _parse_px(value: str) -> float | None:\n \"\"\"Parse a CSS pixel value like ``\"16px\"`` → 16.0, or return None.\"\"\"\n if not value:\n return None\n m = re.match(r\"([\\d.]+)\\s*px\", value.strip(), re.IGNORECASE)\n return float(m.group(1)) if m else None\n\n\ndef analyze_styles(grouped: dict) -> dict:\n \"\"\"Analyse the output of :func:`extract_styles` and return a flat theme dict.\n\n Inferred properties (falling back to DEFAULTS when not found):\n text, text_light, primary, secondary, background,\n font_size, line_height, letter_spacing, font_family, p_margin,\n quote_border, quote_bg, code_bg, code_color, border_radius.\n \"\"\"\n result = dict(DEFAULTS) # start with all defaults\n\n # --- text ------------------------------------------------------------------\n p_styles = grouped.get(\"p\", [])\n raw_text = most_common_value(p_styles, \"color\")\n if raw_text:\n result[\"text\"] = rgb_to_hex(raw_text)\n\n # --- text_light ------------------------------------------------------------\n # Collect foreground colours only (not backgrounds) for text_light candidates\n all_colors = []\n for tag_styles in grouped.values():\n for d in tag_styles:\n val = d.get(\"color\")\n if val:\n all_colors.append(rgb_to_hex(val))\n\n text_light_candidates = [\n c for c in all_colors\n if is_gray(c) and 0.15 \u003c lightness(c) \u003c 0.85 and c != result[\"text\"]\n ]\n if text_light_candidates:\n # Pick the one with the highest lightness\n result[\"text_light\"] = max(text_light_candidates, key=lightness)\n\n # --- primary (accent color) ------------------------------------------------\n # Collect non-gray colors from strong/section/h1-h3/span; boost colors from\n # elements whose font-size is ≥ 20 px (weight × 5).\n # Exclude the dominant text color so near-black body text never wins.\n accent_tags = {\"strong\", \"section\", \"h1\", \"h2\", \"h3\", \"span\"}\n accent_counter: Counter = Counter()\n for tag in accent_tags:\n for d in grouped.get(tag, []):\n color_val = d.get(\"color\")\n if not color_val:\n continue\n hex_c = rgb_to_hex(color_val)\n if is_gray(hex_c):\n continue\n if hex_c == result[\"text\"]:\n continue\n # Check font-size for boost\n fs = d.get(\"font-size\")\n fs_px = _parse_px(fs) if fs else None\n weight = 5 if (fs_px is not None and fs_px >= 20) else 1\n accent_counter[hex_c] += weight\n\n if accent_counter:\n sorted_accents = accent_counter.most_common()\n result[\"primary\"] = sorted_accents[0][0]\n # --- secondary ---------------------------------------------------------\n if len(sorted_accents) >= 2:\n result[\"secondary\"] = sorted_accents[1][0]\n else:\n # Derive: primary + 10% lightness, cap 0.90\n primary_l = lightness(result[\"primary\"])\n result[\"secondary\"] = adjust_lightness(\n result[\"primary\"], min(primary_l + 0.10, 0.90)\n )\n else:\n # No accent found — derive secondary from default primary\n primary_l = lightness(result[\"primary\"])\n result[\"secondary\"] = adjust_lightness(\n result[\"primary\"], min(primary_l + 0.10, 0.90)\n )\n\n # --- background ------------------------------------------------------------\n # Check background-color of the first few \u003csection> elements for high lightness\n for d in (grouped.get(\"section\", []))[:10]:\n bg = d.get(\"background-color\") or d.get(\"background\")\n if bg:\n hex_bg = rgb_to_hex(bg)\n if lightness(hex_bg) > 0.85:\n result[\"background\"] = hex_bg\n break\n\n # --- typography (from \u003cp>) -------------------------------------------------\n if p_styles:\n fs = most_common_value(p_styles, \"font-size\")\n if fs:\n result[\"font_size\"] = fs\n lh = most_common_value(p_styles, \"line-height\")\n if lh:\n result[\"line_height\"] = lh\n ls = most_common_value(p_styles, \"letter-spacing\")\n if ls:\n result[\"letter_spacing\"] = ls\n margin = most_common_value(p_styles, \"margin\")\n if margin:\n result[\"p_margin\"] = margin\n\n # font-family from \u003cspan>\n span_styles = grouped.get(\"span\", [])\n ff = most_common_value(span_styles, \"font-family\")\n if ff:\n result[\"font_family\"] = ff\n\n # --- quote_border / quote_bg -----------------------------------------------\n # Priority: actual \u003cblockquote> elements first.\n # For section/p: only use a background when a border-left is also present on\n # that element (avoids picking up decorative divider colors).\n bq_border = None\n bq_bg = None\n\n # Pass 1: blockquote (highest confidence)\n for d in grouped.get(\"blockquote\", []):\n bl = d.get(\"border-left\") or d.get(\"border-left-color\")\n if bl and not bq_border:\n color_match = re.search(r\"(rgb[a]?\\([^)]+\\)|#[0-9a-fA-F]{3,8})\", bl)\n if color_match:\n bq_border = rgb_to_hex(color_match.group(1))\n bg = d.get(\"background-color\") or d.get(\"background\")\n if bg and not bq_bg:\n hex_bg = rgb_to_hex(bg)\n if hex_bg not in (\"#ffffff\", \"#000000\") and not is_gray(hex_bg, threshold=10):\n bq_bg = hex_bg\n\n # Pass 2: section/p — only trust backgrounds that co-occur with border-left\n if not bq_border:\n for tag in (\"section\", \"p\"):\n for d in grouped.get(tag, []):\n bl = d.get(\"border-left\") or d.get(\"border-left-color\")\n if bl:\n color_match = re.search(r\"(rgb[a]?\\([^)]+\\)|#[0-9a-fA-F]{3,8})\", bl)\n if color_match and not bq_border:\n bq_border = rgb_to_hex(color_match.group(1))\n bg = d.get(\"background-color\") or d.get(\"background\")\n if bg and not bq_bg:\n hex_bg = rgb_to_hex(bg)\n if hex_bg not in (\"#ffffff\", \"#000000\") and not is_gray(\n hex_bg, threshold=10\n ):\n bq_bg = hex_bg\n\n if bq_border:\n result[\"quote_border\"] = bq_border\n else:\n result[\"quote_border\"] = result[\"primary\"]\n\n if bq_bg:\n result[\"quote_bg\"] = bq_bg\n else:\n # Derive a light tint of primary\n primary_l = lightness(result[\"primary\"])\n result[\"quote_bg\"] = adjust_lightness(result[\"primary\"], min(primary_l + 0.35, 0.95))\n\n # --- code_bg / code_color --------------------------------------------------\n for tag in (\"pre\", \"code\"):\n tag_styles = grouped.get(tag, [])\n bg = most_common_value(tag_styles, \"background-color\") or most_common_value(\n tag_styles, \"background\"\n )\n if bg:\n result[\"code_bg\"] = rgb_to_hex(bg)\n color = most_common_value(tag_styles, \"color\")\n if color:\n result[\"code_color\"] = rgb_to_hex(color)\n\n # --- border_radius ---------------------------------------------------------\n all_radii = []\n for tag_styles in grouped.values():\n for d in tag_styles:\n br = d.get(\"border-radius\")\n if br:\n all_radii.append(br)\n if all_radii:\n result[\"border_radius\"] = Counter(all_radii).most_common(1)[0][0]\n\n return result\n\n\n# ---------------------------------------------------------------------------\n# 4. Theme YAML generation\n# ---------------------------------------------------------------------------\n\ndef _load_template_css() -> str:\n \"\"\"Load the base_css from the professional-clean template theme.\"\"\"\n template_path = THEMES_DIR / f\"{TEMPLATE_THEME}.yaml\"\n with open(template_path, encoding=\"utf-8\") as fh:\n data = yaml.safe_load(fh)\n return data[\"base_css\"]\n\n\ndef generate_theme_yaml(name: str, title: str, analyzed: dict) -> str:\n \"\"\"Generate a complete theme YAML string from analyzed style properties.\n\n Parameters\n ----------\n name: Theme identifier (used as filename and --theme reference).\n title: Article title (used in description).\n analyzed: Dict returned by :func:`analyze_styles`.\n \"\"\"\n css = _load_template_css()\n\n # Build colors dict\n colors = {\n \"primary\": analyzed.get(\"primary\", \"#2563eb\"),\n \"secondary\": analyzed.get(\"secondary\", \"#3b82f6\"),\n \"text\": analyzed.get(\"text\", \"#333333\"),\n \"text_light\": analyzed.get(\"text_light\", \"#666666\"),\n \"background\": analyzed.get(\"background\", \"#ffffff\"),\n \"code_bg\": analyzed.get(\"code_bg\", \"#1e293b\"),\n \"code_color\": analyzed.get(\"code_color\", \"#e2e8f0\"),\n \"quote_border\": analyzed.get(\"quote_border\", \"#2563eb\"),\n \"quote_bg\": analyzed.get(\"quote_bg\", \"#eff6ff\"),\n \"border_radius\": analyzed.get(\"border_radius\", \"8px\"),\n }\n\n # Replace template colors in CSS\n css = css.replace(\"#2563eb\", colors[\"primary\"])\n css = css.replace(\"#3b82f6\", colors[\"secondary\"])\n css = css.replace(\"#333333\", colors[\"text\"])\n css = css.replace(\"#666666\", colors[\"text_light\"])\n css = css.replace(\"#1e293b\", colors[\"code_bg\"])\n css = css.replace(\"#e2e8f0\", colors[\"code_color\"])\n css = css.replace(\"#eff6ff\", colors[\"quote_bg\"])\n\n # Replace typography via regex\n font_size = analyzed.get(\"font_size\", \"16px\")\n line_height = analyzed.get(\"line_height\", \"1.8\")\n font_family = analyzed.get(\"font_family\",\n '-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, '\n '\"Helvetica Neue\", Arial, \"PingFang SC\", \"Hiragino Sans GB\", '\n '\"Microsoft YaHei\", sans-serif')\n p_margin = analyzed.get(\"p_margin\", \"12px 0\")\n border_radius = colors[\"border_radius\"]\n\n # body font-size\n css = re.sub(\n r\"(body\\s*\\{[^}]*font-size:\\s*)[\\d.]+px\",\n lambda m: m.group(1) + font_size,\n css,\n flags=re.DOTALL,\n )\n # body line-height\n css = re.sub(\n r\"(body\\s*\\{[^}]*line-height:\\s*)[\\d.]+\",\n lambda m: m.group(1) + line_height,\n css,\n flags=re.DOTALL,\n )\n # body font-family (replace the quoted chain)\n css = re.sub(\n r'(body\\s*\\{[^}]*font-family:\\s*)[^;]+;',\n lambda m: m.group(1) + font_family + \";\",\n css,\n flags=re.DOTALL,\n )\n # p line-height\n css = re.sub(\n r'(p\\s*\\{[^}]*line-height:\\s*)[\\d.]+',\n lambda m: m.group(1) + line_height,\n css,\n flags=re.DOTALL,\n )\n # p margin\n css = re.sub(\n r'(p\\s*\\{[^}]*margin:\\s*)[^;]+;',\n lambda m: m.group(1) + p_margin + \";\",\n css,\n flags=re.DOTALL,\n )\n # li line-height (match p line-height)\n css = re.sub(\n r'(li\\s*\\{[^}]*line-height:\\s*)[\\d.]+',\n lambda m: m.group(1) + line_height,\n css,\n flags=re.DOTALL,\n )\n # border-radius: 8px and border-radius: 4px\n css = css.replace(\"border-radius: 8px\", f\"border-radius: {border_radius}\")\n css = css.replace(\"border-radius: 4px\", f\"border-radius: {border_radius}\")\n\n # Derive dark mode\n darkmode = derive_darkmode(colors)\n\n # Description\n if title:\n description = f\"从「{title}」学习的排版主题\"\n else:\n description = f\"Learned theme: {name}\"\n\n # Build theme data\n colors_with_dark = dict(colors)\n colors_with_dark[\"darkmode\"] = darkmode\n\n theme_data = {\n \"name\": name,\n \"description\": description,\n \"colors\": colors_with_dark,\n \"base_css\": css,\n }\n\n return yaml.dump(theme_data, allow_unicode=True, default_flow_style=False, sort_keys=False)\n\n\n# ---------------------------------------------------------------------------\n# CLI entry point / smoke test\n# ---------------------------------------------------------------------------\n\ndef _load_from_file(path: str):\n \"\"\"Load #js_content from a local HTML file (for smoke testing).\"\"\"\n with open(path, encoding=\"utf-8\") as fh:\n soup = BeautifulSoup(fh.read(), \"html.parser\")\n content = soup.find(id=\"js_content\")\n if content is None:\n print(f\"Error: #js_content not found in {path}\", file=sys.stderr)\n sys.exit(1)\n _attach_title(soup, content)\n return content\n\n\ndef main():\n parser = argparse.ArgumentParser(\n description=\"Learn a WeChat formatting theme from an article URL.\",\n )\n parser.add_argument(\"url\", help=\"WeChat article URL (https://mp.weixin.qq.com/s/...)\")\n parser.add_argument(\"--name\", required=True, help=\"Theme name (used as filename and reference)\")\n parser.add_argument(\"--output-dir\", default=None, help=\"Output directory (default: toolkit/themes/)\")\n args = parser.parse_args()\n\n # Validate name: only letters, digits, hyphens, underscores\n if not re.match(r\"^[a-zA-Z0-9_-]+$\", args.name):\n print(\"Error: --name must contain only letters, digits, hyphens, and underscores.\", file=sys.stderr)\n raise SystemExit(1)\n\n output_dir = Path(args.output_dir) if args.output_dir else THEMES_DIR\n output_path = output_dir / f\"{args.name}.yaml\"\n\n if output_path.exists():\n print(f\"Warning: {output_path} already exists, will be overwritten.\", file=sys.stderr)\n\n # Fetch\n print(\"Fetching article...\")\n content = fetch_article(args.url)\n title = getattr(content, \"_wewrite_title\", \"\")\n if title:\n print(f\"Title: {title}\")\n\n # Extract\n grouped = extract_styles(content)\n styled_count = sum(len(v) for v in grouped.values())\n print(f\"Extracted {styled_count} styled elements.\")\n\n # Analyze\n analyzed = analyze_styles(grouped)\n\n # Generate & write\n theme_yaml = generate_theme_yaml(args.name, title, analyzed)\n output_dir.mkdir(parents=True, exist_ok=True)\n output_path.write_text(theme_yaml, encoding=\"utf-8\")\n\n # Report\n print()\n print(f\"Learned theme from: {title or args.url}\")\n print(f\" text: {analyzed['text']}\")\n print(f\" text_light: {analyzed['text_light']}\")\n print(f\" primary: {analyzed['primary']}\")\n print(f\" secondary: {analyzed['secondary']}\")\n print(f\" background: {analyzed['background']}\")\n print(f\" font: {analyzed['font_family'][:50]}\")\n print(f\" size: {analyzed['font_size']} / line-height {analyzed['line_height']} / spacing {analyzed['letter_spacing']}\")\n print()\n print(f\"Theme saved → {output_path}\")\n print(f\"Use it: python3 toolkit/cli.py preview article.md --theme {args.name}\")\n print(f\"Or set: theme: {args.name} in style.yaml\")\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":22375,"content_sha256":"a4effba7812eec6e0c5b4a68816b9c5ab9a5b94d31bd00edbb3bcbdfdce2b6c6"},{"filename":"scripts/seo_keywords.py","content":"#!/usr/bin/env python3\n\"\"\"\nSEO keyword research tool.\n\nQueries real search data to evaluate keyword popularity:\n 1. Baidu search suggestions (autocomplete volume proxy)\n 2. Baidu related searches\n 3. WeChat sogou index (search volume proxy)\n\nUsage:\n python3 seo_keywords.py \"AI大模型\"\n python3 seo_keywords.py \"AI大模型\" \"科技股\" \"创业\"\n python3 seo_keywords.py --json \"AI大模型\"\n\nOutput: keyword popularity score, related keywords, trending signals.\n\"\"\"\n\nimport argparse\nimport json\nimport sys\nimport urllib.parse\n\nimport requests\n\nTIMEOUT = 10\nHEADERS = {\n \"User-Agent\": \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \"\n \"AppleWebKit/537.36 (KHTML, like Gecko) \"\n \"Chrome/120.0.0.0 Safari/537.36\",\n}\n\n\ndef baidu_suggestions(keyword: str) -> list[str]:\n \"\"\"Get Baidu search autocomplete suggestions — proxy for search volume.\"\"\"\n try:\n resp = requests.get(\n \"https://suggestion.baidu.com/su\",\n params={\"wd\": keyword, \"action\": \"opensearch\", \"ie\": \"utf-8\"},\n headers=HEADERS,\n timeout=TIMEOUT,\n )\n data = resp.json()\n # Response format: [query, [suggestions...]]\n if isinstance(data, list) and len(data) >= 2:\n return data[1]\n return []\n except Exception as e:\n print(f\"[warn] baidu suggestions failed: {e}\", file=sys.stderr)\n return []\n\n\ndef so360_suggestions(keyword: str) -> list[str]:\n \"\"\"Get 360 search suggestions — second source for search volume proxy.\"\"\"\n try:\n resp = requests.get(\n \"https://sug.so.360.cn/suggest\",\n params={\"word\": keyword, \"encodein\": \"utf-8\", \"encodeout\": \"utf-8\", \"format\": \"json\"},\n headers=HEADERS,\n timeout=TIMEOUT,\n )\n data = resp.json()\n return [item.get(\"word\", \"\") for item in data.get(\"result\", []) if item.get(\"word\")]\n except Exception as e:\n print(f\"[warn] 360 suggestions failed: {e}\", file=sys.stderr)\n return []\n\n\ndef analyze_keyword(keyword: str) -> dict:\n \"\"\"Analyze a keyword's SEO potential.\"\"\"\n baidu_suggs = baidu_suggestions(keyword)\n so360_suggs = so360_suggestions(keyword)\n\n # Popularity score (0-10) based on suggestion count\n # More suggestions = more search demand\n baidu_score = min(len(baidu_suggs), 10)\n so360_score = min(len(so360_suggs), 10)\n\n # Combined score: average of two sources\n combined_score = round((baidu_score + so360_score) / 2, 1)\n\n # Extract related keywords (dedup)\n all_related = list(dict.fromkeys(baidu_suggs + so360_suggs))\n\n return {\n \"keyword\": keyword,\n \"seo_score\": combined_score,\n \"baidu_score\": baidu_score,\n \"so360_score\": so360_score,\n \"baidu_suggestions\": baidu_suggs[:5],\n \"so360_suggestions\": so360_suggs[:5],\n \"related_keywords\": all_related[:10],\n }\n\n\ndef main():\n parser = argparse.ArgumentParser(description=\"SEO keyword analysis\")\n parser.add_argument(\"keywords\", nargs=\"+\", help=\"Keywords to analyze\")\n parser.add_argument(\"--json\", action=\"store_true\", help=\"Output as JSON\")\n args = parser.parse_args()\n\n results = []\n for kw in args.keywords:\n result = analyze_keyword(kw)\n results.append(result)\n\n if args.json:\n json.dump(results, sys.stdout, ensure_ascii=False, indent=2)\n else:\n for r in results:\n print(f\"\\n关键词: {r['keyword']}\")\n print(f\" 综合 SEO 评分: {r['seo_score']}/10(百度 {r['baidu_score']} + 360 {r['so360_score']})\")\n if r[\"so360_suggestions\"]:\n print(f\" 360热搜词: {', '.join(r['so360_suggestions'][:5])}\")\n if r[\"related_keywords\"]:\n print(f\" 相关关键词: {', '.join(r['related_keywords'][:5])}\")\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":3883,"content_sha256":"afcb030435df87f7755f475ab45ba97e7f0c3a45f9f9b841f2fd112c7a32aa15"},{"filename":"style.example.yaml","content":"# WeWrite 风格配置\n# 复制为 style.yaml 并修改为你的公众号信息\n# 或让 WeWrite 在首次使用时通过对话自动生成\n\nname: \"Demo科技\"\nindustry: \"科技/互联网\"\ntarget_audience: \"25-40岁互联网从业者、科技爱好者\"\n\n# 内容方向\ntopics:\n - AI/人工智能\n - 产品设计\n - 创业/商业模式\n - 效率工具\n\n# 写作风格\ntone: \"专业但不学术,有观点但不偏激,偶尔幽默\"\nvoice: \"第一人称,像一个懂行的朋友在分享见解\"\nword_count: \"1500-2500\"\n\n# 内容风格(干货/故事/情绪/热点/测评)\n# 影响选题偏好和框架推荐\ncontent_style: \"干货\"\n\n# 写作人格(决定文章语感和 AI 检测通过率)\n# 可选:midnight-friend / industry-observer / sharp-journalist / warm-editor / cold-analyst\n# 详见 personas/ 目录\nwriting_persona: \"midnight-friend\"\n\n# 禁忌\nblacklist:\n words: [\"震惊\", \"必看\", \"不转不是中国人\", \"赶紧收藏\"]\n topics: [\"政治敏感\", \"宗教\", \"色情\", \"赌博\"]\n\n# 参考账号风格\nreference_accounts:\n - \"36氪\"\n - \"虎嗅\"\n - \"少数派\"\n\n# 排版\ntheme: \"professional-clean\"\n\n# 封面\ncover_style: \"简洁科技感,蓝色调,扁平化设计\"\n# cover_template: \"\" # 设置后跳过 AI 生成,直接使用该文件\n\n# 署名\nauthor: \"Demo编辑部\"\n","content_type":"application/yaml; charset=utf-8","language":"yaml","size":1308,"content_sha256":"66bfb0bbe2fd0c3b0ab3d049c240f6c7ba812725834b32e454a4be8b7688d222"},{"filename":"toolkit/cli.py","content":"#!/usr/bin/env python3\n\"\"\"\nCLI entry point for WeWrite.\n\nUsage:\n python cli.py preview article.md --theme professional-clean\n python cli.py publish article.md --appid wx123 --secret abc123\n python cli.py themes\n\"\"\"\n\nimport argparse\nimport sys\nimport webbrowser\nfrom pathlib import Path\n\nimport yaml\n\nfrom converter import WeChatConverter, preview_html\nfrom theme import load_theme, list_themes\nfrom wechat_api import get_access_token, upload_image, upload_thumb\nfrom publisher import create_draft, create_image_post\n\n# Config file search order\nCONFIG_PATHS = [\n Path.cwd() / \"config.yaml\",\n Path(__file__).parent.parent / \"config.yaml\", # skill root\n Path(__file__).parent / \"config.yaml\", # toolkit dir\n Path.home() / \".config\" / \"wewrite\" / \"config.yaml\",\n]\n\n\ndef load_config() -> dict:\n \"\"\"Load config from first found config.yaml.\"\"\"\n for p in CONFIG_PATHS:\n if p.exists():\n with open(p, \"r\", encoding=\"utf-8\") as f:\n return yaml.safe_load(f) or {}\n return {}\n\n\ndef cmd_preview(args):\n \"\"\"Generate HTML preview and open in browser.\"\"\"\n theme = load_theme(args.theme)\n converter = WeChatConverter(theme=theme)\n result = converter.convert_file(args.input)\n\n # Wrap in full HTML for browser preview\n full_html = preview_html(result.html, theme)\n\n # Write to temp file\n input_path = Path(args.input)\n output = args.output or str(input_path.with_suffix(\".html\"))\n Path(output).write_text(full_html, encoding=\"utf-8\")\n\n print(f\"Title: {result.title}\")\n print(f\"Digest: {result.digest}\")\n print(f\"Images: {len(result.images)}\")\n print(f\"Output: {output}\")\n\n if not args.no_open:\n webbrowser.open(f\"file://{Path(output).absolute()}\")\n print(\"Opened in browser.\")\n\n\ndef cmd_publish(args):\n \"\"\"Convert, upload images, and create WeChat draft.\"\"\"\n cfg = load_config()\n wechat_cfg = cfg.get(\"wechat\", {})\n\n # Resolve from CLI args → config.yaml fallback\n appid = args.appid or wechat_cfg.get(\"appid\")\n secret = args.secret or wechat_cfg.get(\"secret\")\n theme_name = args.theme or cfg.get(\"theme\", \"professional-clean\")\n author = args.author or wechat_cfg.get(\"author\")\n\n if not appid or not secret:\n print(\"Error: --appid and --secret required (or set in config.yaml)\", file=sys.stderr)\n sys.exit(1)\n\n theme = load_theme(theme_name)\n converter = WeChatConverter(theme=theme)\n result = converter.convert_file(args.input)\n\n print(f\"Title: {result.title}\")\n print(f\"Digest: {result.digest}\")\n print(f\"Images found: {len(result.images)}\")\n\n # Get access token\n token = get_access_token(appid, secret)\n print(\"Access token obtained.\")\n\n # Upload images referenced in article and replace src\n # Resolve relative paths against the markdown file's directory\n md_dir = Path(args.input).resolve().parent\n html = result.html\n for img_src in result.images:\n if img_src.startswith((\"http://\", \"https://\")):\n print(f\"Skipping remote image: {img_src}\")\n continue\n\n # Try: absolute → relative to CWD → relative to markdown file\n img_path = Path(img_src)\n if not img_path.is_absolute():\n if not img_path.exists():\n img_path = md_dir / img_src\n\n if img_path.exists():\n print(f\"Uploading image: {img_src}\")\n wechat_url = upload_image(token, str(img_path))\n html = html.replace(img_src, wechat_url)\n print(f\" -> {wechat_url}\")\n else:\n print(f\"Warning: image not found: {img_src} (searched {md_dir})\")\n\n # Upload cover image if provided\n thumb_media_id = None\n if args.cover:\n print(f\"Uploading cover: {args.cover}\")\n thumb_media_id = upload_thumb(token, args.cover)\n print(f\" -> media_id: {thumb_media_id}\")\n\n # Create draft\n title = args.title or result.title or Path(args.input).stem\n digest = args.digest or result.digest\n draft = create_draft(\n access_token=token,\n title=title,\n html=html,\n digest=digest,\n thumb_media_id=thumb_media_id,\n author=author,\n )\n\n print(f\"\\nDraft created! media_id: {draft.media_id}\")\n\n\ndef cmd_themes(args):\n \"\"\"List available themes.\"\"\"\n names = list_themes()\n for name in names:\n theme = load_theme(name)\n print(f\" {name:24s} {theme.description}\")\n\n\ndef cmd_image_post(args):\n \"\"\"Create a WeChat image post (小绿书) from image files.\"\"\"\n cfg = load_config()\n wechat_cfg = cfg.get(\"wechat\", {})\n\n appid = args.appid or wechat_cfg.get(\"appid\")\n secret = args.secret or wechat_cfg.get(\"secret\")\n\n if not appid or not secret:\n print(\"Error: --appid and --secret required (or set in config.yaml)\", file=sys.stderr)\n sys.exit(1)\n\n images = args.images\n if not images:\n print(\"Error: at least 1 image required\", file=sys.stderr)\n sys.exit(1)\n if len(images) > 20:\n print(f\"Error: max 20 images, got {len(images)}\", file=sys.stderr)\n sys.exit(1)\n\n token = get_access_token(appid, secret)\n print(f\"Uploading {len(images)} images as permanent materials...\")\n\n media_ids = []\n for img_path in images:\n p = Path(img_path)\n if not p.exists():\n print(f\"Error: image not found: {img_path}\", file=sys.stderr)\n sys.exit(1)\n print(f\" Uploading: {p.name}\")\n mid = upload_thumb(token, str(p))\n media_ids.append(mid)\n print(f\" -> {mid}\")\n\n title = args.title\n if len(title) > 32:\n print(f\"Warning: title truncated to 32 chars (was {len(title)})\")\n title = title[:32]\n\n content = args.content or \"\"\n\n result = create_image_post(\n access_token=token,\n title=title,\n image_media_ids=media_ids,\n content=content,\n open_comment=True,\n )\n\n print(f\"\\nImage post draft created!\")\n print(f\" media_id: {result.media_id}\")\n print(f\" images: {result.image_count}\")\n print(f\" title: {title}\")\n print(f\" 请到公众号后台草稿箱检查并发布\")\n\n\ndef cmd_gallery(args):\n \"\"\"Render all themes side by side in a browser gallery.\"\"\"\n from concurrent.futures import ThreadPoolExecutor\n\n # Use provided markdown or a built-in sample\n if args.input:\n md_text = Path(args.input).read_text(encoding=\"utf-8\")\n else:\n md_text = _gallery_sample_markdown()\n\n names = list_themes()\n results = {}\n\n def render_theme(name):\n theme = load_theme(name)\n converter = WeChatConverter(theme=theme)\n result = converter.convert(md_text)\n return name, theme.description, result.html\n\n # Parallel rendering\n with ThreadPoolExecutor(max_workers=8) as pool:\n for name, desc, html in pool.map(lambda n: render_theme(n), names):\n results[name] = (desc, html)\n\n # Build gallery HTML\n gallery_html = _build_gallery_html(results, names)\n output = args.output or \"/tmp/wewrite-gallery.html\"\n Path(output).write_text(gallery_html, encoding=\"utf-8\")\n print(f\"Gallery: {output} ({len(names)} themes)\")\n\n if not args.no_open:\n webbrowser.open(f\"file://{Path(output).absolute()}\")\n\n\ndef cmd_learn_theme(args):\n \"\"\"Learn a theme from a WeChat article URL.\"\"\"\n import subprocess\n script = Path(__file__).parent.parent / \"scripts\" / \"learn_theme.py\"\n cmd = [sys.executable, str(script), args.url, \"--name\", args.name]\n result = subprocess.run(cmd)\n sys.exit(result.returncode)\n\n\ndef _gallery_sample_markdown():\n return \"\"\"# 示例文章标题\n\n## 第一部分\n\n这是一段正常的文章内容,用来展示不同主题的排版效果。WeWrite 支持多种排版主题,每种都有独特的视觉风格。\n\n说实话,选主题这件事——看截图永远不如看实际渲染效果。\n\n## 关键数据\n\n| 指标 | 数值 | 变化 |\n|------|------|------|\n| 阅读量 | 12,580 | +23% |\n| 分享率 | 4.7% | +0.8% |\n| 完读率 | 68% | -2% |\n\n## 代码示例\n\n```python\ndef hello():\n print(\"Hello, WeWrite!\")\n```\n\n> 好的排版不是让读者注意到设计,而是让读者忘记设计,只记住内容。\n\n## 列表展示\n\n- 第一个要点:简洁是设计的灵魂\n- 第二个要点:一致性比创意更重要\n- 第三个要点:移动端体验优先\n\n**加粗文本**和*斜体文本*的样式也需要关注。\n\n最后这段用来展示文章结尾的留白和间距效果。一篇好文章的结尾,应该像一首好歌的最后一个音符——恰到好处地收束。\n\"\"\"\n\n\ndef _join_newline(items):\n \"\"\"Join items with comma + newline (workaround for f-string limitation).\"\"\"\n return \",\\n\".join(items)\n\n\ndef _build_gallery_html(results, names):\n cards = []\n for name in names:\n desc, html = results[name]\n # Escape for embedding in JS\n escaped_html = html.replace('\\\\', '\\\\\\\\').replace('`', '\\\\`').replace('

WeWrite — 公众号文章全流程 行为声明 角色 :用户的公众号内容编辑 Agent。 模式 : - 默认全自动 ——一口气跑完 Step 1-8,不中途停下。只在出错时停。 - 交互模式 ——用户说"交互模式"/"我要自己选"时,在选题/框架/配图处暂停。 降级原则 :每一步都有降级方案。Step 1 检测到的降级标记( 、 )在后续 Step 自动生效,不重复报错。 进度追踪 :主管道启动时,用 TaskCreate 为 8 个 Step 创建任务。每开始一个 Step 标记 in progress,完成后标记 completed。用户可随时看到当前进度。 完成协议 : - DONE — 全流程完成,文章已保存/推送 - DONE WITH CONCERNS — 完成但部分步骤降级,列出降级项 - BLOCKED — 关键步骤无法继续(如 Python 依赖缺失且用户拒绝安装) - NEEDS CONTEXT — 需要用户提供信息才能继续(如首次设置需要公众号名称) 路径约定 :本文档中 指本 SKILL.md 所在的目录(即 WeWrite 的根目录)。 Onboard 例外 :Onboard 是交互式的(需要问用户问题),不受"全自动"约束。Onboard 完成后回到全自动管道。 辅助功能 (按需加载,不在主管道内): - 用户说"重新设置风格" → - 用户说"学习…

, '\\\\

WeWrite — 公众号文章全流程 行为声明 角色 :用户的公众号内容编辑 Agent。 模式 : - 默认全自动 ——一口气跑完 Step 1-8,不中途停下。只在出错时停。 - 交互模式 ——用户说"交互模式"/"我要自己选"时,在选题/框架/配图处暂停。 降级原则 :每一步都有降级方案。Step 1 检测到的降级标记( 、 )在后续 Step 自动生效,不重复报错。 进度追踪 :主管道启动时,用 TaskCreate 为 8 个 Step 创建任务。每开始一个 Step 标记 in progress,完成后标记 completed。用户可随时看到当前进度。 完成协议 : - DONE — 全流程完成,文章已保存/推送 - DONE WITH CONCERNS — 完成但部分步骤降级,列出降级项 - BLOCKED — 关键步骤无法继续(如 Python 依赖缺失且用户拒绝安装) - NEEDS CONTEXT — 需要用户提供信息才能继续(如首次设置需要公众号名称) 路径约定 :本文档中 指本 SKILL.md 所在的目录(即 WeWrite 的根目录)。 Onboard 例外 :Onboard 是交互式的(需要问用户问题),不受"全自动"约束。Onboard 完成后回到全自动管道。 辅助功能 (按需加载,不在主管道内): - 用户说"重新设置风格" → - 用户说"学习…

)\n cards.append(f\"\"\"\n \u003cdiv class=\"theme-card\" onclick=\"selectTheme('{name}')\">\n \u003cdiv class=\"theme-name\">{name}\u003c/div>\n \u003cdiv class=\"theme-desc\">{desc}\u003c/div>\n \u003cdiv class=\"phone-frame\">\n \u003cdiv class=\"phone-content\" id=\"preview-{name}\">{html}\u003c/div>\n \u003c/div>\n \u003cbutton class=\"copy-btn\" onclick=\"event.stopPropagation(); copyHTML('{name}')\">复制 HTML\u003c/button>\n \u003c/div>\"\"\")\n\n # Store HTML data for copy\n data_entries = []\n for name in names:\n desc, html = results[name]\n safe = html.replace('\\\\', '\\\\\\\\').replace(\"'\", \"\\\\'\").replace('\\n', '\\\\n')\n data_entries.append(f\" '{name}': '{safe}'\")\n\n return f\"\"\"\u003c!DOCTYPE html>\n\u003chtml lang=\"zh-CN\">\n\u003chead>\n\u003cmeta charset=\"UTF-8\">\n\u003cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n\u003ctitle>WeWrite 主题画廊\u003c/title>\n\u003cstyle>\n* {{ margin: 0; padding: 0; box-sizing: border-box; }}\nbody {{ font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif; background: #0f0f0f; color: #fff; }}\n.header {{ text-align: center; padding: 40px 20px 20px; }}\n.header h1 {{ font-size: 28px; font-weight: 700; }}\n.header p {{ color: #888; margin-top: 8px; font-size: 15px; }}\n.grid {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 24px; padding: 24px; max-width: 1440px; margin: 0 auto; }}\n.theme-card {{ background: #1a1a1a; border-radius: 12px; padding: 16px; cursor: pointer; transition: transform 0.2s, box-shadow 0.2s; }}\n.theme-card:hover {{ transform: translateY(-4px); box-shadow: 0 8px 24px rgba(0,0,0,0.4); }}\n.theme-name {{ font-size: 16px; font-weight: 700; margin-bottom: 4px; }}\n.theme-desc {{ font-size: 13px; color: #888; margin-bottom: 12px; }}\n.phone-frame {{ background: #fff; border-radius: 8px; overflow: hidden; max-height: 480px; overflow-y: auto; }}\n.phone-content {{ padding: 16px; font-size: 14px; transform: scale(0.85); transform-origin: top left; width: 118%; }}\n.copy-btn {{ margin-top: 12px; width: 100%; padding: 8px; background: #333; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; }}\n.copy-btn:hover {{ background: #555; }}\n.toast {{ position: fixed; bottom: 40px; left: 50%; transform: translateX(-50%); background: #333; color: #fff; padding: 10px 24px; border-radius: 8px; font-size: 14px; display: none; z-index: 999; }}\n\u003c/style>\n\u003c/head>\n\u003cbody>\n\u003cdiv class=\"header\">\n \u003ch1>WeWrite 主题画廊\u003c/h1>\n \u003cp>{len(names)} 个主题 · 点击卡片查看大图 · 点击「复制 HTML」直接粘贴到公众号编辑器\u003c/p>\n\u003c/div>\n\u003cdiv class=\"grid\">\n{''.join(cards)}\n\u003c/div>\n\u003cdiv class=\"toast\" id=\"toast\">已复制到剪贴板\u003c/div>\n\u003cscript>\nconst themeData = {{\n{_join_newline(data_entries)}\n}};\nfunction copyHTML(name) {{\n const html = themeData[name];\n if (html) {{\n navigator.clipboard.writeText(html).then(() => {{\n const t = document.getElementById('toast');\n t.style.display = 'block';\n setTimeout(() => t.style.display = 'none', 1500);\n }});\n }}\n}}\nfunction selectTheme(name) {{\n localStorage.setItem('wewrite-theme', name);\n // Scroll to card for visual feedback\n const el = document.getElementById('preview-' + name);\n if (el) el.scrollIntoView({{ behavior: 'smooth', block: 'center' }});\n}}\n\u003c/script>\n\u003c/body>\n\u003c/html>\"\"\"\n\n\ndef main():\n parser = argparse.ArgumentParser(\n prog=\"wewrite\",\n description=\"Markdown to WeChat HTML converter and publisher\",\n )\n sub = parser.add_subparsers(dest=\"command\", required=True)\n\n # preview\n p_preview = sub.add_parser(\"preview\", help=\"Generate HTML and open in browser\")\n p_preview.add_argument(\"input\", help=\"Markdown file path\")\n p_preview.add_argument(\"-t\", \"--theme\", default=\"professional-clean\", help=\"Theme name\")\n p_preview.add_argument(\"-o\", \"--output\", help=\"Output HTML file path\")\n p_preview.add_argument(\"--no-open\", action=\"store_true\", help=\"Don't open browser\")\n\n # publish\n p_publish = sub.add_parser(\"publish\", help=\"Convert and publish as WeChat draft\")\n p_publish.add_argument(\"input\", help=\"Markdown file path\")\n p_publish.add_argument(\"-t\", \"--theme\", default=None, help=\"Theme name\")\n p_publish.add_argument(\"--appid\", default=None, help=\"WeChat AppID (or set in config.yaml)\")\n p_publish.add_argument(\"--secret\", default=None, help=\"WeChat AppSecret (or set in config.yaml)\")\n p_publish.add_argument(\"--cover\", help=\"Cover image file path\")\n p_publish.add_argument(\"--title\", help=\"Override article title\")\n p_publish.add_argument(\"--author\", default=None, help=\"Article author\")\n p_publish.add_argument(\"--digest\", default=None, help=\"Override article digest (≤120 UTF-8 bytes)\")\n\n # themes\n sub.add_parser(\"themes\", help=\"List available themes\")\n\n # image-post (小绿书)\n p_imgpost = sub.add_parser(\"image-post\", help=\"Create WeChat image post (小绿书)\")\n p_imgpost.add_argument(\"images\", nargs=\"+\", help=\"Image file paths (1-20, first = cover)\")\n p_imgpost.add_argument(\"-t\", \"--title\", required=True, help=\"Post title (max 32 chars)\")\n p_imgpost.add_argument(\"-c\", \"--content\", default=\"\", help=\"Plain text description (max ~1000 chars)\")\n p_imgpost.add_argument(\"--appid\", default=None, help=\"WeChat AppID\")\n p_imgpost.add_argument(\"--secret\", default=None, help=\"WeChat AppSecret\")\n\n # gallery\n p_gallery = sub.add_parser(\"gallery\", help=\"Open theme gallery in browser\")\n p_gallery.add_argument(\"input\", nargs=\"?\", default=None, help=\"Markdown file (optional, uses sample if omitted)\")\n p_gallery.add_argument(\"-o\", \"--output\", help=\"Output HTML file path\")\n p_gallery.add_argument(\"--no-open\", action=\"store_true\", help=\"Don't open browser\")\n\n # learn-theme\n p_learn = sub.add_parser(\"learn-theme\", help=\"Learn formatting theme from a WeChat article URL\")\n p_learn.add_argument(\"url\", help=\"WeChat article URL\")\n p_learn.add_argument(\"--name\", required=True, help=\"Theme name\")\n\n args = parser.parse_args()\n\n try:\n if args.command == \"preview\":\n cmd_preview(args)\n elif args.command == \"publish\":\n cmd_publish(args)\n elif args.command == \"themes\":\n cmd_themes(args)\n elif args.command == \"image-post\":\n cmd_image_post(args)\n elif args.command == \"gallery\":\n cmd_gallery(args)\n elif args.command == \"learn-theme\":\n cmd_learn_theme(args)\n except Exception as e:\n print(f\"Error: {e}\", file=sys.stderr)\n sys.exit(1)\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":15549,"content_sha256":"060ec986a92a9faf5821ffaee3771d5f43f6d7167cbe80bbea64b352f734fbf0"},{"filename":"toolkit/converter.py","content":"\"\"\"\nMarkdown to WeChat-compatible HTML converter.\n\nForked from wechat_article_skills/scripts/markdown_to_html.py,\nadapted for YAML-driven themes and agent integration.\n\"\"\"\n\nimport re\nfrom dataclasses import dataclass, field\nfrom pathlib import Path\nfrom typing import Optional\n\nimport markdown\nfrom bs4 import BeautifulSoup\n\nfrom theme import Theme, load_theme, get_inline_css_rules\n\n\n@dataclass\nclass ConvertResult:\n \"\"\"Result of a Markdown → WeChat HTML conversion.\"\"\"\n\n html: str # WeChat-compatible inline-style HTML (body content only)\n title: str # Extracted H1 title\n digest: str # Auto-generated summary (first 120 chars)\n images: list[str] = field(default_factory=list) # Image references found\n\n\nclass WeChatConverter:\n \"\"\"Convert Markdown to WeChat-compatible inline-style HTML.\"\"\"\n\n def __init__(self, theme: Optional[Theme] = None, theme_name: str = \"professional-clean\"):\n if theme is not None:\n self._theme = theme\n else:\n self._theme = load_theme(theme_name)\n self._css_rules = get_inline_css_rules(self._theme)\n\n def convert(self, markdown_text: str) -> ConvertResult:\n \"\"\"\n Convert Markdown text to WeChat-compatible HTML.\n\n Returns ConvertResult with:\n - html: inline-style HTML (body content only, no \u003chtml>/\u003chead> wrapper)\n - title: extracted H1 title (or empty string)\n - digest: first 120 characters of plain text\n - images: list of image src references\n \"\"\"\n title = self._extract_title(markdown_text)\n markdown_text = self._strip_h1(markdown_text)\n\n # Pre-process container blocks (:::dialogue, :::timeline, etc.)\n markdown_text = self._preprocess_containers(markdown_text)\n\n # CJK fix: auto-space between CJK and Latin characters\n markdown_text = self._fix_cjk_spacing(markdown_text)\n\n # Parse Markdown → HTML\n html = self._markdown_to_html(markdown_text)\n\n # Enhance code blocks (add data-lang attribute)\n html = self._enhance_code_blocks(html)\n\n # Process images (ensure responsive styling)\n html, images = self._process_images(html)\n\n # CJK fix: move punctuation outside bold tags\n html = self._fix_cjk_bold_punctuation(html)\n\n # CJK fix: convert ul/ol to section-based lists (WeChat renders native lists unreliably)\n html = self._convert_lists_to_sections(html)\n\n # Convert external links to footnotes (WeChat blocks external links)\n html = self._convert_links_to_footnotes(html)\n\n # Apply inline CSS from theme\n html = self._apply_inline_styles(html)\n\n # Apply WeChat compatibility fixes\n html = self._apply_wechat_fixes(html)\n\n # Inject dark mode attributes\n html = self._inject_darkmode(html)\n\n # Generate digest from plain text\n digest = self._generate_digest(html)\n\n return ConvertResult(html=html, title=title, digest=digest, images=images)\n\n def convert_file(self, input_path: str) -> ConvertResult:\n \"\"\"Convert a Markdown file.\"\"\"\n path = Path(input_path)\n if not path.exists():\n raise FileNotFoundError(f\"Input file not found: {input_path}\")\n\n text = path.read_text(encoding=\"utf-8\")\n return self.convert(text)\n\n # -- internal methods --\n\n def _extract_title(self, text: str) -> str:\n \"\"\"Extract the first H1 title from Markdown text.\"\"\"\n for line in text.split(\"\\n\"):\n stripped = line.strip()\n if stripped.startswith(\"# \") and not stripped.startswith(\"## \"):\n return stripped[2:].strip()\n return \"\"\n\n def _strip_h1(self, text: str) -> str:\n \"\"\"Remove H1 lines — WeChat has a separate title field.\"\"\"\n lines = []\n for line in text.split(\"\\n\"):\n stripped = line.strip()\n if stripped.startswith(\"# \") and not stripped.startswith(\"## \"):\n continue\n lines.append(line)\n return \"\\n\".join(lines)\n\n def _markdown_to_html(self, text: str) -> str:\n \"\"\"Parse Markdown to HTML using python-markdown with extensions.\"\"\"\n extensions = [\n \"markdown.extensions.fenced_code\",\n \"markdown.extensions.tables\",\n \"markdown.extensions.nl2br\",\n \"markdown.extensions.sane_lists\",\n \"markdown.extensions.codehilite\",\n ]\n extension_configs = {\n \"codehilite\": {\n \"linenums\": False,\n \"guess_lang\": True,\n \"noclasses\": True, # Inline syntax highlight styles\n }\n }\n md = markdown.Markdown(extensions=extensions, extension_configs=extension_configs)\n return md.convert(text)\n\n def _enhance_code_blocks(self, html: str) -> str:\n \"\"\"Add data-lang attribute to \u003cpre> elements for language labeling.\"\"\"\n soup = BeautifulSoup(html, \"html.parser\")\n for pre in soup.find_all(\"pre\"):\n code = pre.find(\"code\")\n if code:\n for cls in code.get(\"class\", []):\n if cls.startswith(\"language-\"):\n pre[\"data-lang\"] = cls.replace(\"language-\", \"\")\n break\n return str(soup)\n\n def _process_images(self, html: str) -> tuple[str, list[str]]:\n \"\"\"Extract image references and ensure responsive styling.\"\"\"\n soup = BeautifulSoup(html, \"html.parser\")\n images = []\n for img in soup.find_all(\"img\"):\n src = img.get(\"src\", \"\")\n if src:\n images.append(src)\n # Ensure responsive image styles\n existing = img.get(\"style\", \"\")\n if \"max-width\" not in existing:\n additions = \"max-width: 100%; height: auto; display: block; margin: 24px auto\"\n img[\"style\"] = f\"{existing}; {additions}\" if existing else additions\n return str(soup), images\n\n def _apply_inline_styles(self, html: str) -> str:\n \"\"\"Apply theme CSS rules as inline styles on matching elements.\"\"\"\n soup = BeautifulSoup(html, \"html.parser\")\n\n for selector, styles in self._css_rules.items():\n # Skip body — we don't wrap in body tag\n if selector.strip() == \"body\":\n continue\n\n try:\n elements = soup.select(selector)\n except Exception:\n continue\n\n for elem in elements:\n existing = elem.get(\"style\", \"\")\n style_dict = {}\n\n # Parse existing inline styles\n if existing:\n for item in existing.split(\";\"):\n if \":\" in item:\n key, val = item.split(\":\", 1)\n style_dict[key.strip()] = val.strip()\n\n # Add theme styles (existing styles take precedence)\n for prop, val in styles.items():\n if prop not in style_dict:\n style_dict[prop] = val\n\n elem[\"style\"] = \"; \".join(f\"{k}: {v}\" for k, v in style_dict.items())\n\n return str(soup)\n\n def _apply_wechat_fixes(self, html: str) -> str:\n \"\"\"\n Apply WeChat-specific compatibility fixes:\n 1. Force explicit color on every \u003cp> tag\n 2. Ensure code blocks preserve whitespace\n \"\"\"\n soup = BeautifulSoup(html, \"html.parser\")\n text_color = self._theme.colors.get(\"text\", \"#333333\")\n\n # Fix 1: Ensure all \u003cp> tags have explicit color\n for p in soup.find_all(\"p\"):\n style = p.get(\"style\", \"\")\n if \"color\" not in style:\n p[\"style\"] = f\"{style}; color: {text_color}\" if style else f\"color: {text_color}\"\n\n # Fix 2: Ensure \u003cpre> has whitespace preservation\n for pre in soup.find_all(\"pre\"):\n style = pre.get(\"style\", \"\")\n if \"white-space\" not in style:\n pre[\"style\"] = f\"{style}; white-space: pre-wrap; word-wrap: break-word\" if style else \"white-space: pre-wrap; word-wrap: break-word\"\n\n return str(soup)\n\n # -- CJK compatibility fixes --\n\n def _fix_cjk_spacing(self, text: str) -> str:\n \"\"\"Auto-insert thin space between CJK and Latin/digit characters.\n\n WeChat renders CJK-Latin without spacing, making mixed text hard to read.\n This inserts a thin space (U+200A) at CJK↔Latin boundaries.\n Runs on raw Markdown before parsing, skipping code blocks and links.\n \"\"\"\n # CJK unicode ranges\n cjk = r'[\\u4e00-\\u9fff\\u3400-\\u4dbf\\u3000-\\u303f\\uff00-\\uffef]'\n latin = r'[A-Za-z0-9]'\n\n lines = text.split('\\n')\n result = []\n in_code_block = False\n\n for line in lines:\n if line.strip().startswith('```'):\n in_code_block = not in_code_block\n result.append(line)\n continue\n if in_code_block:\n result.append(line)\n continue\n\n # CJK followed by Latin\n line = re.sub(f'({cjk})({latin})', r'\\1 \\2', line)\n # Latin followed by CJK\n line = re.sub(f'({latin})({cjk})', r'\\1 \\2', line)\n result.append(line)\n\n return '\\n'.join(result)\n\n def _fix_cjk_bold_punctuation(self, html: str) -> str:\n \"\"\"Move Chinese punctuation outside bold/strong tags.\n\n WeChat renders bold CJK punctuation with ugly spacing.\n Move trailing punctuation (,。!?;:、) outside \u003c/strong>.\n \"\"\"\n # Match: \u003cstrong>内容+中文标点\u003c/strong> → \u003cstrong>内容\u003c/strong>标点\n pattern = r'(\u003cstrong>)(.*?)([,。!?;:、]+)(\u003c/strong>)'\n return re.sub(pattern, r'\\1\\2\\4\\3', html)\n\n def _convert_lists_to_sections(self, html: str) -> str:\n \"\"\"Convert \u003cul>/\u003col> to styled \u003csection> elements.\n\n WeChat's native list rendering is unreliable (inconsistent bullet\n style, broken indentation on some devices). Using section+span\n for bullets/numbers gives full control over appearance.\n \"\"\"\n soup = BeautifulSoup(html, \"html.parser\")\n text_color = self._theme.colors.get(\"text\", \"#333333\")\n primary = self._theme.colors.get(\"primary\", \"#2563eb\")\n\n for ul in soup.find_all(\"ul\"):\n section = soup.new_tag(\"section\")\n for li in ul.find_all(\"li\", recursive=False):\n item = soup.new_tag(\"section\", style=f\"display: flex; align-items: flex-start; margin-bottom: 8px; color: {text_color}\")\n bullet = soup.new_tag(\"span\", style=f\"color: {primary}; margin-right: 8px; flex-shrink: 0; font-size: 18px; line-height: 1.6\")\n bullet.string = \"•\"\n content = soup.new_tag(\"span\", style=\"flex: 1\")\n for child in list(li.children):\n content.append(child.extract() if hasattr(child, 'extract') else child)\n item.append(bullet)\n item.append(content)\n section.append(item)\n ul.replace_with(section)\n\n for idx, ol in enumerate(soup.find_all(\"ol\")):\n section = soup.new_tag(\"section\")\n for num, li in enumerate(ol.find_all(\"li\", recursive=False), 1):\n item = soup.new_tag(\"section\", style=f\"display: flex; align-items: flex-start; margin-bottom: 8px; color: {text_color}\")\n number = soup.new_tag(\"span\", style=f\"color: {primary}; margin-right: 8px; flex-shrink: 0; font-weight: 700; line-height: 1.8\")\n number.string = f\"{num}.\"\n content = soup.new_tag(\"span\", style=\"flex: 1\")\n for child in list(li.children):\n content.append(child.extract() if hasattr(child, 'extract') else child)\n item.append(number)\n item.append(content)\n section.append(item)\n ol.replace_with(section)\n\n return str(soup)\n\n # -- External link → footnote conversion --\n\n def _convert_links_to_footnotes(self, html: str) -> str:\n \"\"\"Convert external \u003ca> links to superscript footnote numbers.\n\n WeChat blocks external links — readers see dead text. This converts\n each external link to a superscript number with the URL collected\n into a reference list appended at the end.\n \"\"\"\n soup = BeautifulSoup(html, \"html.parser\")\n footnotes = []\n counter = 0\n primary = self._theme.colors.get(\"primary\", \"#2563eb\")\n\n for a in soup.find_all(\"a\"):\n href = a.get(\"href\", \"\")\n if not href or href.startswith(\"#\"):\n continue # skip anchors\n\n counter += 1\n text = a.get_text()\n footnotes.append((counter, text, href))\n\n # Replace \u003ca> with text + superscript number\n sup = soup.new_tag(\"sup\")\n sup_link = soup.new_tag(\"span\", style=f\"color: {primary}; font-size: 12px\")\n sup_link.string = f\"[{counter}]\"\n sup.append(sup_link)\n a.replace_with(text, sup)\n\n if footnotes:\n # Append reference section\n hr = soup.new_tag(\"hr\", style=\"border: none; border-top: 1px solid #e5e5e5; margin: 32px 0 16px\")\n soup.append(hr)\n ref_title = soup.new_tag(\"p\", style=\"font-size: 13px; color: #999999; margin-bottom: 8px; font-weight: 700\")\n ref_title.string = \"参考链接\"\n soup.append(ref_title)\n for num, text, href in footnotes:\n ref = soup.new_tag(\"p\", style=\"font-size: 12px; color: #999999; margin: 2px 0; word-break: break-all\")\n ref.string = f\"[{num}] {text}: {href}\"\n soup.append(ref)\n\n return str(soup)\n\n # -- Dark mode --\n\n def _inject_darkmode(self, html: str) -> str:\n \"\"\"Inject data-darkmode-* attributes for WeChat dark mode.\n\n WeChat auto-inverts colors in dark mode, which often breaks\n designed color schemes. Explicit darkmode attributes tell WeChat\n exactly what colors to use instead of guessing.\n \"\"\"\n darkmode = self._theme.colors.get(\"darkmode\", {})\n if not darkmode:\n return html\n\n soup = BeautifulSoup(html, \"html.parser\")\n dm_text = darkmode.get(\"text\", \"#c8c8c8\")\n dm_bg = darkmode.get(\"background\", \"#1e1e1e\")\n dm_primary = darkmode.get(\"primary\", \"#6aadff\")\n\n # Body-level elements (p, li, section, span)\n for tag_name in (\"p\", \"span\", \"section\"):\n for elem in soup.find_all(tag_name):\n style = elem.get(\"style\", \"\")\n # Only set if element has a color\n if \"color\" in style:\n elem[\"data-darkmode-color\"] = dm_text\n elem[\"data-darkmode-bgcolor\"] = \"transparent\"\n\n # Headings\n dm_heading = darkmode.get(\"text\", \"#e0e0e0\")\n for tag_name in (\"h1\", \"h2\", \"h3\", \"h4\"):\n for elem in soup.find_all(tag_name):\n elem[\"data-darkmode-color\"] = dm_heading\n elem[\"data-darkmode-bgcolor\"] = \"transparent\"\n\n # Code blocks\n dm_code_bg = darkmode.get(\"code_bg\", \"#2d2d2d\")\n dm_code_color = darkmode.get(\"code_color\", \"#d4d4d4\")\n for pre in soup.find_all(\"pre\"):\n pre[\"data-darkmode-bgcolor\"] = dm_code_bg\n pre[\"data-darkmode-color\"] = dm_code_color\n for code in soup.find_all(\"code\"):\n code[\"data-darkmode-color\"] = dm_code_color\n\n # Blockquotes\n dm_quote_bg = darkmode.get(\"quote_bg\", \"#2a2a2a\")\n for bq in soup.find_all(\"blockquote\"):\n bq[\"data-darkmode-bgcolor\"] = dm_quote_bg\n bq[\"data-darkmode-color\"] = dm_text\n\n # Strong/em with primary color\n for strong in soup.find_all(\"strong\"):\n strong[\"data-darkmode-color\"] = dm_primary\n\n return str(soup)\n\n # -- Container block syntax --\n\n def _preprocess_containers(self, text: str) -> str:\n \"\"\"Pre-process :::container blocks into styled HTML before Markdown parsing.\n\n Supports:\n :::dialogue — chat bubble layout\n :::timeline — vertical timeline with dots\n :::callout — Obsidian-style callout (tip/warning/info/danger)\n :::quote — styled pull quote\n \"\"\"\n text = self._process_dialogue(text)\n text = self._process_timeline(text)\n text = self._process_callout(text)\n text = self._process_quote_block(text)\n return text\n\n def _process_dialogue(self, text: str) -> str:\n \"\"\"Convert :::dialogue blocks to chat bubble HTML.\"\"\"\n primary = self._theme.colors.get(\"primary\", \"#2563eb\")\n\n def replace_dialogue(match):\n content = match.group(1).strip()\n bubbles = []\n for line in content.split('\\n'):\n line = line.strip()\n if not line:\n continue\n if line.startswith('> '):\n # Right-aligned (reply) bubble\n msg = line[2:].strip()\n bubbles.append(f'\u003csection style=\"display: flex; justify-content: flex-end; margin-bottom: 12px\">'\n f'\u003csection style=\"background: {primary}; color: white; padding: 10px 14px; border-radius: 12px 12px 2px 12px; max-width: 80%; font-size: 15px; line-height: 1.6\">{msg}\u003c/section>\u003c/section>')\n else:\n # Left-aligned bubble\n bubbles.append(f'\u003csection style=\"display: flex; justify-content: flex-start; margin-bottom: 12px\">'\n f'\u003csection style=\"background: #f3f4f6; color: #333; padding: 10px 14px; border-radius: 12px 12px 12px 2px; max-width: 80%; font-size: 15px; line-height: 1.6\">{line}\u003c/section>\u003c/section>')\n return '\\n'.join(bubbles)\n\n return re.sub(r':::dialogue\\n(.*?)\\n:::', replace_dialogue, text, flags=re.DOTALL)\n\n def _process_timeline(self, text: str) -> str:\n \"\"\"Convert :::timeline blocks to vertical timeline HTML.\"\"\"\n primary = self._theme.colors.get(\"primary\", \"#2563eb\")\n\n def replace_timeline(match):\n content = match.group(1).strip()\n items = []\n for line in content.split('\\n'):\n line = line.strip()\n if not line:\n continue\n # Format: \"**title** description\" or just \"description\"\n items.append(\n f'\u003csection style=\"display: flex; margin-bottom: 16px\">'\n f'\u003csection style=\"flex-shrink: 0; width: 12px; display: flex; flex-direction: column; align-items: center\">'\n f'\u003csection style=\"width: 10px; height: 10px; border-radius: 50%; background: {primary}; margin-top: 6px\">\u003c/section>'\n f'\u003csection style=\"width: 2px; flex: 1; background: #e5e7eb; margin-top: 4px\">\u003c/section>'\n f'\u003c/section>'\n f'\u003csection style=\"flex: 1; padding-left: 12px; padding-bottom: 8px; font-size: 15px; line-height: 1.7\">{line}\u003c/section>'\n f'\u003c/section>'\n )\n return '\\n'.join(items)\n\n return re.sub(r':::timeline\\n(.*?)\\n:::', replace_timeline, text, flags=re.DOTALL)\n\n def _process_callout(self, text: str) -> str:\n \"\"\"Convert :::callout blocks to styled callout boxes.\n\n Syntax: :::callout tip/warning/info/danger\n \"\"\"\n colors_map = {\n \"tip\": (\"#059669\", \"#ecfdf5\", \"💡\"),\n \"warning\": (\"#d97706\", \"#fffbeb\", \"⚠️\"),\n \"info\": (\"#2563eb\", \"#eff6ff\", \"ℹ️\"),\n \"danger\": (\"#dc2626\", \"#fef2f2\", \"🚨\"),\n }\n\n def replace_callout(match):\n ctype = match.group(1).strip().lower()\n content = match.group(2).strip()\n color, bg, icon = colors_map.get(ctype, colors_map[\"info\"])\n return (f'\u003csection style=\"background: {bg}; border-left: 4px solid {color}; '\n f'padding: 14px 16px; border-radius: 4px; margin: 16px 0; font-size: 15px; line-height: 1.7\">'\n f'\u003csection style=\"font-weight: 700; color: {color}; margin-bottom: 6px\">{icon} {ctype.upper()}\u003c/section>'\n f'{content}\u003c/section>')\n\n return re.sub(r':::callout\\s+(\\w+)\\n(.*?)\\n:::', replace_callout, text, flags=re.DOTALL)\n\n def _process_quote_block(self, text: str) -> str:\n \"\"\"Convert :::quote blocks to styled pull quotes.\"\"\"\n primary = self._theme.colors.get(\"primary\", \"#2563eb\")\n\n def replace_quote(match):\n content = match.group(1).strip()\n return (f'\u003csection style=\"margin: 24px 0; padding: 20px 24px; border-left: 4px solid {primary}; '\n f'background: linear-gradient(135deg, #f8f9fa 0%, #ffffff 100%); border-radius: 0 8px 8px 0\">'\n f'\u003csection style=\"font-size: 18px; line-height: 1.8; color: #333; font-style: italic\">'\n f'\"{content}\"\u003c/section>\u003c/section>')\n\n return re.sub(r':::quote\\n(.*?)\\n:::', replace_quote, text, flags=re.DOTALL)\n\n # -- Digest generation --\n\n def _generate_digest(self, html: str, max_bytes: int = 120) -> str:\n \"\"\"Generate a digest that fits within WeChat's byte limit (120 bytes UTF-8).\"\"\"\n soup = BeautifulSoup(html, \"html.parser\")\n text = soup.get_text(separator=\" \", strip=True)\n text = re.sub(r\"\\s+\", \" \", text).strip()\n\n # Truncate to fit within max_bytes (UTF-8)\n ellipsis = \"...\"\n ellipsis_bytes = len(ellipsis.encode(\"utf-8\"))\n target_bytes = max_bytes - ellipsis_bytes\n\n encoded = text.encode(\"utf-8\")\n if len(encoded) \u003c= max_bytes:\n return text\n\n # Truncate at valid UTF-8 boundary\n truncated = encoded[:target_bytes].decode(\"utf-8\", errors=\"ignore\").rstrip()\n return truncated + ellipsis\n\n\ndef preview_html(body_html: str, theme: Theme) -> str:\n \"\"\"\n Wrap body content in a full HTML document for browser preview.\n This is only for local preview — NOT for WeChat publishing.\n \"\"\"\n return f\"\"\"\u003c!DOCTYPE html>\n\u003chtml lang=\"zh-CN\">\n\u003chead>\n \u003cmeta charset=\"UTF-8\">\n \u003cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n \u003ctitle>Preview\u003c/title>\n \u003cstyle>\n{theme.base_css}\n \u003c/style>\n\u003c/head>\n\u003cbody>\n {body_html}\n\u003c/body>\n\u003c/html>\"\"\"\n","content_type":"text/x-python; charset=utf-8","language":"python","size":22519,"content_sha256":"4d31a7df1f8a4a787d73c4b6b9234cb3f9deec15a8be020604d2dac6b8b90113"},{"filename":"toolkit/image_gen.py","content":"#!/usr/bin/env python3\n\"\"\"\nAI image generation module for WeWrite.\n\nSupports multiple providers via a simple abstraction:\n - doubao-seedream (Volcengine Ark) — default, good for Chinese prompts\n - openai (DALL-E 3) — broad availability\n - gemini (Google Gemini Imagen) — multimodal image generation\n - dashscope (Alibaba Tongyi Wanxiang) — good for Chinese prompts\n - minimax — Chinese provider\n - replicate — open-source models\n - azure_openai — Azure-hosted DALL-E\n - openrouter — multi-model proxy\n - jimeng (ByteDance) — good for Chinese prompts\n - Custom providers via ImageProvider base class\n\nUsage as CLI:\n python3 image_gen.py --prompt \"描述\" --output cover.png\n python3 image_gen.py --prompt \"描述\" --output cover.png --size cover\n python3 image_gen.py --prompt \"描述\" --output cover.png --provider gemini\n\nUsage as module:\n from image_gen import generate_image\n path = generate_image(\"prompt text\", \"output.png\", size=\"cover\")\n\"\"\"\n\nimport abc\nimport argparse\nimport base64\nimport hashlib\nimport hmac\nimport json\nimport sys\nimport time\nfrom datetime import datetime, timezone\nfrom pathlib import Path\n\nimport requests\nimport yaml\n\n# --- Config ---\n\nCONFIG_PATHS = [\n Path.cwd() / \"config.yaml\",\n Path(__file__).parent.parent / \"config.yaml\", # skill root\n Path(__file__).parent / \"config.yaml\", # toolkit dir\n Path.home() / \".config\" / \"wewrite\" / \"config.yaml\",\n]\n\n\ndef _load_config() -> dict:\n for p in CONFIG_PATHS:\n if p.exists():\n with open(p, \"r\", encoding=\"utf-8\") as f:\n return yaml.safe_load(f) or {}\n return {}\n\n\n# --- Size presets ---\n\n# Cover: 2.35:1 微信封面比例\n# Article: 16:9 横版内文配图\n# Vertical: 9:16 竖版\n_DEFAULT = \"1792x1024\"\n_DEFAULT_V = \"1024x1792\"\n_DEFAULT_SQ = \"1024x1024\"\n\nSIZE_PRESETS = {\n \"cover\": {\n \"doubao\": \"2952x1256\", \"openai\": _DEFAULT, \"gemini\": _DEFAULT,\n \"dashscope\": _DEFAULT, \"minimax\": _DEFAULT, \"replicate\": _DEFAULT,\n \"azure_openai\": _DEFAULT, \"openrouter\": _DEFAULT, \"jimeng\": _DEFAULT,\n },\n \"article\": {\n \"doubao\": \"2560x1440\", \"openai\": _DEFAULT, \"gemini\": _DEFAULT,\n \"dashscope\": _DEFAULT, \"minimax\": _DEFAULT, \"replicate\": _DEFAULT,\n \"azure_openai\": _DEFAULT, \"openrouter\": _DEFAULT, \"jimeng\": _DEFAULT,\n },\n \"vertical\": {\n \"doubao\": \"1088x2560\", \"openai\": _DEFAULT_V, \"gemini\": _DEFAULT_V,\n \"dashscope\": _DEFAULT_V, \"minimax\": _DEFAULT_V, \"replicate\": _DEFAULT_V,\n \"azure_openai\": _DEFAULT_V, \"openrouter\": _DEFAULT_V, \"jimeng\": _DEFAULT_V,\n },\n \"square\": {\n \"doubao\": \"2048x2048\", \"openai\": _DEFAULT_SQ, \"gemini\": _DEFAULT_SQ,\n \"dashscope\": _DEFAULT_SQ, \"minimax\": _DEFAULT_SQ, \"replicate\": _DEFAULT_SQ,\n \"azure_openai\": _DEFAULT_SQ, \"openrouter\": _DEFAULT_SQ, \"jimeng\": _DEFAULT_SQ,\n },\n}\n\nMAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB\n\n\ndef _compress_image(raw_bytes: bytes, max_size: int) -> bytes:\n \"\"\"Compress image to fit under max_size by reducing JPEG quality.\"\"\"\n from io import BytesIO\n from PIL import Image\n\n img = Image.open(BytesIO(raw_bytes))\n if img.mode == \"RGBA\":\n img = img.convert(\"RGB\")\n\n for quality in (90, 80, 70, 60, 50):\n buf = BytesIO()\n img.save(buf, format=\"JPEG\", quality=quality, optimize=True)\n if buf.tell() \u003c= max_size:\n return buf.getvalue()\n\n return buf.getvalue()\n\n\ndef _size_to_aspect(size: str) -> str:\n \"\"\"Convert 'WxH' to nearest standard aspect ratio string.\"\"\"\n if \":\" in size:\n return size\n try:\n w, h = (int(x) for x in size.split(\"x\", 1))\n except ValueError:\n return \"16:9\"\n ratio = w / h\n for ar, val in [(\"1:1\", 1.0), (\"16:9\", 16/9), (\"9:16\", 9/16),\n (\"4:3\", 4/3), (\"3:4\", 3/4), (\"3:2\", 3/2), (\"2:3\", 2/3)]:\n if abs(ratio - val) \u003c 0.15:\n return ar\n return \"16:9\"\n\n\ndef _download_image(url: str) -> bytes:\n \"\"\"Download image bytes from URL.\"\"\"\n resp = requests.get(url, timeout=60)\n resp.raise_for_status()\n return resp.content\n\n\n# --- Provider abstraction ---\n\nclass ImageProvider(abc.ABC):\n \"\"\"Base class for image generation providers.\"\"\"\n\n @abc.abstractmethod\n def generate(self, prompt: str, size: str) -> bytes:\n \"\"\"Generate an image and return raw bytes.\"\"\"\n ...\n\n def resolve_size(self, preset: str) -> str:\n \"\"\"Resolve a size preset to a concrete size string for this provider.\"\"\"\n provider_key = self.provider_key\n if preset in SIZE_PRESETS:\n return SIZE_PRESETS[preset].get(provider_key, list(SIZE_PRESETS[preset].values())[0])\n return preset\n\n @property\n @abc.abstractmethod\n def provider_key(self) -> str:\n ...\n\n\n# --- Providers ---\n\nclass DoubaoProvider(ImageProvider):\n \"\"\"doubao-seedream via Volcengine Ark API.\"\"\"\n\n provider_key = \"doubao\"\n\n def __init__(self, api_key: str, model: str = \"doubao-seedream-5-0-260128\",\n base_url: str = \"https://ark.cn-beijing.volces.com/api/v3\", **_kw):\n self._api_key = api_key\n self._model = model\n self._base_url = base_url\n\n def generate(self, prompt: str, size: str) -> bytes:\n resp = requests.post(\n f\"{self._base_url}/images/generations\",\n headers={\"Content-Type\": \"application/json\",\n \"Authorization\": f\"Bearer {self._api_key}\"},\n json={\"model\": self._model, \"prompt\": prompt,\n \"response_format\": \"url\", \"size\": size,\n \"stream\": False, \"watermark\": False},\n timeout=120,\n )\n data = resp.json()\n if resp.status_code != 200:\n raise ValueError(f\"Doubao error ({resp.status_code}): \"\n f\"{data.get('error', {}).get('message', str(data))}\")\n url = data.get(\"data\", [{}])[0].get(\"url\")\n if not url:\n raise ValueError(f\"No image URL: {data}\")\n return _download_image(url)\n\n\nclass OpenAIProvider(ImageProvider):\n \"\"\"OpenAI DALL-E 3 provider.\"\"\"\n\n provider_key = \"openai\"\n\n def __init__(self, api_key: str, model: str = \"dall-e-3\",\n base_url: str = \"https://api.openai.com/v1\", **_kw):\n self._api_key = api_key\n self._model = model\n self._base_url = base_url\n\n def generate(self, prompt: str, size: str) -> bytes:\n resp = requests.post(\n f\"{self._base_url}/images/generations\",\n headers={\"Content-Type\": \"application/json\",\n \"Authorization\": f\"Bearer {self._api_key}\"},\n json={\"model\": self._model, \"prompt\": prompt,\n \"n\": 1, \"size\": size, \"response_format\": \"url\"},\n timeout=120,\n )\n data = resp.json()\n if resp.status_code != 200:\n raise ValueError(f\"OpenAI error ({resp.status_code}): \"\n f\"{data.get('error', {}).get('message', str(data))}\")\n url = data.get(\"data\", [{}])[0].get(\"url\")\n if not url:\n raise ValueError(f\"No image URL: {data}\")\n return _download_image(url)\n\n\nclass GeminiProvider(ImageProvider):\n \"\"\"Google Gemini Imagen provider.\"\"\"\n\n provider_key = \"gemini\"\n\n def __init__(self, api_key: str, model: str = \"gemini-3.1-flash-image-preview\",\n base_url: str = \"https://generativelanguage.googleapis.com/v1beta\", **_kw):\n self._api_key = api_key\n self._model = model\n self._base_url = base_url\n\n def generate(self, prompt: str, size: str) -> bytes:\n if \"x\" in size:\n w, h = size.split(\"x\", 1)\n prompt = f\"{prompt}\\n\\nGenerate this image at {w}x{h} resolution.\"\n resp = requests.post(\n f\"{self._base_url}/models/{self._model}:generateContent\",\n headers={\"Content-Type\": \"application/json\",\n \"x-goog-api-key\": self._api_key},\n json={\"contents\": [{\"parts\": [{\"text\": prompt}]}],\n \"generationConfig\": {\"responseModalities\": [\"TEXT\", \"IMAGE\"]}},\n timeout=120,\n )\n if resp.status_code != 200:\n msg = resp.text[:200]\n try:\n msg = resp.json().get(\"error\", {}).get(\"message\", msg)\n except Exception:\n pass\n raise ValueError(f\"Gemini error ({resp.status_code}): {msg}\")\n for part in resp.json().get(\"candidates\", [{}])[0].get(\"content\", {}).get(\"parts\", []):\n inline = part.get(\"inlineData\")\n if inline and inline.get(\"mimeType\", \"\").startswith(\"image/\"):\n return base64.b64decode(inline[\"data\"])\n raise ValueError(\"No image in Gemini response\")\n\n\nclass DashScopeProvider(ImageProvider):\n \"\"\"Alibaba Tongyi Wanxiang (通义万相) via DashScope API.\"\"\"\n\n provider_key = \"dashscope\"\n\n def __init__(self, api_key: str, model: str = \"qwen-image-2.0-pro\",\n base_url: str = \"https://dashscope.aliyuncs.com/api/v1\", **_kw):\n self._api_key = api_key\n self._model = model\n self._base_url = base_url\n\n def generate(self, prompt: str, size: str) -> bytes:\n ds_size = size.replace(\"x\", \"*\") # DashScope uses \"W*H\"\n resp = requests.post(\n f\"{self._base_url}/services/aigc/multimodal-generation/generation\",\n headers={\"Content-Type\": \"application/json\",\n \"Authorization\": f\"Bearer {self._api_key}\"},\n json={\n \"model\": self._model,\n \"input\": {\"messages\": [{\"role\": \"user\", \"content\": [{\"text\": prompt}]}]},\n \"parameters\": {\"prompt_extend\": False, \"size\": ds_size, \"watermark\": False},\n },\n timeout=120,\n )\n data = resp.json()\n if resp.status_code != 200:\n raise ValueError(f\"DashScope error ({resp.status_code}): \"\n f\"{data.get('message', str(data))}\")\n # Try output.result_image first, then output.choices\n output = data.get(\"output\", {})\n img = output.get(\"result_image\")\n if not img:\n choices = output.get(\"choices\", [])\n if choices:\n for c in choices[0].get(\"message\", {}).get(\"content\", []):\n if \"image\" in c:\n img = c[\"image\"]\n break\n if not img:\n raise ValueError(f\"No image in DashScope response: {data}\")\n if img.startswith(\"http\"):\n return _download_image(img)\n return base64.b64decode(img)\n\n\nclass MiniMaxProvider(ImageProvider):\n \"\"\"MiniMax image generation.\"\"\"\n\n provider_key = \"minimax\"\n\n def __init__(self, api_key: str, model: str = \"image-01\",\n base_url: str = \"https://api.minimax.io/v1\", **_kw):\n self._api_key = api_key\n self._model = model\n self._base_url = base_url\n\n def generate(self, prompt: str, size: str) -> bytes:\n w, h = 1024, 1024\n try:\n w, h = (int(x) for x in size.split(\"x\", 1))\n except ValueError:\n pass\n resp = requests.post(\n f\"{self._base_url}/image_generation\",\n headers={\"Content-Type\": \"application/json\",\n \"Authorization\": f\"Bearer {self._api_key}\"},\n json={\"model\": self._model, \"prompt\": prompt,\n \"response_format\": \"base64\",\n \"width\": w, \"height\": h, \"n\": 1},\n timeout=120,\n )\n data = resp.json()\n if resp.status_code != 200:\n raise ValueError(f\"MiniMax error ({resp.status_code}): {data}\")\n b64_list = data.get(\"data\", {}).get(\"image_base64\", [])\n if not b64_list:\n raise ValueError(f\"No image in MiniMax response: {data}\")\n return base64.b64decode(b64_list[0])\n\n\nclass ReplicateProvider(ImageProvider):\n \"\"\"Replicate API — supports many open-source image models.\"\"\"\n\n provider_key = \"replicate\"\n _POLL_INTERVAL = 2\n _POLL_TIMEOUT = 300\n\n def __init__(self, api_key: str, model: str = \"google/nano-banana-pro\",\n base_url: str = \"https://api.replicate.com/v1\", **_kw):\n self._api_key = api_key\n self._model = model\n self._base_url = base_url\n\n def generate(self, prompt: str, size: str) -> bytes:\n aspect = _size_to_aspect(size)\n headers = {\"Content-Type\": \"application/json\",\n \"Authorization\": f\"Bearer {self._api_key}\",\n \"Prefer\": \"wait=60\"}\n resp = requests.post(\n f\"{self._base_url}/models/{self._model}/predictions\",\n headers=headers,\n json={\"input\": {\"prompt\": prompt, \"aspect_ratio\": aspect,\n \"number_of_images\": 1, \"output_format\": \"png\"}},\n timeout=120,\n )\n data = resp.json()\n if resp.status_code not in (200, 201):\n raise ValueError(f\"Replicate error ({resp.status_code}): {data}\")\n\n # Poll if not completed yet\n poll_url = data.get(\"urls\", {}).get(\"get\")\n deadline = time.monotonic() + self._POLL_TIMEOUT\n while data.get(\"status\") not in (\"succeeded\", \"failed\", \"canceled\"):\n if time.monotonic() > deadline:\n raise ValueError(\"Replicate polling timeout\")\n time.sleep(self._POLL_INTERVAL)\n data = requests.get(poll_url, headers=headers, timeout=30).json()\n\n if data.get(\"status\") != \"succeeded\":\n raise ValueError(f\"Replicate failed: {data.get('error')}\")\n\n output = data.get(\"output\")\n if isinstance(output, list):\n output = output[0]\n if isinstance(output, dict):\n output = output.get(\"url\", output.get(\"uri\"))\n if not output or not isinstance(output, str):\n raise ValueError(f\"No image URL in Replicate output: {data}\")\n return _download_image(output)\n\n\nclass AzureOpenAIProvider(ImageProvider):\n \"\"\"Azure-hosted OpenAI DALL-E.\"\"\"\n\n provider_key = \"azure_openai\"\n\n def __init__(self, api_key: str, model: str = \"dall-e-3\",\n base_url: str = \"\", deployment: str = \"\", **_kw):\n self._api_key = api_key\n self._deployment = deployment or model\n self._base_url = base_url.rstrip(\"/\")\n\n def generate(self, prompt: str, size: str) -> bytes:\n if not self._base_url:\n raise ValueError(\"Azure OpenAI requires base_url \"\n \"(e.g. https://YOUR-RESOURCE.openai.azure.com/openai)\")\n resp = requests.post(\n f\"{self._base_url}/deployments/{self._deployment}\"\n f\"/images/generations?api-version=2025-04-01-preview\",\n headers={\"Content-Type\": \"application/json\",\n \"api-key\": self._api_key},\n json={\"prompt\": prompt, \"size\": size, \"n\": 1, \"quality\": \"medium\"},\n timeout=120,\n )\n data = resp.json()\n if resp.status_code != 200:\n raise ValueError(f\"Azure OpenAI error ({resp.status_code}): {data}\")\n item = data.get(\"data\", [{}])[0]\n if item.get(\"url\"):\n return _download_image(item[\"url\"])\n if item.get(\"b64_json\"):\n return base64.b64decode(item[\"b64_json\"])\n raise ValueError(f\"No image in Azure response: {data}\")\n\n\nclass OpenRouterProvider(ImageProvider):\n \"\"\"OpenRouter — multi-model proxy using chat completions format.\"\"\"\n\n provider_key = \"openrouter\"\n\n def __init__(self, api_key: str, model: str = \"google/gemini-3.1-flash-image-preview\",\n base_url: str = \"https://openrouter.ai/api/v1\", **_kw):\n self._api_key = api_key\n self._model = model\n self._base_url = base_url\n\n def generate(self, prompt: str, size: str) -> bytes:\n aspect = _size_to_aspect(size)\n resp = requests.post(\n f\"{self._base_url}/chat/completions\",\n headers={\"Content-Type\": \"application/json\",\n \"Authorization\": f\"Bearer {self._api_key}\"},\n json={\n \"model\": self._model,\n \"messages\": [{\"role\": \"user\", \"content\": prompt}],\n \"modalities\": [\"image\"],\n \"stream\": False,\n \"image_config\": {\"aspect_ratio\": aspect},\n \"provider\": {\"require_parameters\": True},\n },\n timeout=120,\n )\n data = resp.json()\n if resp.status_code != 200:\n raise ValueError(f\"OpenRouter error ({resp.status_code}): {data}\")\n\n # Extract image from multiple possible locations\n choice = data.get(\"choices\", [{}])[0].get(\"message\", {})\n # Path 1: images array\n images = choice.get(\"images\", [])\n if images:\n img = images[0]\n if img.startswith(\"http\"):\n return _download_image(img)\n if img.startswith(\"data:\"):\n _, b64 = img.split(\",\", 1)\n return base64.b64decode(b64)\n # Path 2: content array with image items\n content = choice.get(\"content\", [])\n if isinstance(content, list):\n for item in content:\n if isinstance(item, dict) and item.get(\"type\") == \"image\":\n url = item.get(\"url\") or item.get(\"image_url\", {}).get(\"url\")\n if url:\n if url.startswith(\"data:\"):\n _, b64 = url.split(\",\", 1)\n return base64.b64decode(b64)\n return _download_image(url)\n raise ValueError(f\"No image in OpenRouter response: {data}\")\n\n\nclass JimengProvider(ImageProvider):\n \"\"\"ByteDance Jimeng (即梦) — async submit + poll with HMAC-SHA256 auth.\"\"\"\n\n provider_key = \"jimeng\"\n _POLL_INTERVAL = 2\n _POLL_MAX_ATTEMPTS = 60\n\n def __init__(self, api_key: str, secret_key: str = \"\",\n model: str = \"jimeng_t2i_v40\",\n base_url: str = \"https://visual.volcengineapi.com\", **_kw):\n self._access_key = api_key\n self._secret_key = secret_key\n self._model = model\n self._base_url = base_url\n\n def _sign(self, method: str, path: str, query: str,\n headers: dict, payload: bytes) -> dict:\n \"\"\"Generate Volcengine HMAC-SHA256 signed headers.\"\"\"\n now = datetime.now(timezone.utc)\n date_stamp = now.strftime(\"%Y%m%d\")\n amz_date = now.strftime(\"%Y%m%dT%H%M%SZ\")\n\n signed_headers_list = sorted(k.lower() for k in headers)\n signed_headers_str = \";\".join(signed_headers_list)\n\n canonical = \"\\n\".join([\n method, path, query,\n \"\".join(f\"{k.lower()}:{headers[k]}\\n\" for k in sorted(headers)),\n signed_headers_str,\n hashlib.sha256(payload).hexdigest(),\n ])\n\n region = \"cn-north-1\"\n service = \"cv\"\n scope = f\"{date_stamp}/{region}/{service}/request\"\n string_to_sign = \"\\n\".join([\n \"HMAC-SHA256\", amz_date, scope,\n hashlib.sha256(canonical.encode()).hexdigest(),\n ])\n\n def _hmac(key: bytes, msg: str) -> bytes:\n return hmac.new(key, msg.encode(), hashlib.sha256).digest()\n\n k_date = _hmac(self._secret_key.encode(), date_stamp)\n k_region = _hmac(k_date, region)\n k_service = _hmac(k_region, service)\n k_signing = _hmac(k_service, \"request\")\n signature = hmac.new(k_signing, string_to_sign.encode(),\n hashlib.sha256).hexdigest()\n\n auth = (f\"HMAC-SHA256 Credential={self._access_key}/{scope}, \"\n f\"SignedHeaders={signed_headers_str}, Signature={signature}\")\n return {**headers, \"Authorization\": auth, \"X-Date\": amz_date}\n\n def _request(self, action: str, body: dict) -> dict:\n payload = json.dumps(body).encode()\n path = \"/\"\n query = f\"Action={action}&Version=2022-08-31\"\n headers = {\n \"Content-Type\": \"application/json\",\n \"Host\": self._base_url.replace(\"https://\", \"\").replace(\"http://\", \"\"),\n }\n signed = self._sign(\"POST\", path, query, headers, payload)\n resp = requests.post(\n f\"{self._base_url}/?{query}\",\n headers=signed, data=payload, timeout=120,\n )\n data = resp.json()\n if resp.status_code != 200:\n raise ValueError(f\"Jimeng error ({resp.status_code}): {data}\")\n return data\n\n def generate(self, prompt: str, size: str) -> bytes:\n if not self._secret_key:\n raise ValueError(\"Jimeng requires both api_key (access_key_id) \"\n \"and secret_key (secret_access_key)\")\n w, h = 1024, 1024\n try:\n w, h = (int(x) for x in size.split(\"x\", 1))\n except ValueError:\n pass\n\n # Submit task\n submit = self._request(\"CVSync2AsyncSubmitTask\", {\n \"req_key\": self._model, \"prompt\": prompt,\n \"width\": w, \"height\": h,\n })\n task_id = submit.get(\"data\", {}).get(\"task_id\")\n if not task_id:\n raise ValueError(f\"No task_id from Jimeng: {submit}\")\n\n # Poll for result\n for _ in range(self._POLL_MAX_ATTEMPTS):\n time.sleep(self._POLL_INTERVAL)\n result = self._request(\"CVSync2AsyncGetResult\", {\n \"req_key\": self._model, \"task_id\": task_id,\n })\n code = result.get(\"code\")\n if code == 10000:\n data = result.get(\"data\", {})\n b64_list = data.get(\"binary_data_base64\", [])\n if b64_list:\n return base64.b64decode(b64_list[0])\n urls = data.get(\"image_urls\", [])\n if urls:\n return _download_image(urls[0])\n raise ValueError(f\"No image data in Jimeng result: {result}\")\n if code and code != 10000:\n status = result.get(\"data\", {}).get(\"status\")\n if status in (\"failed\", \"canceled\"):\n raise ValueError(f\"Jimeng task failed: {result}\")\n\n raise ValueError(\"Jimeng polling timeout\")\n\n\n# --- Provider registry ---\n\nPROVIDERS = {\n \"doubao\": DoubaoProvider,\n \"openai\": OpenAIProvider,\n \"gemini\": GeminiProvider,\n \"dashscope\": DashScopeProvider,\n \"minimax\": MiniMaxProvider,\n \"replicate\": ReplicateProvider,\n \"azure_openai\": AzureOpenAIProvider,\n \"openrouter\": OpenRouterProvider,\n \"jimeng\": JimengProvider,\n}\n\n\ndef _build_provider_from_entry(entry: dict) -> ImageProvider:\n \"\"\"Build a single ImageProvider from a provider config entry.\"\"\"\n provider_name = entry.get(\"provider\", \"doubao\")\n api_key = entry.get(\"api_key\")\n\n if not api_key:\n raise ValueError(f\"No api_key for provider '{provider_name}'\")\n\n provider_cls = PROVIDERS.get(provider_name)\n if not provider_cls:\n raise ValueError(\n f\"Unknown provider: '{provider_name}'. \"\n f\"Available: {', '.join(PROVIDERS.keys())}\"\n )\n\n kwargs = {\"api_key\": api_key}\n if entry.get(\"model\"):\n kwargs[\"model\"] = entry[\"model\"]\n if entry.get(\"base_url\"):\n kwargs[\"base_url\"] = entry[\"base_url\"]\n if entry.get(\"secret_key\"):\n kwargs[\"secret_key\"] = entry[\"secret_key\"]\n if entry.get(\"deployment\"):\n kwargs[\"deployment\"] = entry[\"deployment\"]\n\n return provider_cls(**kwargs)\n\n\ndef _build_provider_chain(config: dict) -> list[ImageProvider]:\n \"\"\"Build an ordered list of providers to try.\n\n Supports two config formats:\n - Legacy: image.provider + image.api_key (single provider)\n - New: image.providers (list, tried in order with auto-fallback)\n \"\"\"\n img_cfg = config.get(\"image\", {})\n providers_list = img_cfg.get(\"providers\")\n\n if providers_list and isinstance(providers_list, list):\n chain = []\n for entry in providers_list:\n try:\n chain.append(_build_provider_from_entry(entry))\n except ValueError:\n continue # skip misconfigured entries\n if not chain:\n raise ValueError(\n \"No valid providers in image.providers list. \"\n \"Each entry needs 'provider' and 'api_key'.\"\n )\n return chain\n\n # Legacy single-provider format\n api_key = img_cfg.get(\"api_key\")\n if not api_key:\n raise ValueError(\n \"image.api_key not set in config.yaml. \"\n \"Configure your API key to enable image generation.\"\n )\n return [_build_provider_from_entry(img_cfg)]\n\n\ndef _build_provider(config: dict) -> ImageProvider:\n \"\"\"Build an ImageProvider from config.yaml (backward-compatible entry point).\"\"\"\n return _build_provider_chain(config)[0]\n\n\n# --- Public API ---\n\ndef generate_image(\n prompt: str,\n output_path: str,\n size: str = \"cover\",\n config: dict = None,\n) -> str:\n \"\"\"\n Generate an image using configured providers with auto-fallback.\n\n Tries each provider in order. If one fails, falls back to the next.\n Supports both single-provider (legacy) and multi-provider config.\n\n Args:\n prompt: Image generation prompt (Chinese or English).\n output_path: Where to save the image.\n size: Size preset (\"cover\", \"article\", \"vertical\", \"square\") or explicit \"WxH\".\n config: Optional config dict. If None, loads from config.yaml.\n\n Returns:\n The output file path.\n \"\"\"\n if config is None:\n config = _load_config()\n\n chain = _build_provider_chain(config)\n last_error = None\n\n for provider in chain:\n resolved_size = provider.resolve_size(size)\n try:\n raw_bytes = provider.generate(prompt, resolved_size)\n except Exception as e:\n last_error = e\n print(\n f\"Provider '{provider.provider_key}' failed: {e}. \"\n f\"Trying next...\",\n file=sys.stderr,\n )\n continue\n\n # Compress if over 5MB (WeChat upload limit)\n if len(raw_bytes) > MAX_FILE_SIZE:\n raw_bytes = _compress_image(raw_bytes, MAX_FILE_SIZE)\n\n output = Path(output_path)\n output.parent.mkdir(parents=True, exist_ok=True)\n output.write_bytes(raw_bytes)\n return str(output)\n\n raise ValueError(\n f\"All providers failed. Last error: {last_error}\"\n )\n\n\ndef main():\n ap = argparse.ArgumentParser(description=\"Generate images using AI\")\n ap.add_argument(\"--prompt\", required=True, help=\"Image generation prompt\")\n ap.add_argument(\"--output\", required=True, help=\"Output file path\")\n ap.add_argument(\"--size\", default=\"cover\",\n help=\"Size: cover, article, vertical, square, or WxH\")\n ap.add_argument(\"--provider\", default=None,\n help=f\"Override provider ({', '.join(PROVIDERS)})\")\n args = ap.parse_args()\n\n try:\n config = _load_config()\n if args.provider:\n config.setdefault(\"image\", {})[\"provider\"] = args.provider\n path = generate_image(args.prompt, args.output, size=args.size, config=config)\n print(f\"Image saved: {path}\")\n except Exception as e:\n print(f\"Error: {e}\", file=sys.stderr)\n sys.exit(1)\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":27328,"content_sha256":"568cd695c39040c085e712d71bfee5843e538af4523b7278444b8dc4d0ffad34"},{"filename":"toolkit/publisher.py","content":"import json\n\nimport requests\nfrom dataclasses import dataclass\nfrom typing import Optional\n\n\n@dataclass\nclass DraftResult:\n media_id: str\n\n\n@dataclass\nclass ImagePostResult:\n media_id: str\n image_count: int\n\n\ndef create_draft(\n access_token: str,\n title: str,\n html: str,\n digest: str,\n thumb_media_id: Optional[str] = None,\n author: Optional[str] = None,\n) -> DraftResult:\n \"\"\"\n Create a draft in WeChat.\n API: POST https://api.weixin.qq.com/cgi-bin/draft/add\n Returns DraftResult.\n Raise ValueError on error (errcode present and != 0).\n \"\"\"\n article = {\n \"title\": title,\n \"author\": author or \"\",\n \"digest\": digest,\n \"content\": html,\n \"show_cover_pic\": 0,\n }\n\n # thumb_media_id is required by WeChat API — if not provided,\n # upload a default 1x1 white pixel, or skip if truly empty\n if thumb_media_id:\n article[\"thumb_media_id\"] = thumb_media_id\n\n body = {\"articles\": [article]}\n\n # MUST use ensure_ascii=False — otherwise Chinese becomes \\uXXXX\n # and WeChat stores the escape sequences literally, causing title\n # length overflow and garbled content.\n resp = requests.post(\n \"https://api.weixin.qq.com/cgi-bin/draft/add\",\n params={\"access_token\": access_token},\n data=json.dumps(body, ensure_ascii=False).encode(\"utf-8\"),\n headers={\"Content-Type\": \"application/json; charset=utf-8\"},\n )\n\n data = resp.json()\n\n errcode = data.get(\"errcode\", 0)\n if errcode != 0:\n errmsg = data.get(\"errmsg\", \"unknown error\")\n raise ValueError(f\"WeChat create_draft error: errcode={errcode}, errmsg={errmsg}\")\n\n if \"media_id\" not in data:\n raise ValueError(f\"WeChat create_draft error: missing media_id in response: {data}\")\n\n return DraftResult(media_id=data[\"media_id\"])\n\n\ndef get_draft(access_token: str, media_id: str) -> str:\n \"\"\"\n Get draft content from WeChat by media_id.\n API: POST https://api.weixin.qq.com/cgi-bin/draft/get\n Returns the HTML content of the first article.\n \"\"\"\n resp = requests.post(\n \"https://api.weixin.qq.com/cgi-bin/draft/get\",\n params={\"access_token\": access_token},\n json={\"media_id\": media_id},\n )\n resp.encoding = \"utf-8\"\n data = resp.json()\n\n errcode = data.get(\"errcode\", 0)\n if errcode != 0:\n errmsg = data.get(\"errmsg\", \"unknown error\")\n raise ValueError(f\"WeChat get_draft error: errcode={errcode}, errmsg={errmsg}\")\n\n articles = data.get(\"news_item\", [])\n if not articles:\n raise ValueError(f\"WeChat get_draft: no articles in draft {media_id}\")\n\n return articles[0].get(\"content\", \"\")\n\n\ndef html_to_plaintext(html: str) -> str:\n \"\"\"Extract plain text from WeChat HTML, stripping all tags and styles.\"\"\"\n import re\n # Remove script/style blocks\n text = re.sub(r\"\u003c(script|style)[^>]*>.*?\u003c/\\1>\", \"\", html, flags=re.DOTALL | re.IGNORECASE)\n # Replace block-level tags with newlines\n text = re.sub(r\"\u003c(br|p|div|section|h[1-6])[^>]*>\", \"\\n\", text, flags=re.IGNORECASE)\n # Remove all remaining tags\n text = re.sub(r\"\u003c[^>]+>\", \"\", text)\n # Decode HTML entities\n import html as html_module\n text = html_module.unescape(text)\n # Collapse whitespace\n text = re.sub(r\"[ \\t]+\", \" \", text)\n text = re.sub(r\"\\n{3,}\", \"\\n\\n\", text)\n return text.strip()\n\n\ndef create_image_post(\n access_token: str,\n title: str,\n image_media_ids: list[str],\n content: str = \"\",\n open_comment: bool = False,\n fans_only_comment: bool = False,\n) -> ImagePostResult:\n \"\"\"\n Create a WeChat image post (小绿书/图片帖) draft.\n\n This uses article_type=\"newspic\" which displays as a horizontal\n swipe carousel (3:4 ratio), similar to Xiaohongshu.\n\n Args:\n access_token: WeChat access token.\n title: Post title, max 32 characters.\n image_media_ids: List of permanent media_ids from upload_thumb().\n Min 1, max 20. First image becomes the cover.\n content: Plain text description, max ~1000 chars. No HTML.\n open_comment: Allow comments.\n fans_only_comment: Only followers can comment.\n\n Returns ImagePostResult with media_id of created draft.\n \"\"\"\n if not image_media_ids:\n raise ValueError(\"At least 1 image is required for image post\")\n if len(image_media_ids) > 20:\n raise ValueError(f\"Max 20 images allowed, got {len(image_media_ids)}\")\n if len(title) > 32:\n raise ValueError(f\"Title max 32 chars for image post, got {len(title)}\")\n\n article = {\n \"article_type\": \"newspic\",\n \"title\": title,\n \"content\": content,\n \"image_info\": {\n \"image_list\": [\n {\"image_media_id\": mid} for mid in image_media_ids\n ]\n },\n \"need_open_comment\": 1 if open_comment else 0,\n \"only_fans_can_comment\": 1 if fans_only_comment else 0,\n }\n\n body = {\"articles\": [article]}\n\n resp = requests.post(\n \"https://api.weixin.qq.com/cgi-bin/draft/add\",\n params={\"access_token\": access_token},\n data=json.dumps(body, ensure_ascii=False).encode(\"utf-8\"),\n headers={\"Content-Type\": \"application/json; charset=utf-8\"},\n )\n\n data = resp.json()\n\n errcode = data.get(\"errcode\", 0)\n if errcode != 0:\n errmsg = data.get(\"errmsg\", \"unknown error\")\n raise ValueError(f\"WeChat create_image_post error: errcode={errcode}, errmsg={errmsg}\")\n\n if \"media_id\" not in data:\n raise ValueError(f\"WeChat create_image_post: missing media_id in response: {data}\")\n\n return ImagePostResult(\n media_id=data[\"media_id\"],\n image_count=len(image_media_ids),\n )\n","content_type":"text/x-python; charset=utf-8","language":"python","size":5722,"content_sha256":"4849356c7c30ae595179eb59f5667c13c22ed6b6aed1728163f9fe924144db2d"},{"filename":"toolkit/theme.py","content":"\"\"\"\nTheme system for WeWrite.\n\nLoads YAML theme definitions and provides CSS parsing utilities\nfor the inline style converter.\n\"\"\"\n\nimport logging\nimport os\nimport re\nfrom dataclasses import dataclass, field\nfrom pathlib import Path\nfrom typing import Optional\n\nimport cssutils\nimport yaml\n\n# Suppress cssutils warnings (it's very noisy about non-standard properties)\ncssutils.log.setLevel(logging.CRITICAL)\n\n\n@dataclass\nclass Theme:\n \"\"\"A theme definition with colors and base CSS.\"\"\"\n\n name: str\n description: str\n base_css: str\n colors: dict = field(default_factory=dict)\n\n\ndef _default_themes_dir() -> str:\n \"\"\"Return the themes/ directory relative to this file.\"\"\"\n return str(Path(__file__).parent / \"themes\")\n\n\ndef load_theme(name: str, themes_dir: str = None) -> Theme:\n \"\"\"\n Load a theme by name from a YAML file.\n\n Args:\n name: Theme name (without .yaml extension).\n themes_dir: Directory containing theme YAML files.\n Defaults to themes/ relative to this file.\n\n Returns:\n A Theme object.\n\n Raises:\n FileNotFoundError: If the theme YAML file does not exist.\n ValueError: If the YAML is malformed or missing required fields.\n \"\"\"\n if themes_dir is None:\n themes_dir = _default_themes_dir()\n\n theme_path = os.path.join(themes_dir, f\"{name}.yaml\")\n if not os.path.exists(theme_path):\n raise FileNotFoundError(f\"Theme file not found: {theme_path}\")\n\n with open(theme_path, \"r\", encoding=\"utf-8\") as f:\n data = yaml.safe_load(f)\n\n if not isinstance(data, dict):\n raise ValueError(f\"Invalid theme file: {theme_path}\")\n\n required = (\"name\", \"description\", \"base_css\", \"colors\")\n for key in required:\n if key not in data:\n raise ValueError(f\"Theme file missing required field '{key}': {theme_path}\")\n\n return Theme(\n name=data[\"name\"],\n description=data[\"description\"],\n base_css=data[\"base_css\"],\n colors=data.get(\"colors\", {}),\n )\n\n\ndef list_themes(themes_dir: str = None) -> list[str]:\n \"\"\"\n List available theme names.\n\n Args:\n themes_dir: Directory containing theme YAML files.\n Defaults to themes/ relative to this file.\n\n Returns:\n Sorted list of theme names (without .yaml extension).\n \"\"\"\n if themes_dir is None:\n themes_dir = _default_themes_dir()\n\n if not os.path.isdir(themes_dir):\n return []\n\n names = []\n for filename in os.listdir(themes_dir):\n if filename.endswith(\".yaml\") or filename.endswith(\".yml\"):\n names.append(filename.rsplit(\".\", 1)[0])\n\n return sorted(names)\n\n\ndef _resolve_css_variables(css_text: str, colors: dict) -> str:\n \"\"\"\n Replace var(--xxx) references in CSS with actual color values.\n\n Supports var(--primary), var(--secondary), etc. based on the\n colors dict keys. The CSS variable name is mapped by stripping\n the leading --.\n \"\"\"\n def replacer(match: re.Match) -> str:\n var_name = match.group(1).strip()\n # Strip leading -- prefix\n key = var_name.lstrip(\"-\")\n # Also try with hyphens converted to underscores\n key_underscore = key.replace(\"-\", \"_\")\n if key in colors:\n return str(colors[key])\n if key_underscore in colors:\n return str(colors[key_underscore])\n # Return original if not found\n return match.group(0)\n\n return re.sub(r\"var\\(\\s*--([a-zA-Z0-9_-]+)\\s*\\)\", replacer, css_text)\n\n\ndef _is_simple_selector(selector: str) -> bool:\n \"\"\"\n Check if a selector is simple enough for inline styling.\n\n Rejects pseudo-classes, pseudo-elements, media queries,\n and complex combinators.\n \"\"\"\n selector = selector.strip()\n\n # Reject if contains any of these characters\n reject_chars = (\":\", \"@\", \">\", \"+\", \"~\", \"[\", \"*\")\n for ch in reject_chars:\n if ch in selector:\n return False\n\n return True\n\n\ndef get_inline_css_rules(theme: Theme) -> dict[str, dict[str, str]]:\n \"\"\"\n Parse a theme's base_css into a selector -> {property: value} dict.\n\n This resolves CSS variable references using theme.colors, then\n parses the CSS with cssutils. Only simple selectors are included\n (no pseudo-classes, pseudo-elements, media queries, or complex\n combinators).\n\n Args:\n theme: A Theme object with base_css and colors.\n\n Returns:\n Dict mapping CSS selectors to dicts of {property: value}.\n Example: {\"h1\": {\"color\": \"#333\", \"font-size\": \"28px\"}, ...}\n \"\"\"\n # Resolve CSS variables first\n resolved_css = _resolve_css_variables(theme.base_css, theme.colors)\n\n # Parse with cssutils\n sheet = cssutils.parseString(resolved_css, validate=False)\n\n rules: dict[str, dict[str, str]] = {}\n\n for rule in sheet:\n if rule.type != rule.STYLE_RULE:\n continue\n\n selector_text = rule.selectorText\n\n # A rule can have multiple comma-separated selectors\n selectors = [s.strip() for s in selector_text.split(\",\")]\n\n # Build property dict for this rule\n props: dict[str, str] = {}\n for prop in rule.style:\n props[prop.name] = prop.value\n\n if not props:\n continue\n\n for selector in selectors:\n if not _is_simple_selector(selector):\n continue\n\n if selector in rules:\n # Merge (later rules override)\n rules[selector].update(props)\n else:\n rules[selector] = dict(props)\n\n return rules\n","content_type":"text/x-python; charset=utf-8","language":"python","size":5589,"content_sha256":"850913a0d12914e04529b72bdd82781a28ba038efaca7895133234fd2c329676"},{"filename":"toolkit/themes/bauhaus.yaml","content":"name: \"bauhaus\"\ndescription: \"包豪斯设计风格:纯白底黑色为主,红蓝黄色块点缀,几何感强烈\"\ncolors:\n primary: \"#e63226\"\n secondary: \"#004592\"\n text: \"#1a1a1a\"\n text_light: \"#555555\"\n background: \"#ffffff\"\n code_bg: \"#f0f0f0\"\n code_color: \"#004592\"\n quote_border: \"#e63226\"\n quote_bg: \"#fff5f5\"\n border_radius: \"0px\"\ndarkmode:\n background: \"#111111\"\n text: \"#e8e8e8\"\n text_light: \"#a0a0a0\"\n primary: \"#f04438\"\n code_bg: \"#222222\"\n code_color: \"#5b9bd5\"\n quote_bg: \"#1e1212\"\n quote_border: \"#f04438\"\nbase_css: |\n body {\n font-family: \"Helvetica Neue\", Helvetica, Arial, \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", sans-serif;\n font-size: 16px;\n line-height: 1.75;\n color: #1a1a1a;\n background: #ffffff;\n max-width: 720px;\n margin: 0 auto;\n padding: 20px;\n word-wrap: break-word;\n }\n\n h1 {\n font-size: 30px;\n font-weight: 900;\n color: #1a1a1a;\n margin: 36px 0 18px 0;\n padding: 12px 16px;\n background: #e63226;\n color: #ffffff;\n line-height: 1.3;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n }\n\n h2 {\n font-size: 22px;\n font-weight: 800;\n color: #004592;\n margin: 32px 0 14px 0;\n padding: 8px 0;\n border-bottom: 4px solid #1a1a1a;\n line-height: 1.4;\n }\n\n h3 {\n font-size: 18px;\n font-weight: 700;\n color: #1a1a1a;\n margin: 24px 0 12px 0;\n padding-left: 12px;\n border-left: 6px solid #f5b700;\n line-height: 1.4;\n }\n\n h4 {\n font-size: 16px;\n font-weight: 700;\n color: #1a1a1a;\n margin: 20px 0 10px 0;\n line-height: 1.4;\n }\n\n p {\n font-size: 16px;\n line-height: 1.75;\n color: #1a1a1a;\n margin: 12px 0;\n }\n\n strong {\n font-weight: 800;\n color: #e63226;\n }\n\n em {\n font-style: italic;\n color: #555555;\n }\n\n code {\n font-family: \"SFMono-Regular\", Consolas, \"Liberation Mono\", Menlo, Courier, monospace;\n font-size: 14px;\n background: #f0f0f0;\n color: #004592;\n padding: 2px 6px;\n border-radius: 0px;\n border: 1px solid #ddd;\n }\n\n pre {\n background: #1a1a1a;\n color: #f0f0f0;\n padding: 16px;\n border-radius: 0px;\n overflow-x: auto;\n margin: 16px 0;\n line-height: 1.6;\n border-left: 6px solid #e63226;\n }\n\n pre code {\n font-family: \"SFMono-Regular\", Consolas, \"Liberation Mono\", Menlo, Courier, monospace;\n font-size: 14px;\n background: none;\n color: #f0f0f0;\n padding: 0;\n border-radius: 0;\n border: none;\n }\n\n blockquote {\n border-left: 6px solid #e63226;\n background: #fff5f5;\n margin: 16px 0;\n padding: 12px 16px;\n border-radius: 0;\n color: #1a1a1a;\n }\n\n blockquote p {\n margin: 8px 0;\n color: #1a1a1a;\n }\n\n ul {\n padding-left: 24px;\n margin: 12px 0;\n }\n\n ol {\n padding-left: 24px;\n margin: 12px 0;\n }\n\n li {\n font-size: 16px;\n line-height: 1.75;\n color: #1a1a1a;\n margin: 6px 0;\n }\n\n table {\n width: 100%;\n border-collapse: collapse;\n margin: 16px 0;\n font-size: 15px;\n }\n\n thead {\n background: #004592;\n }\n\n th {\n background: #004592;\n color: #ffffff;\n font-weight: 700;\n padding: 10px 14px;\n text-align: left;\n border: 2px solid #1a1a1a;\n }\n\n td {\n padding: 10px 14px;\n border: 2px solid #1a1a1a;\n color: #1a1a1a;\n }\n\n tr {\n background: #ffffff;\n }\n\n img {\n max-width: 100%;\n height: auto;\n display: block;\n margin: 24px auto;\n border-radius: 0px;\n border: 2px solid #1a1a1a;\n }\n\n a {\n color: #004592;\n text-decoration: none;\n font-weight: 700;\n border-bottom: 2px solid #f5b700;\n }\n\n hr {\n border: none;\n height: 4px;\n background: #1a1a1a;\n margin: 28px 0;\n }\n","content_type":"application/yaml; charset=utf-8","language":"yaml","size":3708,"content_sha256":"6839eb96a36ea5ad76d7d3c2f1a977a50f0a2149621e2efb7e4a0a340d1b1153"},{"filename":"toolkit/themes/bold-green.yaml","content":"name: \"bold-green\"\ndescription: \"大胆森林绿风格:白底绿色主色,清新自然,适合环保健康和可持续发展内容\"\ncolors:\n primary: \"#16a34a\"\n secondary: \"#22c55e\"\n text: \"#1a2e1a\"\n text_light: \"#4a6a4a\"\n background: \"#ffffff\"\n code_bg: \"#f0fdf4\"\n code_color: \"#15803d\"\n quote_border: \"#16a34a\"\n quote_bg: \"#f0fdf4\"\n border_radius: \"8px\"\ndarkmode:\n background: \"#0f1a0f\"\n text: \"#d8e8d8\"\n text_light: \"#8aaa8a\"\n primary: \"#4ade80\"\n code_bg: \"#162816\"\n code_color: \"#6ee7a0\"\n quote_bg: \"#142014\"\n quote_border: \"#4ade80\"\nbase_css: |\n body {\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", sans-serif;\n font-size: 16px;\n line-height: 1.8;\n color: #1a2e1a;\n background: #ffffff;\n max-width: 720px;\n margin: 0 auto;\n padding: 20px;\n word-wrap: break-word;\n }\n\n h1 {\n font-size: 26px;\n font-weight: 700;\n color: #ffffff;\n margin: 32px 0 16px 0;\n padding: 12px 16px;\n background: #16a34a;\n border-radius: 8px;\n line-height: 1.4;\n }\n\n h2 {\n font-size: 22px;\n font-weight: 700;\n color: #16a34a;\n margin: 28px 0 14px 0;\n padding: 8px 0 8px 12px;\n border-left: 4px solid #16a34a;\n line-height: 1.4;\n }\n\n h3 {\n font-size: 18px;\n font-weight: 600;\n color: #15803d;\n margin: 24px 0 12px 0;\n line-height: 1.4;\n }\n\n h4 {\n font-size: 16px;\n font-weight: 600;\n color: #1a2e1a;\n margin: 20px 0 10px 0;\n line-height: 1.4;\n }\n\n p {\n font-size: 16px;\n line-height: 1.8;\n color: #1a2e1a;\n margin: 12px 0;\n }\n\n strong {\n font-weight: 700;\n color: #16a34a;\n }\n\n em {\n font-style: italic;\n color: #4a6a4a;\n }\n\n code {\n font-family: \"SFMono-Regular\", Consolas, \"Liberation Mono\", Menlo, Courier, monospace;\n font-size: 14px;\n background: #f0fdf4;\n color: #15803d;\n padding: 2px 6px;\n border-radius: 4px;\n }\n\n pre {\n background: #f0fdf4;\n color: #1a2e1a;\n padding: 16px;\n border-radius: 8px;\n overflow-x: auto;\n margin: 16px 0;\n line-height: 1.6;\n border: 1px solid #bbf7d0;\n }\n\n pre code {\n font-family: \"SFMono-Regular\", Consolas, \"Liberation Mono\", Menlo, Courier, monospace;\n font-size: 14px;\n background: none;\n color: #1a2e1a;\n padding: 0;\n border-radius: 0;\n }\n\n blockquote {\n border-left: 4px solid #16a34a;\n background: #f0fdf4;\n margin: 16px 0;\n padding: 12px 16px;\n border-radius: 0 8px 8px 0;\n color: #1a2e1a;\n }\n\n blockquote p {\n margin: 8px 0;\n color: #1a2e1a;\n }\n\n ul {\n padding-left: 24px;\n margin: 12px 0;\n }\n\n ol {\n padding-left: 24px;\n margin: 12px 0;\n }\n\n li {\n font-size: 16px;\n line-height: 1.8;\n color: #1a2e1a;\n margin: 6px 0;\n }\n\n table {\n width: 100%;\n border-collapse: collapse;\n margin: 16px 0;\n font-size: 15px;\n }\n\n thead {\n background: #16a34a;\n }\n\n th {\n background: #16a34a;\n color: #ffffff;\n font-weight: 600;\n padding: 10px 14px;\n text-align: left;\n border: 1px solid #16a34a;\n }\n\n td {\n padding: 10px 14px;\n border: 1px solid #d1fae5;\n color: #1a2e1a;\n }\n\n tr {\n background: #ffffff;\n }\n\n img {\n max-width: 100%;\n height: auto;\n display: block;\n margin: 24px auto;\n border-radius: 8px;\n }\n\n a {\n color: #16a34a;\n text-decoration: none;\n font-weight: 500;\n }\n\n hr {\n border: none;\n border-top: 2px solid #d1fae5;\n margin: 24px 0;\n }\n","content_type":"application/yaml; charset=utf-8","language":"yaml","size":3542,"content_sha256":"ed0fbba455311d57741751ea6ebf91963b4bdaf885e787e8480c4d3d860d027a"},{"filename":"toolkit/themes/bold-navy.yaml","content":"name: \"bold-navy\"\ndescription: \"大胆藏青风格:白底藏青主色,稳重专业,适合金融商务和行业分析内容\"\ncolors:\n primary: \"#1e3a5f\"\n secondary: \"#2c5282\"\n text: \"#1a1a2e\"\n text_light: \"#4a5568\"\n background: \"#ffffff\"\n code_bg: \"#f0f4f8\"\n code_color: \"#1e3a5f\"\n quote_border: \"#1e3a5f\"\n quote_bg: \"#f0f4f8\"\n border_radius: \"6px\"\ndarkmode:\n background: \"#0a0f1a\"\n text: \"#d8dce8\"\n text_light: \"#8890a0\"\n primary: \"#5b8cc8\"\n code_bg: \"#141a28\"\n code_color: \"#7ca8e0\"\n quote_bg: \"#101828\"\n quote_border: \"#5b8cc8\"\nbase_css: |\n body {\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", sans-serif;\n font-size: 16px;\n line-height: 1.8;\n color: #1a1a2e;\n background: #ffffff;\n max-width: 720px;\n margin: 0 auto;\n padding: 20px;\n word-wrap: break-word;\n }\n\n h1 {\n font-size: 26px;\n font-weight: 700;\n color: #1e3a5f;\n margin: 32px 0 16px 0;\n padding-bottom: 12px;\n border-bottom: 3px solid #1e3a5f;\n line-height: 1.4;\n }\n\n h2 {\n font-size: 22px;\n font-weight: 700;\n color: #1e3a5f;\n margin: 28px 0 14px 0;\n padding: 8px 0 8px 12px;\n border-left: 4px solid #1e3a5f;\n border-bottom: 1px solid #e2e8f0;\n line-height: 1.4;\n }\n\n h3 {\n font-size: 18px;\n font-weight: 600;\n color: #2c5282;\n margin: 24px 0 12px 0;\n line-height: 1.4;\n }\n\n h4 {\n font-size: 16px;\n font-weight: 600;\n color: #1a1a2e;\n margin: 20px 0 10px 0;\n line-height: 1.4;\n }\n\n p {\n font-size: 16px;\n line-height: 1.8;\n color: #1a1a2e;\n margin: 12px 0;\n }\n\n strong {\n font-weight: 700;\n color: #1e3a5f;\n }\n\n em {\n font-style: italic;\n color: #4a5568;\n }\n\n code {\n font-family: \"SFMono-Regular\", Consolas, \"Liberation Mono\", Menlo, Courier, monospace;\n font-size: 14px;\n background: #f0f4f8;\n color: #1e3a5f;\n padding: 2px 6px;\n border-radius: 4px;\n }\n\n pre {\n background: #1e3a5f;\n color: #e2e8f0;\n padding: 16px;\n border-radius: 6px;\n overflow-x: auto;\n margin: 16px 0;\n line-height: 1.6;\n }\n\n pre code {\n font-family: \"SFMono-Regular\", Consolas, \"Liberation Mono\", Menlo, Courier, monospace;\n font-size: 14px;\n background: none;\n color: #e2e8f0;\n padding: 0;\n border-radius: 0;\n }\n\n blockquote {\n border-left: 4px solid #1e3a5f;\n background: #f0f4f8;\n margin: 16px 0;\n padding: 12px 16px;\n border-radius: 0 6px 6px 0;\n color: #2c5282;\n }\n\n blockquote p {\n margin: 8px 0;\n color: #2c5282;\n }\n\n ul {\n padding-left: 24px;\n margin: 12px 0;\n }\n\n ol {\n padding-left: 24px;\n margin: 12px 0;\n }\n\n li {\n font-size: 16px;\n line-height: 1.8;\n color: #1a1a2e;\n margin: 6px 0;\n }\n\n table {\n width: 100%;\n border-collapse: collapse;\n margin: 16px 0;\n font-size: 15px;\n }\n\n thead {\n background: #1e3a5f;\n }\n\n th {\n background: #1e3a5f;\n color: #ffffff;\n font-weight: 600;\n padding: 10px 14px;\n text-align: left;\n border: 1px solid #1e3a5f;\n }\n\n td {\n padding: 10px 14px;\n border: 1px solid #e2e8f0;\n color: #1a1a2e;\n }\n\n tr {\n background: #ffffff;\n }\n\n img {\n max-width: 100%;\n height: auto;\n display: block;\n margin: 24px auto;\n border-radius: 6px;\n }\n\n a {\n color: #2c5282;\n text-decoration: none;\n font-weight: 500;\n }\n\n hr {\n border: none;\n border-top: 2px solid #e2e8f0;\n margin: 24px 0;\n }\n","content_type":"application/yaml; charset=utf-8","language":"yaml","size":3533,"content_sha256":"34b77866adceb13bbeb604edae40938c2aa667f2650647147e8164d5c7deb404"},{"filename":"toolkit/themes/bytedance.yaml","content":"name: \"bytedance\"\ndescription: \"字节跳动风:白底品牌蓝,现代无衬线,大间距,适合科技产品内容\"\ncolors:\n primary: \"#1966FF\"\n secondary: \"#4e8fff\"\n text: \"#1f2329\"\n text_light: \"#646a73\"\n background: \"#ffffff\"\n code_bg: \"#f5f6f7\"\n code_color: \"#1966FF\"\n quote_border: \"#1966FF\"\n quote_bg: \"#f0f5ff\"\n border_radius: \"8px\"\ndarkmode:\n background: \"#1a1a1a\"\n text: \"#e8e8e8\"\n text_light: \"#a0a0a0\"\n primary: \"#4e8fff\"\n code_bg: \"#2a2a2a\"\n code_color: \"#6ea8fe\"\n quote_bg: \"#1e2a3a\"\n quote_border: \"#4e8fff\"\nbase_css: |\n body {\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", sans-serif;\n font-size: 16px;\n line-height: 2;\n color: #1f2329;\n background: #ffffff;\n max-width: 720px;\n margin: 0 auto;\n padding: 24px;\n word-wrap: break-word;\n letter-spacing: 0.02em;\n }\n\n h1 {\n font-size: 28px;\n font-weight: 800;\n color: #1f2329;\n margin: 40px 0 20px 0;\n padding-bottom: 14px;\n border-bottom: 3px solid #1966FF;\n line-height: 1.4;\n letter-spacing: -0.01em;\n }\n\n h2 {\n font-size: 22px;\n font-weight: 700;\n color: #1f2329;\n margin: 36px 0 16px 0;\n padding: 10px 0 10px 14px;\n border-left: 4px solid #1966FF;\n line-height: 1.4;\n }\n\n h3 {\n font-size: 18px;\n font-weight: 600;\n color: #1966FF;\n margin: 28px 0 14px 0;\n line-height: 1.4;\n }\n\n h4 {\n font-size: 16px;\n font-weight: 600;\n color: #1f2329;\n margin: 24px 0 12px 0;\n line-height: 1.4;\n }\n\n p {\n font-size: 16px;\n line-height: 2;\n color: #1f2329;\n margin: 16px 0;\n }\n\n strong {\n font-weight: 700;\n color: #1966FF;\n }\n\n em {\n font-style: italic;\n color: #646a73;\n }\n\n code {\n font-family: \"SFMono-Regular\", Consolas, \"Liberation Mono\", Menlo, Courier, monospace;\n font-size: 14px;\n background: #f5f6f7;\n color: #1966FF;\n padding: 2px 8px;\n border-radius: 4px;\n }\n\n pre {\n background: #f5f6f7;\n color: #1f2329;\n padding: 20px;\n border-radius: 8px;\n overflow-x: auto;\n margin: 20px 0;\n line-height: 1.6;\n border: 1px solid #e5e6e8;\n }\n\n pre code {\n font-family: \"SFMono-Regular\", Consolas, \"Liberation Mono\", Menlo, Courier, monospace;\n font-size: 14px;\n background: none;\n color: #1f2329;\n padding: 0;\n border-radius: 0;\n }\n\n blockquote {\n border-left: 4px solid #1966FF;\n background: #f0f5ff;\n margin: 20px 0;\n padding: 16px 20px;\n border-radius: 0 8px 8px 0;\n color: #1f2329;\n }\n\n blockquote p {\n margin: 8px 0;\n color: #1f2329;\n }\n\n ul {\n padding-left: 28px;\n margin: 16px 0;\n }\n\n ol {\n padding-left: 28px;\n margin: 16px 0;\n }\n\n li {\n font-size: 16px;\n line-height: 2;\n color: #1f2329;\n margin: 8px 0;\n }\n\n table {\n width: 100%;\n border-collapse: collapse;\n margin: 20px 0;\n font-size: 15px;\n }\n\n thead {\n background: #1966FF;\n }\n\n th {\n background: #1966FF;\n color: #ffffff;\n font-weight: 600;\n padding: 12px 16px;\n text-align: left;\n border: 1px solid #1966FF;\n }\n\n td {\n padding: 12px 16px;\n border: 1px solid #e5e6e8;\n color: #1f2329;\n }\n\n tr {\n background: #ffffff;\n }\n\n img {\n max-width: 100%;\n height: auto;\n display: block;\n margin: 28px auto;\n border-radius: 8px;\n }\n\n a {\n color: #1966FF;\n text-decoration: none;\n font-weight: 500;\n }\n\n hr {\n border: none;\n border-top: 1px solid #e5e6e8;\n margin: 32px 0;\n }\n","content_type":"application/yaml; charset=utf-8","language":"yaml","size":3573,"content_sha256":"7296ccb862ee9d7af426fb69fad50d141684cbcf3e9968bc46fd7f184d6aaf2a"},{"filename":"toolkit/themes/elegant-rose.yaml","content":"name: \"elegant-rose\"\ndescription: \"优雅玫瑰风格:浅粉底玫瑰色点缀,温柔精致,适合女性生活和时尚内容\"\ncolors:\n primary: \"#be185d\"\n secondary: \"#db2777\"\n text: \"#3d1f2e\"\n text_light: \"#7a5068\"\n background: \"#fdf2f8\"\n code_bg: \"#fce7f3\"\n code_color: \"#be185d\"\n quote_border: \"#be185d\"\n quote_bg: \"#fce7f3\"\n border_radius: \"12px\"\ndarkmode:\n background: \"#1a0f14\"\n text: \"#e8d0dc\"\n text_light: \"#a07888\"\n primary: \"#f472b6\"\n code_bg: \"#2a1520\"\n code_color: \"#f9a8d4\"\n quote_bg: \"#221018\"\n quote_border: \"#f472b6\"\nbase_css: |\n body {\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", sans-serif;\n font-size: 16px;\n line-height: 1.85;\n color: #3d1f2e;\n background: #fdf2f8;\n max-width: 720px;\n margin: 0 auto;\n padding: 20px;\n word-wrap: break-word;\n }\n\n h1 {\n font-size: 26px;\n font-weight: 700;\n color: #be185d;\n margin: 32px 0 16px 0;\n text-align: center;\n padding-bottom: 12px;\n border-bottom: 2px solid #f9a8d4;\n line-height: 1.4;\n }\n\n h2 {\n font-size: 22px;\n font-weight: 700;\n color: #be185d;\n margin: 28px 0 14px 0;\n padding: 8px 0 8px 12px;\n border-left: 4px solid #be185d;\n line-height: 1.4;\n }\n\n h3 {\n font-size: 18px;\n font-weight: 600;\n color: #db2777;\n margin: 24px 0 12px 0;\n line-height: 1.4;\n }\n\n h4 {\n font-size: 16px;\n font-weight: 600;\n color: #3d1f2e;\n margin: 20px 0 10px 0;\n line-height: 1.4;\n }\n\n p {\n font-size: 16px;\n line-height: 1.85;\n color: #3d1f2e;\n margin: 12px 0;\n }\n\n strong {\n font-weight: 700;\n color: #be185d;\n }\n\n em {\n font-style: italic;\n color: #7a5068;\n }\n\n code {\n font-family: \"SFMono-Regular\", Consolas, \"Liberation Mono\", Menlo, Courier, monospace;\n font-size: 14px;\n background: #fce7f3;\n color: #be185d;\n padding: 2px 6px;\n border-radius: 6px;\n }\n\n pre {\n background: #fce7f3;\n color: #3d1f2e;\n padding: 16px;\n border-radius: 12px;\n overflow-x: auto;\n margin: 16px 0;\n line-height: 1.6;\n border: 1px solid #fbcfe8;\n }\n\n pre code {\n font-family: \"SFMono-Regular\", Consolas, \"Liberation Mono\", Menlo, Courier, monospace;\n font-size: 14px;\n background: none;\n color: #3d1f2e;\n padding: 0;\n border-radius: 0;\n }\n\n blockquote {\n border-left: 4px solid #be185d;\n background: #fce7f3;\n margin: 16px 0;\n padding: 12px 16px;\n border-radius: 0 12px 12px 0;\n color: #7a5068;\n }\n\n blockquote p {\n margin: 8px 0;\n color: #7a5068;\n }\n\n ul {\n padding-left: 24px;\n margin: 12px 0;\n }\n\n ol {\n padding-left: 24px;\n margin: 12px 0;\n }\n\n li {\n font-size: 16px;\n line-height: 1.85;\n color: #3d1f2e;\n margin: 6px 0;\n }\n\n table {\n width: 100%;\n border-collapse: collapse;\n margin: 16px 0;\n font-size: 15px;\n }\n\n thead {\n background: #be185d;\n }\n\n th {\n background: #be185d;\n color: #ffffff;\n font-weight: 600;\n padding: 10px 14px;\n text-align: left;\n border: 1px solid #be185d;\n }\n\n td {\n padding: 10px 14px;\n border: 1px solid #fbcfe8;\n color: #3d1f2e;\n }\n\n tr {\n background: #fdf2f8;\n }\n\n img {\n max-width: 100%;\n height: auto;\n display: block;\n margin: 24px auto;\n border-radius: 12px;\n }\n\n a {\n color: #be185d;\n text-decoration: none;\n font-weight: 500;\n }\n\n hr {\n border: none;\n border-top: 1px solid #fbcfe8;\n margin: 24px 0;\n }\n","content_type":"application/yaml; charset=utf-8","language":"yaml","size":3561,"content_sha256":"55e46778bbf5816da08c2af4bdd97b6f89343b4ee64bcc1ae8ad6afddd68462e"},{"filename":"toolkit/themes/focus-red.yaml","content":"name: \"focus-red\"\ndescription: \"聚焦红风格:白底中国红标题和引用边框,醒目有力,适合新闻评论和观点输出\"\ncolors:\n primary: \"#dc2626\"\n secondary: \"#ef4444\"\n text: \"#1a1a1a\"\n text_light: \"#555555\"\n background: \"#ffffff\"\n code_bg: \"#fef2f2\"\n code_color: \"#b91c1c\"\n quote_border: \"#dc2626\"\n quote_bg: \"#fef2f2\"\n border_radius: \"6px\"\ndarkmode:\n background: \"#1a0f0f\"\n text: \"#e8d8d8\"\n text_light: \"#a08888\"\n primary: \"#f87171\"\n code_bg: \"#2a1515\"\n code_color: \"#fca5a5\"\n quote_bg: \"#221010\"\n quote_border: \"#f87171\"\nbase_css: |\n body {\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", sans-serif;\n font-size: 16px;\n line-height: 1.8;\n color: #1a1a1a;\n background: #ffffff;\n max-width: 720px;\n margin: 0 auto;\n padding: 20px;\n word-wrap: break-word;\n }\n\n h1 {\n font-size: 26px;\n font-weight: 800;\n color: #dc2626;\n margin: 32px 0 16px 0;\n padding-bottom: 12px;\n border-bottom: 3px solid #dc2626;\n line-height: 1.4;\n }\n\n h2 {\n font-size: 22px;\n font-weight: 700;\n color: #dc2626;\n margin: 28px 0 14px 0;\n padding: 8px 0 8px 12px;\n border-left: 4px solid #dc2626;\n line-height: 1.4;\n }\n\n h3 {\n font-size: 18px;\n font-weight: 600;\n color: #1a1a1a;\n margin: 24px 0 12px 0;\n line-height: 1.4;\n }\n\n h4 {\n font-size: 16px;\n font-weight: 600;\n color: #1a1a1a;\n margin: 20px 0 10px 0;\n line-height: 1.4;\n }\n\n p {\n font-size: 16px;\n line-height: 1.8;\n color: #1a1a1a;\n margin: 12px 0;\n }\n\n strong {\n font-weight: 700;\n color: #dc2626;\n }\n\n em {\n font-style: italic;\n color: #555555;\n }\n\n code {\n font-family: \"SFMono-Regular\", Consolas, \"Liberation Mono\", Menlo, Courier, monospace;\n font-size: 14px;\n background: #fef2f2;\n color: #b91c1c;\n padding: 2px 6px;\n border-radius: 4px;\n }\n\n pre {\n background: #fef2f2;\n color: #1a1a1a;\n padding: 16px;\n border-radius: 6px;\n overflow-x: auto;\n margin: 16px 0;\n line-height: 1.6;\n border: 1px solid #fecaca;\n }\n\n pre code {\n font-family: \"SFMono-Regular\", Consolas, \"Liberation Mono\", Menlo, Courier, monospace;\n font-size: 14px;\n background: none;\n color: #1a1a1a;\n padding: 0;\n border-radius: 0;\n }\n\n blockquote {\n border-left: 4px solid #dc2626;\n background: #fef2f2;\n margin: 16px 0;\n padding: 12px 16px;\n border-radius: 0 6px 6px 0;\n color: #333333;\n }\n\n blockquote p {\n margin: 8px 0;\n color: #333333;\n }\n\n ul {\n padding-left: 24px;\n margin: 12px 0;\n }\n\n ol {\n padding-left: 24px;\n margin: 12px 0;\n }\n\n li {\n font-size: 16px;\n line-height: 1.8;\n color: #1a1a1a;\n margin: 6px 0;\n }\n\n table {\n width: 100%;\n border-collapse: collapse;\n margin: 16px 0;\n font-size: 15px;\n }\n\n thead {\n background: #dc2626;\n }\n\n th {\n background: #dc2626;\n color: #ffffff;\n font-weight: 700;\n padding: 10px 14px;\n text-align: left;\n border: 1px solid #dc2626;\n }\n\n td {\n padding: 10px 14px;\n border: 1px solid #e5e7eb;\n color: #1a1a1a;\n }\n\n tr {\n background: #ffffff;\n }\n\n img {\n max-width: 100%;\n height: auto;\n display: block;\n margin: 24px auto;\n border-radius: 6px;\n }\n\n a {\n color: #dc2626;\n text-decoration: none;\n font-weight: 500;\n }\n\n hr {\n border: none;\n border-top: 2px solid #fecaca;\n margin: 24px 0;\n }\n","content_type":"application/yaml; charset=utf-8","language":"yaml","size":3535,"content_sha256":"4459350083a5fe4151fab88bf4c8db1c2abceb4f6ec505f60b89e0133a537d00"},{"filename":"toolkit/themes/github.yaml","content":"name: \"github\"\ndescription: \"GitHub风格:白底蓝色链接,等宽代码块,简洁清晰,适合技术文档和开发者内容\"\ncolors:\n primary: \"#0969da\"\n secondary: \"#0550ae\"\n text: \"#1f2328\"\n text_light: \"#656d76\"\n background: \"#ffffff\"\n code_bg: \"#f6f8fa\"\n code_color: \"#0550ae\"\n quote_border: \"#d0d7de\"\n quote_bg: \"#f6f8fa\"\n border_radius: \"6px\"\ndarkmode:\n background: \"#0d1117\"\n text: \"#e6edf3\"\n text_light: \"#8b949e\"\n primary: \"#58a6ff\"\n code_bg: \"#161b22\"\n code_color: \"#79c0ff\"\n quote_bg: \"#161b22\"\n quote_border: \"#30363d\"\nbase_css: |\n body {\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Noto Sans\", Helvetica, Arial, \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", sans-serif;\n font-size: 16px;\n line-height: 1.75;\n color: #1f2328;\n background: #ffffff;\n max-width: 720px;\n margin: 0 auto;\n padding: 20px;\n word-wrap: break-word;\n }\n\n h1 {\n font-size: 26px;\n font-weight: 600;\n color: #1f2328;\n margin: 32px 0 16px 0;\n padding-bottom: 10px;\n border-bottom: 1px solid #d1d9e0;\n line-height: 1.4;\n }\n\n h2 {\n font-size: 22px;\n font-weight: 600;\n color: #1f2328;\n margin: 28px 0 14px 0;\n padding-bottom: 8px;\n border-bottom: 1px solid #d1d9e0;\n line-height: 1.4;\n }\n\n h3 {\n font-size: 18px;\n font-weight: 600;\n color: #1f2328;\n margin: 24px 0 12px 0;\n line-height: 1.4;\n }\n\n h4 {\n font-size: 16px;\n font-weight: 600;\n color: #1f2328;\n margin: 20px 0 10px 0;\n line-height: 1.4;\n }\n\n p {\n font-size: 16px;\n line-height: 1.75;\n color: #1f2328;\n margin: 12px 0;\n }\n\n strong {\n font-weight: 600;\n color: #1f2328;\n }\n\n em {\n font-style: italic;\n color: #1f2328;\n }\n\n code {\n font-family: ui-monospace, \"SFMono-Regular\", \"SF Mono\", Menlo, Consolas, \"Liberation Mono\", monospace;\n font-size: 13.6px;\n background: rgba(175,184,193,0.2);\n color: #1f2328;\n padding: 3px 6px;\n border-radius: 6px;\n }\n\n pre {\n background: #f6f8fa;\n color: #1f2328;\n padding: 16px;\n border-radius: 6px;\n overflow-x: auto;\n margin: 16px 0;\n line-height: 1.5;\n border: 1px solid #d1d9e0;\n }\n\n pre code {\n font-family: ui-monospace, \"SFMono-Regular\", \"SF Mono\", Menlo, Consolas, \"Liberation Mono\", monospace;\n font-size: 13.6px;\n background: none;\n color: #1f2328;\n padding: 0;\n border-radius: 0;\n }\n\n blockquote {\n border-left: 4px solid #d0d7de;\n background: transparent;\n margin: 16px 0;\n padding: 4px 16px;\n border-radius: 0;\n color: #656d76;\n }\n\n blockquote p {\n margin: 8px 0;\n color: #656d76;\n }\n\n ul {\n padding-left: 28px;\n margin: 12px 0;\n }\n\n ol {\n padding-left: 28px;\n margin: 12px 0;\n }\n\n li {\n font-size: 16px;\n line-height: 1.75;\n color: #1f2328;\n margin: 4px 0;\n }\n\n table {\n width: 100%;\n border-collapse: collapse;\n margin: 16px 0;\n font-size: 15px;\n }\n\n thead {\n background: #f6f8fa;\n }\n\n th {\n background: #f6f8fa;\n color: #1f2328;\n font-weight: 600;\n padding: 8px 14px;\n text-align: left;\n border: 1px solid #d1d9e0;\n }\n\n td {\n padding: 8px 14px;\n border: 1px solid #d1d9e0;\n color: #1f2328;\n }\n\n tr {\n background: #ffffff;\n }\n\n img {\n max-width: 100%;\n height: auto;\n display: block;\n margin: 24px auto;\n border-radius: 6px;\n }\n\n a {\n color: #0969da;\n text-decoration: none;\n font-weight: 400;\n }\n\n hr {\n border: none;\n height: 2px;\n background: #d1d9e0;\n margin: 24px 0;\n }\n","content_type":"application/yaml; charset=utf-8","language":"yaml","size":3579,"content_sha256":"176ed0c463e1627d5e1405a6181e196034a7c6d89ef1f5eb5ce4f62e1426b8ee"},{"filename":"toolkit/themes/ink.yaml","content":"name: \"ink\"\ndescription: \"水墨中国风:宣纸底墨色文字,中文衬线字体,留白疏朗,适合文化和人文内容\"\ncolors:\n primary: \"#4a4a4a\"\n secondary: \"#6b6b6b\"\n text: \"#1a1a1a\"\n text_light: \"#666666\"\n background: \"#f8f5f0\"\n code_bg: \"#f0ebe3\"\n code_color: \"#555555\"\n quote_border: \"#999999\"\n quote_bg: \"#f4f0e8\"\n border_radius: \"2px\"\ndarkmode:\n background: \"#1a1816\"\n text: \"#d8d2c8\"\n text_light: \"#9a9488\"\n primary: \"#b0a898\"\n code_bg: \"#252220\"\n code_color: \"#c0b8a8\"\n quote_bg: \"#222018\"\n quote_border: \"#706858\"\nbase_css: |\n body {\n font-family: \"Songti SC\", \"SimSun\", \"Noto Serif SC\", Georgia, serif;\n font-size: 16px;\n line-height: 2;\n color: #1a1a1a;\n background: #f8f5f0;\n max-width: 680px;\n margin: 0 auto;\n padding: 28px;\n word-wrap: break-word;\n }\n\n h1 {\n font-size: 28px;\n font-weight: 700;\n color: #1a1a1a;\n margin: 48px 0 24px 0;\n text-align: center;\n line-height: 1.4;\n letter-spacing: 0.1em;\n }\n\n h2 {\n font-size: 22px;\n font-weight: 700;\n color: #1a1a1a;\n margin: 40px 0 16px 0;\n text-align: center;\n padding-bottom: 12px;\n line-height: 1.4;\n letter-spacing: 0.05em;\n }\n\n h3 {\n font-size: 18px;\n font-weight: 600;\n color: #333333;\n margin: 32px 0 14px 0;\n line-height: 1.4;\n letter-spacing: 0.03em;\n }\n\n h4 {\n font-size: 16px;\n font-weight: 600;\n color: #333333;\n margin: 24px 0 12px 0;\n line-height: 1.4;\n }\n\n p {\n font-size: 16px;\n line-height: 2;\n color: #1a1a1a;\n margin: 16px 0;\n text-indent: 2em;\n }\n\n strong {\n font-weight: 700;\n color: #1a1a1a;\n }\n\n em {\n font-style: italic;\n color: #666666;\n }\n\n code {\n font-family: \"SFMono-Regular\", Consolas, \"Liberation Mono\", Menlo, Courier, monospace;\n font-size: 14px;\n background: #f0ebe3;\n color: #555555;\n padding: 2px 6px;\n border-radius: 2px;\n }\n\n pre {\n background: #f0ebe3;\n color: #1a1a1a;\n padding: 16px;\n border-radius: 2px;\n overflow-x: auto;\n margin: 20px 0;\n line-height: 1.6;\n border: 1px solid #ddd5c8;\n }\n\n pre code {\n font-family: \"SFMono-Regular\", Consolas, \"Liberation Mono\", Menlo, Courier, monospace;\n font-size: 14px;\n background: none;\n color: #1a1a1a;\n padding: 0;\n border-radius: 0;\n }\n\n blockquote {\n border-left: 3px solid #999999;\n background: #f4f0e8;\n margin: 20px 0;\n padding: 14px 18px;\n border-radius: 0 2px 2px 0;\n color: #555555;\n font-style: italic;\n }\n\n blockquote p {\n margin: 8px 0;\n color: #555555;\n text-indent: 0;\n }\n\n ul {\n padding-left: 24px;\n margin: 16px 0;\n }\n\n ol {\n padding-left: 24px;\n margin: 16px 0;\n }\n\n li {\n font-size: 16px;\n line-height: 2;\n color: #1a1a1a;\n margin: 8px 0;\n }\n\n table {\n width: 100%;\n border-collapse: collapse;\n margin: 20px 0;\n font-size: 15px;\n }\n\n thead {\n background: #4a4a4a;\n }\n\n th {\n background: #4a4a4a;\n color: #f8f5f0;\n font-weight: 600;\n padding: 10px 14px;\n text-align: left;\n border: 1px solid #4a4a4a;\n }\n\n td {\n padding: 10px 14px;\n border: 1px solid #ddd5c8;\n color: #1a1a1a;\n }\n\n tr {\n background: #f8f5f0;\n }\n\n img {\n max-width: 100%;\n height: auto;\n display: block;\n margin: 32px auto;\n border-radius: 2px;\n }\n\n a {\n color: #555555;\n text-decoration: underline;\n }\n\n hr {\n border: none;\n text-align: center;\n margin: 36px 0;\n height: 20px;\n background: transparent;\n border-bottom: 1px solid #ccc5b8;\n }\n","content_type":"application/yaml; charset=utf-8","language":"yaml","size":3584,"content_sha256":"8b066b9c12b3c1bf1803e0877ef869580098a729ae54060a1dc0c8d5cc4bc108"},{"filename":"toolkit/themes/midnight.yaml","content":"name: \"midnight\"\ndescription: \"午夜深色主题:深蓝黑底白色文字,蓝色高亮,适合技术和深夜阅读内容\"\ncolors:\n primary: \"#60a5fa\"\n secondary: \"#93c5fd\"\n text: \"#e2e8f0\"\n text_light: \"#94a3b8\"\n background: \"#0f172a\"\n code_bg: \"#1e293b\"\n code_color: \"#7dd3fc\"\n quote_border: \"#60a5fa\"\n quote_bg: \"#172040\"\n border_radius: \"8px\"\ndarkmode:\n background: \"#0f172a\"\n text: \"#e2e8f0\"\n text_light: \"#94a3b8\"\n primary: \"#60a5fa\"\n code_bg: \"#1e293b\"\n code_color: \"#7dd3fc\"\n quote_bg: \"#172040\"\n quote_border: \"#60a5fa\"\nbase_css: |\n body {\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", sans-serif;\n font-size: 16px;\n line-height: 1.8;\n color: #e2e8f0;\n background: #0f172a;\n max-width: 720px;\n margin: 0 auto;\n padding: 20px;\n word-wrap: break-word;\n }\n\n h1 {\n font-size: 26px;\n font-weight: 700;\n color: #f1f5f9;\n margin: 32px 0 16px 0;\n padding-bottom: 12px;\n border-bottom: 2px solid #60a5fa;\n line-height: 1.4;\n }\n\n h2 {\n font-size: 22px;\n font-weight: 700;\n color: #60a5fa;\n margin: 28px 0 14px 0;\n padding: 8px 0 8px 12px;\n border-left: 4px solid #60a5fa;\n line-height: 1.4;\n }\n\n h3 {\n font-size: 18px;\n font-weight: 600;\n color: #93c5fd;\n margin: 24px 0 12px 0;\n line-height: 1.4;\n }\n\n h4 {\n font-size: 16px;\n font-weight: 600;\n color: #cbd5e1;\n margin: 20px 0 10px 0;\n line-height: 1.4;\n }\n\n p {\n font-size: 16px;\n line-height: 1.8;\n color: #e2e8f0;\n margin: 12px 0;\n }\n\n strong {\n font-weight: 700;\n color: #60a5fa;\n }\n\n em {\n font-style: italic;\n color: #94a3b8;\n }\n\n code {\n font-family: \"SFMono-Regular\", Consolas, \"Liberation Mono\", Menlo, Courier, monospace;\n font-size: 14px;\n background: #1e293b;\n color: #7dd3fc;\n padding: 2px 6px;\n border-radius: 4px;\n }\n\n pre {\n background: #1e293b;\n color: #e2e8f0;\n padding: 16px;\n border-radius: 8px;\n overflow-x: auto;\n margin: 16px 0;\n line-height: 1.6;\n border: 1px solid #334155;\n }\n\n pre code {\n font-family: \"SFMono-Regular\", Consolas, \"Liberation Mono\", Menlo, Courier, monospace;\n font-size: 14px;\n background: none;\n color: #e2e8f0;\n padding: 0;\n border-radius: 0;\n }\n\n blockquote {\n border-left: 4px solid #60a5fa;\n background: #172040;\n margin: 16px 0;\n padding: 12px 16px;\n border-radius: 0 8px 8px 0;\n color: #cbd5e1;\n }\n\n blockquote p {\n margin: 8px 0;\n color: #cbd5e1;\n }\n\n ul {\n padding-left: 24px;\n margin: 12px 0;\n }\n\n ol {\n padding-left: 24px;\n margin: 12px 0;\n }\n\n li {\n font-size: 16px;\n line-height: 1.8;\n color: #e2e8f0;\n margin: 6px 0;\n }\n\n table {\n width: 100%;\n border-collapse: collapse;\n margin: 16px 0;\n font-size: 15px;\n }\n\n thead {\n background: #1e3a5f;\n }\n\n th {\n background: #1e3a5f;\n color: #f1f5f9;\n font-weight: 600;\n padding: 10px 14px;\n text-align: left;\n border: 1px solid #334155;\n }\n\n td {\n padding: 10px 14px;\n border: 1px solid #334155;\n color: #e2e8f0;\n }\n\n tr {\n background: #0f172a;\n }\n\n img {\n max-width: 100%;\n height: auto;\n display: block;\n margin: 24px auto;\n border-radius: 8px;\n }\n\n a {\n color: #60a5fa;\n text-decoration: none;\n font-weight: 500;\n }\n\n hr {\n border: none;\n border-top: 1px solid #334155;\n margin: 24px 0;\n }\n","content_type":"application/yaml; charset=utf-8","language":"yaml","size":3525,"content_sha256":"a6665d30671f3e376506b9eee4f6942f0305007a8da387cb9f86a5c9e59f49a6"},{"filename":"toolkit/themes/minimal-gold.yaml","content":"name: \"minimal-gold\"\ndescription: \"极简金色风格:白底金色细线点缀,奢华但克制,适合高端品牌和精品内容\"\ncolors:\n primary: \"#b8860b\"\n secondary: \"#d4a843\"\n text: \"#2a2a2a\"\n text_light: \"#6b6b6b\"\n background: \"#ffffff\"\n code_bg: \"#faf8f3\"\n code_color: \"#8b6914\"\n quote_border: \"#b8860b\"\n quote_bg: \"#fdfbf5\"\n border_radius: \"4px\"\ndarkmode:\n background: \"#141210\"\n text: \"#e0dcd0\"\n text_light: \"#9a9488\"\n primary: \"#d4a843\"\n code_bg: \"#1e1c18\"\n code_color: \"#e0c060\"\n quote_bg: \"#1a1810\"\n quote_border: \"#d4a843\"\nbase_css: |\n body {\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", sans-serif;\n font-size: 16px;\n line-height: 1.8;\n color: #2a2a2a;\n background: #ffffff;\n max-width: 720px;\n margin: 0 auto;\n padding: 20px;\n word-wrap: break-word;\n letter-spacing: 0.01em;\n }\n\n h1 {\n font-size: 26px;\n font-weight: 600;\n color: #2a2a2a;\n margin: 36px 0 18px 0;\n text-align: center;\n padding-bottom: 14px;\n border-bottom: 1px solid #b8860b;\n line-height: 1.4;\n letter-spacing: 0.05em;\n }\n\n h2 {\n font-size: 21px;\n font-weight: 600;\n color: #2a2a2a;\n margin: 30px 0 14px 0;\n padding-bottom: 8px;\n border-bottom: 1px solid #d4c59a;\n line-height: 1.4;\n }\n\n h3 {\n font-size: 18px;\n font-weight: 600;\n color: #b8860b;\n margin: 24px 0 12px 0;\n line-height: 1.4;\n }\n\n h4 {\n font-size: 16px;\n font-weight: 600;\n color: #2a2a2a;\n margin: 20px 0 10px 0;\n line-height: 1.4;\n }\n\n p {\n font-size: 16px;\n line-height: 1.8;\n color: #2a2a2a;\n margin: 12px 0;\n }\n\n strong {\n font-weight: 700;\n color: #b8860b;\n }\n\n em {\n font-style: italic;\n color: #6b6b6b;\n }\n\n code {\n font-family: \"SFMono-Regular\", Consolas, \"Liberation Mono\", Menlo, Courier, monospace;\n font-size: 14px;\n background: #faf8f3;\n color: #8b6914;\n padding: 2px 6px;\n border-radius: 4px;\n }\n\n pre {\n background: #faf8f3;\n color: #2a2a2a;\n padding: 16px;\n border-radius: 4px;\n overflow-x: auto;\n margin: 16px 0;\n line-height: 1.6;\n border: 1px solid #e8e0c8;\n }\n\n pre code {\n font-family: \"SFMono-Regular\", Consolas, \"Liberation Mono\", Menlo, Courier, monospace;\n font-size: 14px;\n background: none;\n color: #2a2a2a;\n padding: 0;\n border-radius: 0;\n }\n\n blockquote {\n border-left: 2px solid #b8860b;\n background: #fdfbf5;\n margin: 16px 0;\n padding: 12px 16px;\n border-radius: 0 4px 4px 0;\n color: #6b6b6b;\n }\n\n blockquote p {\n margin: 8px 0;\n color: #6b6b6b;\n }\n\n ul {\n padding-left: 24px;\n margin: 12px 0;\n }\n\n ol {\n padding-left: 24px;\n margin: 12px 0;\n }\n\n li {\n font-size: 16px;\n line-height: 1.8;\n color: #2a2a2a;\n margin: 6px 0;\n }\n\n table {\n width: 100%;\n border-collapse: collapse;\n margin: 16px 0;\n font-size: 15px;\n }\n\n thead {\n background: #2a2a2a;\n }\n\n th {\n background: #2a2a2a;\n color: #d4a843;\n font-weight: 600;\n padding: 10px 14px;\n text-align: left;\n border: 1px solid #2a2a2a;\n letter-spacing: 0.03em;\n }\n\n td {\n padding: 10px 14px;\n border: 1px solid #e8e0c8;\n color: #2a2a2a;\n }\n\n tr {\n background: #ffffff;\n }\n\n img {\n max-width: 100%;\n height: auto;\n display: block;\n margin: 24px auto;\n border-radius: 4px;\n }\n\n a {\n color: #b8860b;\n text-decoration: none;\n font-weight: 500;\n }\n\n hr {\n border: none;\n border-top: 1px solid #d4c59a;\n margin: 28px auto;\n width: 40%;\n }\n","content_type":"application/yaml; charset=utf-8","language":"yaml","size":3657,"content_sha256":"5d598e11fde04933a98b98b63aacb5024e9289eafd89f1e192af7689d71b7c83"},{"filename":"toolkit/themes/minimal.yaml","content":"name: \"minimal\"\ndescription: \"极简黑白灰风格,无色彩干扰,内容至上\"\ncolors:\n primary: \"#333333\"\n secondary: \"#666666\"\n text: \"#333333\"\n text_light: \"#666666\"\n background: \"#ffffff\"\n code_bg: \"#f5f5f5\"\n code_color: \"#d73a49\"\n quote_border: \"#cccccc\"\n quote_bg: \"#f9f9f9\"\n border_radius: \"4px\"\n darkmode:\n background: \"#1a1a1a\"\n text: \"#c0c0c0\"\n text_light: \"#888888\"\n primary: \"#e0e0e0\"\n code_bg: \"#252525\"\n code_color: \"#c0c0c0\"\n quote_bg: \"#222222\"\n quote_border: \"#555555\"\nbase_css: |\n body {\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", sans-serif;\n font-size: 16px;\n line-height: 1.8;\n color: #333333;\n background: #ffffff;\n max-width: 720px;\n margin: 0 auto;\n padding: 20px;\n word-wrap: break-word;\n }\n\n h1 {\n font-size: 26px;\n font-weight: 700;\n color: #1a1a1a;\n margin: 32px 0 16px 0;\n text-align: center;\n line-height: 1.4;\n }\n\n h2 {\n font-size: 22px;\n font-weight: 700;\n color: #1a1a1a;\n margin: 28px 0 14px 0;\n padding-bottom: 8px;\n border-bottom: 1px solid #e0e0e0;\n line-height: 1.4;\n }\n\n h3 {\n font-size: 18px;\n font-weight: 600;\n color: #333333;\n margin: 24px 0 12px 0;\n line-height: 1.4;\n }\n\n h4 {\n font-size: 16px;\n font-weight: 600;\n color: #333333;\n margin: 20px 0 10px 0;\n line-height: 1.4;\n }\n\n p {\n font-size: 16px;\n line-height: 1.8;\n color: #333333;\n margin: 12px 0;\n }\n\n strong {\n font-weight: 700;\n color: #1a1a1a;\n }\n\n em {\n font-style: italic;\n color: #333333;\n }\n\n code {\n font-family: \"SFMono-Regular\", Consolas, \"Liberation Mono\", Menlo, Courier, monospace;\n font-size: 14px;\n background: #f5f5f5;\n color: #d73a49;\n padding: 2px 6px;\n border-radius: 4px;\n }\n\n pre {\n background: #f5f5f5;\n color: #333333;\n padding: 16px;\n border-radius: 4px;\n overflow-x: auto;\n margin: 16px 0;\n line-height: 1.6;\n border: 1px solid #e0e0e0;\n }\n\n pre code {\n font-family: \"SFMono-Regular\", Consolas, \"Liberation Mono\", Menlo, Courier, monospace;\n font-size: 14px;\n background: none;\n color: #333333;\n padding: 0;\n border-radius: 0;\n }\n\n blockquote {\n border-left: 3px solid #cccccc;\n background: #f9f9f9;\n margin: 16px 0;\n padding: 12px 16px;\n border-radius: 0 4px 4px 0;\n color: #666666;\n }\n\n blockquote p {\n margin: 8px 0;\n color: #666666;\n }\n\n ul {\n padding-left: 24px;\n margin: 12px 0;\n }\n\n ol {\n padding-left: 24px;\n margin: 12px 0;\n }\n\n li {\n font-size: 16px;\n line-height: 1.8;\n color: #333333;\n margin: 6px 0;\n }\n\n table {\n width: 100%;\n border-collapse: collapse;\n margin: 16px 0;\n font-size: 15px;\n }\n\n thead {\n background: rgba(0,0,0,0.03);\n }\n\n th {\n background: rgba(0,0,0,0.03);\n color: #333333;\n font-weight: 600;\n padding: 10px 14px;\n text-align: left;\n border: 1px solid #e0e0e0;\n }\n\n td {\n padding: 10px 14px;\n border: 1px solid #e0e0e0;\n color: #333333;\n }\n\n tr {\n background: #ffffff;\n }\n\n img {\n max-width: 100%;\n height: auto;\n display: block;\n margin: 24px auto;\n border-radius: 4px;\n }\n\n a {\n color: #333333;\n text-decoration: underline;\n }\n\n hr {\n border: none;\n border-top: 1px solid #e0e0e0;\n margin: 24px 0;\n }\n","content_type":"application/yaml; charset=utf-8","language":"yaml","size":3459,"content_sha256":"3d455f63bc67477e82df4b0825942451e2998856aeb1a593e0644ef1ad55f8c6"},{"filename":"toolkit/themes/newspaper.yaml","content":"name: \"newspaper\"\ndescription: \"经典报纸风格:米黄底深棕文字,衬线字体质感,适合深度报道和评论\"\ncolors:\n primary: \"#8b4513\"\n secondary: \"#a0522d\"\n text: \"#2c2416\"\n text_light: \"#5c4a3a\"\n background: \"#f5f0e8\"\n code_bg: \"#ede7db\"\n code_color: \"#8b4513\"\n quote_border: \"#8b4513\"\n quote_bg: \"#f0eade\"\n border_radius: \"2px\"\ndarkmode:\n background: \"#1e1a14\"\n text: \"#ddd5c8\"\n text_light: \"#a09580\"\n primary: \"#c8915a\"\n code_bg: \"#2a2418\"\n code_color: \"#d4a574\"\n quote_bg: \"#28221a\"\n quote_border: \"#c8915a\"\nbase_css: |\n body {\n font-family: Georgia, \"Songti SC\", \"SimSun\", \"Noto Serif SC\", serif;\n font-size: 16px;\n line-height: 1.9;\n color: #2c2416;\n background: #f5f0e8;\n max-width: 720px;\n margin: 0 auto;\n padding: 20px;\n word-wrap: break-word;\n }\n\n h1 {\n font-size: 28px;\n font-weight: 700;\n color: #2c2416;\n margin: 36px 0 16px 0;\n text-align: center;\n padding-bottom: 12px;\n border-bottom: 3px double #8b4513;\n line-height: 1.3;\n letter-spacing: 0.03em;\n }\n\n h2 {\n font-size: 22px;\n font-weight: 700;\n color: #2c2416;\n margin: 30px 0 14px 0;\n padding-bottom: 8px;\n border-bottom: 1px solid #8b4513;\n line-height: 1.4;\n }\n\n h3 {\n font-size: 18px;\n font-weight: 700;\n color: #5c4a3a;\n margin: 24px 0 12px 0;\n line-height: 1.4;\n text-transform: uppercase;\n letter-spacing: 0.05em;\n font-size: 16px;\n }\n\n h4 {\n font-size: 16px;\n font-weight: 700;\n color: #5c4a3a;\n margin: 20px 0 10px 0;\n line-height: 1.4;\n }\n\n p {\n font-size: 16px;\n line-height: 1.9;\n color: #2c2416;\n margin: 14px 0;\n text-align: justify;\n }\n\n strong {\n font-weight: 700;\n color: #2c2416;\n }\n\n em {\n font-style: italic;\n color: #5c4a3a;\n }\n\n code {\n font-family: \"SFMono-Regular\", Consolas, \"Liberation Mono\", Menlo, Courier, monospace;\n font-size: 14px;\n background: #ede7db;\n color: #8b4513;\n padding: 2px 6px;\n border-radius: 2px;\n }\n\n pre {\n background: #ede7db;\n color: #2c2416;\n padding: 16px;\n border-radius: 2px;\n overflow-x: auto;\n margin: 16px 0;\n line-height: 1.6;\n border: 1px solid #d4cbb8;\n }\n\n pre code {\n font-family: \"SFMono-Regular\", Consolas, \"Liberation Mono\", Menlo, Courier, monospace;\n font-size: 14px;\n background: none;\n color: #2c2416;\n padding: 0;\n border-radius: 0;\n }\n\n blockquote {\n border-left: 3px solid #8b4513;\n background: #f0eade;\n margin: 16px 0;\n padding: 12px 16px;\n border-radius: 0 2px 2px 0;\n color: #5c4a3a;\n font-style: italic;\n }\n\n blockquote p {\n margin: 8px 0;\n color: #5c4a3a;\n }\n\n ul {\n padding-left: 24px;\n margin: 14px 0;\n }\n\n ol {\n padding-left: 24px;\n margin: 14px 0;\n }\n\n li {\n font-size: 16px;\n line-height: 1.9;\n color: #2c2416;\n margin: 6px 0;\n }\n\n table {\n width: 100%;\n border-collapse: collapse;\n margin: 16px 0;\n font-size: 15px;\n }\n\n thead {\n background: #8b4513;\n }\n\n th {\n background: #8b4513;\n color: #f5f0e8;\n font-weight: 700;\n padding: 10px 14px;\n text-align: left;\n border: 1px solid #8b4513;\n }\n\n td {\n padding: 10px 14px;\n border: 1px solid #d4cbb8;\n color: #2c2416;\n }\n\n tr {\n background: #f5f0e8;\n }\n\n img {\n max-width: 100%;\n height: auto;\n display: block;\n margin: 24px auto;\n border-radius: 2px;\n border: 1px solid #d4cbb8;\n }\n\n a {\n color: #8b4513;\n text-decoration: underline;\n font-weight: 500;\n }\n\n hr {\n border: none;\n border-top: 1px solid #d4cbb8;\n margin: 28px auto;\n width: 60%;\n }\n","content_type":"application/yaml; charset=utf-8","language":"yaml","size":3668,"content_sha256":"13b8964db63e2f56ed1c2c3dd1147f1e3485af4ededc8b0b44ee59fe9281a365"},{"filename":"toolkit/themes/professional-clean.yaml","content":"name: \"professional-clean\"\ndescription: \"干净专业的企业公众号风格,适合大多数商业内容\"\ncolors:\n primary: \"#2563eb\"\n secondary: \"#3b82f6\"\n text: \"#333333\"\n text_light: \"#666666\"\n background: \"#ffffff\"\n code_bg: \"#1e293b\"\n code_color: \"#e2e8f0\"\n quote_border: \"#2563eb\"\n quote_bg: \"#eff6ff\"\n border_radius: \"8px\"\n darkmode:\n background: \"#1e1e1e\"\n text: \"#c8c8c8\"\n text_light: \"#999999\"\n primary: \"#6aadff\"\n code_bg: \"#2d2d2d\"\n code_color: \"#d4d4d4\"\n quote_bg: \"#252525\"\n quote_border: \"#4a90d9\"\nbase_css: |\n body {\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", sans-serif;\n font-size: 16px;\n line-height: 1.8;\n color: #333333;\n background: #ffffff;\n max-width: 720px;\n margin: 0 auto;\n padding: 20px;\n word-wrap: break-word;\n }\n\n h1 {\n font-size: 26px;\n font-weight: 700;\n color: #1a1a1a;\n margin: 32px 0 16px 0;\n padding-bottom: 12px;\n border-bottom: 2px solid #2563eb;\n line-height: 1.4;\n }\n\n h2 {\n font-size: 22px;\n font-weight: 700;\n color: #1a1a1a;\n margin: 28px 0 14px 0;\n padding: 8px 0 8px 12px;\n border-left: 4px solid #2563eb;\n border-bottom: 1px solid #e5e7eb;\n line-height: 1.4;\n }\n\n h3 {\n font-size: 18px;\n font-weight: 600;\n color: #333333;\n margin: 24px 0 12px 0;\n line-height: 1.4;\n }\n\n h4 {\n font-size: 16px;\n font-weight: 600;\n color: #333333;\n margin: 20px 0 10px 0;\n line-height: 1.4;\n }\n\n p {\n font-size: 16px;\n line-height: 1.8;\n color: #333333;\n margin: 12px 0;\n }\n\n strong {\n font-weight: 700;\n color: #1a1a1a;\n }\n\n em {\n font-style: italic;\n color: #333333;\n }\n\n code {\n font-family: \"SFMono-Regular\", Consolas, \"Liberation Mono\", Menlo, Courier, monospace;\n font-size: 14px;\n background: #f1f5f9;\n color: #d946ef;\n padding: 2px 6px;\n border-radius: 4px;\n }\n\n pre {\n background: #1e293b;\n color: #e2e8f0;\n padding: 16px;\n border-radius: 8px;\n overflow-x: auto;\n margin: 16px 0;\n line-height: 1.6;\n }\n\n pre code {\n font-family: \"SFMono-Regular\", Consolas, \"Liberation Mono\", Menlo, Courier, monospace;\n font-size: 14px;\n background: none;\n color: #e2e8f0;\n padding: 0;\n border-radius: 0;\n }\n\n blockquote {\n border-left: 4px solid #2563eb;\n background: #eff6ff;\n margin: 16px 0;\n padding: 12px 16px;\n border-radius: 0 8px 8px 0;\n color: #333333;\n }\n\n blockquote p {\n margin: 8px 0;\n color: #333333;\n }\n\n ul {\n padding-left: 24px;\n margin: 12px 0;\n }\n\n ol {\n padding-left: 24px;\n margin: 12px 0;\n }\n\n li {\n font-size: 16px;\n line-height: 1.8;\n color: #333333;\n margin: 6px 0;\n }\n\n table {\n width: 100%;\n border-collapse: collapse;\n margin: 16px 0;\n font-size: 15px;\n }\n\n thead {\n background: #2563eb;\n }\n\n th {\n background: #2563eb;\n color: #ffffff;\n font-weight: 600;\n padding: 10px 14px;\n text-align: left;\n border: 1px solid #2563eb;\n }\n\n td {\n padding: 10px 14px;\n border: 1px solid #e5e7eb;\n color: #333333;\n }\n\n tr {\n background: #ffffff;\n }\n\n img {\n max-width: 100%;\n height: auto;\n display: block;\n margin: 24px auto;\n border-radius: 8px;\n }\n\n a {\n color: #2563eb;\n text-decoration: none;\n font-weight: 500;\n }\n\n hr {\n border: none;\n border-top: 1px solid #e5e7eb;\n margin: 24px 0;\n }\n","content_type":"application/yaml; charset=utf-8","language":"yaml","size":3530,"content_sha256":"0e6fe6f1df2e8b473eac50fa59755e104b8ad6e65b5e33692e1a324587d90e49"},{"filename":"toolkit/themes/sspai.yaml","content":"name: \"sspai\"\ndescription: \"少数派风格:暖白底红色点缀,清爽文艺,适合数码生活和效率工具内容\"\ncolors:\n primary: \"#c7372f\"\n secondary: \"#d4524b\"\n text: \"#333333\"\n text_light: \"#888888\"\n background: \"#fafaf7\"\n code_bg: \"#f5f2ed\"\n code_color: \"#c7372f\"\n quote_border: \"#c7372f\"\n quote_bg: \"#fdf5f4\"\n border_radius: \"6px\"\ndarkmode:\n background: \"#1c1c1a\"\n text: \"#e0ddd8\"\n text_light: \"#9a9790\"\n primary: \"#e05a52\"\n code_bg: \"#2a2825\"\n code_color: \"#e87c76\"\n quote_bg: \"#2a2220\"\n quote_border: \"#e05a52\"\nbase_css: |\n body {\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", sans-serif;\n font-size: 16px;\n line-height: 1.85;\n color: #333333;\n background: #fafaf7;\n max-width: 720px;\n margin: 0 auto;\n padding: 20px;\n word-wrap: break-word;\n }\n\n h1 {\n font-size: 26px;\n font-weight: 700;\n color: #1a1a1a;\n margin: 36px 0 18px 0;\n text-align: center;\n line-height: 1.4;\n }\n\n h2 {\n font-size: 21px;\n font-weight: 700;\n color: #c7372f;\n margin: 30px 0 14px 0;\n padding-bottom: 8px;\n border-bottom: 2px solid #c7372f;\n line-height: 1.4;\n }\n\n h3 {\n font-size: 18px;\n font-weight: 600;\n color: #333333;\n margin: 24px 0 12px 0;\n padding-left: 10px;\n border-left: 3px solid #c7372f;\n line-height: 1.4;\n }\n\n h4 {\n font-size: 16px;\n font-weight: 600;\n color: #333333;\n margin: 20px 0 10px 0;\n line-height: 1.4;\n }\n\n p {\n font-size: 16px;\n line-height: 1.85;\n color: #333333;\n margin: 14px 0;\n }\n\n strong {\n font-weight: 700;\n color: #c7372f;\n }\n\n em {\n font-style: italic;\n color: #666666;\n }\n\n code {\n font-family: \"SFMono-Regular\", Consolas, \"Liberation Mono\", Menlo, Courier, monospace;\n font-size: 14px;\n background: #f5f2ed;\n color: #c7372f;\n padding: 2px 6px;\n border-radius: 4px;\n }\n\n pre {\n background: #f5f2ed;\n color: #333333;\n padding: 16px;\n border-radius: 6px;\n overflow-x: auto;\n margin: 16px 0;\n line-height: 1.6;\n border: 1px solid #e8e4dc;\n }\n\n pre code {\n font-family: \"SFMono-Regular\", Consolas, \"Liberation Mono\", Menlo, Courier, monospace;\n font-size: 14px;\n background: none;\n color: #333333;\n padding: 0;\n border-radius: 0;\n }\n\n blockquote {\n border-left: 4px solid #c7372f;\n background: #fdf5f4;\n margin: 16px 0;\n padding: 12px 16px;\n border-radius: 0 6px 6px 0;\n color: #555555;\n }\n\n blockquote p {\n margin: 8px 0;\n color: #555555;\n }\n\n ul {\n padding-left: 24px;\n margin: 14px 0;\n }\n\n ol {\n padding-left: 24px;\n margin: 14px 0;\n }\n\n li {\n font-size: 16px;\n line-height: 1.85;\n color: #333333;\n margin: 6px 0;\n }\n\n table {\n width: 100%;\n border-collapse: collapse;\n margin: 16px 0;\n font-size: 15px;\n }\n\n thead {\n background: #c7372f;\n }\n\n th {\n background: #c7372f;\n color: #ffffff;\n font-weight: 600;\n padding: 10px 14px;\n text-align: left;\n border: 1px solid #c7372f;\n }\n\n td {\n padding: 10px 14px;\n border: 1px solid #e8e4dc;\n color: #333333;\n }\n\n tr {\n background: #fafaf7;\n }\n\n img {\n max-width: 100%;\n height: auto;\n display: block;\n margin: 24px auto;\n border-radius: 6px;\n }\n\n a {\n color: #c7372f;\n text-decoration: none;\n font-weight: 500;\n }\n\n hr {\n border: none;\n border-top: 1px solid #e8e4dc;\n margin: 28px 0;\n }\n","content_type":"application/yaml; charset=utf-8","language":"yaml","size":3543,"content_sha256":"5efc2d9c505428bbf216ca9ccaa562f6de250b501648ae4c202ef2e1fdf2440e"},{"filename":"toolkit/themes/tech-modern.yaml","content":"name: \"tech-modern\"\ndescription: \"科技感蓝紫渐变风格,适合技术和产品类内容\"\ncolors:\n primary: \"#7c3aed\"\n secondary: \"#3b82f6\"\n text: \"#333333\"\n text_light: \"#666666\"\n background: \"#ffffff\"\n code_bg: \"#282c34\"\n code_color: \"#abb2bf\"\n quote_border: \"#7c3aed\"\n quote_bg: \"#f8f5ff\"\n border_radius: \"8px\"\n darkmode:\n background: \"#1a1a2e\"\n text: \"#c8c8c8\"\n text_light: \"#999999\"\n primary: \"#a78bfa\"\n code_bg: \"#1e1e2e\"\n code_color: \"#c8cad8\"\n quote_bg: \"#232340\"\n quote_border: \"#7c3aed\"\nbase_css: |\n body {\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", sans-serif;\n font-size: 16px;\n line-height: 1.8;\n color: #333333;\n background: #ffffff;\n max-width: 720px;\n margin: 0 auto;\n padding: 20px;\n word-wrap: break-word;\n }\n\n h1 {\n font-size: 26px;\n font-weight: 700;\n color: #1a1a1a;\n margin: 32px 0 16px 0;\n padding: 8px 0 8px 14px;\n border-left: 5px solid #7c3aed;\n line-height: 1.4;\n }\n\n h2 {\n font-size: 22px;\n font-weight: 700;\n color: #1a1a1a;\n margin: 28px 0 14px 0;\n padding-bottom: 10px;\n border-bottom: 2px solid transparent;\n background-image: linear-gradient(90deg, #7c3aed 0%, #3b82f6 50%, transparent 50%);\n background-size: 100% 2px;\n background-position: 0 100%;\n background-repeat: no-repeat;\n line-height: 1.4;\n }\n\n h3 {\n font-size: 18px;\n font-weight: 600;\n color: #333333;\n margin: 24px 0 12px 0;\n padding-left: 12px;\n border-left: 3px solid #7c3aed;\n line-height: 1.4;\n }\n\n h4 {\n font-size: 16px;\n font-weight: 600;\n color: #333333;\n margin: 20px 0 10px 0;\n line-height: 1.4;\n }\n\n p {\n font-size: 16px;\n line-height: 1.8;\n color: #333333;\n margin: 12px 0;\n }\n\n strong {\n font-weight: 700;\n color: #7c3aed;\n }\n\n em {\n font-style: italic;\n color: #333333;\n }\n\n code {\n font-family: \"SFMono-Regular\", Consolas, \"Liberation Mono\", Menlo, Courier, monospace;\n font-size: 14px;\n background: #f5f5f5;\n color: #e83e8c;\n padding: 2px 6px;\n border-radius: 4px;\n }\n\n pre {\n background: #282c34;\n color: #abb2bf;\n padding: 16px;\n border-radius: 8px;\n overflow-x: auto;\n margin: 16px 0;\n line-height: 1.6;\n }\n\n pre code {\n font-family: \"SFMono-Regular\", Consolas, \"Liberation Mono\", Menlo, Courier, monospace;\n font-size: 14px;\n background: none;\n color: #abb2bf;\n padding: 0;\n border-radius: 0;\n }\n\n blockquote {\n border-left: 4px solid #7c3aed;\n background: #f8f5ff;\n margin: 16px 0;\n padding: 12px 16px;\n border-radius: 0 8px 8px 0;\n color: #333333;\n }\n\n blockquote p {\n margin: 8px 0;\n color: #333333;\n }\n\n ul {\n padding-left: 24px;\n margin: 12px 0;\n list-style-type: disc;\n color: #7c3aed;\n }\n\n ol {\n padding-left: 24px;\n margin: 12px 0;\n }\n\n li {\n font-size: 16px;\n line-height: 1.8;\n color: #333333;\n margin: 6px 0;\n }\n\n table {\n width: 100%;\n border-collapse: collapse;\n margin: 16px 0;\n font-size: 15px;\n }\n\n thead {\n background: linear-gradient(135deg, #7c3aed 0%, #3b82f6 100%);\n color: #ffffff;\n }\n\n th {\n color: #ffffff;\n font-weight: 600;\n padding: 10px 14px;\n text-align: left;\n border: 1px solid #7c3aed;\n }\n\n td {\n padding: 10px 14px;\n border: 1px solid #e5e7eb;\n color: #333333;\n }\n\n tr {\n background: #ffffff;\n }\n\n img {\n max-width: 100%;\n height: auto;\n display: block;\n margin: 24px auto;\n border-radius: 8px;\n }\n\n a {\n color: #7c3aed;\n text-decoration: none;\n font-weight: 500;\n }\n\n hr {\n border: none;\n height: 2px;\n background: linear-gradient(90deg, #7c3aed, #3b82f6, transparent);\n margin: 24px 0;\n }\n","content_type":"application/yaml; charset=utf-8","language":"yaml","size":3866,"content_sha256":"d05997293116aeeed76e8ca5745e72c2e488bc1568e3f0a40398160acc0180b9"},{"filename":"toolkit/themes/warm-editorial.yaml","content":"name: \"warm-editorial\"\ndescription: \"暖色编辑风格,适合生活方式和文化类内容\"\ncolors:\n primary: \"#d97706\"\n secondary: \"#ea580c\"\n text: \"#333333\"\n text_light: \"#666666\"\n background: \"#ffffff\"\n code_bg: \"#fef3c7\"\n code_color: \"#92400e\"\n quote_border: \"#d97706\"\n quote_bg: \"#fffbeb\"\n border_radius: \"8px\"\n darkmode:\n background: \"#1e1e1e\"\n text: \"#d4c8b8\"\n text_light: \"#a09080\"\n primary: \"#f0a830\"\n code_bg: \"#2a2520\"\n code_color: \"#d4b896\"\n quote_bg: \"#2a2418\"\n quote_border: \"#d97706\"\nbase_css: |\n body {\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", sans-serif;\n font-size: 16px;\n line-height: 1.8;\n color: #333333;\n background: #ffffff;\n max-width: 720px;\n margin: 0 auto;\n padding: 20px;\n word-wrap: break-word;\n }\n\n h1 {\n font-size: 26px;\n font-weight: 700;\n color: #1a1a1a;\n margin: 32px 0 16px 0;\n padding-bottom: 12px;\n border-bottom: 2px solid #d97706;\n line-height: 1.4;\n }\n\n h2 {\n font-size: 22px;\n font-weight: 700;\n color: #1a1a1a;\n margin: 28px 0 14px 0;\n padding: 8px 0 8px 12px;\n border-left: 4px solid #d97706;\n line-height: 1.4;\n }\n\n h3 {\n font-size: 18px;\n font-weight: 600;\n color: #92400e;\n margin: 24px 0 12px 0;\n line-height: 1.4;\n }\n\n h4 {\n font-size: 16px;\n font-weight: 600;\n color: #92400e;\n margin: 20px 0 10px 0;\n line-height: 1.4;\n }\n\n p {\n font-size: 16px;\n line-height: 1.8;\n color: #333333;\n margin: 12px 0;\n }\n\n strong {\n font-weight: 700;\n color: #92400e;\n }\n\n em {\n font-style: italic;\n color: #333333;\n }\n\n code {\n font-family: \"SFMono-Regular\", Consolas, \"Liberation Mono\", Menlo, Courier, monospace;\n font-size: 14px;\n background: #fef3c7;\n color: #92400e;\n padding: 2px 6px;\n border-radius: 4px;\n }\n\n pre {\n background: #fef3c7;\n color: #92400e;\n padding: 16px;\n border-radius: 8px;\n overflow-x: auto;\n margin: 16px 0;\n line-height: 1.6;\n border: 1px solid #fde68a;\n }\n\n pre code {\n font-family: \"SFMono-Regular\", Consolas, \"Liberation Mono\", Menlo, Courier, monospace;\n font-size: 14px;\n background: none;\n color: #92400e;\n padding: 0;\n border-radius: 0;\n }\n\n blockquote {\n border-left: 4px solid #d97706;\n background: #fffbeb;\n margin: 16px 0;\n padding: 12px 16px;\n border-radius: 0 8px 8px 0;\n color: #333333;\n }\n\n blockquote p {\n margin: 8px 0;\n color: #333333;\n }\n\n ul {\n padding-left: 24px;\n margin: 12px 0;\n }\n\n ol {\n padding-left: 24px;\n margin: 12px 0;\n }\n\n li {\n font-size: 16px;\n line-height: 1.8;\n color: #333333;\n margin: 6px 0;\n }\n\n table {\n width: 100%;\n border-collapse: collapse;\n margin: 16px 0;\n font-size: 15px;\n }\n\n thead {\n background: #d97706;\n }\n\n th {\n background: #d97706;\n color: #ffffff;\n font-weight: 600;\n padding: 10px 14px;\n text-align: left;\n border: 1px solid #d97706;\n }\n\n td {\n padding: 10px 14px;\n border: 1px solid #fde68a;\n color: #333333;\n }\n\n tr {\n background: #ffffff;\n }\n\n img {\n max-width: 100%;\n height: auto;\n display: block;\n margin: 24px auto;\n border-radius: 8px;\n }\n\n a {\n color: #d97706;\n text-decoration: none;\n font-weight: 500;\n }\n\n hr {\n border: none;\n border-top: 1px solid #fde68a;\n margin: 24px 0;\n }\n","content_type":"application/yaml; charset=utf-8","language":"yaml","size":3510,"content_sha256":"f1557dda88537cde4086837b9e52d29649e040fb51fa0e371fffc75230af9f6b"},{"filename":"toolkit/wechat_api.py","content":"import time\nimport mimetypes\nimport requests\nfrom pathlib import Path\nfrom dataclasses import dataclass\n\n# Token cache\n_token_cache: dict = {}\n\n\n@dataclass\nclass TokenResult:\n access_token: str\n expires_at: float # unix timestamp\n\n\ndef get_access_token(appid: str, secret: str, force_refresh: bool = False) -> str:\n \"\"\"\n Get access_token with caching.\n Cache key: appid\n API: GET https://api.weixin.qq.com/cgi-bin/token\n Cache until expires_in - 300 seconds (5 min buffer).\n Raise ValueError on API error.\n \"\"\"\n now = time.time()\n\n if not force_refresh and appid in _token_cache:\n cached: TokenResult = _token_cache[appid]\n if now \u003c cached.expires_at:\n return cached.access_token\n\n resp = requests.get(\n \"https://api.weixin.qq.com/cgi-bin/token\",\n params={\n \"grant_type\": \"client_credential\",\n \"appid\": appid,\n \"secret\": secret,\n },\n )\n data = resp.json()\n\n if \"access_token\" not in data:\n errcode = data.get(\"errcode\", \"unknown\")\n errmsg = data.get(\"errmsg\", \"unknown error\")\n raise ValueError(f\"WeChat API error: errcode={errcode}, errmsg={errmsg}\")\n\n access_token = data[\"access_token\"]\n expires_in = data.get(\"expires_in\", 7200)\n\n _token_cache[appid] = TokenResult(\n access_token=access_token,\n expires_at=now + expires_in - 300,\n )\n\n return access_token\n\n\ndef _guess_content_type(file_path: str) -> str:\n \"\"\"Detect content type from file extension.\"\"\"\n content_type, _ = mimetypes.guess_type(file_path)\n return content_type or \"application/octet-stream\"\n\n\ndef upload_image(access_token: str, image_path: str) -> str:\n \"\"\"\n Upload image for use inside article content.\n API: POST https://api.weixin.qq.com/cgi-bin/media/uploadimg\n Returns the url string.\n Raise ValueError on error.\n \"\"\"\n path = Path(image_path)\n content_type = _guess_content_type(image_path)\n\n with open(path, \"rb\") as f:\n resp = requests.post(\n \"https://api.weixin.qq.com/cgi-bin/media/uploadimg\",\n params={\"access_token\": access_token},\n files={\"media\": (path.name, f, content_type)},\n )\n\n data = resp.json()\n\n if \"url\" not in data:\n errcode = data.get(\"errcode\", \"unknown\")\n errmsg = data.get(\"errmsg\", \"unknown error\")\n raise ValueError(f\"WeChat upload_image error: errcode={errcode}, errmsg={errmsg}\")\n\n return data[\"url\"]\n\n\ndef upload_thumb(access_token: str, image_path: str) -> str:\n \"\"\"\n Upload cover image as permanent material.\n API: POST https://api.weixin.qq.com/cgi-bin/material/add_material\n Returns media_id string.\n Raise ValueError on error.\n \"\"\"\n path = Path(image_path)\n content_type = _guess_content_type(image_path)\n\n with open(path, \"rb\") as f:\n resp = requests.post(\n \"https://api.weixin.qq.com/cgi-bin/material/add_material\",\n params={\"access_token\": access_token, \"type\": \"image\"},\n files={\"media\": (path.name, f, content_type)},\n )\n\n data = resp.json()\n\n if \"media_id\" not in data:\n errcode = data.get(\"errcode\", \"unknown\")\n errmsg = data.get(\"errmsg\", \"unknown error\")\n raise ValueError(f\"WeChat upload_thumb error: errcode={errcode}, errmsg={errmsg}\")\n\n return data[\"media_id\"]\n","content_type":"text/x-python; charset=utf-8","language":"python","size":3372,"content_sha256":"ec1f354c98f2cf52b6a5f48b593ae053b144216d13f173f6a89a2201671518cf"},{"filename":"VERSION","content":"1.3.5\n","content_type":"text/plain; charset=utf-8","language":null,"size":6,"content_sha256":"fc253dae4f432925780b608f87298fbbd2f948cd811d2e894cdcbb98ec855d2e"},{"filename":"writing-config.example.yaml","content":"# WeWrite 写作参数(可优化)\n# 复制为 writing-config.yaml,在对话中说\"优化参数\"让 Agent 迭代调优\n# 或手动调整后用 humanness_score.py 评估\n#\n# 这个文件是起点,不是最优解。\n# 在对话中说\"优化参数\"即可自动调优,每轮调整得分最低的参数。\n#\n# 参数分三层,对应 writing-guide.md 的反检测结构。\n\n# ============================================================\n# 一、统计反检测参数\n# ============================================================\n\n# 句子变化度 (0-1)——越高句长方差越大\n# → 对抗突发性检测 [规则 1.1]\nsentence_variance: 0.7\n\n# 段落节奏\n# structured: 匀称(AI默认,避免)\n# wave: 长→短→长波浪\n# chaotic: 剧烈长短交替(推荐)\n# → 对抗突发性检测 [规则 1.3]\nparagraph_rhythm: \"chaotic\"\n\n# 词汇温度偏向\n# cold: 偏专业书面\n# warm: 偏日常口语\n# hot: 偏网络用语\n# balanced: 均匀混搭(推荐)\n# → 对抗词汇丰富度检测 [规则 1.2]\nword_temperature_bias: \"balanced\"\n\n# 情绪弧线\n# flat: 全程平稳(AI默认,避免)\n# gradual: 缓慢升温\n# restrained_to_burst: 克制→爆发(推荐)\n# volatile: 剧烈波动\n# → 对抗情感分布检测 [规则 1.4]\nemotional_arc: \"restrained_to_burst\"\n\n# 负面情绪最低占比 (0-1)\n# AI 中文文本负面情绪仅 11-12%,人类 25-34%\n# → 对抗情感分布检测 [规则 1.4]\nnegative_emotion_floor: 0.20\n\n# 副词密度上限——每 100 字最多几个副词\n# AI 中文文本副词显著偏多\n# → 对抗词性分布检测 [规则 1.5]\nadverb_max_per_100: 3\n\n# 段落间风格漂移强度 (0-1)\n# 0 = 全文风格一致(AI默认,避免)\n# 1 = 每个 H2 段落语气/正式度明显不同\n# → 对抗风格一致性检测 [规则 1.6]\nstyle_drift: 0.6\n\n# ============================================================\n# 二、语言反检测参数\n# ============================================================\n\n# 破句/不完整句比率——每 1000 字出现几次\n# → 对抗语法完美性检测 [规则 2.2]\nbroken_sentence_rate: 0.04\n\n# 自我纠正频率——\"不对,准确说是...\"\n# → 对抗语法完美性检测 [规则 2.2]\nself_correction_rate: 0.02\n\n# 意外用词频率——每 1000 字出现几次非常规但说得通的表达\n# → 对抗困惑度检测 [规则 2.3]\nunexpected_word_rate: 0.02\n\n# 口语填充词风格\n# literary: 坦白讲、怎么说呢\n# casual: 说白了、懂的都懂\n# mixed: 混合(推荐)\n# minimal: 尽量少用\nfiller_style: \"mixed\"\n\n# 跑题频率\n# never / every_500_chars / every_800_chars / every_1200_chars\n# → 对抗连贯性检测 [规则 2.4]\ntangent_frequency: \"every_800_chars\"\n\n# 结构线性度 (0-1)\n# 0 = 完全非线性(跳跃、倒叙、插叙)\n# 1 = 完全线性(观点→论据→总结,AI默认)\n# → 对抗连贯性检测 [规则 2.4]\nstructure_linearity: 0.3\n\n# ============================================================\n# 三、内容反检测参数\n# ============================================================\n\n# 真实数据引用密度\n# low: 每 H2 段 1 条\n# medium: 每 H2 段 2 条\n# high: 每 H2 段 3 条+(推荐)\n# → 对抗 token 概率分布检测 [规则 3.1]\nreal_data_density: \"high\"\n\n# 成语/俗语密度——每段平均出现几次\nidiom_density: 0.15\n\n# 开头策略\n# scene: 场景描写\n# data: 数据冲击\n# question: 反问\n# anecdote: 个人经历\n# cold_open: 冷开场(直接切入)\nopening_style: \"scene\"\n\n# 收尾策略\n# summary: 总结回顾(AI默认,避免)\n# open_question: 留一个没答案的问题\n# image: 用一个画面收束\n# abrupt: 戛然而止\nclosing_style: \"open_question\"\n\n# 写作人设——影响整体语感和视角\npersona: \"科技媒体资深编辑,写了八年公众号,对AI行业有深度认知\"\n","content_type":"application/yaml; charset=utf-8","language":"yaml","size":3850,"content_sha256":"029dc6c3f493416128bc69a0584da0f6b1a7aec1cae9fa3432a168dab6951b99"}],"content_json":{"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"text":"WeWrite — 公众号文章全流程","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"行为声明","type":"text"}]},{"type":"paragraph","content":[{"text":"角色","type":"text","marks":[{"type":"strong"}]},{"text":":用户的公众号内容编辑 Agent。","type":"text"}]},{"type":"paragraph","content":[{"text":"模式","type":"text","marks":[{"type":"strong"}]},{"text":":","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"默认全自动","type":"text","marks":[{"type":"strong"}]},{"text":"——一口气跑完 Step 1-8,不中途停下。只在出错时停。","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"交互模式","type":"text","marks":[{"type":"strong"}]},{"text":"——用户说\"交互模式\"/\"我要自己选\"时,在选题/框架/配图处暂停。","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"降级原则","type":"text","marks":[{"type":"strong"}]},{"text":":每一步都有降级方案。Step 1 检测到的降级标记(","type":"text"},{"text":"skip_publish","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":"skip_image_gen","type":"text","marks":[{"type":"code_inline"}]},{"text":")在后续 Step 自动生效,不重复报错。","type":"text"}]},{"type":"paragraph","content":[{"text":"进度追踪","type":"text","marks":[{"type":"strong"}]},{"text":":主管道启动时,用 TaskCreate 为 8 个 Step 创建任务。每开始一个 Step 标记 in_progress,完成后标记 completed。用户可随时看到当前进度。","type":"text"}]},{"type":"paragraph","content":[{"text":"完成协议","type":"text","marks":[{"type":"strong"}]},{"text":":","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"DONE","type":"text","marks":[{"type":"strong"}]},{"text":" — 全流程完成,文章已保存/推送","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"DONE_WITH_CONCERNS","type":"text","marks":[{"type":"strong"}]},{"text":" — 完成但部分步骤降级,列出降级项","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"BLOCKED","type":"text","marks":[{"type":"strong"}]},{"text":" — 关键步骤无法继续(如 Python 依赖缺失且用户拒绝安装)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"NEEDS_CONTEXT","type":"text","marks":[{"type":"strong"}]},{"text":" — 需要用户提供信息才能继续(如首次设置需要公众号名称)","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"路径约定","type":"text","marks":[{"type":"strong"}]},{"text":":本文档中 ","type":"text"},{"text":"{skill_dir}","type":"text","marks":[{"type":"code_inline"}]},{"text":" 指本 SKILL.md 所在的目录(即 WeWrite 的根目录)。","type":"text"}]},{"type":"paragraph","content":[{"text":"Onboard 例外","type":"text","marks":[{"type":"strong"}]},{"text":":Onboard 是交互式的(需要问用户问题),不受\"全自动\"约束。Onboard 完成后回到全自动管道。","type":"text"}]},{"type":"paragraph","content":[{"text":"辅助功能","type":"text","marks":[{"type":"strong"}]},{"text":"(按需加载,不在主管道内):","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"用户说\"重新设置风格\" → ","type":"text"},{"text":"读取: {skill_dir}/references/onboard.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"用户说\"学习我的修改\" → ","type":"text"},{"text":"读取: {skill_dir}/references/learn-edits.md","type":"text","marks":[{"type":"code_inline"}]},{"text":"。支持两种来源:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"本地修改","type":"text","marks":[{"type":"strong"}]},{"text":"(默认):用户在 ","type":"text"},{"text":"output/","type":"text","marks":[{"type":"code_inline"}]},{"text":" 的 markdown 文件中修改","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"微信草稿箱同步","type":"text","marks":[{"type":"strong"}]},{"text":":","type":"text"},{"text":"python3 {skill_dir}/scripts/learn_edits.py --from-wechat","type":"text","marks":[{"type":"code_inline"}]},{"text":",自动从草稿箱拉回最新内容,与本地原文做纯文本 diff","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"用户说\"学习排版\"/\"学排版\" → ","type":"text"},{"text":"python3 {skill_dir}/scripts/learn_theme.py \u003curl> --name \u003cname>","type":"text","marks":[{"type":"code_inline"}]},{"text":",用户需提供一个公众号文章 URL 和主题名称。提取完成后提示用户设置 ","type":"text"},{"text":"style.yaml","type":"text","marks":[{"type":"code_inline"}]},{"text":" 的 ","type":"text"},{"text":"theme","type":"text","marks":[{"type":"code_inline"}]},{"text":" 字段。","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"用户说\"学习这篇文章\"/\"导入范文\" + URL → ","type":"text"},{"text":"python3 {skill_dir}/scripts/fetch_article.py \u003curl> -o /tmp/article.md && python3 {skill_dir}/scripts/extract_exemplar.py /tmp/article.md -s \u003c账号名>","type":"text","marks":[{"type":"code_inline"}]},{"text":",从公众号文章 URL 提取正文并导入范文库。支持三级降级(requests → Playwright → 手动 HTML)。","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"用户说\"看看文章数据\" → ","type":"text"},{"text":"读取: {skill_dir}/references/effect-review.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"用户说\"检查一下\"/\"自检\"/\"这篇文章怎么样\" → 对最近一篇生成的文章(或用户指定的文章)执行自检,输出生成报告:","type":"text"}]},{"type":"paragraph","content":[{"text":"第一部分:生成档案","type":"text","marks":[{"type":"strong"}]},{"text":"(告诉用户这篇文章是怎么来的)","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"读取 ","type":"text"},{"text":"history.yaml","type":"text","marks":[{"type":"code_inline"}]},{"text":" 最近一条记录,提取:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"使用的框架类型 + 写作人格","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"激活的维度随机化组合","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"素材采集来源(WebSearch 还是降级到 LLM)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"内容增强策略(角度发现/密度强化/细节锚定/真实体感)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"范文风格库是否命中(用了哪几篇 exemplar,还是 fallback 到种子)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"playbook 中生效的规则条数","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"如果 history.yaml 无记录或用户指定了外部文章 → 跳过此部分,提示\"这篇文章不是 WeWrite 生成的,只做质量检查\"","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"第二部分:质量检查","type":"text","marks":[{"type":"strong"}]},{"text":"(告诉用户哪里还能改)","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"python3 {skill_dir}/scripts/humanness_score.py {article_path} --json","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Agent 解读 JSON 中每项得分,翻译为用户可操作的建议,格式:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"每条建议定位到具体段落或句子(\"第 3 段连续 4 句长度接近\")","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"给出具体改法(\"建议把第 3 句拆成两个短句\"、\"这里可以加一句你自己的感受\")","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"按影响度排序,最多 5 条","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"如果所有项得分都不错 → \"这篇文章质量不错,建议在编辑锚点处加入你的个人内容就可以发了。\"","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"输出格式","type":"text","marks":[{"type":"strong"}]},{"text":":自然语言报告,不输出 JSON 或分数(用户不需要看数字)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"用户说\"更新\"/\"更新 WeWrite\"/\"升级\" → 在 ","type":"text"},{"text":"{skill_dir}","type":"text","marks":[{"type":"code_inline"}]},{"text":" 执行 ","type":"text"},{"text":"git pull origin main","type":"text","marks":[{"type":"code_inline"}]},{"text":",完成后告知版本变化","type":"text"}]}]}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"主管道(Step 1-8)","type":"text"}]},{"type":"paragraph","content":[{"text":"主管道启动时,创建以下 8 个任务用于进度追踪:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"TaskCreate: \"Step 1: 环境 + 配置\"\nTaskCreate: \"Step 2: 选题\"\nTaskCreate: \"Step 3: 框架 + 素材\"\nTaskCreate: \"Step 4: 写作\"\nTaskCreate: \"Step 5: SEO + 验证\"\nTaskCreate: \"Step 6: 视觉 AI\"\nTaskCreate: \"Step 7: 排版 + 发布\"\nTaskCreate: \"Step 8: 收尾\"","type":"text"}]},{"type":"paragraph","content":[{"text":"每开始一个 Step → TaskUpdate status=in_progress。完成 → TaskUpdate status=completed。","type":"text"}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 1: 环境 + 配置","type":"text"}]},{"type":"paragraph","content":[{"text":"1.1 环境检查","type":"text","marks":[{"type":"strong"}]},{"text":"(静默通过或引导修复):","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"python3 -c \"import markdown, bs4, cssutils, requests, yaml, pygments, PIL\" 2>&1","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"检查项","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"通过","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"不通过","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"config.yaml","type":"text","marks":[{"type":"code_inline"}]},{"text":" 存在","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"静默","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"引导创建,或设 ","type":"text"},{"text":"skip_publish = true","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Python 依赖","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"静默","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"提供 ","type":"text"},{"text":"pip install -r requirements.txt","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"wechat.appid","type":"text","marks":[{"type":"code_inline"}]},{"text":" + ","type":"text"},{"text":"secret","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"静默","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"设 ","type":"text"},{"text":"skip_publish = true","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"image.api_key","type":"text","marks":[{"type":"code_inline"}]},{"text":" 或 ","type":"text"},{"text":"image.providers","type":"text","marks":[{"type":"code_inline"}]},{"text":" 至少一项有效","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"静默","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"设 ","type":"text"},{"text":"skip_image_gen = true","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"references/exemplars/index.yaml","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"静默","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"提示:\"范文库为空。如果你有已发布的文章(markdown),可以说**'导入范文'**建立风格库,写出来的文章会更像你。没有也不影响使用。\"","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"1.2 版本检查","type":"text","marks":[{"type":"strong"}]},{"text":"(静默通过或提醒):","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"cd {skill_dir} && git fetch origin main --quiet 2>/dev/null","type":"text"}]},{"type":"paragraph","content":[{"text":"比对本地 ","type":"text"},{"text":"{skill_dir}/VERSION","type":"text","marks":[{"type":"code_inline"}]},{"text":" 与远程 ","type":"text"},{"text":"git show origin/main:VERSION","type":"text","marks":[{"type":"code_inline"}]},{"text":":","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"相同 → 静默通过","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"不同 → 提示用户:\"WeWrite 有新版本可用(当前 X → 最新 Y),说「更新」即可升级。\"","type":"text"},{"text":"不阻断流程","type":"text","marks":[{"type":"strong"}]},{"text":",继续 1.3","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"git 不可用(无 .git 目录或 fetch 失败)→ 静默跳过","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"1.3 加载风格","type":"text","marks":[{"type":"strong"}]},{"text":":","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"检查: {skill_dir}/style.yaml","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"存在 → 提取 ","type":"text"},{"text":"name","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":"topics","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":"tone","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":"voice","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":"blacklist","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":"theme","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":"cover_style","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":"author","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":"content_style","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"不存在 → ","type":"text"},{"text":"读取: {skill_dir}/references/onboard.md","type":"text","marks":[{"type":"code_inline"}]},{"text":",完成后回到 Step 1","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"如果用户直接给了选题 → 跳到 Step 3(仍需框架选择和素材采集,不可跳过)。","type":"text"}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 2: 选题","type":"text"}]},{"type":"paragraph","content":[{"text":"2.1 热点抓取","type":"text","marks":[{"type":"strong"}]},{"text":":","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"python3 {skill_dir}/scripts/fetch_hotspots.py --limit 30","type":"text"}]},{"type":"paragraph","content":[{"text":"降级","type":"text","marks":[{"type":"strong"}]},{"text":":脚本报错 → WebSearch \"今日热点 {topics第一个垂类}\"","type":"text"}]},{"type":"paragraph","content":[{"text":"2.2 历史分析 + SEO","type":"text","marks":[{"type":"strong"}]},{"text":":","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"读取: {skill_dir}/history.yaml(不存在则跳过)","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"python3 {skill_dir}/scripts/seo_keywords.py --json {关键词}","type":"text"}]},{"type":"paragraph","content":[{"text":"历史分析(有 stats 数据时):","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"统计哪种 ","type":"text"},{"text":"framework","type":"text","marks":[{"type":"code_inline"}]},{"text":" 的文章表现最好(阅读量/分享率)→ 推荐框架时加权","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"统计哪种 ","type":"text"},{"text":"enhance_strategy","type":"text","marks":[{"type":"code_inline"}]},{"text":" 的文章表现最好 → 增强策略选择时参考","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"近 7 天已写的关键词降分(去重)","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"降级","type":"text","marks":[{"type":"strong"}]},{"text":":SEO 脚本报错 → LLM 判断;history 无 stats → 跳过效果分析,仅做去重","type":"text"}]},{"type":"paragraph","content":[{"text":"2.3 生成选题","type":"text","marks":[{"type":"strong"}]},{"text":":","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"读取: {skill_dir}/references/topic-selection.md","type":"text"}]},{"type":"paragraph","content":[{"text":"生成 ","type":"text"},{"text":"10 个选题","type":"text","marks":[{"type":"strong"}]},{"text":",其中:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"7-8 个热点选题","type":"text","marks":[{"type":"strong"}]},{"text":":基于 2.1 的热点,按 topic-selection.md 规则评分","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"2-3 个常青选题","type":"text","marks":[{"type":"strong"}]},{"text":":不依赖热点,从用户的 ","type":"text"},{"text":"topics","type":"text","marks":[{"type":"code_inline"}]},{"text":" 领域生成长尾内容(教程/方法论/经验总结/工具推荐),标注为\"常青\"。适合 content_style 为干货型/测评型的用户","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"每个选题含标题、评分、点击率潜力、SEO 友好度、推荐框架。","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"自动模式 → 选最高分","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"交互模式 → 展示全部,等用户选","type":"text"}]}]}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 3: 框架 + 素材","type":"text"}]},{"type":"paragraph","content":[{"text":"3.1 框架选择","type":"text","marks":[{"type":"strong"}]},{"text":":","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"读取: {skill_dir}/references/frameworks.md","type":"text"}]},{"type":"paragraph","content":[{"text":"7 套框架(痛点/故事/清单/对比/热点解读/纯观点/复盘),自动选推荐指数最高的。","type":"text"}]},{"type":"paragraph","content":[{"text":"3.2 素材采集 + 内容增强","type":"text","marks":[{"type":"strong"}]},{"text":"(合并执行,共用搜索结果):","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"读取: {skill_dir}/references/content-enhance.md","type":"text"}]},{"type":"paragraph","content":[{"text":"根据 3.1 选定的框架类型,一次搜索同时完成素材采集和内容增强:","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"框架","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"搜索策略","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"从结果中提取","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"热点解读 / 纯观点","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"\"{关键词} site:mp.weixin.qq.com OR site:36kr.com\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" + ","type":"text"},{"text":"\"{关键词} 观点 OR 评论\"","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"真实素材(数据/引述)","type":"text"},{"text":"+","type":"text","marks":[{"type":"strong"}]},{"text":" 已有文章的主流观点(供角度发现)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"痛点 / 清单","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"\"{关键词} 教程 OR 工具 OR 实操\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" + ","type":"text"},{"text":"\"{关键词} 数据 报告\"","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"真实素材 ","type":"text"},{"text":"+","type":"text","marks":[{"type":"strong"}]},{"text":" 具体工具名/步骤/参数(供密度强化)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"故事 / 复盘","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"\"{人物/事件} 采访 OR 专访 OR 细节\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" + ","type":"text"},{"text":"\"{关键词} 数据 报告\"","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"真实素材 ","type":"text"},{"text":"+","type":"text","marks":[{"type":"strong"}]},{"text":" 时间锚/数字锚/对话锚/感官锚(供细节锚定)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"对比","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"\"{方案A} vs {方案B} 评测 OR 体验\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" + ","type":"text"},{"text":"\"{方案A OR 方案B} 踩坑 OR 缺点 site:v2ex.com OR site:zhihu.com\"","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"真实素材 ","type":"text"},{"text":"+","type":"text","marks":[{"type":"strong"}]},{"text":" 真实用户评价和踩坑信息(供真实体感)","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"每次搜索 2 轮,从结果中","type":"text"},{"text":"同时","type":"text","marks":[{"type":"strong"}]},{"text":"提取:","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"素材","type":"text","marks":[{"type":"strong"}]},{"text":":5-8 条真实素材(具名来源 + 具体数据/引述/案例)。","type":"text"},{"text":"禁止编造","type":"text","marks":[{"type":"strong"}]},{"text":"。","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"增强材料","type":"text","marks":[{"type":"strong"}]},{"text":":按 content-enhance.md 对应策略的要求提取(角度/密度要点/细节/用户声音)。","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"两者并入框架大纲,一起传入 Step 4 写作。","type":"text"}]},{"type":"paragraph","content":[{"text":"降级","type":"text","marks":[{"type":"strong"}]},{"text":":WebSearch 不可用 → 用 LLM 训练数据中可验证的公开信息。但需告知用户:\"素材采集未能使用 WebSearch,建议在编辑锚点处多加入你自己的内容。\"密度强化不依赖搜索,始终执行。","type":"text"}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 4: 写作","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"读取: {skill_dir}/references/writing-guide.md\n读取: {skill_dir}/playbook.md(如果存在,按 confidence 分级执行)\n读取: {skill_dir}/history.yaml(最近 3 篇的 dimensions + closing_type 字段)\n读取: {skill_dir}/references/exemplars/index.yaml(如果存在)","type":"text"}]},{"type":"paragraph","content":[{"text":"4.1 维度随机化","type":"text","marks":[{"type":"strong"}]},{"text":":","type":"text"}]},{"type":"paragraph","content":[{"text":"从以下维度池随机激活 2-3 个维度,让每篇文章的表达方式不同。如果 history.yaml 有最近 3 篇的 ","type":"text"},{"text":"dimensions","type":"text","marks":[{"type":"code_inline"}]},{"text":" 字段,避免使用相同组合。","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"维度","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"选项","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"叙事视角","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"第一人称亲历 / 旁观者分析 / 对话体 / 自问自答","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"时间线","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"正序 / 倒叙 / 插叙","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"类比域","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"体育 / 做饭 / 军事 / 恋爱 / 游戏 / 电影 / 建筑 / 医学","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"情绪基调","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"克制冷静 / 热血激动 / 讽刺吐槽 / 温暖治愈 / 焦虑警示","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"节奏","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"短句密集 / 长叙述慢推 / 长短急切交替 / 慢开头快收尾","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"4.2 加载写作人格","type":"text","marks":[{"type":"strong"}]},{"text":":","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"读取: {skill_dir}/personas/{style.yaml 的 writing_persona 字段}.yaml\n如果 style.yaml 没有 writing_persona 字段 → 默认 midnight-friend","type":"text"}]},{"type":"paragraph","content":[{"text":"人格文件定义了:语气浓度、数据呈现方式、情绪弧线、段落节奏、不确定性表达模板等。作为写作的硬性约束执行。","type":"text"}]},{"type":"paragraph","content":[{"text":"优先级","type":"text","marks":[{"type":"strong"}]},{"text":":playbook.md(confidence ≥ 5 的规则)> persona > 范文风格 > writing-guide.md。writing-guide 是底线(基础写作规范),范文提供风格示范(句长节奏、情绪表达方式),persona 在此基础上特化风格参数(语气浓度、数据呈现),playbook 中高置信度规则是用户个性化的最终覆盖。playbook 中 confidence \u003c 5 的规则作为软性参考。","type":"text"}]},{"type":"paragraph","content":[{"text":"4.3 范文风格注入","type":"text","marks":[{"type":"strong"}]},{"text":"(有 ","type":"text"},{"text":"references/exemplars/index.yaml","type":"text","marks":[{"type":"code_inline"}]},{"text":" 时执行):","type":"text"}]},{"type":"paragraph","content":[{"text":"从 index.yaml 筛选 category 匹配当前框架类型的范文,取 top 3。读取对应 .md 文件的片段内容。","type":"text"}]},{"type":"paragraph","content":[{"text":"在写作 prompt 中注入:","type":"text"}]},{"type":"blockquote","content":[{"type":"paragraph","content":[{"text":"以下是该公众号风格的真实段落示例,模仿其句长节奏、情绪强度和口语化程度:","type":"text"}]},{"type":"paragraph","content":[{"text":"【开头风格】 {exemplar_1 的开头钩子段}","type":"text"}]},{"type":"paragraph","content":[{"text":"【情绪段风格】 {exemplar_2 的情绪高峰段}","type":"text"}]},{"type":"paragraph","content":[{"text":"【转折风格】 {exemplar_2 或 exemplar_3 的转折/自纠段(如有)}","type":"text"}]},{"type":"paragraph","content":[{"text":"【收尾风格】 {exemplar_3 的收尾段}","type":"text"}]}]},{"type":"paragraph","content":[{"text":"Category 映射规则:","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"框架类型","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"exemplar category","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"痛点型","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"tech-opinion","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"故事型 / 复盘型","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"story-emotional","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"清单型 / 对比型","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"list-practical","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"热点解读型 / 纯观点型","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"hot-take","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"其他","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"general","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"如果匹配到的范文不足 3 篇,用 general category 补足。","type":"text"}]},{"type":"paragraph","content":[{"text":"Fallback(范文库为空时)","type":"text","marks":[{"type":"strong"}]},{"text":":读取 ","type":"text"},{"text":"{skill_dir}/references/exemplar-seeds.yaml","type":"text","marks":[{"type":"code_inline"}]},{"text":",从每个段落类型中随机选 1 个注入 prompt。种子段落只示范人类写作的结构模式(句长方差、情绪锐度、自我纠正、非总结式收尾),不携带特定风格。注入时使用:","type":"text"}]},{"type":"blockquote","content":[{"type":"paragraph","content":[{"text":"以下是人类写作的结构模式示例,注意模仿其句长节奏和情绪表达方式(不要模仿具体内容或风格):","type":"text"}]},{"type":"paragraph","content":[{"text":"【开头模式】{seeds.opening_hooks 随机 1 个}","type":"text"}]},{"type":"paragraph","content":[{"text":"【情绪段模式】{seeds.emotional_peaks 随机 1 个}","type":"text"}]},{"type":"paragraph","content":[{"text":"【转折模式】{seeds.transitions 随机 1 个}","type":"text"}]},{"type":"paragraph","content":[{"text":"【收尾模式】{seeds.closings 随机 1 个}","type":"text"}]}]},{"type":"paragraph","content":[{"text":"建库命令:","type":"text"},{"text":"python3 {skill_dir}/scripts/extract_exemplar.py article.md","type":"text","marks":[{"type":"code_inline"}]}]},{"type":"paragraph","content":[{"text":"4.4 写文章","type":"text","marks":[{"type":"strong"}]},{"text":":","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"H1 标题(20-28 字) + H2 结构,1500-2500 字","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"素材 + 增强约束","type":"text","marks":[{"type":"strong"}]},{"text":":Step 3.2 的素材和增强材料分散嵌入各 H2 段落。增强策略的核心输出(角度/密度要点/细节/用户声音)必须贯穿全文,不只装饰性出现一次","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"写作人格","type":"text","marks":[{"type":"strong"}]},{"text":":按 4.2 加载的人格参数写作(数据呈现方式、个人声音浓度、不确定性表达等)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"收尾方式","type":"text","marks":[{"type":"strong"}]},{"text":":persona 的 ","type":"text"},{"text":"closing_tendency","type":"text","marks":[{"type":"code_inline"}]},{"text":" 仅作为倾向参考。根据文章内容和情绪弧线自行判断最自然的收尾方式。如果 history.yaml 中最近 3 篇有 ","type":"text"},{"text":"closing_type","type":"text","marks":[{"type":"code_inline"}]},{"text":" 字段,避免使用相同的收尾类型","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"写作规范","type":"text","marks":[{"type":"strong"}]},{"text":":writing-guide.md 中的基础规则(禁用词、句长方差、词汇混用等)在初稿阶段生效","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"2-3 个编辑锚点:","type":"text"},{"text":"\u003c!-- ✏️ 编辑建议:在这里加一句你自己的经历/看法 -->","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"可选容器语法:","type":"text"},{"text":":::dialogue","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":":::timeline","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":":::callout","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":":::quote","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"paragraph","content":[{"text":"保存到 ","type":"text"},{"text":"{skill_dir}/output/{date}-{slug}.md","type":"text","marks":[{"type":"code_inline"}]}]},{"type":"paragraph","content":[{"text":"4.5 快速自检","type":"text","marks":[{"type":"strong"}]},{"text":"(写完后立即执行,减少 Step 5 重写概率):","type":"text"}]},{"type":"paragraph","content":[{"text":"对初稿做 5 项快速扫描,","type":"text"},{"text":"当场修复","type":"text","marks":[{"type":"strong"}]},{"text":",不留到 Step 5:","type":"text"}]},{"type":"paragraph","content":[{"text":"写作层面","type":"text","marks":[{"type":"strong"}]},{"text":":","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"禁用词扫描","type":"text","marks":[{"type":"strong"}]},{"text":":检查 writing-guide.md 2.1 的禁用词列表,命中的直接替换","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"句长方差","type":"text","marks":[{"type":"strong"}]},{"text":":是否有连续 3 句以上长度接近的段落,有则拆句或加短句","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"内容层面","type":"text","marks":[{"type":"strong"}]},{"text":": 3. ","type":"text"},{"text":"开头钩子","type":"text","marks":[{"type":"strong"}]},{"text":":前 3 句是否制造了悬念/冲突/好奇心?如果是平铺直叙的背景介绍,重写开头 4. ","type":"text"},{"text":"增强贯穿","type":"text","marks":[{"type":"strong"}]},{"text":":增强策略的核心输出是否只出现在一段?如果是,在其他 H2 中补充 5. ","type":"text"},{"text":"金句检查","type":"text","marks":[{"type":"strong"}]},{"text":":全文是否有至少 1 句可独立截图转发的句子?如果没有,在情绪高点处补一句","type":"text"}]},{"type":"paragraph","content":[{"text":"LLM 自行完成,不需要调用脚本。","type":"text"}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 5: SEO + 验证","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"读取: {skill_dir}/references/seo-rules.md","type":"text"}]},{"type":"paragraph","content":[{"text":"5.1 SEO","type":"text","marks":[{"type":"strong"}]},{"text":":3 个备选标题 + 摘要(≤40 字)+ 5 标签 + 关键词密度优化","type":"text"}]},{"type":"paragraph","content":[{"text":"5.2 质量验证","type":"text","marks":[{"type":"strong"}]},{"text":"(两个维度,每项逐一检查):","type":"text"}]},{"type":"paragraph","content":[{"text":"A. 写作质量","type":"text","marks":[{"type":"strong"}]},{"text":"(writing-guide.md 基础规则):","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"检查项","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"标准","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"规则","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"句长方差","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"最短与最长句相差 ≥ 30 字","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"1.1","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"词汇温度","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"任意 500 字 ≥ 3 种温度","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"1.2","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"段落节奏","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"无连续 2 个相近长度段落","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"1.3","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"情绪极性","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"负面情绪 ≥ 2 处,无平铺直叙","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"1.4","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"禁用词","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"命中数 = 0","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"2.1","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"真实锚定","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"每个 H2 ≥ 1 条真实素材,零编造","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"3.1","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"具体性","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"每 500 字 ≥ 2 处具体细节","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"3.2","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"B. 内容质量","type":"text","marks":[{"type":"strong"}]},{"text":"(基于 Step 3.2 的增强策略检查):","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"检查项","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"标准","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"适用框架","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"增强贯穿","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"增强策略的核心输出(角度/密度/细节/体感)在全文可见,不只出现在一段","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"所有","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"开头钩子","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"前 3 句能制造悬念、冲突或好奇心(不是背景铺垫)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"所有","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"金句密度","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"至少 1 处可独立截图转发的句子","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"所有","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"操作密度","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"每个 H2 有可操作要点(工具/步骤/参数)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"痛点/清单","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"角度锐度","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"核心观点能引发同意或反对,不是\"两面都有道理\"","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"热点解读/纯观点","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"场景感","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"至少 2 处有时间/地点/对话等画面细节","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"故事/复盘","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"真实声音","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"至少 1 处引用真实用户评价或体验","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"对比","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"不通过 → ","type":"text"},{"text":"定向修复","type":"text","marks":[{"type":"strong"}]},{"text":":只替换不达标的具体句子/段落,不动已通过的部分。每轮最多改 3 处,改完立即重新检查该项。2 轮仍不过 → 标注跳过,继续下一项。","type":"text"}]},{"type":"paragraph","content":[{"text":"5.3 脚本辅助验证","type":"text","marks":[{"type":"strong"}]},{"text":"(补充 5.2 的逐项检查):","type":"text"}]},{"type":"paragraph","content":[{"text":"Agent 在 5.2 检查过程中同步完成综合评估(各 H2 之间的语气差异度、信息密度的高低交替、段落间的节奏变化、整体阅读流畅度),产出 0-1 分数。","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"python3 {skill_dir}/scripts/humanness_score.py {article_path} --json --tier3 {agent_tier3_score}","type":"text"}]},{"type":"paragraph","content":[{"text":"解读 JSON 中 ","type":"text"},{"text":"composite_score","type":"text","marks":[{"type":"code_inline"}]},{"text":"(0=质量高, 100=问题多):","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"\u003c 30 → 通过,继续 Step 6","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"30-50 → 查看 ","type":"text"},{"text":"param_scores","type":"text","marks":[{"type":"code_inline"}]},{"text":" 中最低分的 1-2 项,只修复对应的具体句子(不重写整段),改完重新打分。1 轮即可","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"> 50 → 取 ","type":"text"},{"text":"param_scores","type":"text","marks":[{"type":"code_inline"}]},{"text":" 最低的 2-3 项,逐项定向修复(每项只改最相关的 1-2 处),最多 2 轮。仍 > 50 则标记 DONE_WITH_CONCERNS 继续","type":"text"}]}]}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 6: 视觉 AI","type":"text"}]},{"type":"paragraph","content":[{"text":"如果 ","type":"text","marks":[{"type":"strong"}]},{"text":"skip_image_gen = true","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" → 只执行 6.1。","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"读取: {skill_dir}/references/visual-prompts.md","type":"text"}]},{"type":"paragraph","content":[{"text":"6.1 实体提取","type":"text","marks":[{"type":"strong"}]},{"text":":从终稿中提取 3-5 个","type":"text"},{"text":"具体实体","type":"text","marks":[{"type":"strong"}]},{"text":"(人物、产品名、场景、数据点、行业术语)。后续所有提示词必须包含至少 2 个实体。","type":"text"}]},{"type":"paragraph","content":[{"text":"6.2 封面生成","type":"text","marks":[{"type":"strong"}]},{"text":":生成封面 3 组创意提示词(按 visual-prompts.md),选最佳 1 组调用 image_gen.py 生成。","type":"text"}]},{"type":"paragraph","content":[{"text":"6.3 封面验证","type":"text","marks":[{"type":"strong"}]},{"text":":","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"交互模式","type":"text","marks":[{"type":"strong"}]},{"text":":展示封面,问用户\"封面效果如何?\"。用户 OK → 继续;不满意 → 调整提示词重新生成。","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"全自动模式","type":"text","marks":[{"type":"strong"}]},{"text":":agent 自检——提示词中的实体是否在画面描述中可识别?如果提示词过于泛化(仅含\"科技感\"\"未来感\"等抽象词,无具体实体),换一组提示词重试 1 次。","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"6.3b 风格锚定","type":"text","marks":[{"type":"strong"}]},{"text":":封面确认后,提取视觉锚点(色板 hex、风格关键词、画面调性),后续所有内文配图的提示词必须引用这组锚点,保证全文视觉一致。","type":"text"}]},{"type":"paragraph","content":[{"text":"6.4 内文配图","type":"text","marks":[{"type":"strong"}]},{"text":":分析文章结构,为每个需要配图的段落选择图片类型(infographic/scene/flowchart/comparison/framework/timeline),使用对应的结构化提示词模板生成 3-6 张配图提示词(按 visual-prompts.md)。批量调用 image_gen.py,替换 Markdown 占位符。","type":"text"}]},{"type":"paragraph","content":[{"text":"降级","type":"text","marks":[{"type":"strong"}]},{"text":":image_gen.py 支持多 provider 自动 fallback(按 config.yaml 中 providers 列表顺序尝试)。全部失败 → 输出提示词 + 备选图库关键词,继续。","type":"text"}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 7: 排版 + 发布","type":"text"}]},{"type":"paragraph","content":[{"text":"7.1 Metadata 预检","type":"text","marks":[{"type":"strong"}]},{"text":"(发布前必须通过):","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"检查项","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"标准","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"不通过时","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"H1 标题","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"存在且 5-64 字节","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"自动修正或提示用户","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"摘要","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"存在且 ≤ 120 UTF-8 字节","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"converter 自动生成","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"封面图","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"推送模式下需要","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"无封面则警告,仍可推送(微信会显示默认封面)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"正文字数","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"≥ 200 字","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"警告\"内容过短,微信可能不收录\"","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"图片数量","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"≤ 10 张","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"超出则移除末尾多余图片","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"预检全部通过后才进入排版。","type":"text"}]},{"type":"paragraph","content":[{"text":"7.2 排版 + 发布","type":"text","marks":[{"type":"strong"}]},{"text":":","type":"text"}]},{"type":"paragraph","content":[{"text":"如果 ","type":"text","marks":[{"type":"strong"}]},{"text":"skip_publish = true","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" → 直接走 preview。","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"读取: {skill_dir}/references/wechat-constraints.md","type":"text"}]},{"type":"paragraph","content":[{"text":"Converter 自动处理:CJK 加空格、加粗标点外移、列表转 section、外链转脚注、暗黑模式、容器语法。","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# 发布\npython3 {skill_dir}/toolkit/cli.py publish {markdown} --cover {cover} --theme {theme} --title \"{title}\" --digest \"{digest}\"\n\n# 降级:本地预览\npython3 {skill_dir}/toolkit/cli.py preview {markdown} --theme {theme} --no-open -o {output}.html","type":"text"}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 8: 收尾","type":"text"}]},{"type":"paragraph","content":[{"text":"8.1 写入历史","type":"text","marks":[{"type":"strong"}]},{"text":"(推送成功或降级都要写,文件不存在则创建):","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"yaml"},"content":[{"text":"# → {skill_dir}/history.yaml\n- date: \"{日期}\"\n title: \"{标题}\"\n topic_source: \"热点抓取\" # 或 \"用户指定\"\n topic_keywords: [\"{词1}\", \"{词2}\"]\n output_file: \"{output 文件路径}\" # e.g. output/2026-03-31-zhangxue-slow-accumulation.md\n framework: \"{框架}\"\n enhance_strategy: \"{增强策略}\" # angle_discovery/density_boost/detail_anchoring/real_feel\n word_count: {字数}\n media_id: \"{id}\" # 降级时 null\n writing_persona: \"{人格名}\"\n dimensions:\n - \"{维度}: {选项}\"\n closing_type: \"{收尾类型}\" # trailing_off/unanswered/scene_revert/abrupt_stop/anti_conclusion/image\n composite_score: {Step 5.3 的 composite_score} # 0=质量高, 100=问题多\n writing_config_snapshot: # 本次使用的关键参数(从 writing-config.yaml 提取)\n sentence_variance: {值}\n paragraph_rhythm: \"{值}\"\n emotional_arc: \"{值}\"\n word_temperature_bias: \"{值}\"\n broken_sentence_rate: {值}\n tangent_frequency: \"{值}\"\n style_drift: {值}\n negative_emotion_floor: {值}\n stats: null","type":"text"}]},{"type":"paragraph","content":[{"text":"8.2 回复用户","type":"text","marks":[{"type":"strong"}]},{"text":":","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"最终标题 + 2 备选 + 摘要 + 5 标签 + media_id","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"编辑建议:\"文章有 2-3 个编辑锚点,建议加入你自己的话。你可以在本地 markdown 里改,也可以直接在微信草稿箱改——改完后说**'学习我的修改'**,WeWrite 都能学到你的风格。\"","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"8.3 后续操作","type":"text","marks":[{"type":"strong"}]},{"text":":","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"用户说","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"动作","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"润色/缩写/扩写/换语气","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"编辑文章","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"封面换暖色调","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"重新生图","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"用框架 B 重写","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"回到 Step 4","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"换一个选题","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"回到 Step 2.3","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"看看有什么主题","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"python3 {skill_dir}/toolkit/cli.py gallery","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"换成 XX 主题","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"重新渲染","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"看看文章数据","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"读取: {skill_dir}/references/effect-review.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"学习我的修改","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"读取: {skill_dir}/references/learn-edits.md","type":"text","marks":[{"type":"code_inline"}]},{"text":"。支持本地 markdown 修改和微信草稿箱同步(","type":"text"},{"text":"--from-wechat","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"学习排版 / 学排版","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"python3 {skill_dir}/scripts/learn_theme.py \u003curl> --name \u003cname>","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"做一个小绿书/图片帖","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"python3 {skill_dir}/toolkit/cli.py image-post img1.jpg img2.jpg -t \"标题\"","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"检查一下 / 自检 / 这篇文章怎么样","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"生成报告(生成档案 + 质量检查,见辅助功能)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"导入范文 / 建范文库","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"python3 {skill_dir}/scripts/extract_exemplar.py article.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"查看范文库","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"python3 {skill_dir}/scripts/extract_exemplar.py --list","type":"text","marks":[{"type":"code_inline"}]}]}]}]}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"错误处理","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"步骤","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"降级","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"环境检查","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"逐项引导,设降级标记","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"热点抓取","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"WebSearch 替代","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"选题为空","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"请用户手动给选题","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"SEO 脚本","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"LLM 判断","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"素材采集(WebSearch)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"LLM 训练数据中可验证的公开信息","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"维度随机化","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"history 空时跳过去重","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Persona 文件不存在","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"回退到 midnight-friend(默认)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"范文库为空","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Fallback 到 exemplar-seeds.yaml(通用模式)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"去 AI 验证","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"2 轮定向修复不过则跳过该项","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"生图失败","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"输出提示词","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"推送失败","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"本地 HTML","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"历史写入","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"警告不阻断","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"效果数据","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"告知等 24h","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Playbook 不存在","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"用 writing-guide.md","type":"text"}]}]}]}]},{"type":"hr","attrs":{"markup":"---"}}]},"metadata":{"date":"2026-06-05","name":"wewrite","author":"@skillopedia","source":{"stars":2162,"repo_name":"wewrite","origin_url":"https://github.com/oaker-io/wewrite/blob/HEAD/SKILL.md","repo_owner":"oaker-io","body_sha256":"f642309659697f9f2eca086a6be540d4d57544ca5bcaf22e8ebc062e6a1c2cc5","cluster_key":"db06dc355e815db9e072f0b4a93cb7ce0e470c638272c5b6be3c79a1f91687df","clean_bundle":{"format":"clean-skill-bundle-v1","source":"oaker-io/wewrite/SKILL.md","attachments":[{"id":"78f75d4a-1437-583c-a367-b7faad459ec8","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/78f75d4a-1437-583c-a367-b7faad459ec8/attachment.yml","path":".github/workflows/build-openclaw.yml","size":1001,"sha256":"3c5d248d5b47399239e76c429bbad3d9d2915e611cc7eb3ce222065b4ad14c6f","contentType":"application/yaml; charset=utf-8"},{"id":"16b61b48-98c1-5dfe-825b-a89d5fbe20bb","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/16b61b48-98c1-5dfe-825b-a89d5fbe20bb/attachment","path":".gitignore","size":585,"sha256":"b5bea11330de1447f5ae83bbf3dac3e966d843f0d65817076ce70c28d782f010","contentType":"text/plain; charset=utf-8"},{"id":"c8198038-7788-5045-aebb-673c2f0208b7","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/c8198038-7788-5045-aebb-673c2f0208b7/attachment.md","path":"README.md","size":12241,"sha256":"7ff75535de402b1b17886096958122ee23c43c9e2b0592e02823e5a97c4ef3ea","contentType":"text/markdown; charset=utf-8"},{"id":"2fed4a04-5a9b-595b-8215-30bd15b6d782","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/2fed4a04-5a9b-595b-8215-30bd15b6d782/attachment","path":"VERSION","size":6,"sha256":"fc253dae4f432925780b608f87298fbbd2f948cd811d2e894cdcbb98ec855d2e","contentType":"text/plain; charset=utf-8"},{"id":"811131d6-c05a-54a4-aba4-34ea6d6478f1","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/811131d6-c05a-54a4-aba4-34ea6d6478f1/attachment.yaml","path":"config.example.yaml","size":3743,"sha256":"67a65f3a5660abcf74bb72d77a6b141918f5856884846857d59509923042c718","contentType":"application/yaml; charset=utf-8"},{"id":"04d97c5c-572a-5833-8398-4348fe05dbc2","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/04d97c5c-572a-5833-8398-4348fe05dbc2/attachment.json","path":"evals/evals.json","size":4534,"sha256":"6f02485ca53e17a4e37c25e11d738ce48f84da45e4e4cb68dc590cc40fd4a1c0","contentType":"application/json; charset=utf-8"},{"id":"523108a5-c6c7-5fb3-a106-9656cf3fa571","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/523108a5-c6c7-5fb3-a106-9656cf3fa571/attachment","path":"output/.gitkeep","size":0,"sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","contentType":"text/plain; charset=utf-8"},{"id":"30e2ac0e-8b40-5213-91cd-e81d1f8df214","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/30e2ac0e-8b40-5213-91cd-e81d1f8df214/attachment.yaml","path":"personas/cold-analyst.yaml","size":2351,"sha256":"299efa8fd262661b09326a91debce5f181297ff24b6f143f102bab453d4155b7","contentType":"application/yaml; charset=utf-8"},{"id":"3087fa95-1f8c-558b-b223-c8874e091ec9","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/3087fa95-1f8c-558b-b223-c8874e091ec9/attachment.yaml","path":"personas/industry-observer.yaml","size":2079,"sha256":"7746cb541a2565bbe53b216c1ce5a4831ebfa422f66f6f349bc57bfade0fbb73","contentType":"application/yaml; charset=utf-8"},{"id":"d9a42908-01ff-598e-9468-cf88ac3705e3","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/d9a42908-01ff-598e-9468-cf88ac3705e3/attachment.yaml","path":"personas/midnight-friend.yaml","size":2331,"sha256":"8f9a16ac758e4b88c4eff7df54f296b8b1760910b8a5e437b7fbe12ba5b57e04","contentType":"application/yaml; charset=utf-8"},{"id":"8a468b26-4924-5e6e-b979-9b8000794463","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/8a468b26-4924-5e6e-b979-9b8000794463/attachment.yaml","path":"personas/sharp-journalist.yaml","size":1894,"sha256":"70913ee4c778af9efeb94f85719c65459f9ffbe91d211ed71364d7cfd748a3e7","contentType":"application/yaml; charset=utf-8"},{"id":"105689d1-b080-570e-a1b9-81eb636f5601","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/105689d1-b080-570e-a1b9-81eb636f5601/attachment.yaml","path":"personas/warm-editor.yaml","size":2142,"sha256":"bd6ab0dce3589498da680e5fe8d4867fefb55bd2d02348a525739129c04a8e46","contentType":"application/yaml; charset=utf-8"},{"id":"0c2b0bde-e4a5-57f7-8052-5f90e315b1fe","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/0c2b0bde-e4a5-57f7-8052-5f90e315b1fe/attachment.md","path":"references/content-enhance.md","size":6578,"sha256":"65e97b90da2ef37548d29bd23e75cfdccbf8eb21bf96fb818f790c60f48e8ebc","contentType":"text/markdown; charset=utf-8"},{"id":"f70925cd-9dfc-5585-a501-b2feeae2f7b8","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/f70925cd-9dfc-5585-a501-b2feeae2f7b8/attachment.md","path":"references/effect-review.md","size":650,"sha256":"330775d0ae22899113355c5b070c05faa994b109cd9787914b49a6a8667d538b","contentType":"text/markdown; charset=utf-8"},{"id":"f45d66d6-1f99-513f-ad5c-58bed22ff7a7","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/f45d66d6-1f99-513f-ad5c-58bed22ff7a7/attachment.yaml","path":"references/exemplar-seeds.yaml","size":5316,"sha256":"90c63211fdb99c85a76a91592cf309e635ea8eafab942701e9af4f2e17ae6a25","contentType":"application/yaml; charset=utf-8"},{"id":"03159c91-dfee-55ff-b8e0-b4dda350b90a","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/03159c91-dfee-55ff-b8e0-b4dda350b90a/attachment","path":"references/exemplars/.gitkeep","size":0,"sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","contentType":"text/plain; charset=utf-8"},{"id":"ae8dd2d9-9c51-55f6-b38e-1aab74232484","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/ae8dd2d9-9c51-55f6-b38e-1aab74232484/attachment.md","path":"references/frameworks.md","size":9172,"sha256":"e47bc4ab76bbbfe777de9da248dd98aeecfa760b9231c08ee0cae890a2f4ea60","contentType":"text/markdown; charset=utf-8"},{"id":"b7bb29f1-f3cc-530d-9bb3-fd6058c5e3b7","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/b7bb29f1-f3cc-530d-9bb3-fd6058c5e3b7/attachment.md","path":"references/learn-edits.md","size":3689,"sha256":"f39795a445af082a0d133008cbfdf694d15540a7a32eae43e0b709f5876f4968","contentType":"text/markdown; charset=utf-8"},{"id":"64b66f59-2eed-5652-ab97-6d41892c379d","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/64b66f59-2eed-5652-ab97-6d41892c379d/attachment.md","path":"references/onboard.md","size":4326,"sha256":"ab17d2707bf80d76996d9fd1a49842c967d6a83fc93d461fc18eede398b22e0e","contentType":"text/markdown; charset=utf-8"},{"id":"4ce938c8-3f59-518e-a83c-b121ef7da11c","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/4ce938c8-3f59-518e-a83c-b121ef7da11c/attachment.md","path":"references/seo-rules.md","size":2410,"sha256":"ac6c5290fb5db74fd59671bb7cae7d5f26efc19188040ef4ddc0e5c5bdc384e3","contentType":"text/markdown; charset=utf-8"},{"id":"ba781072-6076-5b0b-9c5c-bd95d200108c","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/ba781072-6076-5b0b-9c5c-bd95d200108c/attachment.md","path":"references/style-template.md","size":2194,"sha256":"60280364f03faa6b25de8ebe3cbe32595aeecb8bafd72460a86c2c0abf83df1f","contentType":"text/markdown; charset=utf-8"},{"id":"0e9258ba-9eef-53a7-b2bd-0d4cdf33e00d","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/0e9258ba-9eef-53a7-b2bd-0d4cdf33e00d/attachment.md","path":"references/topic-selection.md","size":5188,"sha256":"e7aec251cbc0ad833c486c1d9b10590a596d99d46e96a4b9a136d6a23e365e51","contentType":"text/markdown; charset=utf-8"},{"id":"93b3029c-a084-5a8a-b68a-771113249029","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/93b3029c-a084-5a8a-b68a-771113249029/attachment.md","path":"references/visual-prompts.md","size":10877,"sha256":"4703478ff2d7a6d77fb69dd67d9f73a8f4024c17aaaacacdb1e6ec743c713eb2","contentType":"text/markdown; charset=utf-8"},{"id":"ece89a99-f5cd-5a6b-a911-01a101e323b8","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/ece89a99-f5cd-5a6b-a911-01a101e323b8/attachment.md","path":"references/wechat-constraints.md","size":11443,"sha256":"7c2db5a6b32ba1537a81d90ed31c1c904fe80f81374f61c5662909b765c9ab6d","contentType":"text/markdown; charset=utf-8"},{"id":"264ddc3e-0f99-5541-99ec-76140370686b","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/264ddc3e-0f99-5541-99ec-76140370686b/attachment.md","path":"references/writing-guide.md","size":17842,"sha256":"184e988611d95617c7d357c7e5f8526558802e1ef3db7fc117a9b1c244f5d709","contentType":"text/markdown; charset=utf-8"},{"id":"ec643c89-434d-55e9-a1e8-c8682d2288c2","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/ec643c89-434d-55e9-a1e8-c8682d2288c2/attachment.txt","path":"requirements.txt","size":125,"sha256":"08783fdb1e13751d25d1df4a062b7fa31df7a33a02ab07b10961fbcc46efdc29","contentType":"text/plain; charset=utf-8"},{"id":"3df1fb94-6c7a-584e-8a24-79d35a0cbd3d","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/3df1fb94-6c7a-584e-8a24-79d35a0cbd3d/attachment.py","path":"scripts/build_openclaw.py","size":4188,"sha256":"a2009449e0c42aebbc8eec9afe576b323d08f0f881a58da9c52d7a3c77ca3741","contentType":"text/x-python; charset=utf-8"},{"id":"aa494747-cdcd-5404-b60f-d6a6ab60f3e9","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/aa494747-cdcd-5404-b60f-d6a6ab60f3e9/attachment.py","path":"scripts/build_playbook.py","size":6243,"sha256":"a22147cc76deace830c0de87ac84db97a6e7a099a2442aaa8cff14e3e44adc38","contentType":"text/x-python; charset=utf-8"},{"id":"effdd6d6-c1fb-5e67-be33-9c6ac8d1cebe","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/effdd6d6-c1fb-5e67-be33-9c6ac8d1cebe/attachment.py","path":"scripts/diagnose.py","size":13014,"sha256":"7d23273aa43c88613b6d94d3208e9825e028c00c5831ec78b0a1990b019b827a","contentType":"text/x-python; charset=utf-8"},{"id":"61a7d06a-eca4-566e-b9d6-d1adffbb3691","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/61a7d06a-eca4-566e-b9d6-d1adffbb3691/attachment.py","path":"scripts/extract_exemplar.py","size":12967,"sha256":"f08849d794d6f55e8a4548453def75d70316410ff8130fb3e350b4a551bf9680","contentType":"text/x-python; charset=utf-8"},{"id":"c50ae2b7-c2b5-5c75-a33f-a3cb30ab0555","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/c50ae2b7-c2b5-5c75-a33f-a3cb30ab0555/attachment.py","path":"scripts/fetch_article.py","size":11112,"sha256":"9fedbc952ef2f990a4a79ebabe8e5fd03dc2d5567837e1b51078345ea9295035","contentType":"text/x-python; charset=utf-8"},{"id":"90d0a630-e121-59b7-834a-33ba1f05d985","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/90d0a630-e121-59b7-834a-33ba1f05d985/attachment.py","path":"scripts/fetch_hotspots.py","size":5727,"sha256":"b27e66cd5240855fc7446f5bfc8ff379373de47545dbe8fabfb5e76997a85f00","contentType":"text/x-python; charset=utf-8"},{"id":"0eb89ffb-7891-564d-a41a-175c843be30d","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/0eb89ffb-7891-564d-a41a-175c843be30d/attachment.py","path":"scripts/fetch_stats.py","size":5556,"sha256":"ccd0d483dfeab205f4ff609550d407fd7c636b537a7906e2a48640bffba1cf8d","contentType":"text/x-python; charset=utf-8"},{"id":"8c080de9-65d8-594f-bed1-565df45e9024","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/8c080de9-65d8-594f-bed1-565df45e9024/attachment.py","path":"scripts/humanness_score.py","size":21913,"sha256":"de2d9a7df1be4c9ef0c1043a5eddc6e6fdeea063fce5c595bc978fa640ebfea0","contentType":"text/x-python; charset=utf-8"},{"id":"0accd357-683e-55c9-a77d-b8649ead029b","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/0accd357-683e-55c9-a77d-b8649ead029b/attachment.py","path":"scripts/learn_edits.py","size":18936,"sha256":"bef0b6ea9241ffe0d64ce1cea8c03eb1424c3fb1ddcf317e9aea88559fc920e4","contentType":"text/x-python; charset=utf-8"},{"id":"5b72fc94-0524-56a2-9293-8a97d2ccbcae","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/5b72fc94-0524-56a2-9293-8a97d2ccbcae/attachment.py","path":"scripts/learn_theme.py","size":22375,"sha256":"a4effba7812eec6e0c5b4a68816b9c5ab9a5b94d31bd00edbb3bcbdfdce2b6c6","contentType":"text/x-python; charset=utf-8"},{"id":"09dca70b-db8c-5439-bf1e-75bbfdf54776","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/09dca70b-db8c-5439-bf1e-75bbfdf54776/attachment.py","path":"scripts/seo_keywords.py","size":3883,"sha256":"afcb030435df87f7755f475ab45ba97e7f0c3a45f9f9b841f2fd112c7a32aa15","contentType":"text/x-python; charset=utf-8"},{"id":"d262686b-70c5-512e-9dda-c40a021fc977","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/d262686b-70c5-512e-9dda-c40a021fc977/attachment.yaml","path":"style.example.yaml","size":1308,"sha256":"66bfb0bbe2fd0c3b0ab3d049c240f6c7ba812725834b32e454a4be8b7688d222","contentType":"application/yaml; charset=utf-8"},{"id":"0d49491c-2e40-599d-ad8d-d4f658600098","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/0d49491c-2e40-599d-ad8d-d4f658600098/attachment.py","path":"toolkit/cli.py","size":15549,"sha256":"060ec986a92a9faf5821ffaee3771d5f43f6d7167cbe80bbea64b352f734fbf0","contentType":"text/x-python; charset=utf-8"},{"id":"00a5f58c-cb84-581c-8327-fc5281389a9b","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/00a5f58c-cb84-581c-8327-fc5281389a9b/attachment.py","path":"toolkit/converter.py","size":22519,"sha256":"4d31a7df1f8a4a787d73c4b6b9234cb3f9deec15a8be020604d2dac6b8b90113","contentType":"text/x-python; charset=utf-8"},{"id":"9e840973-54b5-5960-9df8-fe7b1f52e58e","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/9e840973-54b5-5960-9df8-fe7b1f52e58e/attachment.py","path":"toolkit/image_gen.py","size":27328,"sha256":"568cd695c39040c085e712d71bfee5843e538af4523b7278444b8dc4d0ffad34","contentType":"text/x-python; charset=utf-8"},{"id":"f82388f1-1e95-524c-80fe-0a5f5576f928","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/f82388f1-1e95-524c-80fe-0a5f5576f928/attachment.py","path":"toolkit/publisher.py","size":5722,"sha256":"4849356c7c30ae595179eb59f5667c13c22ed6b6aed1728163f9fe924144db2d","contentType":"text/x-python; charset=utf-8"},{"id":"722a2d47-ae04-5f6a-8cfc-c328b27208d0","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/722a2d47-ae04-5f6a-8cfc-c328b27208d0/attachment.py","path":"toolkit/theme.py","size":5589,"sha256":"850913a0d12914e04529b72bdd82781a28ba038efaca7895133234fd2c329676","contentType":"text/x-python; charset=utf-8"},{"id":"58142cb1-6e06-5c9e-a2f8-7514018f7e1b","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/58142cb1-6e06-5c9e-a2f8-7514018f7e1b/attachment.yaml","path":"toolkit/themes/bauhaus.yaml","size":3708,"sha256":"6839eb96a36ea5ad76d7d3c2f1a977a50f0a2149621e2efb7e4a0a340d1b1153","contentType":"application/yaml; charset=utf-8"},{"id":"71ca16fd-00e6-5a4c-afaa-aca67423b04e","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/71ca16fd-00e6-5a4c-afaa-aca67423b04e/attachment.yaml","path":"toolkit/themes/bold-green.yaml","size":3542,"sha256":"ed0fbba455311d57741751ea6ebf91963b4bdaf885e787e8480c4d3d860d027a","contentType":"application/yaml; charset=utf-8"},{"id":"f94ccb5c-d996-5a70-8852-198d2574cbb5","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/f94ccb5c-d996-5a70-8852-198d2574cbb5/attachment.yaml","path":"toolkit/themes/bold-navy.yaml","size":3533,"sha256":"34b77866adceb13bbeb604edae40938c2aa667f2650647147e8164d5c7deb404","contentType":"application/yaml; charset=utf-8"},{"id":"41c9be3b-c8ab-57c4-a614-7bc0fbd26015","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/41c9be3b-c8ab-57c4-a614-7bc0fbd26015/attachment.yaml","path":"toolkit/themes/bytedance.yaml","size":3573,"sha256":"7296ccb862ee9d7af426fb69fad50d141684cbcf3e9968bc46fd7f184d6aaf2a","contentType":"application/yaml; charset=utf-8"},{"id":"4f7ec37e-0130-5f97-a566-db7088f54e13","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/4f7ec37e-0130-5f97-a566-db7088f54e13/attachment.yaml","path":"toolkit/themes/elegant-rose.yaml","size":3561,"sha256":"55e46778bbf5816da08c2af4bdd97b6f89343b4ee64bcc1ae8ad6afddd68462e","contentType":"application/yaml; charset=utf-8"},{"id":"aba90de4-bb1d-5ba1-b34c-6b3f84410de2","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/aba90de4-bb1d-5ba1-b34c-6b3f84410de2/attachment.yaml","path":"toolkit/themes/focus-red.yaml","size":3535,"sha256":"4459350083a5fe4151fab88bf4c8db1c2abceb4f6ec505f60b89e0133a537d00","contentType":"application/yaml; charset=utf-8"},{"id":"510e280e-3ec6-5fec-98eb-901d8046735e","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/510e280e-3ec6-5fec-98eb-901d8046735e/attachment.yaml","path":"toolkit/themes/github.yaml","size":3579,"sha256":"176ed0c463e1627d5e1405a6181e196034a7c6d89ef1f5eb5ce4f62e1426b8ee","contentType":"application/yaml; charset=utf-8"},{"id":"ff9f2d16-274e-56cf-b005-ee8825b02dd9","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/ff9f2d16-274e-56cf-b005-ee8825b02dd9/attachment.yaml","path":"toolkit/themes/ink.yaml","size":3584,"sha256":"8b066b9c12b3c1bf1803e0877ef869580098a729ae54060a1dc0c8d5cc4bc108","contentType":"application/yaml; charset=utf-8"},{"id":"03090e72-8d30-543d-b490-a660f59c3bcc","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/03090e72-8d30-543d-b490-a660f59c3bcc/attachment.yaml","path":"toolkit/themes/midnight.yaml","size":3525,"sha256":"a6665d30671f3e376506b9eee4f6942f0305007a8da387cb9f86a5c9e59f49a6","contentType":"application/yaml; charset=utf-8"},{"id":"75968242-8107-556c-a1ac-8c71ea756818","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/75968242-8107-556c-a1ac-8c71ea756818/attachment.yaml","path":"toolkit/themes/minimal-gold.yaml","size":3657,"sha256":"5d598e11fde04933a98b98b63aacb5024e9289eafd89f1e192af7689d71b7c83","contentType":"application/yaml; charset=utf-8"},{"id":"1ee4ae26-44a2-5cf9-9e2b-cc274ec1fde1","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/1ee4ae26-44a2-5cf9-9e2b-cc274ec1fde1/attachment.yaml","path":"toolkit/themes/minimal.yaml","size":3459,"sha256":"3d455f63bc67477e82df4b0825942451e2998856aeb1a593e0644ef1ad55f8c6","contentType":"application/yaml; charset=utf-8"},{"id":"e79b7404-09b2-55a2-a6ea-93260bf559eb","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/e79b7404-09b2-55a2-a6ea-93260bf559eb/attachment.yaml","path":"toolkit/themes/newspaper.yaml","size":3668,"sha256":"13b8964db63e2f56ed1c2c3dd1147f1e3485af4ededc8b0b44ee59fe9281a365","contentType":"application/yaml; charset=utf-8"},{"id":"022bb242-b33c-5bb5-a76e-52bb9750f7af","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/022bb242-b33c-5bb5-a76e-52bb9750f7af/attachment.yaml","path":"toolkit/themes/professional-clean.yaml","size":3530,"sha256":"0e6fe6f1df2e8b473eac50fa59755e104b8ad6e65b5e33692e1a324587d90e49","contentType":"application/yaml; charset=utf-8"},{"id":"182e7f0f-a541-5fb3-b126-34f8c2d8110d","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/182e7f0f-a541-5fb3-b126-34f8c2d8110d/attachment.yaml","path":"toolkit/themes/sspai.yaml","size":3543,"sha256":"5efc2d9c505428bbf216ca9ccaa562f6de250b501648ae4c202ef2e1fdf2440e","contentType":"application/yaml; charset=utf-8"},{"id":"8c1c717b-92a8-5d39-9faf-45ec2273cbf6","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/8c1c717b-92a8-5d39-9faf-45ec2273cbf6/attachment.yaml","path":"toolkit/themes/tech-modern.yaml","size":3866,"sha256":"d05997293116aeeed76e8ca5745e72c2e488bc1568e3f0a40398160acc0180b9","contentType":"application/yaml; charset=utf-8"},{"id":"9edca4f9-ba65-5774-86aa-96a080ff7db8","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/9edca4f9-ba65-5774-86aa-96a080ff7db8/attachment.yaml","path":"toolkit/themes/warm-editorial.yaml","size":3510,"sha256":"f1557dda88537cde4086837b9e52d29649e040fb51fa0e371fffc75230af9f6b","contentType":"application/yaml; charset=utf-8"},{"id":"9f740bdc-cbc6-50df-88d9-164c24e51f3c","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/9f740bdc-cbc6-50df-88d9-164c24e51f3c/attachment.py","path":"toolkit/wechat_api.py","size":3372,"sha256":"ec1f354c98f2cf52b6a5f48b593ae053b144216d13f173f6a89a2201671518cf","contentType":"text/x-python; charset=utf-8"},{"id":"f67f31e5-ba82-5fcd-a693-f498b9a0dcb8","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/f67f31e5-ba82-5fcd-a693-f498b9a0dcb8/attachment.yaml","path":"writing-config.example.yaml","size":3850,"sha256":"029dc6c3f493416128bc69a0584da0f6b1a7aec1cae9fa3432a168dab6951b99","contentType":"application/yaml; charset=utf-8"}],"bundle_sha256":"1f8f9eceed40c1f295f7553c2e4f6a8f18b97af9a3b06a4138a759a4df21bb22","attachment_count":61,"text_attachments":61,"attachment_storage":"skillopedia-attachments-v1","binary_attachments":0,"excluded_attachments":[]},"cluster_size":1,"skill_md_path":"SKILL.md","import_metadata":{"date":"2026-06-05","author":"@skillopedia","version":"v1","category":"business-sales-marketing","category_label":"Business"},"exact_dupes_collapsed_into_this":0},"version":"v1","category":"business-sales-marketing","import_tag":"clean-skills-v1","description":"微信公众号内容全流程助手:热点抓取 → 选题 → 框架 → 内容增强 → 写作 → SEO → 视觉AI → 排版推送草稿箱。\n触发关键词:公众号、推文、微信文章、微信推文、草稿箱、微信排版、选题、热搜、\n热点抓取、封面图、配图、写公众号、写一篇、主题画廊、排版主题、容器语法。\n也覆盖:markdown 转微信格式、学习用户改稿风格、文章数据复盘、风格设置、\n主题预览/切换、:::dialogue/:::timeline/:::callout 容器语法。\n不应被通用的\"写文章\"、blog、邮件、PPT、抖音/短视频、网站 SEO 触发——\n需要有公众号/微信等明确上下文。\n","allowed-tools":["Bash","Read","Write","Edit","Glob","Grep","WebSearch","WebFetch"]}},"renderedAt":1782980292097}

WeWrite — 公众号文章全流程 行为声明 角色 :用户的公众号内容编辑 Agent。 模式 : - 默认全自动 ——一口气跑完 Step 1-8,不中途停下。只在出错时停。 - 交互模式 ——用户说"交互模式"/"我要自己选"时,在选题/框架/配图处暂停。 降级原则 :每一步都有降级方案。Step 1 检测到的降级标记( 、 )在后续 Step 自动生效,不重复报错。 进度追踪 :主管道启动时,用 TaskCreate 为 8 个 Step 创建任务。每开始一个 Step 标记 in progress,完成后标记 completed。用户可随时看到当前进度。 完成协议 : - DONE — 全流程完成,文章已保存/推送 - DONE WITH CONCERNS — 完成但部分步骤降级,列出降级项 - BLOCKED — 关键步骤无法继续(如 Python 依赖缺失且用户拒绝安装) - NEEDS CONTEXT — 需要用户提供信息才能继续(如首次设置需要公众号名称) 路径约定 :本文档中 指本 SKILL.md 所在的目录(即 WeWrite 的根目录)。 Onboard 例外 :Onboard 是交互式的(需要问用户问题),不受"全自动"约束。Onboard 完成后回到全自动管道。 辅助功能 (按需加载,不在主管道内): - 用户说"重新设置风格" → - 用户说"学习…