Find all files related to "zhihu" mode in this project. Search for:
1. Any file containing "zhihu" (case insensitive)
2. Any prompt templates or mode definitions
3. The generation pipeline - how text goes from input to output

Report the file paths, relevant code sections, and how the pipeline works.


I'll help you find all files related to "zhihu" mode in this project. Let me start by searching for files containing "zhihu" and exploring the structure.


tag20260215/CLAUDE.md
tag20260302/CLAUDE.md
health.md
tag20260215/health.md
tag20260302/health.md
decision.md
tag20260215/decision.md
tag20260302/decision.md
health/utils/__init__.py
health/utils/date_utils.py
health/utils/exceptions.py
health/__init__.py
tag20260215/health/utils/__init__.py
tag20260215/health/utils/date_utils.py
tag20260215/health/utils/exceptions.py
tag20260215/health/__init__.py
tag20260302/health/utils/__init__.py
tag20260302/health/utils/date_utils.py
tag20260302/health/utils/exceptions.py
tag20260302/health/__init__.py
health/db/__init__.py
health/db/schema.py
tag20260215/health/db/__init__.py
tag20260215/health/db/schema.py
tag20260302/health/db/__init__.py
tag20260302/health/db/schema.py
health/db/repository.py
tag20260215/health/db/repository.py
tag20260302/health/db/repository.py
health/models/__init__.py
health/models/sync_metadata.py
tag20260215/health/models/__init__.py
tag20260215/health/models/sync_metadata.py
tag20260302/health/models/__init__.py
tag20260302/health/models/sync_metadata.py
health/services/__init__.py
tag20260215/health/services/__init__.py
tag20260302/health/services/__init__.py
tag20260215/health/services/storage.py
tag20260302/health/services/storage.py
health/cli/__init__.py
health/cli/sync.py
tag20260215/health/cli/__init__.py
tag20260215/health/cli/sync.py
tag20260302/health/cli/__init__.py
tag20260302/health/cli/sync.py
scripts/setup_health.py
tag20260215/scripts/setup_health.py
tag20260302/scripts/setup_health.py
skills/health_sync.py
tag20260215/skills/health_sync.py
tag20260302/skills/health_sync.py
tag20260215/docs/CRON_SETUP.md
health/models/body_metrics.py
tag20260215/health/models/body_metrics.py
tag20260302/health/models/body_metrics.py
scripts/verify_system.py
tag20260215/scripts/verify_system.py
tag20260302/scripts/verify_system.py
tag20260215/docs/HEALTH_SYNC_README.md
data/health/daily_metrics/heart_rate/2024/01/2024-01-01.json
data/health/daily_metrics/heart_rate/2024/01/2024-01-02.json
data/health/daily_metrics/heart_rate/2024/01/2024-01-03.json
data/health/daily_metrics/heart_rate/2024/01/2024-01-04.json
data/health/daily_metrics/heart_rate/2024/01/2024-01-05.json
data/health/daily_metrics/heart_rate/2024/01/2024-01-06.json
data/health/daily_metrics/heart_rate/2024/01/2024-01-07.json
data/health/daily_metrics/heart_rate/2024/01/2024-01-08.json
data/health/daily_metrics/heart_rate/2024/01/2024-01-09.json
data/health/daily_metrics/heart_rate/2024/01/2024-01-10.json
data/health/daily_metrics/heart_rate/2024/01/2024-01-11.json
data/health/daily_metrics/heart_rate/2024/01/2024-01-12.json
data/health/daily_metrics/heart_rate/2024/01/2024-01-13.json
data/health/daily_metrics/heart_rate/2024/01/2024-01-14.json
data/health/daily_metrics/heart_rate/2024/01/2024-01-15.json
data/health/daily_metrics/heart_rate/2024/01/2024-01-16.json
data/health/daily_metrics/heart_rate/2024/01/2024-01-17.json
data/health/daily_metrics/heart_rate/2024/01/2024-01-18.json
data/health/daily_metrics/heart_rate/2024/01/2024-01-19.json
data/health/daily_metrics/heart_rate/2024/01/2024-01-20.json
data/health/daily_metrics/heart_rate/2024/01/2024-01-21.json
data/health/daily_metrics/heart_rate/2024/01/2024-01-22.json
data/health/daily_metrics/heart_rate/2024/01/2024-01-23.json
data/health/daily_metrics/heart_rate/2024/01/2024-01-24.json
data/health/daily_metrics/heart_rate/2024/01/2024-01-25.json
data/health/daily_metrics/heart_rate/2024/01/2024-01-26.json
data/health/daily_metrics/heart_rate/2024/01/2024-01-27.json
data/health/daily_metrics/heart_rate/2024/01/2024-01-28.json
data/health/daily_metrics/heart_rate/2024/01/2024-01-29.json
data/health/daily_metrics/heart_rate/2024/01/2024-01-30.json
data/health/daily_metrics/sleep/2024/01/2024-01-01.json
data/health/daily_metrics/sleep/2024/01/2024-01-02.json
data/health/daily_metrics/sleep/2024/01/2024-01-03.json
data/health/daily_metrics/sleep/2024/01/2024-01-04.json
data/health/daily_metrics/sleep/2024/01/2024-01-05.json
data/health/daily_metrics/sleep/2024/01/2024-01-06.json
data/health/daily_metrics/sleep/2024/01/2024-01-07.json
data/health/daily_metrics/sleep/2024/01/2024-01-08.json
data/health/daily_metrics/sleep/2024/01/2024-01-09.json
data/health/daily_metrics/sleep/2024/01/2024-01-10.json
(Results are truncated. Consider using a more specific path or pattern.)


Found 28 files
data/health/obsidian_context/D0AACF9G4J0.json
logs/obsidian.log
data/health/obsidian_context/D0AACF9G4J0_state.json
logs/obsidian_bot.log
slack_bot/obsidian/dispatcher.py
slack_bot/zhihu/zhihu_hunter.py
slack_bot/obsidian/generators.py
.claude/settings.local.json
zhihu-bot-style.md
zhihu-style.md
scripts/zhihu_e2e_test.py
slack_bot/zhihu/zhihu_playwright_engine.py
slack_bot/zhihu/slack_interactive_gateway.py
zhihu-hunter-guide.md
scripts/zhihu_debug_publish.py
scripts/obsidian_bot.py
htmlcov/z_fd0c8b47222b138b_generators_py.html
htmlcov/z_fd0c8b47222b138b_dispatcher_py.html
tag20260215/logs/obsidian.log
tag20260215/slack_bot/obsidian/dispatcher.py
tag20260302/slack_bot/obsidian/dispatcher.py
tag20260215/slack_bot/obsidian/generators.py
tag20260302/slack_bot/obsidian/generators.py
tag20260215/zhihu-style.md
tag20260302/zhihu-style.md
tag20260215/zhihu-sample/12.md
tag20260302/zhihu-sample/12.md
zhihu-sample/12.md


Great! I found multiple files related to "zhihu". Let me now explore the key files to understand the structure and pipeline.


1	"""Zhihu Hunter — vault scanning, question discovery, and answer drafting.
2
3	Workflow:
4	    1. scan_and_hunt()  →  scans recent vault files, extracts keywords,
5	                           searches Tavily for matching Zhihu questions.
6	    2. draft_answer()   →  uses existing RAG + ZhihuGenerator to produce
7	                           a Zhihu-style answer draft ready for review.
8	"""
9
10	import os
11	import re
12	import requests
13	from datetime import datetime, timezone, timedelta
14	from pathlib import Path
15	from typing import List, Optional
16
17	from pydantic import BaseModel, Field
18
19	from health.utils.logging_config import setup_logger
20	from slack_bot.llm.gemini import GeminiLLM
21	from slack_bot.obsidian.generators import ZhihuGenerator
22	from slack_bot.obsidian.indexer import ObsidianIndexer
23	from slack_bot.obsidian.vector_store import ChromaVectorStore
24
25	logger = setup_logger(__name__)
26
27
28	# ── Vault directories to watch for new/updated content ────────────────────────
29	# Paths are relative to vault_path root.
30	WATCH_DIRS: List[str] = [
31	    "notes",
32	    "Article",
33	    "auto_report/deep-dive",
34	    "CLAW/deepfact",
35	    "Clippings",
36	]
37
38	# Article is always included regardless of time; Revised files are prioritized.
39	_ARTICLE_DIR = "Article"
40
41	# Short names users can type to target a specific directory.
42	DIR_SHORTCUTS: dict[str, str] = {
43	    "notes":      "notes",
44	    "article":    "Article",
45	    "clippings":  "Clippings",
46	    "claw":       "CLAW/deepfact",
47	    "deepfact":   "CLAW/deepfact",
48	    "deep-dive":  "auto_report/deep-dive",
49	}
50
51	_MAX_FILES_PER_DIR   = 4    # most-recent files per directory (keeps dir diversity)
52	_MAX_FILES_ARTICLE   = 8    # Article gets more slots (higher priority)
53	_MAX_KEYWORDS        = 10   # keywords extracted from vault scan
54	_MAX_QUESTIONS_TOTAL = 12   # total Zhihu questions surfaced per hunt
55	# Must contain /question/<id>; answer/comment subpages are excluded in _find_questions
56	_MIN_QUESTION_URL_RE = re.compile(r'zhihu\.com/question/\d+')
57
58
59	# ── Data Models ────────────────────────────────────────────────────────────────
60
61	class ZhihuQuestion(BaseModel):
62	    """A Zhihu question candidate surfaced by the hunter."""
63
64	    title: str
65	    url: str
66	    snippet: Optional[str] = None
67	    keywords: List[str] = Field(default_factory=list)
68	    found_at: str = Field(
69	        default_factory=lambda: datetime.now(tz=timezone.utc).isoformat()
70	    )
71	    description: Optional[str] = None   # question body/detail text from Zhihu page
72	    source_file: Optional[str] = None  # vault-relative path, set by scan_single_file()
73	    outline: Optional[str] = None       # user-provided answer logic/structure (direct URL mode)
74
75
76	class AnswerDraft(BaseModel):
77	    """A generated answer draft awaiting human review."""
78
79	    question: ZhihuQuestion
80	    content: str
81	    generated_at: str = Field(
82	        default_factory=lambda: datetime.now(tz=timezone.utc).isoformat()
83	    )
84	    rag_sources: List[str] = Field(default_factory=list)
85	    vault_file: Optional[str] = None  # absolute path set after saving to vault
86
87
88	# ── Main Class ─────────────────────────────────────────────────────────────────
89
90	class ZhihuHunter:
91	    """Discovers Zhihu questions from vault content and drafts answers.
92
93	    Example:
94	        hunter = ZhihuHunter(vault_path, vector_store, indexer)
95	        questions = hunter.scan_and_hunt(since_days=3)
96	        draft = hunter.draft_answer(questions[0])
97	    """
98
99	    def __init__(
100	        self,
101	        vault_path: Path,
102	        vector_store: ChromaVectorStore,
103	        indexer: ObsidianIndexer,
104	    ) -> None:
105	        """Initialize the hunter.
106
107	        Args:
108	            vault_path: Root path of the Obsidian vault.
109	            vector_store: ChromaDB vector store for semantic retrieval.
110	            indexer: ObsidianIndexer for keyword-based search and writing samples.
111	        """
112	        self.vault_path = vault_path
113	        self.vector_store = vector_store
114	        self.indexer = indexer
115	        self.llm = GeminiLLM()
116
117	    # ── Public API ─────────────────────────────────────────────────────────────
118
119	    def scan_and_hunt(
120	        self,
121	        since_days: int = 7,
122	        dirs: Optional[List[str]] = None,
123	        topic_hint: str = "",
124	    ) -> List[ZhihuQuestion]:
125	        """Scan vault files and surface matching Zhihu questions.
126
127	        Args:
128	            since_days: Age threshold for non-Article directories.
129	            dirs: Restrict scan to these vault dirs (relative paths). None = all WATCH_DIRS.
130	            topic_hint: Optional focus hint injected into keyword extraction prompt.
131
132	        Returns:
133	            List of ZhihuQuestion candidates (up to _MAX_QUESTIONS_TOTAL).
134	        """
135	        scan_desc = f"dirs={dirs or 'all'}" + (f", topic='{topic_hint}'" if topic_hint else "")
136	        logger.info(f"ZhihuHunter: scanning vault ({scan_desc})")
137
138	        keywords = self._scan_vault_for_keywords(since_days, dirs=dirs, topic_hint=topic_hint)
139	        if not keywords:
140	            logger.info("No keywords extracted from vault files")
141	            return []
142
143	        logger.info(f"Extracted keywords: {keywords}")
144	        questions = self._find_questions(keywords)
145	        logger.info(f"Found {len(questions)} Zhihu questions")
146	        return questions
147
148	    def scan_single_file(self, rel_path: str) -> List[ZhihuQuestion]:
149	        """Extract keywords from one vault file and find matching Zhihu questions.
150
151	        The returned questions carry ``source_file`` set to ``rel_path`` so that
152	        ``draft_answer()`` will ground the generated answer in that article.
153
154	        Args:
155	            rel_path: Path relative to vault_path (e.g. "Article/8-xxx-Revised.md").
156
157	        Returns:
158	            List of ZhihuQuestion candidates with source_file populated.
159
160	        Raises:
161	            FileNotFoundError: If the file does not exist under the vault root.
162	        """
163	        file_path = self.vault_path / rel_path
164	        if not file_path.exists():
165	            raise FileNotFoundError(f"文件不存在：{file_path}")
166
167	        text = file_path.read_text(encoding="utf-8", errors="ignore")
168	        text = re.sub(r'^---\n.*?\n---\n', '', text, flags=re.DOTALL).strip()
169	        logger.info(f"scan_single_file: {rel_path} ({len(text)} chars)")
170
171	        keywords = self._extract_keywords_with_llm(text[:5000])
172	        if not keywords:
173	            logger.info("No keywords extracted from single file")
174	            return []
175
176	        logger.info(f"Keywords from file: {keywords}")
177	        questions = self._find_questions(keywords)
178
179	        for q in questions:
180	            q.source_file = rel_path
181
182	        return questions
183
184	    def scan_glob_files(self, pattern: str) -> List[ZhihuQuestion]:
185	        """Find vault files matching a glob pattern and hunt Zhihu questions.
186
187	        All matched files are combined for keyword extraction, so the questions
188	        reflect the full set of content.  The most-recently-modified matched file
189	        is stored as ``source_file`` to ground the answer draft.
190
191	        Args:
192	            pattern: Glob pattern relative to vault_path
193	                     (e.g. "Article/*openclaw*-Revised.md").
194
195	        Returns:
196	            List of ZhihuQuestion candidates with source_file set.
197
198	        Raises:
199	            FileNotFoundError: If no files match the pattern.
200	        """
201	        matched = sorted(
202	            self.vault_path.glob(pattern),
203	            key=lambda p: p.stat().st_mtime,
204	            reverse=True,
205	        )
206	        if not matched:
207	            raise FileNotFoundError(f"没有文件匹配：{pattern}")
208
209	        names = [p.name for p in matched]
210	        logger.info(f"scan_glob_files: '{pattern}' → {len(matched)} files: {names}")
211
212	        # Combine content from all matched files (per-file budget so no one file
213	        # dominates) and extract keywords from the aggregate.
214	        parts: List[str] = []
215	        per_file_budget = 1500 if len(matched) == 1 else 800
216	        for fp in matched:
217	            try:
218	                text = fp.read_text(encoding="utf-8", errors="ignore")
219	                text = re.sub(r'^---\n.*?\n---\n', '', text, flags=re.DOTALL).strip()
220	                parts.append(f"[{fp.name}]\n{text[:per_file_budget]}")
221	            except OSError as e:
222	                logger.debug(f"Could not read {fp}: {e}")
223
224	        combined = "\n\n---\n\n".join(parts)
225	        keywords = self._extract_keywords_with_llm(combined[:8000])
226	        if not keywords:
227	            logger.info("No keywords extracted from glob-matched files")
228	            return []
229
230	        logger.info(f"Keywords from glob: {keywords}")
231	        questions = self._find_questions(keywords)
232
233	        # Primary source = most-recently-modified matched file
234	        primary = str(matched[0].relative_to(self.vault_path))
235	        for q in questions:
236	            q.source_file = primary
237
238	        return questions
239
240	    def hunt_direct_url(self, url: str) -> List[ZhihuQuestion]:
241	        """Create a question card directly from a Zhihu question URL.
242
243	        Used when the user pastes a URL they were invited to answer.
244	        Fetches the page title and description so the card displays readable info.
245
246	        Args:
247	            url: A zhihu.com/question/<id> URL.
248
249	        Returns:
250	            Single-element list containing the ZhihuQuestion.
251	        """
252	        clean_url = re.sub(r'[>\'")\].,;]+$', '', url).lstrip("<").split("?")[0].split("#")[0].rstrip("/")
253	        title, description = self._fetch_question_info(clean_url)
254	        logger.info(f"Direct URL hunt: {clean_url!r} → title={title!r}, desc={len(description or '')} chars")
255	        return [ZhihuQuestion(title=title, url=clean_url, description=description)]
256
257	    def _fetch_question_info(self, url: str) -> tuple[str, Optional[str]]:
258	        """Fetch the question title and description via Playwright.
259
260	        Falls back to a URL-derived label if Playwright is unavailable or fails.
261
262	        Args:
263	            url: Cleaned question URL.
264
265	        Returns:
266	            Tuple of (title, description). Description may be None.
267	        """
268	        state_file = Path(__file__).resolve().parents[3] / "data" / "zhihu_state.json"
269	        try:
270	            from playwright.sync_api import sync_playwright
271	            from playwright_stealth import Stealth
272	            import json as _json
273
274	            with sync_playwright() as p:
275	                browser = p.chromium.launch(headless=True)
276	                context = browser.new_context(
277	                    user_agent=(
278	                        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
279	                        "AppleWebKit/537.36 (KHTML, like Gecko) "
280	                        "Chrome/122.0.0.0 Safari/537.36"
281	                    )
282	                )
283	                # Load saved cookies if available
284	                if state_file.exists():
285	                    data = _json.loads(state_file.read_text(encoding="utf-8"))
286	                    cookies = data.get("cookies", [])
287	                    if cookies:
288	                        context.add_cookies(cookies)
289
290	                page = context.new_page()
291	                Stealth().apply_stealth_sync(page)
292	                page.goto(url, wait_until="domcontentloaded", timeout=15000)
293	                page.wait_for_timeout(2000)
294
295	                # Extract title
296	                title = ""
297	                title_el = page.query_selector("h1.QuestionHeader-title")
298	                if title_el:
299	                    title = title_el.inner_text().strip()
300	                if not title:
301	                    title = page.title() or ""
302	                    title = title.split(" - 知乎")[0].strip()
303	                # Strip "(XX 条消息)" notification prefix from tab title
304	                title = re.sub(r'^\(\d+ 条消息\)\s*', '', title)
305
306	                # Extract question description
307	                description = None
308	                desc_el = page.query_selector("div.QuestionRichText")
309	                if desc_el:
310	                    description = desc_el.inner_text().strip()
311	                    # Clean up truncation artifacts and zero-width chars
312	                    description = description.replace("\u200b", "").strip()
313	                    description = re.sub(r'…?\s*显示全部\s*$', '', description).strip()
314	                if not description:
315	                    og_el = page.query_selector('meta[property="og:description"]')
316	                    if og_el:
317	                        description = (og_el.get_attribute("content") or "").strip()
318	                        description = re.sub(r'…$', '', description).strip()
319	                # Filter out Zhihu generic site description (not question-specific)
320	                if description and "知乎，中文互联网" in description:
321	                    description = None
322	                if description:
323	                    description = description[:2000]
324	                else:
325	                    description = None
326
327	                browser.close()
328
329	                if title and title != "知乎":
330	                    return title, description
331	        except Exception as e:
332	            logger.debug(f"Playwright question fetch failed for {url}: {e}")
333
334	        # Fallback: use the question ID
335	        qid = url.rstrip("/").split("/")[-1]
336	        return f"知乎问题 #{qid}", None
337
338	    def _fetch_recent_web_context(self, query: str) -> str:
339	        """Search the web for recent articles/news about the query topic.
340
341	        Results are injected into the answer prompt so it reads like it
342	        comes from someone who's actively following the latest developments.
343
344	        Args:
345	            query: Topic or question title to search.
346
347	        Returns:
348	            Formatted string of recent snippets, or empty string on failure.
349	        """
350	        api_key = os.environ.get("TAVILY_API_KEY")
351	        if not api_key:
352	            return ""
353	        try:
354	            from tavily import TavilyClient
355	            client = TavilyClient(api_key=api_key)
356	            results = client.search(
357	                query=query,
358	                search_depth="basic",
359	                max_results=4,
360	                days=90,
361	                exclude_domains=["zhihu.com"],
362	            )
363	            items = results.get("results", [])
364	            if not items:
365	                return ""
366	            parts = [
367	                f"- {r['title']} ({r['url'].split('/')[2]}): {r.get('content', '')[:250]}"
368	                for r in items
369	            ]
370	            return "\n".join(parts)
371	        except Exception as e:
372	            logger.debug(f"Recent web context fetch failed: {e}")
373	            return ""
374
375	    def draft_answer(self, question: ZhihuQuestion, feedback: str = "") -> AnswerDraft:
376	        """Generate a Zhihu-style answer draft for a question.
377
378	        Delegates to the existing ZhihuGenerator (reuses prompt + style guide).
379	        The RAG context is enriched by both semantic (ChromaDB) and
380	        keyword-based (ObsidianIndexer) retrieval.
381
382	        Args:
383	            question: ZhihuQuestion to answer.
384	            feedback: Optional revision instructions from the user (e.g. "更口语化一些").
385
386	        Returns:
387	            AnswerDraft with generated content and source list.
388	        """
389	        logger.info(f"Drafting answer for: {question.title}" + (f" [feedback: {feedback[:40]}]" if feedback else ""))
390
391	        # Collect RAG sources for transparency
392	        rag_sources: List[str] = []
393
394	        semantic_results = self.vector_store.search_knowledge(question.title, top_k=3)
395	        for r in semantic_results:
396	            rag_sources.append(Path(r.source_path).name)
397
398	        keyword_results = self.indexer.search(question.title, limit=5)
399	        for note in keyword_results:
400	            first_line = note.split("\n")[0].strip("# ").strip()
401	            if first_line:
402	                rag_sources.append(first_line)
403
404	        # If the question was pinned to a specific source file, load it as the
405	        # primary article so the answer stays grounded in its viewpoints.
406	        primary_article = ""
407	        if question.source_file:
408	            fp = self.vault_path / question.source_file
409	            try:
410	                raw = fp.read_text(encoding="utf-8", errors="ignore")
411	                raw = re.sub(r'^---\n.*?\n---\n', '', raw, flags=re.DOTALL).strip()
412	                primary_article = raw[:4000]
413	                rag_sources.insert(0, Path(question.source_file).name)
414	                logger.info(f"Loaded primary article: {question.source_file} ({len(primary_article)} chars)")
415	            except OSError as e:
416	                logger.warning(f"Could not read source file {question.source_file}: {e}")
417
418	        # Delegate to existing ZhihuGenerator for consistent style
419	        generator = ZhihuGenerator(self.indexer, self.vector_store)
420	        prompt_input = question.title
421	        if question.description:
422	            prompt_input += f"\n\n问题详细描述：{question.description}"
423	        if question.snippet:
424	            prompt_input += f" | 背景：{question.snippet}"
425
426	        # User-provided outline takes highest priority — structure the answer strictly
427	        # around it, pulling matching content from the knowledge base for each point.
428	        if question.outline:
429	            prompt_input += (
430	                f"\n\n用户提供的回答思路（必须严格按照这个逻辑框架展开，"
431	                f"从知识库中找到对应的内容来支撑每个论点，不要自由发挥框架结构）：\n{question.outline}"
432	            )
433	            logger.info(f"Injected user outline: {question.outline[:60]!r}")
434
435	        if primary_article:
436	            prompt_input += (
437	                f"\n\n主要参考文章（请基于此文的观点和论据来回答，"
438	                f"可直接引用其中的分析和结论）：\n{primary_article}"
439	            )
440
441	        # Inject recent web context so the answer feels up-to-date
442	        recent_context = self._fetch_recent_web_context(question.title)
443	        if recent_context:
444	            prompt_input += (
445	                f"\n\n近期相关资讯（最近90天内的动态，用来补充最新工具/发布/讨论，"
446	                f"让回答更有时效感，自然地融入即可，不要刻意罗列）：\n{recent_context}"
447	            )
448	            logger.info(f"Injected recent web context ({len(recent_context)} chars)")
449
450	        if feedback:
451	            prompt_input += f"\n\n用户修改意见：{feedback}"
452
453	        content, _ = generator.chat(prompt_input, history=[])
454
455	        return AnswerDraft(
456	            question=question,
457	            content=content,
458	            rag_sources=list(dict.fromkeys(rag_sources)),  # deduplicate, keep order
459	        )
460
461	    # ── Vault Scanning ─────────────────────────────────────────────────────────
462
463	    def _scan_vault_for_keywords(
464	        self,
465	        since_days: int,
466	        dirs: Optional[List[str]] = None,
467	        topic_hint: str = "",
468	    ) -> List[str]:
469	        """Collect vault files and extract topic keywords via LLM.
470
471	        Args:
472	            since_days: Age threshold for non-Article directories.
473	            dirs: Dirs to scan; None = all WATCH_DIRS.
474	            topic_hint: Optional focus instruction for keyword extraction.
475
476	        Returns:
477	            Deduplicated list of topic keywords (up to _MAX_KEYWORDS).
478	        """
479	        cutoff = datetime.now(tz=timezone.utc) - timedelta(days=since_days)
480	        files = self._collect_recent_files(cutoff, dirs=dirs)
481
482	        if not files:
483	            return []
484
485	        # Article-only scans get a larger per-file budget (higher priority content)
486	        scan_dirs = dirs or WATCH_DIRS
487	        per_file_budget = 800 if scan_dirs == [_ARTICLE_DIR] else 500
488	        combined = self._read_files_summary(files, max_chars_per_file=per_file_budget)
489	        return self._extract_keywords_with_llm(combined, topic_hint=topic_hint)
490
491	    def _collect_recent_files(
492	        self,
493	        cutoff: datetime,
494	        dirs: Optional[List[str]] = None,
495	    ) -> List[Path]:
496	        """Walk vault directories and return candidate files.
497
498	        Article directory:
499	          - No time filter (always included regardless of cutoff)
500	          - Revised files sorted first, then by mtime descending
501	          - Up to _MAX_FILES_ARTICLE slots
502	        Other directories:
503	          - Only files modified after cutoff
504	          - Sorted by mtime descending
505	          - Up to _MAX_FILES_PER_DIR slots
506
507	        Args:
508	            cutoff: Freshness threshold for non-Article directories.
509	            dirs: Directories to scan (relative paths). None = all WATCH_DIRS.
510
511	        Returns:
512	            List of Path objects.
513	        """
514	        scan_dirs = dirs if dirs is not None else WATCH_DIRS
515	        collected: List[Path] = []
516
517	        for rel_dir in scan_dirs:
518	            watch_dir = self.vault_path / rel_dir
519	            if not watch_dir.is_dir():
520	                logger.debug(f"Watch dir not found, skipping: {watch_dir}")
521	                continue
522
523	            # (mtime, is_revised, path)
524	            candidates: List[tuple[float, bool, Path]] = []
525	            for md_file in watch_dir.rglob("*.md"):
526	                try:
527	                    mtime = md_file.stat().st_mtime
528	                    if rel_dir != _ARTICLE_DIR:
529	                        file_dt = datetime.fromtimestamp(mtime, tz=timezone.utc)
530	                        if file_dt < cutoff:
531	                            continue
532	                    is_revised = md_file.name.lower().endswith("-revised.md")
533	                    candidates.append((mtime, is_revised, md_file))
534	                except OSError:
535	                    continue
536
537	            if rel_dir == _ARTICLE_DIR:
538	                # Revised files first, then newest; give double slots
539	                candidates.sort(key=lambda t: (0 if t[1] else 1, -t[0]))
540	                max_files = _MAX_FILES_ARTICLE
541	            else:
542	                candidates.sort(key=lambda t: -t[0])
543	                max_files = _MAX_FILES_PER_DIR
544
545	            collected.extend(p for _, _, p in candidates[:max_files])
546
547	        logger.debug(f"Collected {len(collected)} files from vault (dirs={scan_dirs})")
548	        return collected
549
550	    def _read_files_summary(
551	        self,
552	        files: List[Path],
553	        max_chars: int = 8000,
554	        max_chars_per_file: int = 500,
555	    ) -> str:
556	        """Read and concatenate file contents up to a character budget.
557
558	        Each file is capped at max_chars_per_file so that no single large file
559	        exhausts the total budget and crowds out other directories.
560
561	        Args:
562	            files: List of markdown file paths.
563	            max_chars: Total character budget across all files.
564	            max_chars_per_file: Per-file cap to ensure directory diversity.
565
566	        Returns:
567	            Concatenated content string with file-name headers.
568	        """
569	        parts: List[str] = []
570	        remaining = max_chars
571
572	        for f in files:
573	            if remaining <= 0:
574	                break
575	            try:
576	                text = f.read_text(encoding="utf-8", errors="ignore")
577	                # Strip YAML frontmatter
578	                text = re.sub(r'^---\n.*?\n---\n', '', text, flags=re.DOTALL).strip()
579	                chunk = text[:min(max_chars_per_file, remaining)]
580	                parts.append(f"[{f.name}]\n{chunk}")
581	                remaining -= len(chunk)
582	            except OSError as e:
583	                logger.debug(f"Could not read {f}: {e}")
584
585	        return "\n\n---\n\n".join(parts)
586
587	    def _extract_keywords_with_llm(
588	        self,
589	        combined_text: str,
590	        topic_hint: str = "",
591	    ) -> List[str]:
592	        """Use LLM to distill topic keywords from aggregated vault content.
593
594	        Args:
595	            combined_text: Concatenated recent vault content.
596	            topic_hint: Optional focus instruction (e.g. "agent开发").
597
598	        Returns:
599	            List of keyword strings (up to _MAX_KEYWORDS).
600	        """
601	        topic_instruction = (
602	            f"\n- 重点关注与「{topic_hint}」相关的话题，优先提取这个方向的关键词"
603	            if topic_hint else ""
604	        )
605	        prompt = f"""你是一位知乎内容运营专家。请从以下笔记内容中提炼出适合在知乎搜索问题的关键词。
606
607	要求：
608	- 输出 {_MAX_KEYWORDS} 个以内的关键词，每行一个，无序号无符号
609	- 关键词要在知乎上有真实讨论热度，即普通中国用户会关心并提问的话题领域
610	- 避免过于偏门的专有名词（如具体项目代号、小众产品名），要用它所属的上一级概念
611	  例：「OpenClaw」→「AI智能体安全」；「BadJs」→「微信监控机制」；「Moon Monitor」→「健康监测设备」
612	- 不要太泛（如"AI""技术""健康"），也不要太窄（如只有一个工具代码名称）
613	- 优先选择最近讨论最多、最有深度的话题{topic_instruction}
614	- 只输出关键词列表，不要解释
615
616	=== 笔记内容 ===
617	{combined_text[:5000]}
618	"""
619	        try:
620	            response, _ = self.llm.generate_response(prompt, [])
621	            keywords = [
622	                line.strip()
623	                for line in response.strip().splitlines()
624	                if line.strip() and not line.strip().startswith("#")
625	            ]
626	            return keywords[:_MAX_KEYWORDS]
627	        except Exception as e:
628	            logger.error(f"Keyword extraction failed: {e}")
629	            return []
630
631	    # ── Question Discovery ─────────────────────────────────────────────────────
632
633	    def _find_questions(self, keywords: List[str]) -> List[ZhihuQuestion]:
634	        """Search Tavily for Zhihu questions matching each keyword.
635
636	        Collects candidates from ALL keywords before truncating so that later
637	        keywords (which can cover different facets) are not silently dropped.
638
639	        Args:
640	            keywords: List of topic keywords to search.
641
642	        Returns:
643	            Deduplicated ZhihuQuestion list (up to _MAX_QUESTIONS_TOTAL).
644	        """
645	        from tavily import TavilyClient
646
647	        api_key = os.environ.get("TAVILY_API_KEY")
648	        if not api_key:
649	            raise RuntimeError("TAVILY_API_KEY not set")
650
651	        client = TavilyClient(api_key=api_key)
652	        seen_urls: set[str] = set()
653	        questions: List[ZhihuQuestion] = []
654
655	        for kw in keywords:
656	            try:
657	                results = self._tavily_search(client, kw)
658	                for item in results:
659	                    url = item.get("url", "")
660	                    if not _MIN_QUESTION_URL_RE.search(url):
661	                        continue  # skip non-question URLs (e.g. zhihu.com/people/...)
662	                    # Normalize: extract bare URL to strip invisible chars, then remove params
663	                    _m = re.search(r'https?://\S+', url)
664	                    if not _m:
665	                        continue
666	                    clean_url = re.sub(r'[>\'")\].,;]+$', '', _m.group(0)).split("?")[0].split("#")[0].rstrip("/")
667	                    # Skip answer/comment subpages — we want the question itself
668	                    if re.search(r'/answer/|/comment/', clean_url):
669	                        continue
670	                    if clean_url in seen_urls:
671	                        continue
672	                    seen_urls.add(clean_url)
673	                    questions.append(
674	                        ZhihuQuestion(
675	                            title=item.get("title", kw),
676	                            url=clean_url,
677	                            snippet=item.get("content", "")[:200] or None,
678	                            keywords=[kw],
679	                        )
680	                    )
681	            except Exception as e:
682	                logger.warning(f"Tavily search failed for keyword '{kw}': {e}")
683
684	        logger.info(f"Found {len(questions)} Zhihu questions (capped at {_MAX_QUESTIONS_TOTAL})")
685	        return questions[:_MAX_QUESTIONS_TOTAL]
686
687	    def _tavily_search(self, client, keyword: str) -> List[dict]:
688	        """Run a single Tavily search scoped to zhihu.com/question.
689
690	        Args:
691	            client: Authenticated TavilyClient instance.
692	            keyword: Search keyword.
693
694	        Returns:
695	            List of result dicts from Tavily (url, title, content, score).
696	        """
697	        query = f"site:zhihu.com/question {keyword}"
698	        logger.debug(f"Tavily search: {query}")
699	        response = client.search(
700	            query=query,
701	            search_depth="basic",
702	            max_results=8,
703	            include_domains=["zhihu.com"],
704	            days=365,   # prefer questions with recent activity (higher chance of top rank)
705	        )
706	        return response.get("results", [])
707


1	# 知乎回答风格指南
2
3	> 本文档基于 ./zhihu-sample 目录下12个知乎回答示例分析生成
4	> 用于指导 powellllll Bot 的知乎回答模式生成符合风格的内容
5
6	---
7
8	## 一、核心风格定位
9
10	**专业而不失趣味、严谨而不失温度**
11
12	这是一种基于深厚技术功底、真实实践经验、人文素养的写作风格，适合技术社区的深度讨论。
13
14	### 五大特色
15
16	1. **真诚实用** - 分享真实经验，不贩卖焦虑
17	2. **技术底蕴** - 深厚的技术功底支撑论述
18	3. **人文情怀** - 引经据典，文理兼修
19	4. **幽默风趣** - 自嘲式幽默，轻松易读
20	5. **务实态度** - 重视实际问题解决，反对概念炒作
21
22	---
23
24	## 二、写作风格原则
25
26	### 1. 语气和口吻
27
28	#### ✅ 要这样做：
29	- **正式与非正式交织**：技术内容保持专业性，但整体口吻轻松幽默
30	  - 使用"谢邀"、"我厂"、"我云"等亲切自称
31	  - 适当使用口语化表达，如"没什么x用"、"简直是画面太美"
32
33	- **主观性强**：明确表达个人观点，不回避立场
34	  - 频繁使用"我认为..."、"我觉得..."、"我们认为..."
35	  - 直接表达态度："这点是不可取的"、"必须要..."
36
37	- **谦逊中带自信**：既表现谦虚，又展示专业自信
38	  - 谦虚："微小的工作"、"一点点贡献"
39	  - 自信："我的决定也是很重要的"、"我们对此很有信心"
40
41	#### ❌ 不要这样做：
42	- 过度正式、官腔官调
43	- 模棱两可、没有立场
44	- 过分谦虚或过分自大
45
46	### 2. 句式特点
47
48	#### ✅ 要这样做：
49	- **长短句结合**：
50	  - 短句干脆利落："是真的有用"、"真的不难，真的。"
51	  - 长句层层递进："云计算要体现出革命性的突破，是需要挑战到这么多年来我们一直坚信和死守的运营实践和技术基础。"
52
53	- **重复强调**：通过重复关键词或句式加强表达
54	  - "真的"重复使用强调真实性
55	  - 重要的论点重复三次
56
57	- **设问与自答**：营造对话感
58	  - "首先，我们当时的问题是什么？OpenStack的优势是什么？"
59
60	#### ❌ 不要这样做：
61	- 全是长句或全是短句
62	- 单调的陈述句式
63	- 缺乏节奏感
64
65	### 3. 第一人称使用
66
67	#### ✅ 要这样做：
68	- **频繁使用第一人称**："我"、"我们"贯穿全文
69	- **区分个人与团队**：
70	  - 个人观点："我想起..."、"我觉得..."
71	  - 团队工作："我们团队..."、"我们认为..."
72	- **拉近距离**：用"我们"包含读者，形成共同体
73
74	#### ❌ 不要这样做：
75	- 第三人称客观叙述（太学术）
76	- 完全避免使用"我"（太疏离）
77
78	### 4. 修辞手法
79
80	#### ✅ 要这样做：
81	- **适度比喻**：
82	  - 鼓励大众化、接地气的比喻（"云就是互联网的水和电"、"aws就像是郭靖，结硬寨，打呆仗"）
83	  - 每篇文章不超过 1-2 个比喻，避免重复使用同一个
84	  - **永久禁用**："简装房"（仅限特定历史场景，不再使用）
85
86	- **反讽与自嘲**：
87	  - "我厂的程序员在婚恋市场其实挺吃香的"
88	  - "我是不愿意在半夜起来处理故障时面对它们的"
89
90	- **引经据典**：
91	  - "天下有道，丘不与易也"
92	  - "桃李不言，下自成蹊"
93	  - "亢龙有悔"
94
95	#### ❌ 不要这样做：
96	- 过度使用比喻导致花哨
97	- 引用不恰当或生硬
98	- 自嘲过度变成自贬
99
100	---
101
102	## 三、内容组织规范
103
104	### 1. 开头引入（4种方式任选）
105
106	#### 方式一：谢邀开场
107	```
108	谢邀。
109
110	[核心观点或总结]
111	```
112
113	#### 方式二：直接表态
114	```
115	总而言之：惊险刺激。
116
117	一句话回复：...
118	```
119
120	#### 方式三：肯定理解
121	```
122	首先肯定题主的质疑心态...
123
124	这是个好问题...
125	```
126
127	#### 方式四：利益相关
128	```
129	利益相关：腾讯云普通程序员一名
130
131	[表明立场]
132	```
133
134	### 2. 论述结构（推荐总分总）
135
136	```
137	【开头】核心观点（1-2段）
138
139	【主体】分点详细论述
140	1. 第一个要点
141	   - 子要点
142	   - 具体例子
143
144	2. 第二个要点
145	   - 子要点
146	   - 实践经验
147
148	3. 第三个要点
149	   ...
150
151	【结尾】升华或总结
152	- 诗句/名言
153	- 展望未来
154	- 回归核心观点
155	```
156
157	### 3. 小标题和列表
158
159	#### ✅ 要这样做：
160	- **数字编号**：清晰标注论点层次
161	  ```
162	  1. 可维护性
163	  2. 容灾能力与高可用
164	  3. 可追溯能力
165	  ```
166
167	- **分割线**：用 `---` 分隔不同部分
168
169	- **故事标题**：用"故事一"、"故事二"划分趣味内容
170
171	#### ❌ 不要这样做：
172	- 过多层级嵌套（不超过3层）
173	- 使用复杂的大纲格式
174
175	### 4. 举例说明
176
177	#### ✅ 要这样做：
178	- **真实场景**：使用实际工作场景
179	  ```
180	  我们的运营环境有数十万host...
181
182	  有位用户是某中型网站的技术人员，因为错误的操作...
183	  ```
184
185	- **对话形式**：通过对话展现场景
186	  ```
187	  程序员："..."
188	  面试官："..."
189	  ```
190
191	- **技术细节**：提供具体产品名称、链接、代码示例
192
193	#### ❌ 不要这样做：
194	- 虚构毫无根据的案例
195	- 例子太抽象看不懂
196	- 缺少具体细节
197
198	### 5. 结尾方式（3种方式任选）
199
200	#### 方式一：诗句升华
201	```
202	天下有道，丘不与易也。（完）
203
204	亢龙有悔。
205	```
206
207	#### 方式二：总结回归
208	```
209	所以，回到最开始的问题...
210
211	总的来说...
212	```
213
214	#### 方式三：展望未来
215	```
216	我们对未来还是比较有信心的。
217
218	这个方向值得持续探索。
219	```
220
221	---
222
223	## 四、语言特点规范
224
225	### 1. 专业术语使用
226
227	#### ✅ 要这样做：
228	- **适度平衡**：既有专业术语，又有通俗解释
229	  - 专业：OpenStack, KVM, RabbitMQ, netlink, ctypes
230	  - 通俗："填坑"、"堆叠"、"脏活累活"
231
232	- **缩写与全称**：首次出现给全称，后续用缩写
233	  - 首次：Full Deployment Controller (FDC)
234	  - 后续：FDC系统...
235
236	- **造词能力**：创造易懂的概念
237	  - "链改云" vs "云链结合"
238	  - "MS-FDC (闷声-发大财)"
239
240	#### ❌ 不要这样做：
241	- 滥用术语显得故弄玄虚
242	- 不解释专业概念
243	- 避免使用术语导致不专业
244
245	### 2. 口语化表达
246
247	#### ✅ 要这样做：
248	- **网络用语**（适度）：
249	  - "手动狗头"
250	  - "x用"（如：没什么卵用）
251	  - "逼格"
252
253	- **俗语方言**：
254	  - "脏活累活"
255	  - "如丝般顺滑"
256	  - "闷声发大财"
257
258	- **语气词**："呃"、"嗯"、"啊"等增加口语感
259
260	#### ❌ 不要这样做：
261	- 过度使用网络流行语
262	- 使用太过地方性的方言
263	- 让语气词影响可读性
264
265	### 3. 常用词汇和短语
266
267	#### 高频过渡词：
268	- **逻辑连接**：首先、另外、然后、最后
269	- **转折强调**：但是、然而、事实上、实际上
270	- **总结归纳**：简单来说、总的来说、总而言之
271
272	#### 高频强调词：
273	- **程度副词**：非常、极其、十分、相当
274	- **必然性词**：必须、一定、永远、总是
275	- **谦虚表达**：微小的、一点点、稍微
276
277	### 4. 排版特点
278
279	#### ✅ 要这样做：
280	- **加粗**：仅用于核心专业术语，全文不超过 3-4 处；普通叙述词汇不加粗
281	- **空行**：用空行分隔段落，增强可读性
282	  - 段落之间：1个空行
283	  - 大章节之间：2个空行
284
285	#### ❌ 不要这样做：
286	- 滥用加粗，让加粗失去重点
287	- 正文普通叙述中频繁使用 `**xxx**` `---` 等 Markdown 装饰符号
288	- 排版密集难以阅读
289
290	---
291
292	## 五、内容深度要求
293
294	### 1. 实践经验导向
295
296	#### ✅ 要这样做：
297	- **真实案例**：几乎每个观点都有实际工作场景支撑
298	- **问题驱动**：从实际问题出发展开论述
299	- **教训总结**：分享踩坑经验
300	  ```
301	  我们曾嘲笑aws api的道貌岸然...后来才明白这是成熟产品的必经之路
302	  ```
303
304	#### ❌ 不要这样做：
305	- 纯理论堆砌
306	- 虚构经验
307	- 回避失败经历
308
309	### 2. 理论与实践平衡
310
311	#### ✅ 要这样做：
312	- **理论支撑**：
313	  - 引用设计原则（如：Batteries Included）
314	  - 技术原理解释（虚拟化、消息队列等）
315
316	- **实践验证**：
317	  - 用实际运行数据证明
318	  - 例："120倍的规模增长"
319
320	- **批判性思考**：不盲从，提出自己的见解
321	  - 对主流技术的独立分析
322
323	#### ❌ 不要这样做：
324	- 只谈理论不谈实践
325	- 缺乏理论支撑的空谈
326	- 盲目跟风没有思考
327
328	### 3. 提供具体工具、方法和建议
329
330	#### ✅ 要这样做：
331	- **工具推荐**：
332	  - ctypes, ipython, requests, celery, flask
333	  - 提供具体使用场景
334
335	- **方法论**：
336	  - "结硬寨，打呆仗"
337	  - "自下而上的产品化"
338
339	- **可操作建议**：
340	  - "import this" 查看pythonic
341	  - 具体的面试回答模板
342	  - Step by step的操作步骤
343
344	#### ❌ 不要这样做：
345	- 只推荐不说明用途
346	- 方法论太抽象无法落地
347	- 建议不可操作
348
349	### 4. 深度分析能力
350
351	#### ✅ 要这样做：
352	- **多维度分析**：从技术、产品、市场、组织等角度分析
353	- **历史脉络**：追溯技术演进过程
354	- **竞品对比**：深入对比不同方案优劣
355	- **战略思考**：不止于技术，更关注战略意义
356
357	#### ❌ 不要这样做：
358	- 单一维度分析
359	- 缺乏历史观
360	- 浅尝辄止
361
362	---
363
364	## 六、避免事项清单
365
366	### ❌ 绝对不要：
367	1. **AI味太重**：避免"让我们一起探索"、"在这个快速发展的时代"等AI腔
368	2. **贩卖焦虑**：不制造恐慌，不夸大问题
369	3. **无脑吹捧**：不盲目推崇某项技术或产品
370	4. **假大空**：避免空洞的口号和概念
371	5. **炫技**：不为了显示技术能力而堆砌术语
372	6. **抄袭**：必须基于真实经验，不能虚构
373	7. **政治敏感**：避免涉及敏感话题
374	8. **人身攻击**：可以批评技术，不能攻击个人
375	9. **比喻堆积**：同一篇文章比喻超过 2 个就显得过；"简装房"永久禁用
376	10. **Markdown 装饰**：不要用加粗、分割线给普通语句制造仪式感
377	11. **抽象黑话**：绝对禁止使用"赋能"、"底层逻辑"、"仪式性努力"、"错失恐惧症"、"精神枷锁"等社科或互联网黑话。用具体的动作或大白话替代（例如：把"仪式性努力"直接说成"花钱买心理安慰"）
378	12. **强行分类**：禁止为了结构工整而将事物强行拆分为"保守派/激进派"、"向内求索/向外扩展"等对称的伪概念
379	13. **常识科普与爹味说教**：默认受众是具备高级技术认知（如熟悉 Python 开发、系统架构）的同行。绝对禁止解释基础概念（如禁用"编程的本质是……"、"提示词的意思是……"）。不教育读者，只提供洞察
380	14. **鸡汤与情绪抚慰**：禁止所有类似"危机就是转机"、"这是成长的机会"、"最重要的选择"等带有情绪抚慰和职场导师口吻的废话
381
382	### ⚠ 谨慎使用：
383	1. **网络流行梗**：适度即可，不要过度
384	2. **负面情绪**：可以表达不满，但要建设性
385	3. **绝对判断**：少用"永远"、"绝不"等绝对词
386	4. **商业推广**：可以提及产品，但不能变成广告
387
388	---
389
390	## 七、生成检查清单
391
392	每次生成知乎回答后，请自检以下要点：
393
394	### 风格检查
395	- [ ] 是否使用了第一人称（我/我们）？
396	- [ ] 语气是否自然、不做作？
397	- [ ] 是否有具体的实践经验支撑？
398	- [ ] 是否平衡了专业性和可读性？
399
400	### 结构检查
401	- [ ] 是否有明确的开头、主体、结尾？
402	- [ ] 逻辑是否清晰（总分总/递进/对比）？
403	- [ ] 是否使用了数字编号或小标题？
404	- [ ] 段落长度是否适中？
405
406	### 内容检查
407	- [ ] 是否提供了具体案例或数据？
408	- [ ] 是否有实用的工具/方法推荐？
409	- [ ] 是否避免了AI腔和假大空？
410	- [ ] 是否有独立思考和见解？
411
412	### 语言检查
413	- [ ] 专业术语是否解释清楚？
414	- [ ] 是否有适度的口语化表达？
415	- [ ] 是否只用了 1-2 个比喻，且没有重复同一个？"简装房"是否完全没有出现？
416	- [ ] 是否避免了 Markdown 加粗和分割线装饰普通内容？
417	- [ ] 是否避免了过度使用网络流行语？
418
419	---
420
421	## 八、参考模板
422
423	### 模板一：技术对比类
424
425	```markdown
426	谢邀。
427
428	简单来说，[技术A] 和 [技术B] 的核心差异在于 [一句话总结]。
429
430	作为 [你的身份/经验背景]，我想从以下几个角度来分析：
431
432	1. **[维度一]**
433
434	   [技术A] 的做法是...例如我们在 [具体场景] 中...
435
436	   而 [技术B] 则...这个设计在 [具体场景] 时...
437
438	2. **[维度二]**
439
440	   ...实际案例...
441
442	3. **[维度三]**
443
444	   ...数据支撑...
445
446	总的来说，两者各有优劣：
447	- [技术A] 适合 [场景]
448	- [技术B] 适合 [场景]
449
450	我们团队最终选择了 [X]，主要考虑是 [原因]。实践下来，[效果]。
451
452	---
453
454	[可选：诗句/名言升华]
455	```
456
457	### 模板二：经验分享类
458
459	```markdown
460	[利益相关：身份声明]
461
462	这个问题问到点子上了。[表达共鸣]
463
464	想起 [时间] 的时候，我们团队遇到了类似的情况...
465
466	**问题背景**
467
468	[具体描述问题场景，包含数据和细节]
469
470	**我们的尝试**
471
472	1. 最开始，我们尝试了 [方案A]
473
474	   结果：[失败/教训]
475
476	2. 然后，我们改用 [方案B]
477
478	   过程：[具体做法]
479
480	   效果：[数据/结果]
481
482	**几点经验**
483
484	基于这次经历，我总结了几点：
485
486	1. **[经验一]**：[详细说明]
487	2. **[经验二]**：[详细说明]
488	3. **[经验三]**：[详细说明]
489
490	**工具推荐**
491
492	如果你也遇到类似问题，推荐尝试：
493	- [工具1]：[用途]
494	- [工具2]：[用途]
495
496	希望这些经验对你有帮助。[结尾升华]
497	```
498
499	### 模板三：观点评论类
500
501	```markdown
502	首先肯定题主的 [质疑/观察]。
503
504	我的看法是：[一句话核心观点]
505
506	**为什么这么说？**
507
508	从 [维度] 来看，[论述]。举个例子，[具体案例]。
509
510	但是，[转折]。这就像 [比喻]，[深入解释]。
511
512	**更深层的原因**
513
514	其实这背后反映的是 [更深层问题]。我们经常看到 [现象]，但很少有人意识到 [本质]。
515
516	在我们的实践中，[经验或数据]。这说明 [结论]。
517
518	**我的建议**
519
520	针对这个问题，我认为应该：
521
522	1. [建议一]
523	2. [建议二]
524	3. [建议三]
525
526	[诗句/名言结尾]
527	```
528
529	---
530
531	## 九、素材来源要求
532
533	在生成知乎回答时，应优先从以下来源获取素材：
534
535	### 主要素材源
536	- **Obsidian笔记库**：Article目录下
537	  - 筛选条件：以数字开头 + revised.md结尾
538	  - 用途：提供技术见解、实践经验、案例素材
539
540	### 素材使用原则
541	1. **不是照搬**：要基于素材进行创作，不能直接复制
542	2. **融合改写**：将笔记内容与回答问题相结合
543	3. **保持真实**：基于真实经验，可以适度整合
544	4. **风格统一**：将素材转化为符合本指南的风格
545
546	---
547
548	## 十、使用说明
549
550	### 对于 PowerLLLLL Bot：
551
552	当用户使用 `zhihu` 模式时：
553
554	1. **输入解析**：
555	   - 知乎问题
556	   - 用户想要表达的核心想法
557
558	2. **素材检索**：
559	   - 在 Obsidian Article 目录中搜索相关笔记
560	   - 筛选以数字开头的 *revised.md 文件
561
562	3. **内容生成**：
563	   - 使用 Gemini 根据本风格指南生成回答
564	   - 将素材融合进回答中
565	   - 确保符合风格检查清单
566
567	4. **质量控制**：
568	   - 自动运行检查清单
569	   - 标注可能需要人工审核的部分
570	   - 提供修改建议
571
572	### Prompt 建议：
573
574	```
575	你是一位资深技术人，需要回答知乎问题。请严格遵循 zhihu-style.md 中的风格指南。
576
577	【问题】
578	{知乎问题}
579
580	【核心想法】
581	{用户的想法}
582
583	【相关素材】
584	{从Obsidian检索的笔记内容}
585
586	【要求】
587	1. 使用第一人称，分享真实经验
588	2. 保持专业而不失趣味的语气
589	3. 总分总结构，逻辑清晰
590	4. 提供具体案例和工具推荐
591	5. 适度使用口语化和比喻
592	6. 避免AI腔和假大空
593	7. 参考模板但不要生搬硬套
594
595	请生成回答：
596	```
597
598	---
599
600	## 版本信息
601
602	- **生成日期**：2026-02-04
603	- **基于示例数量**：12篇知乎回答
604	- **示例来源**：./zhihu-sample 目录
605	- **分析依据**：深度文本分析 + 风格特征提取
606
607	---
608
609	*"天下有道，丘不与易也。"*
610
611	*保持真诚，保持专业，保持有趣。*
612


1	"""Zhihu Playwright Engine — authentication and answer publishing.
2
3	Handles:
4	- Persistent session via zhihu_state.json (cookies)
5	- QR-code login flow with Slack notification + 2-minute polling
6	- Answer publishing via headless Chromium + playwright-stealth
7	"""
8
9	import json
10	import time
11	from pathlib import Path
12	from typing import Optional
13
14	from slack_sdk import WebClient
15
16	from health.utils.logging_config import setup_logger
17
18	logger = setup_logger(__name__)
19
20
21	# ── Selectors ─────────────────────────────────────────────────────────────────
22	# Zhihu may change its DOM; all click/wait operations use _try_selectors()
23	# so multiple candidates are attempted before raising.
24
25	_QR_IMAGE_SELECTORS = [
26	    ".SignFlow-qrcode img",
27	    ".LoginQrcode img",
28	    "canvas.SignFlow-qrcodeCanvas",      # newer Zhihu: QR drawn on canvas
29	    ".SignFlow-qrcode canvas",
30	]
31
32	_QR_TAB_SELECTORS = [
33	    "div.SignFlow-tab:has-text('扫码登录')",
34	    "li.SignFlow-tab:has-text('扫码')",
35	    "span:has-text('扫码登录')",
36	]
37
38	_LOGIN_CHECK_SELECTORS = [
39	    ".AppHeader-profileAvatar",
40	    "div[data-za-detail-view-path-module='AppHeader'] img.Avatar",
41	    ".TopstoryTabs",                     # home page tabs visible only when logged in
42	]
43
44	_WRITE_ANSWER_SELECTORS = [
45	    "button:has-text('写回答')",
46	    "a:has-text('写回答')",
47	    "button:has-text('编辑回答')",
48	    "a:has-text('编辑回答')",
49	    "[role='button']:has-text('写回答')",
50	    "[role='button']:has-text('编辑回答')",
51	    "[data-za-detail-view-element-name='WriteAnswer']",
52	    "[data-za-element-name='QuestionDetail_WriteAnswer']",
53	    ".QuestionDetailPageHeaderRight button",
54	    ".QuestionDetail-buttonGroup button",
55	    "[class*='WriteAnswer']",
56	]
57
58	_EDITOR_SELECTORS = [
59	    ".DraftEditor-content[contenteditable='true']",
60	    ".RichText [contenteditable='true']",
61	    "[contenteditable='true'].public-DraftEditor-content",
62	]
63
64	_PUBLISH_SELECTORS = [
65	    "button:has-text('发布回答')",           # edit existing answer
66	    ".AnswerForm button:has-text('发布')",   # new answer, scoped to form
67	    ".AnswerForm button:has-text('提交')",
68	    "button:has-text('发布')",
69	    "button:has-text('提交')",
70	    "button.SubmitAnswer",
71	    "[data-za-element-name='SubmitAnswer']",
72	]
73
74
75	# ── Constants ─────────────────────────────────────────────────────────────────
76
77	ZHIHU_SIGNIN_URL = "https://www.zhihu.com/signin"
78	ZHIHU_HOME_URL   = "https://www.zhihu.com/"
79
80	_QR_TMP_PATH      = Path("/tmp/zhihu_qr.png")
81	_LOGIN_TIMEOUT_S  = 120   # total seconds to wait for QR scan
82	_POLL_INTERVAL_S  = 5     # seconds between login-check polls
83	_MAX_QR_ATTEMPTS  = 2     # refresh QR this many times before giving up
84
85
86	class ZhihuPlaywrightEngine:
87	    """Playwright-powered Zhihu auth and answer publisher.
88
89	    Example:
90	        engine = ZhihuPlaywrightEngine(slack_client, channel_id)
91	        if engine.ensure_logged_in():
92	            engine.publish_answer(question_url, answer_text)
93	    """
94
95	    def __init__(
96	        self,
97	        slack_client: WebClient,
98	        notify_channel: str,
99	        state_file: Optional[Path] = None,
100	    ) -> None:
101	        """Initialize the engine.
102
103	        Args:
104	            slack_client: Authenticated Slack WebClient for QR upload.
105	            notify_channel: Slack channel/DM ID to send QR images to.
106	            state_file: Override path for session cookie persistence.
107	                Defaults to <project_root>/data/zhihu_state.json.
108	        """
109	        self.slack_client = slack_client
110	        self.notify_channel = notify_channel
111	        self.state_file = state_file or (
112	            Path(__file__).resolve().parents[3] / "data" / "zhihu_state.json"
113	        )
114	        self.state_file.parent.mkdir(parents=True, exist_ok=True)
115
116	    # ── Public API ─────────────────────────────────────────────────────────────
117
118	    def ensure_logged_in(self) -> bool:
119	        """Verify session validity; run QR login flow if cookie is expired.
120
121	        Returns:
122	            True if a valid authenticated session exists after this call.
123	        """
124	        from playwright.sync_api import sync_playwright
125	        from playwright_stealth import Stealth
126
127	        with sync_playwright() as p:
128	            browser = p.chromium.launch(headless=True)
129	            context = browser.new_context(
130	                viewport={"width": 1280, "height": 800},
131	                user_agent=(
132	                    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
133	                    "AppleWebKit/537.36 (KHTML, like Gecko) "
134	                    "Chrome/122.0.0.0 Safari/537.36"
135	                ),
136	            )
137	            page = context.new_page()
138	            Stealth().apply_stealth_sync(page)
139
140	            # ── Try saved session first ──
141	            if self._load_state(context):
142	                if self._is_logged_in(page):
143	                    logger.info("Zhihu: session valid (loaded from state file)")
144	                    browser.close()
145	                    return True
146	                logger.info("Zhihu: saved session expired, starting QR login")
147	            else:
148	                logger.info("Zhihu: no state file, starting QR login")
149
150	            success = self._qr_login_flow(context, page)
151	            browser.close()
152	            return success
153
154	    def publish_answer(self, question_url: str, answer_text: str) -> str:
155	        """Navigate to a Zhihu question page and publish an answer.
156
157	        Args:
158	            question_url: Full URL of the target Zhihu question.
159	            answer_text: Plain-text answer body to publish.
160
161	        Returns:
162	            URL of the successfully published answer.
163
164	        Raises:
165	            RuntimeError: If session is not loaded, or DOM interaction fails.
166	        """
167	        from playwright.sync_api import sync_playwright
168	        from playwright_stealth import Stealth
169
170	        with sync_playwright() as p:
171	            browser = p.chromium.launch(headless=True)
172	            context = browser.new_context(
173	                viewport={"width": 1280, "height": 800},
174	                user_agent=(
175	                    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
176	                    "AppleWebKit/537.36 (KHTML, like Gecko) "
177	                    "Chrome/122.0.0.0 Safari/537.36"
178	                ),
179	            )
180	            try:
181	                if not self._load_state(context):
182	                    raise RuntimeError(
183	                        "No session loaded. Call ensure_logged_in() before publishing."
184	                    )
185
186	                page = context.new_page()
187	                Stealth().apply_stealth_sync(page)
188
189	                # Extract the bare URL — strip invisible/zero-width characters
190	                # that .strip() misses (BOM, zero-width spaces, etc.)
191	                import re as _re
192	                _m = _re.search(r'https?://\S+', question_url)
193	                if not _m:
194	                    raise RuntimeError(f"Invalid question URL: {question_url!r}")
195	                # Strip trailing HTML/Slack/markdown junk chars (>, ", ', ), ], etc.)
196	                question_url = _re.sub(r'[>\'")\].,;]+$', '', _m.group(0)).rstrip("/")
197
198	                logger.info(f"Navigating to question: {question_url}")
199
200	                # Visit home page first to warm up the session before going to the question.
201	                # Zhihu's anti-bot layer returns 404 when a headless browser jumps straight
202	                # to a question URL without prior navigation history.
203	                page.goto(ZHIHU_HOME_URL, wait_until="domcontentloaded", timeout=15000)
204	                page.wait_for_timeout(2000)
205
206	                page.goto(question_url, wait_until="domcontentloaded", timeout=30000)
207	                page.wait_for_timeout(3000)   # let React finish rendering
208
209	                # Detect redirect to login or 404 early
210	                if "signin" in page.url or "login" in page.url:
211	                    raise RuntimeError(
212	                        "知乎 session 已失效，导航时跳转至登录页。"
213	                        "请重新点击「确认发布」重新触发扫码登录。"
214	                    )
215	                is_404 = page.evaluate(
216	                    """() => {
217	                        const t = document.title || '';
218	                        const b = document.body ? document.body.textContent : '';
219	                        return t.includes('404') || b.includes('你访问的页面不存在')
220	                            || b.includes('页面不存在');
221	                    }"""
222	                )
223	                if is_404:
224	                    raise RuntimeError(
225	                        f"问题页面返回 404，可能该问题已删除或 URL 有误：{question_url}"
226	                    )
227
228	                self._click_write_answer(page)
229	                self._fill_answer(page, answer_text)
230	                answer_url = self._submit_answer(page)
231
232	                # Refresh state (cookies may have been renewed mid-session)
233	                self._save_state(context)
234	                return answer_url
235
236	            finally:
237	                browser.close()
238
239	    # ── Session Persistence ────────────────────────────────────────────────────
240
241	    def _load_state(self, context) -> bool:
242	        """Restore saved cookies into a browser context.
243
244	        Args:
245	            context: Playwright BrowserContext to inject cookies into.
246
247	        Returns:
248	            True if state file existed and cookies were loaded.
249	        """
250	        if not self.state_file.exists():
251	            return False
252	        try:
253	            data = json.loads(self.state_file.read_text(encoding="utf-8"))
254	            cookies = data.get("cookies", [])
255	            if not cookies:
256	                return False
257	            context.add_cookies(cookies)
258	            logger.debug(f"Loaded {len(cookies)} cookies from {self.state_file}")
259	            return True
260	        except Exception as e:
261	            logger.warning(f"State load failed: {e}")
262	            return False
263
264	    def _save_state(self, context) -> None:
265	        """Persist current browser cookies to the state file.
266
267	        Args:
268	            context: Playwright BrowserContext after successful authentication.
269	        """
270	        state = {
271	            "cookies": context.cookies(),
272	            "saved_at": time.time(),
273	        }
274	        self.state_file.write_text(
275	            json.dumps(state, ensure_ascii=False, indent=2),
276	            encoding="utf-8",
277	        )
278	        logger.info(f"Saved {len(state['cookies'])} cookies to {self.state_file}")
279
280	    # ── Login Check ────────────────────────────────────────────────────────────
281
282	    def _is_logged_in(self, page) -> bool:
283	        """Navigate to Zhihu home and verify login-specific elements appear.
284
285	        Args:
286	            page: Playwright Page with cookies loaded.
287
288	        Returns:
289	            True if logged-in UI elements are found.
290	        """
291	        try:
292	            page.goto(ZHIHU_HOME_URL, wait_until="domcontentloaded", timeout=15000)
293	            for sel in _LOGIN_CHECK_SELECTORS:
294	                try:
295	                    page.wait_for_selector(sel, timeout=4000)
296	                    logger.debug(f"Login confirmed via selector: {sel}")
297	                    return True
298	                except Exception:
299	                    continue
300	        except Exception as e:
301	            logger.debug(f"Login check navigation failed: {e}")
302	        return False
303
304	    # ── QR Code Login Flow ─────────────────────────────────────────────────────
305
306	    def _qr_login_flow(self, context, page) -> bool:
307	        """Execute the full QR login loop (up to _MAX_QR_ATTEMPTS refreshes).
308
309	        Navigates to sign-in, switches to QR tab, screenshots the code,
310	        uploads to Slack, then polls for login success.
311
312	        Args:
313	            context: Playwright BrowserContext (for saving state on success).
314	            page: Active Playwright Page.
315
316	        Returns:
317	            True if login succeeded before all attempts exhausted.
318	        """
319	        page.goto(ZHIHU_SIGNIN_URL, wait_until="domcontentloaded", timeout=15000)
320	        self._switch_to_qr_tab(page)
321
322	        for attempt in range(1, _MAX_QR_ATTEMPTS + 1):
323	            logger.info(f"QR login attempt {attempt}/{_MAX_QR_ATTEMPTS}")
324
325	            qr_path = self._capture_qr(page)
326	            if qr_path is None:
327	                self._notify_slack(f"❌ 无法截取知乎二维码（第 {attempt} 次），放弃登录。")
328	                return False
329
330	            self._send_qr_to_slack(qr_path, attempt)
331
332	            if self._poll_for_login(page):
333	                self._save_state(context)
334	                self._notify_slack("✅ 知乎登录成功，Cookie 已保存。")
335	                return True
336
337	            # Timeout — try to refresh QR for next attempt
338	            if attempt < _MAX_QR_ATTEMPTS:
339	                logger.info("QR timed out, refreshing page for new QR code...")
340	                page.reload(wait_until="domcontentloaded")
341	                self._switch_to_qr_tab(page)
342
343	        self._notify_slack(
344	            f"❌ 知乎二维码扫码超时（已重试 {_MAX_QR_ATTEMPTS} 次）。\n"
345	            "请稍后重新发起 `mode zhihu-hunter` 流程。"
346	        )
347	        return False
348
349	    def _switch_to_qr_tab(self, page) -> None:
350	        """Click the QR-code tab on the login page if it exists.
351
352	        Args:
353	            page: Page showing the Zhihu sign-in form.
354	        """
355	        for sel in _QR_TAB_SELECTORS:
356	            try:
357	                tab = page.locator(sel).first
358	                if tab.is_visible(timeout=2000):
359	                    tab.click()
360	                    page.wait_for_timeout(800)
361	                    logger.debug(f"Switched to QR tab via: {sel}")
362	                    return
363	            except Exception:
364	                continue
365
366	    def _capture_qr(self, page) -> Optional[Path]:
367	        """Screenshot the QR code element and save it to a temp file.
368
369	        Tries specific element selectors first; falls back to a full-page
370	        screenshot if none match.
371
372	        Args:
373	            page: Page currently showing the QR code.
374
375	        Returns:
376	            Path to the saved PNG file, or None on complete failure.
377	        """
378	        # Try element-level screenshot (cleaner crop)
379	        for sel in _QR_IMAGE_SELECTORS:
380	            try:
381	                page.wait_for_selector(sel, timeout=8000)
382	                elem = page.locator(sel).first
383	                elem.screenshot(path=str(_QR_TMP_PATH))
384	                logger.info(f"QR captured via selector '{sel}' → {_QR_TMP_PATH}")
385	                return _QR_TMP_PATH
386	            except Exception:
387	                continue
388
389	        # Fallback: full-page screenshot (user will see entire login page)
390	        logger.warning("Element QR selectors failed; falling back to full-page screenshot")
391	        try:
392	            page.screenshot(path=str(_QR_TMP_PATH), full_page=False)
393	            return _QR_TMP_PATH
394	        except Exception as e:
395	            logger.error(f"Full-page screenshot also failed: {e}")
396	            return None
397
398	    def _send_qr_to_slack(self, qr_path: Path, attempt: int) -> None:
399	        """Upload the QR code PNG to Slack with instructions.
400
401	        Args:
402	            qr_path: Local path to the QR code image.
403	            attempt: Current attempt number shown in the message.
404	        """
405	        caption = (
406	            f"🔑 *请用知乎 App 扫描以下二维码完成登录*（第 {attempt}/{_MAX_QR_ATTEMPTS} 次）\n"
407	            f"⏱ 二维码有效期约 2 分钟，超时将自动刷新。"
408	        )
409	        try:
410	            with qr_path.open("rb") as f:
411	                self.slack_client.files_upload_v2(
412	                    channel=self.notify_channel,
413	                    file=f,
414	                    filename="zhihu_qr.png",
415	                    title=f"知乎登录二维码（第 {attempt} 次）",
416	                    initial_comment=caption,
417	                )
418	            logger.info("QR image uploaded to Slack")
419	        except Exception as e:
420	            logger.error(f"Slack QR upload failed: {e}")
421
422	    def _poll_for_login(self, page) -> bool:
423	        """Poll until the page leaves the sign-in URL or timeout expires.
424
425	        Checks every _POLL_INTERVAL_S seconds for up to _LOGIN_TIMEOUT_S total.
426
427	        Args:
428	            page: Page that started at ZHIHU_SIGNIN_URL.
429
430	        Returns:
431	            True if login was detected before the deadline.
432	        """
433	        deadline = time.monotonic() + _LOGIN_TIMEOUT_S
434	        while time.monotonic() < deadline:
435	            try:
436	                # wait_for_url raises on timeout; catches login redirect
437	                page.wait_for_url(
438	                    lambda url: "zhihu.com" in url and "signin" not in url,
439	                    timeout=_POLL_INTERVAL_S * 1000,
440	                )
441	                logger.info(f"Login confirmed — redirected to {page.url}")
442	                return True
443	            except Exception:
444	                remaining = int(deadline - time.monotonic())
445	                logger.debug(f"Waiting for QR scan... {remaining}s left")
446
447	        return False
448
449	    # ── Publishing Helpers ─────────────────────────────────────────────────────
450
451	    def _click_write_answer(self, page) -> None:
452	        """Find and click the 写回答 button on a question page.
453
454	        Args:
455	            page: Page showing a Zhihu question.
456
457	        Raises:
458	            RuntimeError: If no write-answer button is found by any strategy.
459	        """
460	        # Scroll to trigger lazy-loaded elements
461	        page.evaluate("window.scrollBy(0, 400)")
462	        page.wait_for_timeout(800)
463
464	        for sel in _WRITE_ANSWER_SELECTORS:
465	            try:
466	                btn = page.locator(sel).first
467	                btn.wait_for(state="visible", timeout=3000)
468	                btn.click()
469	                page.wait_for_timeout(1500)
470	                logger.debug(f"Clicked '写回答' via: {sel}")
471	                return
472	            except Exception:
473	                continue
474
475	        # Diagnostic: log every short button/link text to help debug DOM changes
476	        all_texts = page.evaluate(
477	            """() => {
478	                const els = document.querySelectorAll('button, a, [role="button"]');
479	                return Array.from(els)
480	                    .map(e => e.textContent.trim())
481	                    .filter(t => t.length > 0 && t.length < 30);
482	            }"""
483	        )
484	        logger.warning(f"'写回答' not found. Buttons/links on page: {all_texts[:30]}")
485
486	        # JS fallback v2: use TreeWalker but strip zero-width chars before comparing
487	        # (Zhihu wraps button text with \u200b which JS .trim() does NOT remove)
488	        clicked = page.evaluate(
489	            """() => {
490	                const TARGETS = new Set(['写回答','回答问题','回答','发起回答','编辑回答']);
491	                const ZW_RE = /[\u200b\u200c\u200d\ufeff\u00a0]/g;
492	                const walker = document.createTreeWalker(
493	                    document.body, NodeFilter.SHOW_TEXT
494	                );
495	                let node;
496	                while ((node = walker.nextNode())) {
497	                    const cleaned = node.textContent.replace(ZW_RE, '').trim();
498	                    if (TARGETS.has(cleaned)) {
499	                        const el = node.parentElement;
500	                        const btn = el.closest('button, a, [role="button"]') || el;
501	                        btn.click();
502	                        return cleaned;
503	                    }
504	                }
505	                return null;
506	            }"""
507	        )
508	        if clicked:
509	            page.wait_for_timeout(1500)
510	            logger.info(f"Clicked '写回答' via text-node fallback (text='{clicked}')")
511	            return
512
513	        # Upload a screenshot to Slack so we can see what the page looks like
514	        try:
515	            import tempfile
516	            tmp_ss = Path(tempfile.mktemp(suffix=".png"))
517	            page.screenshot(path=str(tmp_ss), full_page=False)
518	            self.slack_client.files_upload_v2(
519	                channel=self.notify_channel,
520	                file=str(tmp_ss),
521	                filename="zhihu_debug.png",
522	                initial_comment=(
523	                    "🔍 发布调试截图：找不到「写回答」按钮，"
524	                    "请确认页面状态（是否有弹窗/登录墙/按钮文字变了）。"
525	                ),
526	            )
527	            tmp_ss.unlink(missing_ok=True)
528	        except Exception as _ss_err:
529	            logger.warning(f"Debug screenshot upload failed: {_ss_err}")
530
531	        raise RuntimeError(
532	            "Could not find '写回答' button. Zhihu DOM may have changed — "
533	            "update _WRITE_ANSWER_SELECTORS in zhihu_playwright_engine.py"
534	        )
535
536	    def _fill_answer(self, page, text: str) -> None:
537	        """Input answer text into Zhihu's Draft.js rich-text editor.
538
539	        Tries three strategies in order, verifying content after each attempt:
540	          1. execCommand('insertText') — most reliable for contenteditable
541	          2. Synthetic ClipboardEvent paste
542	          3. keyboard.type() — slow but universally compatible
543
544	        Args:
545	            page: Page with the answer editor open.
546	            text: Plain-text answer to insert.
547	        """
548	        # Locate the contenteditable editor
549	        editor = None
550	        for sel in _EDITOR_SELECTORS:
551	            try:
552	                page.wait_for_selector(sel, timeout=8000)
553	                editor = page.locator(sel).first
554	                logger.debug(f"Found editor via: {sel}")
555	                break
556	            except Exception:
557	                continue
558
559	        if editor is None:
560	            raise RuntimeError(
561	                "Could not find answer editor. "
562	                "Update _EDITOR_SELECTORS in zhihu_playwright_engine.py"
563	            )
564
565	        editor.click()
566	        page.wait_for_timeout(500)
567
568	        def _content_length() -> int:
569	            """Return current character count inside the editor."""
570	            return page.evaluate(
571	                """() => {
572	                    const ed = document.querySelector(
573	                        '.DraftEditor-content[contenteditable="true"],'
574	                        + '.RichText [contenteditable="true"],'
575	                        + '[contenteditable="true"].public-DraftEditor-content'
576	                    );
577	                    return ed ? ed.textContent.length : 0;
578	                }"""
579	            )
580
581	        # Strategy 1: execCommand('insertText') — works best with Draft.js
582	        page.evaluate(
583	            """(text) => {
584	                const ed = document.querySelector(
585	                    '.DraftEditor-content[contenteditable="true"],'
586	                    + '.RichText [contenteditable="true"],'
587	                    + '[contenteditable="true"].public-DraftEditor-content'
588	                );
589	                if (!ed) return false;
590	                ed.focus();
591	                document.execCommand('selectAll');
592	                document.execCommand('insertText', false, text);
593	                return true;
594	            }""",
595	            text,
596	        )
597	        page.wait_for_timeout(600)
598
599	        if _content_length() >= len(text) // 2:
600	            logger.info(f"Filled {len(text)} chars via execCommand insertText")
601	            return
602
603	        # Strategy 2: Synthetic ClipboardEvent paste
604	        logger.warning("execCommand insertText insufficient; trying ClipboardEvent")
605	        page.evaluate(
606	            """(text) => {
607	                const ed = document.querySelector(
608	                    '.DraftEditor-content[contenteditable="true"],'
609	                    + '.RichText [contenteditable="true"],'
610	                    + '[contenteditable="true"].public-DraftEditor-content'
611	                );
612	                if (!ed) return;
613	                ed.focus();
614	                const dt = new DataTransfer();
615	                dt.setData('text/plain', text);
616	                ed.dispatchEvent(new ClipboardEvent('paste', {
617	                    bubbles: true, cancelable: true, clipboardData: dt
618	                }));
619	            }""",
620	            text,
621	        )
622	        page.wait_for_timeout(600)
623
624	        if _content_length() >= len(text) // 2:
625	            logger.info(f"Filled {len(text)} chars via ClipboardEvent")
626	            return
627
628	        # Strategy 3: keyboard.type() — slow but always works
629	        logger.warning("ClipboardEvent also insufficient; falling back to keyboard.type()")
630	        editor.click()
631	        page.keyboard.press("Control+a")
632	        page.keyboard.press("Delete")
633	        page.keyboard.type(text, delay=8)
634	        page.wait_for_timeout(500)
635	        logger.info(f"Filled {len(text)} chars via keyboard.type()")
636
637	    def _submit_answer(self, page) -> str:
638	        """Click the publish button and return the answer URL.
639
640	        After clicking 发布, waits for the page to update then extracts the
641	        answer URL (``/question/<id>/answer/<id>``) from the DOM.  Falls back
642	        to the question URL if no answer link is found.
643
644	        Args:
645	            page: Page with the answer text already filled in.
646
647	        Returns:
648	            URL of the published answer, or question URL as fallback.
649
650	        Raises:
651	            RuntimeError: If the publish button cannot be found.
652	        """
653	        for sel in _PUBLISH_SELECTORS:
654	            try:
655	                btn = page.locator(sel).first
656	                btn.wait_for(state="visible", timeout=5000)
657	                btn.click()
658	                logger.info(f"Clicked publish via: {sel}")
659	                # Zhihu keeps making background requests; don't wait for networkidle.
660	                page.wait_for_timeout(4000)
661
662	                # Try to extract the actual answer URL from the page
663	                answer_url = page.evaluate(
664	                    """() => {
665	                        // Look for a canonical answer link on the page
666	                        const links = Array.from(
667	                            document.querySelectorAll('a[href*="/answer/"]')
668	                        );
669	                        // Prefer links that point to the current question
670	                        const sorted = links
671	                            .map(a => a.href)
672	                            .filter(h => /\\/question\\/\\d+\\/answer\\/\\d+/.test(h));
673	                        return sorted.length ? sorted[sorted.length - 1] : null;
674	                    }"""
675	                )
676	                if answer_url:
677	                    logger.info(f"Answer published: {answer_url}")
678	                    return answer_url
679
680	                # Fallback: return question URL
681	                logger.info(f"Answer submitted (URL fallback): {page.url}")
682	                return page.url
683	            except Exception:
684	                continue
685
686	        raise RuntimeError(
687	            "Could not find publish button. "
688	            "Update _PUBLISH_SELECTORS in zhihu_playwright_engine.py"
689	        )
690
691	    # ── Utility ────────────────────────────────────────────────────────────────
692
693	    def _notify_slack(self, message: str) -> None:
694	        """Send a plain text message to the notify channel.
695
696	        Args:
697	            message: Message text (supports Slack mrkdwn formatting).
698	        """
699	        try:
700	            self.slack_client.chat_postMessage(
701	                channel=self.notify_channel,
702	                text=message,
703	            )
704	        except Exception as e:
705	            logger.error(f"Slack notify failed: {e}")
706


1	"""Slack Interactive Gateway — Block Kit control panel for zhihu-hunter.
2
3	Manages two human-in-the-loop approval gates:
4
5	    Gate 1  [✍ 生成草稿] / [🚫 忽略]
6	            Triggered when ZhihuHunter surfaces candidate questions.
7
8	    Gate 2  [✅ 确认发布] / [🔄 重新生成] / [❌ 取消]
9	            Triggered after an answer draft has been generated.
10
11	Usage:
12	    gateway = SlackInteractiveGateway(slack_client, hunter, engine, channel)
13	    gateway.register(app)   # call once at bot startup
14	    gateway.post_question_cards(questions)  # call from dispatcher
15	"""
16
17	import json
18	import uuid
19	from pathlib import Path
20	from typing import Optional
21
22	from slack_bolt import App
23	from slack_sdk import WebClient
24
25	from health.utils.logging_config import setup_logger
26	from slack_bot.zhihu.zhihu_hunter import AnswerDraft, ZhihuHunter, ZhihuQuestion
27	from slack_bot.zhihu.zhihu_playwright_engine import ZhihuPlaywrightEngine
28
29	logger = setup_logger(__name__)
30
31
32	# ── Action IDs ─────────────────────────────────────────────────────────────────
33	_ACT_GENERATE = "zhihu_generate_draft"
34	_ACT_IGNORE   = "zhihu_ignore"
35	_ACT_PUBLISH  = "zhihu_publish"
36	_ACT_REGEN    = "zhihu_regenerate"
37	_ACT_CANCEL   = "zhihu_cancel"
38	_VIEW_REGEN   = "zhihu_regen_modal"
39
40	# Characters shown in the draft preview section (Slack block limit: 3000)
41	_PREVIEW_CHARS = 800
42
43
44	class SlackInteractiveGateway:
45	    """Block Kit gateway providing two approval gates for the zhihu-hunter workflow.
46
47	    Args:
48	        slack_client: Authenticated WebClient (same token as the Bolt App).
49	        hunter: ZhihuHunter instance for draft generation.
50	        engine: ZhihuPlaywrightEngine instance for publishing.
51	        notify_channel: Default Slack channel/DM for outbound cards.
52	        state_dir: Directory for storing draft JSON files between interactions.
53	            Defaults to <project_root>/data/zhihu_drafts.
54	    """
55
56	    def __init__(
57	        self,
58	        slack_client: WebClient,
59	        hunter: ZhihuHunter,
60	        engine: ZhihuPlaywrightEngine,
61	        notify_channel: str,
62	        state_dir: Optional[Path] = None,
63	    ) -> None:
64	        self.client        = slack_client
65	        self.hunter        = hunter
66	        self.engine        = engine
67	        self.notify_channel = notify_channel
68	        self.state_dir     = state_dir or (
69	            Path(__file__).resolve().parents[3] / "data" / "zhihu_drafts"
70	        )
71	        self.state_dir.mkdir(parents=True, exist_ok=True)
72
73	    # ── Registration ───────────────────────────────────────────────────────────
74
75	    def register(self, app: App) -> None:
76	        """Attach all action handlers to a Slack Bolt App instance.
77
78	        Call this once during bot startup, after the App object is created.
79
80	        Args:
81	            app: The Slack Bolt App that receives Socket Mode events.
82	        """
83	        app.action(_ACT_GENERATE)(self._handle_generate_draft)
84	        app.action(_ACT_IGNORE)(self._handle_ignore)
85	        app.action(_ACT_PUBLISH)(self._handle_publish)
86	        app.action(_ACT_REGEN)(self._handle_regenerate)
87	        app.action(_ACT_CANCEL)(self._handle_cancel)
88	        app.view(_VIEW_REGEN)(self._handle_regenerate_submit)
89	        logger.info("ZhihuHunter: Slack action handlers registered")
90
91	    # ── Outbound: Gate 1 ───────────────────────────────────────────────────────
92
93	    def post_question_cards(
94	        self,
95	        questions: list[ZhihuQuestion],
96	        channel: Optional[str] = None,
97	    ) -> None:
98	        """Post one Block Kit card per discovered question (Gate 1).
99
100	        Each card includes [✍ 生成草稿] and [🚫 忽略] buttons.
101
102	        Args:
103	            questions: Candidate questions returned by ZhihuHunter.scan_and_hunt().
104	            channel: Override channel. Defaults to self.notify_channel.
105	        """
106	        ch = channel or self.notify_channel
107	        if not questions:
108	            self.client.chat_postMessage(
109	                channel=ch,
110	                text="🔍 本次未发现符合条件的知乎问题，下次再试。",
111	            )
112	            return
113
114	        self.client.chat_postMessage(
115	            channel=ch,
116	            text=f"🔍 发现 *{len(questions)}* 个知乎相关问题，请逐一确认：",
117	        )
118	        for q in questions:
119	            try:
120	                self.client.chat_postMessage(
121	                    channel=ch,
122	                    blocks=self._question_blocks(q),
123	                    text=f"🔍 知乎问题: {q.title}",
124	                )
125	            except Exception as e:
126	                logger.error(f"Failed to post question card for '{q.title}': {e}")
127
128	    # ── Outbound: Gate 2 ───────────────────────────────────────────────────────
129
130	    def post_draft_card(
131	        self,
132	        draft: AnswerDraft,
133	        channel: str,
134	        thread_ts: str,
135	    ) -> None:
136	        """Post an answer draft card as a thread reply (Gate 2).
137
138	        Includes [✅ 确认发布], [🔄 重新生成], and [❌ 取消] buttons.
139
140	        Args:
141	            draft: Generated AnswerDraft.
142	            channel: Channel containing the original question card.
143	            thread_ts: ts of the question card message (reply target).
144	        """
145	        draft_id = self._save_draft(draft)
146	        try:
147	            self.client.chat_postMessage(
148	                channel=channel,
149	                thread_ts=thread_ts,
150	                blocks=self._draft_blocks(draft, draft_id),
151	                text=f"📝 草稿已生成，等待确认发布: {draft.question.title}",
152	            )
153	        except Exception as e:
154	            logger.error(f"Failed to post draft card: {e}")
155
156	    # ── Action Handlers ────────────────────────────────────────────────────────
157
158	    def _handle_generate_draft(self, ack, body, client) -> None:
159	        """Gate 1 → [✍ 生成草稿]: generate draft and post as thread reply."""
160	        ack()
161	        channel  = body["channel"]["id"]
162	        msg_ts   = body["message"]["ts"]
163	        raw_val  = body["actions"][0]["value"]
164
165	        # Immediately show loading state
166	        client.chat_update(
167	            channel=channel,
168	            ts=msg_ts,
169	            blocks=self._loading_block("✍ 草稿生成中，请稍候..."),
170	            text="草稿生成中...",
171	        )
172
173	        try:
174	            question = ZhihuQuestion.model_validate_json(raw_val)
175	            draft    = self.hunter.draft_answer(question)
176
177	            # Persist to vault for manual fallback publishing
178	            vault_file = self._save_to_vault(draft)
179	            if vault_file:
180	                draft = draft.model_copy(update={"vault_file": str(vault_file)})
181
182	            draft_id = self._save_draft(draft)
183
184	            # Post draft as thread reply
185	            client.chat_postMessage(
186	                channel=channel,
187	                thread_ts=msg_ts,
188	                blocks=self._draft_blocks(draft, draft_id),
189	                text=f"📝 草稿已生成: {question.title}",
190	            )
191
192	            # Update original question card to show "done" state
193	            client.chat_update(
194	                channel=channel,
195	                ts=msg_ts,
196	                blocks=self._done_block(
197	                    f"📩 草稿已生成，请在上方回复中查看并确认。\n*<{question.url}|{question.title}>*"
198	                ),
199	                text="草稿已生成",
200	            )
201
202	        except Exception as e:
203	            logger.error(f"Draft generation failed: {e}", exc_info=True)
204	            client.chat_update(
205	                channel=channel,
206	                ts=msg_ts,
207	                blocks=self._error_block(f"草稿生成失败：{e}"),
208	                text="草稿生成失败",
209	            )
210
211	    def _handle_ignore(self, ack, respond) -> None:
212	        """Gate 1 → [🚫 忽略]: dismiss the question card."""
213	        ack()
214	        respond(
215	            replace_original=True,
216	            text="🚫 已忽略",
217	            blocks=self._done_block("🚫 已忽略此问题"),
218	        )
219
220	    def _handle_publish(self, ack, body, client) -> None:
221	        """Gate 2 → [✅ 确认发布]: login to Zhihu and publish the answer."""
222	        ack()
223	        channel  = body["channel"]["id"]
224	        msg_ts   = body["message"]["ts"]
225	        draft_id = body["actions"][0]["value"]
226
227	        client.chat_update(
228	            channel=channel,
229	            ts=msg_ts,
230	            blocks=self._loading_block("⏳ 正在登录知乎并发布，请稍候..."),
231	            text="发布中...",
232	        )
233
234	        try:
235	            draft = self._load_draft(draft_id)
236	            if draft is None:
237	                raise RuntimeError(f"草稿文件不存在（id={draft_id}），可能已过期")
238
239	            # Ensure valid Zhihu session (may send QR to Slack)
240	            if not self.engine.ensure_logged_in():
241	                raise RuntimeError("知乎登录失败，请扫码后重试")
242
243	            answer_url = self.engine.publish_answer(
244	                question_url=draft.question.url,
245	                answer_text=draft.content,
246	            )
247
248	            client.chat_update(
249	                channel=channel,
250	                ts=msg_ts,
251	                blocks=self._done_block(
252	                    f"✅ *发布成功！*\n"
253	                    f"问题: *<{draft.question.url}|{draft.question.title}>*\n"
254	                    f"回答: <{answer_url}|查看已发布回答>"
255	                ),
256	                text=f"✅ 已发布: {answer_url}",
257	            )
258	            # Update vault file status to published
259	            if draft.vault_file:
260	                self._update_vault_published(draft.vault_file, answer_url)
261	            # Clean up draft file
262	            self._delete_draft(draft_id)
263
264	        except Exception as e:
265	            logger.error(f"Publish failed: {e}", exc_info=True)
266	            client.chat_update(
267	                channel=channel,
268	                ts=msg_ts,
269	                blocks=self._error_block(
270	                    f"发布失败：{e}\n你可以重新点击发布，或联系排查日志。"
271	                ),
272	                text="发布失败",
273	            )
274
275	    def _handle_regenerate(self, ack, body, client) -> None:
276	        """Gate 2 → [🔄 重新生成]: open a modal for user to enter revision feedback."""
277	        ack()
278	        channel  = body["channel"]["id"]
279	        msg_ts   = body["message"]["ts"]
280	        raw_val  = body["actions"][0]["value"]
281
282	        try:
283	            question = ZhihuQuestion.model_validate_json(raw_val)
284	        except Exception:
285	            question_title = "（问题信息丢失）"
286	        else:
287	            question_title = question.title
288
289	        private_metadata = json.dumps({
290	            "channel":    channel,
291	            "msg_ts":     msg_ts,
292	            "regen_value": raw_val,
293	        })
294
295	        client.views_open(
296	            trigger_id=body["trigger_id"],
297	            view={
298	                "type": "modal",
299	                "callback_id": _VIEW_REGEN,
300	                "title": {"type": "plain_text", "text": "重新生成草稿"},
301	                "submit": {"type": "plain_text", "text": "生成"},
302	                "close":  {"type": "plain_text", "text": "取消"},
303	                "private_metadata": private_metadata,
304	                "blocks": [
305	                    {
306	                        "type": "section",
307	                        "text": {
308	                            "type": "mrkdwn",
309	                            "text": f"*{question_title}*\n\n请输入修改意见，Bot 会据此重新生成草稿。",
310	                        },
311	                    },
312	                    {
313	                        "type": "input",
314	                        "block_id": "feedback_block",
315	                        "optional": True,
316	                        "label": {"type": "plain_text", "text": "修改意见"},
317	                        "element": {
318	                            "type": "plain_text_input",
319	                            "action_id": "feedback_input",
320	                            "multiline": True,
321	                            "placeholder": {
322	                                "type": "plain_text",
323	                                "text": "例：内容太技术了，偏向大众一些；字数控制在500字以内；结尾加一句诗",
324	                            },
325	                        },
326	                    },
327	                ],
328	            },
329	        )
330
331	    def _handle_regenerate_submit(self, ack, body, client) -> None:
332	        """Modal submit → regenerate draft with user feedback."""
333	        ack()
334	        metadata  = json.loads(body["view"]["private_metadata"])
335	        channel   = metadata["channel"]
336	        msg_ts    = metadata["msg_ts"]
337	        raw_val   = metadata["regen_value"]
338	        feedback  = (
339	            body["view"]["state"]["values"]
340	            .get("feedback_block", {})
341	            .get("feedback_input", {})
342	            .get("value") or ""
343	        )
344
345	        client.chat_update(
346	            channel=channel,
347	            ts=msg_ts,
348	            blocks=self._loading_block("🔄 重新生成中，请稍候..."),
349	            text="重新生成中...",
350	        )
351
352	        try:
353	            question = ZhihuQuestion.model_validate_json(raw_val)
354	            draft    = self.hunter.draft_answer(question, feedback=feedback)
355
356	            # Persist to vault (overwrites previous draft file for same question)
357	            vault_file = self._save_to_vault(draft)
358	            if vault_file:
359	                draft = draft.model_copy(update={"vault_file": str(vault_file)})
360
361	            draft_id = self._save_draft(draft)
362
363	            client.chat_update(
364	                channel=channel,
365	                ts=msg_ts,
366	                blocks=self._draft_blocks(draft, draft_id),
367	                text=f"📝 草稿已重新生成: {question.title}",
368	            )
369
370	        except Exception as e:
371	            logger.error(f"Regenerate submit failed: {e}", exc_info=True)
372	            client.chat_update(
373	                channel=channel,
374	                ts=msg_ts,
375	                blocks=self._error_block(f"重新生成失败：{e}"),
376	                text="重新生成失败",
377	            )
378
379	    def _handle_cancel(self, ack, respond) -> None:
380	        """Gate 2 → [❌ 取消]: dismiss the draft card."""
381	        ack()
382	        respond(
383	            replace_original=True,
384	            text="❌ 已取消",
385	            blocks=self._done_block("❌ 已取消本次发布"),
386	        )
387
388	    # ── Block Kit Builders ─────────────────────────────────────────────────────
389
390	    def _question_blocks(self, question: ZhihuQuestion) -> list[dict]:
391	        """Build Gate 1 Block Kit blocks for a single question.
392
393	        Args:
394	            question: ZhihuQuestion to display.
395
396	        Returns:
397	            List of Slack Block Kit block dicts.
398	        """
399	        # Truncate snippet so the button value stays < 2000 chars
400	        q_for_button = question.model_copy(
401	            update={"snippet": (question.snippet or "")[:100]}
402	        )
403	        btn_value = q_for_button.model_dump_json()
404
405	        body_lines = [f"*<{question.url}|{question.title}>*"]
406	        if question.snippet:
407	            body_lines.append(f"> {question.snippet[:200]}")
408	        if question.keywords:
409	            body_lines.append(f"_关键词：{' · '.join(question.keywords)}_")
410
411	        return [
412	            {
413	                "type": "section",
414	                "text": {"type": "mrkdwn", "text": "\n".join(body_lines)},
415	            },
416	            {
417	                "type": "actions",
418	                "elements": [
419	                    {
420	                        "type": "button",
421	                        "text": {"type": "plain_text", "text": "✍ 生成草稿", "emoji": True},
422	                        "style": "primary",
423	                        "action_id": _ACT_GENERATE,
424	                        "value": btn_value,
425	                    },
426	                    {
427	                        "type": "button",
428	                        "text": {"type": "plain_text", "text": "🚫 忽略", "emoji": True},
429	                        "action_id": _ACT_IGNORE,
430	                        "value": "ignore",
431	                    },
432	                ],
433	            },
434	        ]
435
436	    def _draft_blocks(self, draft: AnswerDraft, draft_id: str) -> list[dict]:
437	        """Build Gate 2 Block Kit blocks for a draft answer.
438
439	        Args:
440	            draft: AnswerDraft to display.
441	            draft_id: UUID key used as the publish button value.
442
443	        Returns:
444	            List of Slack Block Kit block dicts.
445	        """
446	        preview = draft.content[:_PREVIEW_CHARS]
447	        if len(draft.content) > _PREVIEW_CHARS:
448	            preview += f"\n_…（共 {len(draft.content)} 字，发布时使用全文）_"
449
450	        # Truncate question for regenerate button value
451	        q_for_btn = draft.question.model_copy(
452	            update={"snippet": (draft.question.snippet or "")[:100]}
453	        )
454	        regen_value = q_for_btn.model_dump_json()
455
456	        blocks: list[dict] = [
457	            {
458	                "type": "section",
459	                "text": {
460	                    "type": "mrkdwn",
461	                    "text": (
462	                        f"📝 *草稿已生成* | "
463	                        f"<{draft.question.url}|{draft.question.title}>"
464	                    ),
465	                },
466	            },
467	            {"type": "divider"},
468	            {
469	                "type": "section",
470	                "text": {"type": "mrkdwn", "text": preview},
471	            },
472	        ]
473
474	        if draft.rag_sources:
475	            blocks.append({
476	                "type": "context",
477	                "elements": [{
478	                    "type": "mrkdwn",
479	                    "text": f"📚 RAG 来源：{' · '.join(draft.rag_sources[:5])}",
480	                }],
481	            })
482
483	        blocks += [
484	            {"type": "divider"},
485	            {
486	                "type": "actions",
487	                "elements": [
488	                    {
489	                        "type": "button",
490	                        "text": {"type": "plain_text", "text": "✅ 确认发布", "emoji": True},
491	                        "style": "primary",
492	                        "action_id": _ACT_PUBLISH,
493	                        "value": draft_id,
494	                    },
495	                    {
496	                        "type": "button",
497	                        "text": {"type": "plain_text", "text": "🔄 重新生成", "emoji": True},
498	                        "action_id": _ACT_REGEN,
499	                        "value": regen_value,
500	                    },
501	                    {
502	                        "type": "button",
503	                        "text": {"type": "plain_text", "text": "❌ 取消", "emoji": True},
504	                        "style": "danger",
505	                        "action_id": _ACT_CANCEL,
506	                        "value": "cancel",
507	                    },
508	                ],
509	            },
510	        ]
511	        return blocks
512
513	    def _loading_block(self, message: str) -> list[dict]:
514	        """Simple single-section block for loading state.
515
516	        Args:
517	            message: Text to display (supports mrkdwn).
518
519	        Returns:
520	            List with one section block.
521	        """
522	        return [{"type": "section", "text": {"type": "mrkdwn", "text": message}}]
523
524	    def _done_block(self, message: str) -> list[dict]:
525	        """Single-section block for a completed/dismissed state.
526
527	        Args:
528	            message: Text to display (supports mrkdwn).
529
530	        Returns:
531	            List with one section block (no action buttons).
532	        """
533	        return [{"type": "section", "text": {"type": "mrkdwn", "text": message}}]
534
535	    def _error_block(self, message: str) -> list[dict]:
536	        """Single-section block for error display.
537
538	        Args:
539	            message: Error description (supports mrkdwn).
540
541	        Returns:
542	            List with one section block prefixed with ⚠.
543	        """
544	        return [{
545	            "type": "section",
546	            "text": {"type": "mrkdwn", "text": f"⚠ {message}"},
547	        }]
548
549	    # ── Draft State ────────────────────────────────────────────────────────────
550
551	    def _save_to_vault(self, draft: AnswerDraft) -> Optional[Path]:
552	        """Persist a draft as a Markdown file in <vault>/zhihu/.
553
554	        Args:
555	            draft: The generated AnswerDraft to save.
556
557	        Returns:
558	            Absolute path of the written file, or None on failure.
559	        """
560	        import re
561	        from datetime import date as _date
562
563	        try:
564	            zhihu_dir = self.hunter.vault_path / "zhihu"
565	            zhihu_dir.mkdir(parents=True, exist_ok=True)
566
567	            date_str = _date.today().strftime("%Y-%m-%d")
568	            slug = re.sub(r"[^\w\u4e00-\u9fff]+", "-", draft.question.title)[:40].strip("-")
569	            file_path = zhihu_dir / f"{date_str}-{slug}.md"
570
571	            file_path.write_text(
572	                f"---\n"
573	                f"title: \"{draft.question.title}\"\n"
574	                f"url: {draft.question.url}\n"
575	                f"date: {date_str}\n"
576	                f"status: draft\n"
577	                f"tags: [zhihu]\n"
578	                f"---\n\n"
579	                f"# {draft.question.title}\n\n"
580	                f"{draft.content}\n",
581	                encoding="utf-8",
582	            )
583	            logger.info(f"Draft saved to vault: {file_path}")
584	            return file_path
585	        except Exception as e:
586	            logger.warning(f"Failed to save draft to vault: {e}")
587	            return None
588
589	    def _update_vault_published(self, vault_file: str, answer_url: str) -> None:
590	        """Update the vault file's frontmatter status to published.
591
592	        Args:
593	            vault_file: Absolute path string stored in AnswerDraft.vault_file.
594	            answer_url: Published answer URL to record.
595	        """
596	        try:
597	            fp = Path(vault_file)
598	            if not fp.exists():
599	                return
600	            content = fp.read_text(encoding="utf-8")
601	            content = content.replace(
602	                "status: draft",
603	                f"status: published\nanswer_url: {answer_url}",
604	                1,
605	            )
606	            fp.write_text(content, encoding="utf-8")
607	            logger.info(f"Vault file marked published: {fp}")
608	        except Exception as e:
609	            logger.warning(f"Failed to update vault file: {e}")
610
611	    def _save_draft(self, draft: AnswerDraft) -> str:
612	        """Persist a draft to disk and return its UUID key.
613
614	        Args:
615	            draft: AnswerDraft to save.
616
617	        Returns:
618	            UUID string key (used as publish button value).
619	        """
620	        draft_id  = uuid.uuid4().hex[:12]
621	        file_path = self.state_dir / f"{draft_id}.json"
622	        file_path.write_text(draft.model_dump_json(), encoding="utf-8")
623	        logger.debug(f"Draft saved: {file_path}")
624	        return draft_id
625
626	    def _load_draft(self, draft_id: str) -> Optional[AnswerDraft]:
627	        """Load a draft by its UUID key.
628
629	        Args:
630	            draft_id: Key returned by _save_draft().
631
632	        Returns:
633	            AnswerDraft if found, None if the file does not exist.
634	        """
635	        file_path = self.state_dir / f"{draft_id}.json"
636	        if not file_path.exists():
637	            return None
638	        try:
639	            return AnswerDraft.model_validate_json(file_path.read_text(encoding="utf-8"))
640	        except Exception as e:
641	            logger.error(f"Failed to load draft {draft_id}: {e}")
642	            return None
643
644	    def _delete_draft(self, draft_id: str) -> None:
645	        """Remove a draft file after successful publish.
646
647	        Args:
648	            draft_id: Key of the draft to delete.
649	        """
650	        file_path = self.state_dir / f"{draft_id}.json"
651	        try:
652	            file_path.unlink(missing_ok=True)
653	        except Exception as e:
654	            logger.warning(f"Could not delete draft file {draft_id}: {e}")
655