Now I have the full picture for the OpenClaw answer:1. **URL fix IS working** - the URL is now `https://www.zhihu.com/question/2015396999915139338` (no leading `<`)2. **Title was fetched but messy** - `(11 条消息) 你会使用OpenClaw吗，风险大于获得？` — the "(11 条消息)" prefix is Zhihu's notification badge in the tab title, it should be stripped
3. **The actual question**: "你会使用OpenClaw吗，风险大于获得？"4.

The user's outline came through clearly: "权限交给ai在现阶段确实存在很大的风险，所以应该在一个隔离的环境中运行" — they're saying AI permissions are risky right now and should run in isolated environments. The web context injected 890 characters and the second-pass review did execute, but I'm noticing the output still carries a heavy AI tone despite the review pass running. This suggests the review prompt itself might not be effectively filtering or rewriting the content to sound more natural.

Looking at what actually gets sent to the generator, the question title still has that "(11 条消息)" junk from the browser tab that needs cleaning. The full prompt being constructed includes the user's outline, the web context, and then wraps everything with multiple style guides and RAG context — including POWELL_REVISE.md which is 430 lines long. The sheer volume of instructions and context might be overwhelming the review pass's ability to actually reshape the tone. 我看到这个提示词确实非常庞大——加上所有的参考文档和上下文，总共可能超过18000个token。Gemini Flash在处理这么多指令时很可能会走捷径，跳过细节要求，只专注于生成内容本身。问题的根源在于标题污染和缺少完整的问题描述——我需要确保提取的是纯净的标题，并包含Zhihu页面上的完整问题正文。

我的解决方案是精简生成提示词，把POWELL_REVISE指南从生成步骤中移除，改为结构化的检查清单，并在审查阶段采用多轮扫描方法来确保不遗漏任何规则。

现在我需要检查实际加载的文件大小和RAG返回的内容，看看这些优化能带来多大的改进。


日志揭示了更多问题。让我深入排查 prompt 构建。


Bash

source /root/projects/butler/venv/bin/activate && python3 -c "
import os
os.chdir('/root/projects/butler')

# Measure all files loaded into the generation prompt
files = {
    'methodology.md': 'methodology.md',
    'POWELL_REVISE.md': 'POWELL_REVISE.md',
    'zhihu-style.md': 'zhihu-style.md',
    'zhihu-bot-style.md': 'zhihu-bot-style.md',
    'writing_style.md': 'writing_style.md',
}
total = 0
for label, path in files.items():
    try:
        size = len(open(path).read())
        tokens_est = size // 3  # rough Chinese char to token ratio
        print(f'{label}: {size:,} chars (~{tokens_est:,} tokens)')
        total += size
    except:
        print(f'{label}: NOT FOUND')
print(f'---')
print(f'Total reference files: {total:,} chars (~{total//3:,} tokens)')
print()

# Estimate total first-pass prompt size for ZhihuGenerator
# Template + methodology + POWELL_REVISE + zhihu_style + zhihu_bot_style + RAG(~5000) + web(~1000)
gen_total = total + 5000 + 1000 + 3000  # prompt template overhead
print(f'Estimated first-pass prompt: ~{gen_total:,} chars (~{gen_total//3:,} tokens)')

# Review prompt = kill list rules(~3000) + zhihu_bot_style + POWELL_REVISE + draft(~3000)
powell_size = len(open('POWELL_REVISE.md').read())
bot_style_size = len(open('zhihu-bot-style.md').read())
review_total = 3000 + bot_style_size + powell_size + 3000
print(f'Estimated review prompt: ~{review_total:,} chars (~{review_total//3:,} tokens)')
print(f'Total both passes: ~{gen_total + review_total:,} chars (~{(gen_total + review_total)//3:,} tokens)')
"

Measure prompt sizes


methodology.md: 1,424 chars (~474 tokens)
POWELL_REVISE.md: 7,816 chars (~2,605 tokens)
zhihu-style.md: 7,523 chars (~2,507 tokens)
zhihu-bot-style.md: 2,433 chars (~811 tokens)
writing_style.md: 1,423 chars (~474 tokens)
---
Total reference files: 20,619 chars (~6,873 tokens)

Estimated first-pass prompt: ~29,619 chars (~9,873 tokens)
Estimated review prompt: ~16,249 chars (~5,416 tokens)
Total both passes: ~45,868 chars (~15,289 tokens)


source /root/projects/butler/venv/bin/activate && python3 -c "
# Check what Tavily web context looks like for the OpenClaw question
import os
os.chdir('/root/projects/butler')
from dotenv import load_dotenv
load_dotenv()

from slack_bot.tools.web import WebSearchTool
result = WebSearchTool.search_web('OpenClaw AI风险 使用')
print(f'Web search result ({len(result)} chars):')
print(result[:1500])
" 2>&1

Test Tavily web context for OpenClaw


2026-03-17 12:02:24 - slack_bot.tools.web - INFO - Searching web for: OpenClaw AI风险 使用
/root/projects/butler/slack_bot/tools/web.py:27: RuntimeWarning: This package (`duckduckgo_search`) has been renamed to `ddgs`! Use `pip install ddgs` instead.
  results = DDGS().text(query, max_results=max_results)
Web search result (44 chars):
No results found for query: OpenClaw AI风险 使用


Interesting! The DuckDuckGo search returns nothing for "OpenClaw AI风险 使用". But the logs show that 890 chars of web context was injected. Let me check — the `_fetch_recent_web_context` method uses Tavily API, not DuckDuckGo. Let me check.


   309→    def _fetch_recent_web_context(self, query: str) -> str:
   310→        """Search the web for recent articles/news about the query topic.
   311→
   312→        Results are injected into the answer prompt so it reads like it
   313→        comes from someone who's actively following the latest developments.
   314→
   315→        Args:
   316→            query: Topic or question title to search.
   317→
   318→        Returns:
   319→            Formatted string of recent snippets, or empty string on failure.
   320→        """
   321→        api_key = os.environ.get("TAVILY_API_KEY")
   322→        if not api_key:
   323→            return ""
   324→        try:
   325→            from tavily import TavilyClient
   326→            client = TavilyClient(api_key=api_key)
   327→            results = client.search(
   328→                query=query,
   329→                search_depth="basic",
   330→                max_results=4,
   331→                days=90,
   332→                exclude_domains=["zhihu.com"],
   333→            )
   334→            items = results.get("results", [])
   335→            if not items:
   336→                return ""
   337→            parts = [
   338→                f"- {r['title']} ({r['url'].split('/')[2]}): {r.get('content', '')[:250]}"
   339→                for r in items
   340→            ]
   341→            return "\n".join(parts)
   342→        except Exception as e:
   343→            logger.debug(f"Recent web context fetch failed: {e}")
   344→            return ""
   345→
   346→    def draft_answer(self, question: ZhihuQuestion, feedback: str = "") -> AnswerDraft:
   347→        """Generate a Zhihu-style answer draft for a question.
   348→

    def _fetch_recent_web_context(self, query: str) -> str:
        """Search the web for recent articles/news about the query topic.

        Results are injected into the answer prompt so it reads like it
        comes from someone who's actively following the latest developments.

        Args:
            query: Topic or question title to search.

        Returns:
            Formatted string of recent snippets, or empty string on failure.
        """
        api_key = os.environ.get("TAVILY_API_KEY")
        if not api_key:
            return ""
        try:
            from tavily import TavilyClient
            client = TavilyClient(api_key=api_key)
            results = client.search(
                query=query,
                search_depth="basic",
                max_results=4,
                days=90,
                exclude_domains=["zhihu.com"],
            )
            items = results.get("results", [])
            if not items:
                return ""
            parts = [
                f"- {r['title']} ({r['url'].split('/')[2]}): {r.get('content', '')[:250]}"
                for r in items
            ]
            return "\n".join(parts)
        except Exception as e:
            logger.debug(f"Recent web context fetch failed: {e}")
            return ""

    def draft_answer(self, question: ZhihuQuestion, feedback: str = "") -> AnswerDraft:
        """Generate a Zhihu-style answer draft for a question.


    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

# Must contain /question/<id>; answer/comment subpages are excluded in _find_questions
_MIN_QUESTION_URL_RE = re.compile(r'zhihu\.com/question/\d+')

# ── Data Models ────────────────────────────────────────────────────────────────

class ZhihuQuestion(BaseModel):
    """A Zhihu question candidate surfaced by the hunter."""

    title: str


好了，让我看看完整日志中 OpenClaw 这次生成的 RAG 和 prompt 情况。


sed -n '4359,4380p' /root/projects/butler/logs/obsidian.log

View full OpenClaw generation log block


2026-03-17 11:50:44 - __main__ - INFO - Obsidian Bot received from U0A8ZCK7RJM: mode zhihu-hunter...
2026-03-17 11:50:45 - slack_bot.context.storage - INFO - Cleared context for D0AACF9G4J0
2026-03-17 11:51:25 - __main__ - INFO - Obsidian Bot received from U0A8ZCK7RJM: <https://www.zhihu.com/question/201539699991513933...
2026-03-17 11:51:32 - slack_bot.zhihu.zhihu_hunter - INFO - Direct URL hunt: 'https://www.zhihu.com/question/2015396999915139338' → title='(11 条消息) 你会使用OpenClaw吗，风险大于获得？'
2026-03-17 11:51:32 - slack_bot.obsidian.dispatcher - INFO - ZhihuHunter: direct URL 'https://www.zhihu.com/question/2015396999915139338' → '(11 条消息) 你会使用OpenClaw吗，风险大于获得？' outline='权限交给ai在现阶段确实存在很大的风险，所以应该在一个隔离的环境中运行'
2026-03-17 11:51:39 - slack_bot.zhihu.zhihu_hunter - INFO - Drafting answer for: (11 条消息) 你会使用OpenClaw吗，风险大于获得？
2026-03-17 11:51:44 - slack_bot.llm.gemini - INFO - Using OpenRouter: https://openrouter.ai/api
2026-03-17 11:51:44 - slack_bot.llm.gemini - INFO - Initialized Gemini model: google/gemini-3-flash-preview
2026-03-17 11:51:44 - slack_bot.zhihu.zhihu_hunter - INFO - Injected user outline: '权限交给ai在现阶段确实存在很大的风险，所以应该在一个隔离的环境中运行'
2026-03-17 11:51:46 - slack_bot.zhihu.zhihu_hunter - INFO - Injected recent web context (890 chars)
2026-03-17 11:51:52 - slack_bot.obsidian.generators - INFO - Generating Zhihu answer for: (11 条消息) 你会使用OpenClaw吗，风险大于获得？

用户提供的回答思路（必须严格按照这个...
2026-03-17 11:52:02 - slack_bot.obsidian.generators - INFO - Running second-pass rule enforcement on Zhihu draft...
2026-03-17 11:52:07 - slack_bot.zhihu.slack_interactive_gateway - INFO - Draft saved to vault: /root/vault/obsidian_vault/obsidian/obsidian/zhihu/2026-03-17-11-条消息-你会使用OpenClaw吗-风险大于获得.md


Now I have a clear picture. Let me summarize the root causes and propose a fix plan.

**Root Causes Identified:**

1. **Title pollution**: "(11 条消息)" prefix in the page title leaks into the question title and even the vault filename. Need to strip this.

2. **No question description**: The bot only fetches `page.title()`, never the question body/description. The question "你会使用OpenClaw吗，风险大于获得？" is a one-liner, but the description text (visible in the body) contains much more context. Without it, the LLM has to fill in with its own knowledge (hallucination).

3. **Prompt overload**: The generation prompt is bloated at ~30K chars with POWELL_REVISE (7.8K chars) embedded, which doesn't help—Gemini Flash struggles with very long instructions, and mixing detailed revision rules into the generation step confuses the model about whether it should focus on creating content or editing it.

4. **Review pass also oversized**: The review prompt repeats POWELL_REVISE as a secondary priority, pushing it to ~16K chars when it should be leaner.

5. **Kill list formatting**: The kill list is scattered in prose format within the larger prompt, but LLMs respond better to clean, numbered, one-item-per-line lists.

6. **Conflating generation and review**: The generation prompt tries to apply anti-AI tone rules while writing, forcing simultaneous generation and self-editing instead of handling them as separate passes.

For fixes, I need to clean up titles by stripping the message count suffix, extract the question description from the page using CSS selectors and add it to the model, then restructure the prompts to be leaner and more focused.

The generation prompt should drop the massive POWELL_REVISE section entirely and just focus on identity, style, context, and the question outline. The review prompt can keep the focused kill lists but remove the 430-line noise section. I'll also make the kill list more scannable with proper numbering and line breaks, and ensure the question description gets passed through to the prompt input. The user wants me to implement these fixes, not just present them. Let me prioritize by impact: fetching the question description prevents hallucination, removing POWELL_REVISE from both generation and review prompts cuts unnecessary bloat and lets the model focus on what matters, and updating the kill lists with fresh analysis items completes the systematic fix.

I'll need to find the right CSS selector to extract the description text from the page, then refactor the fetch function to handle both title and description together.


排查完成。以下是系统性根因和修复计划：

## 排查结果

| 根因 | 影响 | 严重度 |
|------|------|--------|
| **无问题描述** — 只抓 `page.title()`，问题正文完全丢失 | LLM 靠猜写答案 | 高 |
| **Title 污染** — "(11 条消息)" 前缀泄入标题和文件名 | 搜索/RAG 被污染 | 中 |
| **Prompt 过载** — POWELL_REVISE(7.8K chars) 在生成和 review 中各塞了一遍 | Gemini 指令跟随能力被稀释 | 高 |
| **Kill list 被 POWELL_REVISE 淹没** — review prompt 里 kill list 是 PRIORITY 1-2，但 POWELL_REVISE 作为 PRIORITY 4 又加了 7.8K chars | Gemini 跳过 kill list 直接看大块文本 | 高 |
| **Kill list 词汇不全** | 新变体不断出现 | 中 |

修复方案：5 个改动，我一次性做完。


    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→    source_file: Optional[str] = None  # vault-relative path, set by scan_single_file()
    72→    outline: Optional[str] = None       # user-provided answer logic/structure (direct URL mode)
    73→

# ── Data Models ────────────────────────────────────────────────────────────────

class ZhihuQuestion(BaseModel):
    """A Zhihu question candidate surfaced by the hunter."""

    title: str
    url: str
    snippet: Optional[str] = None
    keywords: List[str] = Field(default_factory=list)
    found_at: str = Field(
        default_factory=lambda: datetime.now(tz=timezone.utc).isoformat()
    )
    source_file: Optional[str] = None  # vault-relative path, set by scan_single_file()
    outline: Optional[str] = None       # user-provided answer logic/structure (direct URL mode)


**改动 1: ZhihuQuestion 加 description 字段**


Edit

    source_file: Optional[str] = None  # vault-relative path, set by scan_single_file()
    outline: Optional[str] = None       # user-provided answer logic/structure (direct URL mode)

    description: Optional[str] = None   # question body/detail text from Zhihu page
    source_file: Optional[str] = None  # vault-relative path, set by scan_single_file()
    outline: Optional[str] = None       # user-provided answer logic/structure (direct URL mode)


The file /root/projects/butler/slack_bot/zhihu/zhihu_hunter.py has been updated successfully.

    description: Optional[str] = None   # question body/detail text from Zhihu page
    source_file: Optional[str] = None  # vault-relative path, set by scan_single_file()
    outline: Optional[str] = None       # user-provided answer logic/structure (direct URL mode)

"""Zhihu Hunter — vault scanning, question discovery, and answer drafting.

Workflow:
    1. scan_and_hunt()  →  scans recent vault files, extracts keywords,
                           searches Tavily for matching Zhihu questions.
    2. draft_answer()   →  uses existing RAG + ZhihuGenerator to produce
                           a Zhihu-style answer draft ready for review.
"""

import os
import re
import requests
from datetime import datetime, timezone, timedelta
from pathlib import Path
from typing import List, Optional

from pydantic import BaseModel, Field

from health.utils.logging_config import setup_logger
from slack_bot.llm.gemini import GeminiLLM
from slack_bot.obsidian.generators import ZhihuGenerator
from slack_bot.obsidian.indexer import ObsidianIndexer
from slack_bot.obsidian.vector_store import ChromaVectorStore

logger = setup_logger(__name__)

# ── Vault directories to watch for new/updated content ────────────────────────
# Paths are relative to vault_path root.
WATCH_DIRS: List[str] = [
    "notes",
    "Article",
    "auto_report/deep-dive",
    "CLAW/deepfact",
    "Clippings",
]

# Article is always included regardless of time; Revised files are prioritized.
_ARTICLE_DIR = "Article"

# Short names users can type to target a specific directory.
DIR_SHORTCUTS: dict[str, str] = {
    "notes":      "notes",
    "article":    "Article",
    "clippings":  "Clippings",
    "claw":       "CLAW/deepfact",
    "deepfact":   "CLAW/deepfact",
    "deep-dive":  "auto_report/deep-dive",
}

_MAX_FILES_PER_DIR   = 4    # most-recent files per directory (keeps dir diversity)
_MAX_FILES_ARTICLE   = 8    # Article gets more slots (higher priority)
_MAX_KEYWORDS        = 10   # keywords extracted from vault scan
_MAX_QUESTIONS_TOTAL = 12   # total Zhihu questions surfaced per hunt
# Must contain /question/<id>; answer/comment subpages are excluded in _find_questions
_MIN_QUESTION_URL_RE = re.compile(r'zhihu\.com/question/\d+')

# ── Data Models ────────────────────────────────────────────────────────────────

class ZhihuQuestion(BaseModel):
    """A Zhihu question candidate surfaced by the hunter."""

    title: str
    url: str
    snippet: Optional[str] = None
    keywords: List[str] = Field(default_factory=list)
    found_at: str = Field(
        default_factory=lambda: datetime.now(tz=timezone.utc).isoformat()
    )
    source_file: Optional[str] = None  # vault-relative path, set by scan_single_file()
    outline: Optional[str] = None       # user-provided answer logic/structure (direct URL mode)

class AnswerDraft(BaseModel):
    """A generated answer draft awaiting human review."""

    question: ZhihuQuestion
    content: str
    generated_at: str = Field(
        default_factory=lambda: datetime.now(tz=timezone.utc).isoformat()
    )
    rag_sources: List[str] = Field(default_factory=list)
    vault_file: Optional[str] = None  # absolute path set after saving to vault

# ── Main Class ─────────────────────────────────────────────────────────────────

class ZhihuHunter:
    """Discovers Zhihu questions from vault content and drafts answers.

    Example:
        hunter = ZhihuHunter(vault_path, vector_store, indexer)
        questions = hunter.scan_and_hunt(since_days=3)
        draft = hunter.draft_answer(questions[0])
    """

    def __init__(
        self,
        vault_path: Path,
        vector_store: ChromaVectorStore,
        indexer: ObsidianIndexer,
    ) -> None:
        """Initialize the hunter.

        Args:
            vault_path: Root path of the Obsidian vault.
            vector_store: ChromaDB vector store for semantic retrieval.
            indexer: ObsidianIndexer for keyword-based search and writing samples.
        """
        self.vault_path = vault_path
        self.vector_store = vector_store
        self.indexer = indexer
        self.llm = GeminiLLM()

    # ── Public API ─────────────────────────────────────────────────────────────

    def scan_and_hunt(
        self,
        since_days: int = 7,
        dirs: Optional[List[str]] = None,
        topic_hint: str = "",
    ) -> List[ZhihuQuestion]:
        """Scan vault files and surface matching Zhihu questions.

        Args:
            since_days: Age threshold for non-Article directories.
            dirs: Restrict scan to these vault dirs (relative paths). None = all WATCH_DIRS.
            topic_hint: Optional focus hint injected into keyword extraction prompt.

        Returns:
            List of ZhihuQuestion candidates (up to _MAX_QUESTIONS_TOTAL).
        """
        scan_desc = f"dirs={dirs or 'all'}" + (f", topic='{topic_hint}'" if topic_hint else "")
        logger.info(f"ZhihuHunter: scanning vault ({scan_desc})")

        keywords = self._scan_vault_for_keywords(since_days, dirs=dirs, topic_hint=topic_hint)
        if not keywords:
            logger.info("No keywords extracted from vault files")
            return []

        logger.info(f"Extracted keywords: {keywords}")
        questions = self._find_questions(keywords)
        logger.info(f"Found {len(questions)} Zhihu questions")
        return questions

    def scan_single_file(self, rel_path: str) -> List[ZhihuQuestion]:
        """Extract keywords from one vault file and find matching Zhihu questions.

        The returned questions carry ``source_file`` set to ``rel_path`` so that
        ``draft_answer()`` will ground the generated answer in that article.

        Args:
            rel_path: Path relative to vault_path (e.g. "Article/8-xxx-Revised.md").

        Returns:
            List of ZhihuQuestion candidates with source_file populated.

        Raises:
            FileNotFoundError: If the file does not exist under the vault root.
        """
        file_path = self.vault_path / rel_path
        if not file_path.exists():
            raise FileNotFoundError(f"文件不存在：{file_path}")

        text = file_path.read_text(encoding="utf-8", errors="ignore")
        text = re.sub(r'^---\n.*?\n---\n', '', text, flags=re.DOTALL).strip()
        logger.info(f"scan_single_file: {rel_path} ({len(text)} chars)")

        keywords = self._extract_keywords_with_llm(text[:5000])
        if not keywords:
            logger.info("No keywords extracted from single file")
            return []

        logger.info(f"Keywords from file: {keywords}")
        questions = self._find_questions(keywords)

        for q in questions:
            q.source_file = rel_path

        return questions

    def scan_glob_files(self, pattern: str) -> List[ZhihuQuestion]:
        """Find vault files matching a glob pattern and hunt Zhihu questions.

        All matched files are combined for keyword extraction, so the questions
        reflect the full set of content.  The most-recently-modified matched file
        is stored as ``source_file`` to ground the answer draft.

        Args:
            pattern: Glob pattern relative to vault_path
                     (e.g. "Article/*openclaw*-Revised.md").

        Returns:
            List of ZhihuQuestion candidates with source_file set.

        Raises:
            FileNotFoundError: If no files match the pattern.
        """
        matched = sorted(
            self.vault_path.glob(pattern),
            key=lambda p: p.stat().st_mtime,
            reverse=True,
        )
        if not matched:
            raise FileNotFoundError(f"没有文件匹配：{pattern}")

        names = [p.name for p in matched]
        logger.info(f"scan_glob_files: '{pattern}' → {len(matched)} files: {names}")

        # Combine content from all matched files (per-file budget so no one file
        # dominates) and extract keywords from the aggregate.
        parts: List[str] = []
        per_file_budget = 1500 if len(matched) == 1 else 800
        for fp in matched:
            try:
                text = fp.read_text(encoding="utf-8", errors="ignore")
                text = re.sub(r'^---\n.*?\n---\n', '', text, flags=re.DOTALL).strip()
                parts.append(f"[{fp.name}]\n{text[:per_file_budget]}")
            except OSError as e:
                logger.debug(f"Could not read {fp}: {e}")

        combined = "\n\n---\n\n".join(parts)
        keywords = self._extract_keywords_with_llm(combined[:8000])
        if not keywords:
            logger.info("No keywords extracted from glob-matched files")
            return []

        logger.info(f"Keywords from glob: {keywords}")
        questions = self._find_questions(keywords)

        # Primary source = most-recently-modified matched file
        primary = str(matched[0].relative_to(self.vault_path))
        for q in questions:
            q.source_file = primary

        return questions

    def hunt_direct_url(self, url: str) -> List[ZhihuQuestion]:
        """Create a question card directly from a Zhihu question URL.

        Used when the user pastes a URL they were invited to answer.
        Fetches the page title so the card displays a readable name.

        Args:
            url: A zhihu.com/question/<id> URL.

        Returns:
            Single-element list containing the ZhihuQuestion.
        """
        clean_url = re.sub(r'[>\'")\].,;]+$', '', url).lstrip("<").split("?")[0].split("#")[0].rstrip("/")
        title = self._fetch_question_title(clean_url)
        logger.info(f"Direct URL hunt: {clean_url!r} → title={title!r}")
        return [ZhihuQuestion(title=title, url=clean_url)]

    def _fetch_question_title(self, url: str) -> str:
        """Fetch the question title via Playwright (with saved session cookies).

        Falls back to a URL-derived label if Playwright is unavailable or fails.

        Args:
            url: Cleaned question URL.

        Returns:
            Question title string, or a fallback derived from the URL.
        """
        state_file = Path(__file__).resolve().parents[3] / "data" / "zhihu_state.json"
        try:
            from playwright.sync_api import sync_playwright
            from playwright_stealth import Stealth
            import json as _json

            with sync_playwright() as p:
                browser = p.chromium.launch(headless=True)
                context = browser.new_context(
                    user_agent=(
                        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                        "AppleWebKit/537.36 (KHTML, like Gecko) "
                        "Chrome/122.0.0.0 Safari/537.36"
                    )
                )
                # Load saved cookies if available
                if state_file.exists():
                    data = _json.loads(state_file.read_text(encoding="utf-8"))
                    cookies = data.get("cookies", [])
                    if cookies:
                        context.add_cookies(cookies)

                page = context.new_page()
                Stealth().apply_stealth_sync(page)
                page.goto(url, wait_until="domcontentloaded", timeout=15000)
                page.wait_for_timeout(2000)

                title = page.title()
                browser.close()

                if title:
                    # Page title format: "问题标题 - 知乎" or just "知乎"
                    title = title.split(" - 知乎")[0].strip()
                    if title and title != "知乎":
                        return title
        except Exception as e:
            logger.debug(f"Playwright title fetch failed for {url}: {e}")

        # Fallback: use the question ID
        qid = url.rstrip("/").split("/")[-1]
        return f"知乎问题 #{qid}"

    def _fetch_recent_web_context(self, query: str) -> str:
        """Search the web for recent articles/news about the query topic.

        Results are injected into the answer prompt so it reads like it
        comes from someone who's actively following the latest developments.

        Args:
            query: Topic or question title to search.

        Returns:
            Formatted string of recent snippets, or empty string on failure.
        """
        api_key = os.environ.get("TAVILY_API_KEY")
        if not api_key:
            return ""
        try:
            from tavily import TavilyClient
            client = TavilyClient(api_key=api_key)
            results = client.search(
                query=query,
                search_depth="basic",
                max_results=4,
                days=90,
                exclude_domains=["zhihu.com"],
            )
            items = results.get("results", [])
            if not items:
                return ""
            parts = [
                f"- {r['title']} ({r['url'].split('/')[2]}): {r.get('content', '')[:250]}"
                for r in items
            ]
            return "\n".join(parts)
        except Exception as e:
            logger.debug(f"Recent web context fetch failed: {e}")
            return ""

    def draft_answer(self, question: ZhihuQuestion, feedback: str = "") -> AnswerDraft:
        """Generate a Zhihu-style answer draft for a question.

        Delegates to the existing ZhihuGenerator (reuses prompt + style guide).
        The RAG context is enriched by both semantic (ChromaDB) and
        keyword-based (ObsidianIndexer) retrieval.

        Args:
            question: ZhihuQuestion to answer.
            feedback: Optional revision instructions from the user (e.g. "更口语化一些").

        Returns:
            AnswerDraft with generated content and source list.
        """
        logger.info(f"Drafting answer for: {question.title}" + (f" [feedback: {feedback[:40]}]" if feedback else ""))

        # Collect RAG sources for transparency
        rag_sources: List[str] = []

        semantic_results = self.vector_store.search_knowledge(question.title, top_k=3)
        for r in semantic_results:
            rag_sources.append(Path(r.source_path).name)

        keyword_results = self.indexer.search(question.title, limit=5)
        for note in keyword_results:
            first_line = note.split("\n")[0].strip("# ").strip()
            if first_line:
                rag_sources.append(first_line)

        # If the question was pinned to a specific source file, load it as the
        # primary article so the answer stays grounded in its viewpoints.
        primary_article = ""
        if question.source_file:
            fp = self.vault_path / question.source_file
            try:
                raw = fp.read_text(encoding="utf-8", errors="ignore")
                raw = re.sub(r'^---\n.*?\n---\n', '', raw, flags=re.DOTALL).strip()
                primary_article = raw[:4000]
                rag_sources.insert(0, Path(question.source_file).name)
                logger.info(f"Loaded primary article: {question.source_file} ({len(primary_article)} chars)")
            except OSError as e:
                logger.warning(f"Could not read source file {question.source_file}: {e}")

        # Delegate to existing ZhihuGenerator for consistent style
        generator = ZhihuGenerator(self.indexer, self.vector_store)
        prompt_input = question.title
        if question.snippet:
            prompt_input += f" | 背景：{question.snippet}"

        # User-provided outline takes highest priority — structure the answer strictly
        # around it, pulling matching content from the knowledge base for each point.
        if question.outline:
            prompt_input += (
                f"\n\n用户提供的回答思路（必须严格按照这个逻辑框架展开，"
                f"从知识库中找到对应的内容来支撑每个论点，不要自由发挥框架结构）：\n{question.outline}"
            )
            logger.info(f"Injected user outline: {question.outline[:60]!r}")

        if primary_article:
            prompt_input += (
                f"\n\n主要参考文章（请基于此文的观点和论据来回答，"
                f"可直接引用其中的分析和结论）：\n{primary_article}"
            )

        # Inject recent web context so the answer feels up-to-date
        recent_context = self._fetch_recent_web_context(question.title)
        if recent_context:
            prompt_input += (
                f"\n\n近期相关资讯（最近90天内的动态，用来补充最新工具/发布/讨论，"
                f"让回答更有时效感，自然地融入即可，不要刻意罗列）：\n{recent_context}"
            )
            logger.info(f"Injected recent web context ({len(recent_context)} chars)")

        if feedback:
            prompt_input += f"\n\n用户修改意见：{feedback}"

        content, _ = generator.chat(prompt_input, history=[])

        return AnswerDraft(
            question=question,
            content=content,
            rag_sources=list(dict.fromkeys(rag_sources)),  # deduplicate, keep order
        )

    # ── Vault Scanning ─────────────────────────────────────────────────────────

    def _scan_vault_for_keywords(
        self,
        since_days: int,
        dirs: Optional[List[str]] = None,
        topic_hint: str = "",
    ) -> List[str]:
        """Collect vault files and extract topic keywords via LLM.

        Args:
            since_days: Age threshold for non-Article directories.
            dirs: Dirs to scan; None = all WATCH_DIRS.
            topic_hint: Optional focus instruction for keyword extraction.

        Returns:
            Deduplicated list of topic keywords (up to _MAX_KEYWORDS).
        """
        cutoff = datetime.now(tz=timezone.utc) - timedelta(days=since_days)
        files = self._collect_recent_files(cutoff, dirs=dirs)

        if not files:
            return []

        # Article-only scans get a larger per-file budget (higher priority content)
        scan_dirs = dirs or WATCH_DIRS
        per_file_budget = 800 if scan_dirs == [_ARTICLE_DIR] else 500
        combined = self._read_files_summary(files, max_chars_per_file=per_file_budget)
        return self._extract_keywords_with_llm(combined, topic_hint=topic_hint)

    def _collect_recent_files(
        self,
        cutoff: datetime,
        dirs: Optional[List[str]] = None,
    ) -> List[Path]:
        """Walk vault directories and return candidate files.

        Article directory:
          - No time filter (always included regardless of cutoff)
          - Revised files sorted first, then by mtime descending
          - Up to _MAX_FILES_ARTICLE slots
        Other directories:
          - Only files modified after cutoff
          - Sorted by mtime descending
          - Up to _MAX_FILES_PER_DIR slots

        Args:
            cutoff: Freshness threshold for non-Article directories.
            dirs: Directories to scan (relative paths). None = all WATCH_DIRS.

        Returns:
            List of Path objects.
        """
        scan_dirs = dirs if dirs is not None else WATCH_DIRS
        collected: List[Path] = []

        for rel_dir in scan_dirs:
            watch_dir = self.vault_path / rel_dir
            if not watch_dir.is_dir():
                logger.debug(f"Watch dir not found, skipping: {watch_dir}")
                continue

            # (mtime, is_revised, path)
            candidates: List[tuple[float, bool, Path]] = []
            for md_file in watch_dir.rglob("*.md"):
                try:
                    mtime = md_file.stat().st_mtime
                    if rel_dir != _ARTICLE_DIR:
                        file_dt = datetime.fromtimestamp(mtime, tz=timezone.utc)
                        if file_dt < cutoff:
                            continue
                    is_revised = md_file.name.lower().endswith("-revised.md")
                    candidates.append((mtime, is_revised, md_file))
                except OSError:
                    continue

            if rel_dir == _ARTICLE_DIR:
                # Revised files first, then newest; give double slots
                candidates.sort(key=lambda t: (0 if t[1] else 1, -t[0]))
                max_files = _MAX_FILES_ARTICLE
            else:
                candidates.sort(key=lambda t: -t[0])
                max_files = _MAX_FILES_PER_DIR

            collected.extend(p for _, _, p in candidates[:max_files])

        logger.debug(f"Collected {len(collected)} files from vault (dirs={scan_dirs})")
        return collected

    def _read_files_summary(
        self,
        files: List[Path],
        max_chars: int = 8000,
        max_chars_per_file: int = 500,
    ) -> str:
        """Read and concatenate file contents up to a character budget.

        Each file is capped at max_chars_per_file so that no single large file
        exhausts the total budget and crowds out other directories.

        Args:
            files: List of markdown file paths.
            max_chars: Total character budget across all files.
            max_chars_per_file: Per-file cap to ensure directory diversity.

        Returns:
            Concatenated content string with file-name headers.
        """
        parts: List[str] = []
        remaining = max_chars

        for f in files:
            if remaining <= 0:
                break
            try:
                text = f.read_text(encoding="utf-8", errors="ignore")
                # Strip YAML frontmatter
                text = re.sub(r'^---\n.*?\n---\n', '', text, flags=re.DOTALL).strip()
                chunk = text[:min(max_chars_per_file, remaining)]
                parts.append(f"[{f.name}]\n{chunk}")
                remaining -= len(chunk)
            except OSError as e:
                logger.debug(f"Could not read {f}: {e}")

        return "\n\n---\n\n".join(parts)

    def _extract_keywords_with_llm(
        self,
        combined_text: str,
        topic_hint: str = "",
    ) -> List[str]:
        """Use LLM to distill topic keywords from aggregated vault content.

        Args:
            combined_text: Concatenated recent vault content.
            topic_hint: Optional focus instruction (e.g. "agent开发").

        Returns:
            List of keyword strings (up to _MAX_KEYWORDS).
        """
        topic_instruction = (
            f"\n- 重点关注与「{topic_hint}」相关的话题，优先提取这个方向的关键词"
            if topic_hint else ""
        )
        prompt = f"""你是一位知乎内容运营专家。请从以下笔记内容中提炼出适合在知乎搜索问题的关键词。

要求：
- 输出 {_MAX_KEYWORDS} 个以内的关键词，每行一个，无序号无符号
- 关键词要在知乎上有真实讨论热度，即普通中国用户会关心并提问的话题领域
- 避免过于偏门的专有名词（如具体项目代号、小众产品名），要用它所属的上一级概念
  例：「OpenClaw」→「AI智能体安全」；「BadJs」→「微信监控机制」；「Moon Monitor」→「健康监测设备」
- 不要太泛（如"AI""技术""健康"），也不要太窄（如只有一个工具代码名称）
- 优先选择最近讨论最多、最有深度的话题{topic_instruction}
- 只输出关键词列表，不要解释

=== 笔记内容 ===
{combined_text[:5000]}
"""
        try:
            response, _ = self.llm.generate_response(prompt, [])
            keywords = [
                line.strip()
                for line in response.strip().splitlines()
                if line.strip() and not line.strip().startswith("#")
            ]
            return keywords[:_MAX_KEYWORDS]
        except Exception as e:
            logger.error(f"Keyword extraction failed: {e}")
            return []

    # ── Question Discovery ─────────────────────────────────────────────────────

    def _find_questions(self, keywords: List[str]) -> List[ZhihuQuestion]:
        """Search Tavily for Zhihu questions matching each keyword.

        Collects candidates from ALL keywords before truncating so that later
        keywords (which can cover different facets) are not silently dropped.

        Args:
            keywords: List of topic keywords to search.

        Returns:
            Deduplicated ZhihuQuestion list (up to _MAX_QUESTIONS_TOTAL).
        """
        from tavily import TavilyClient

        api_key = os.environ.get("TAVILY_API_KEY")
        if not api_key:
            raise RuntimeError("TAVILY_API_KEY not set")

        client = TavilyClient(api_key=api_key)
        seen_urls: set[str] = set()
        questions: List[ZhihuQuestion] = []

        for kw in keywords:
            try:
                results = self._tavily_search(client, kw)
                for item in results:
                    url = item.get("url", "")
                    if not _MIN_QUESTION_URL_RE.search(url):
                        continue  # skip non-question URLs (e.g. zhihu.com/people/...)
                    # Normalize: extract bare URL to strip invisible chars, then remove params
                    _m = re.search(r'https?://\S+', url)
                    if not _m:
                        continue
                    clean_url = re.sub(r'[>\'")\].,;]+$', '', _m.group(0)).split("?")[0].split("#")[0].rstrip("/")
                    # Skip answer/comment subpages — we want the question itself
                    if re.search(r'/answer/|/comment/', clean_url):
                        continue
                    if clean_url in seen_urls:
                        continue
                    seen_urls.add(clean_url)
                    questions.append(
                        ZhihuQuestion(
                            title=item.get("title", kw),
                            url=clean_url,
                            snippet=item.get("content", "")[:200] or None,
                            keywords=[kw],
                        )
                    )
            except Exception as e:
                logger.warning(f"Tavily search failed for keyword '{kw}': {e}")

        logger.info(f"Found {len(questions)} Zhihu questions (capped at {_MAX_QUESTIONS_TOTAL})")
        return questions[:_MAX_QUESTIONS_TOTAL]

    def _tavily_search(self, client, keyword: str) -> List[dict]:
        """Run a single Tavily search scoped to zhihu.com/question.

        Args:
            client: Authenticated TavilyClient instance.
            keyword: Search keyword.

        Returns:
            List of result dicts from Tavily (url, title, content, score).
        """
        query = f"site:zhihu.com/question {keyword}"
        logger.debug(f"Tavily search: {query}")
        response = client.search(
            query=query,
            search_depth="basic",
            max_results=8,
            include_domains=["zhihu.com"],
            days=365,   # prefer questions with recent activity (higher chance of top rank)
        )
        return response.get("results", [])

+    description: Optional[str] = None   # question body/detail text from Zhihu page


**改动 2: `_fetch_question_title` → 同时抓标题和描述，清理 title 污染**


   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 so the card displays a readable name.
   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 = self._fetch_question_title(clean_url)
   254→        logger.info(f"Direct URL hunt: {clean_url!r} → title={title!r}")
   255→        return [ZhihuQuestion(title=title, url=clean_url)]
   256→
   257→    def _fetch_question_title(self, url: str) -> str:
   258→        """Fetch the question title via Playwright (with saved session cookies).
   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→            Question title string, or a fallback derived from the URL.
   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→                title = page.title()
   296→                browser.close()
   297→

        return questions

    def hunt_direct_url(self, url: str) -> List[ZhihuQuestion]:
        """Create a question card directly from a Zhihu question URL.

        Used when the user pastes a URL they were invited to answer.
        Fetches the page title so the card displays a readable name.

        Args:
            url: A zhihu.com/question/<id> URL.

        Returns:
            Single-element list containing the ZhihuQuestion.
        """
        clean_url = re.sub(r'[>\'")\].,;]+$', '', url).lstrip("<").split("?")[0].split("#")[0].rstrip("/")
        title = self._fetch_question_title(clean_url)
        logger.info(f"Direct URL hunt: {clean_url!r} → title={title!r}")
        return [ZhihuQuestion(title=title, url=clean_url)]

    def _fetch_question_title(self, url: str) -> str:
        """Fetch the question title via Playwright (with saved session cookies).

        Falls back to a URL-derived label if Playwright is unavailable or fails.

        Args:
            url: Cleaned question URL.

        Returns:
            Question title string, or a fallback derived from the URL.
        """
        state_file = Path(__file__).resolve().parents[3] / "data" / "zhihu_state.json"
        try:
            from playwright.sync_api import sync_playwright
            from playwright_stealth import Stealth
            import json as _json

            with sync_playwright() as p:
                browser = p.chromium.launch(headless=True)
                context = browser.new_context(
                    user_agent=(
                        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                        "AppleWebKit/537.36 (KHTML, like Gecko) "
                        "Chrome/122.0.0.0 Safari/537.36"
                    )
                )
                # Load saved cookies if available
                if state_file.exists():
                    data = _json.loads(state_file.read_text(encoding="utf-8"))
                    cookies = data.get("cookies", [])
                    if cookies:
                        context.add_cookies(cookies)

                page = context.new_page()
                Stealth().apply_stealth_sync(page)
                page.goto(url, wait_until="domcontentloaded", timeout=15000)
                page.wait_for_timeout(2000)

                title = page.title()
                browser.close()


   295→                title = page.title()
   296→                browser.close()
   297→
   298→                if title:
   299→                    # Page title format: "问题标题 - 知乎" or just "知乎"
   300→                    title = title.split(" - 知乎")[0].strip()
   301→                    if title and title != "知乎":
   302→                        return title
   303→        except Exception as e:
   304→            logger.debug(f"Playwright title fetch failed for {url}: {e}")
   305→
   306→        # Fallback: use the question ID
   307→        qid = url.rstrip("/").split("/")[-1]
   308→        return f"知乎问题 #{qid}"
   309→

                title = page.title()
                browser.close()

                if title:
                    # Page title format: "问题标题 - 知乎" or just "知乎"
                    title = title.split(" - 知乎")[0].strip()
                    if title and title != "知乎":
                        return title
        except Exception as e:
            logger.debug(f"Playwright title fetch failed for {url}: {e}")

        # Fallback: use the question ID
        qid = url.rstrip("/").split("/")[-1]
        return f"知乎问题 #{qid}"


    def hunt_direct_url(self, url: str) -> List[ZhihuQuestion]:
        """Create a question card directly from a Zhihu question URL.

        Used when the user pastes a URL they were invited to answer.
        Fetches the page title so the card displays a readable name.

        Args:
            url: A zhihu.com/question/<id> URL.

        Returns:
            Single-element list containing the ZhihuQuestion.
        """
        clean_url = re.sub(r'[>\'")\].,;]+$', '', url).lstrip("<").split("?")[0].split("#")[0].rstrip("/")
        title = self._fetch_question_title(clean_url)
        logger.info(f"Direct URL hunt: {clean_url!r} → title={title!r}")
        return [ZhihuQuestion(title=title, url=clean_url)]

    def _fetch_question_title(self, url: str) -> str:
        """Fetch the question title via Playwright (with saved session cookies).

        Falls back to a URL-derived label if Playwright is unavailable or fails.

        Args:
            url: Cleaned question URL.

        Returns:
            Question title string, or a fallback derived from the URL.
        """
        state_file = Path(__file__).resolve().parents[3] / "data" / "zhihu_state.json"
        try:
            from playwright.sync_api import sync_playwright
            from playwright_stealth import Stealth
            import json as _json

            with sync_playwright() as p:
                browser = p.chromium.launch(headless=True)
                context = browser.new_context(
                    user_agent=(
                        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                        "AppleWebKit/537.36 (KHTML, like Gecko) "
                        "Chrome/122.0.0.0 Safari/537.36"
                    )
                )
                # Load saved cookies if available
                if state_file.exists():
                    data = _json.loads(state_file.read_text(encoding="utf-8"))
                    cookies = data.get("cookies", [])
                    if cookies:
                        context.add_cookies(cookies)

                page = context.new_page()
                Stealth().apply_stealth_sync(page)
                page.goto(url, wait_until="domcontentloaded", timeout=15000)
                page.wait_for_timeout(2000)

                title = page.title()
                browser.close()

                if title:
                    # Page title format: "问题标题 - 知乎" or just "知乎"
                    title = title.split(" - 知乎")[0].strip()
                    if title and title != "知乎":
                        return title
        except Exception as e:
            logger.debug(f"Playwright title fetch failed for {url}: {e}")

        # Fallback: use the question ID
        qid = url.rstrip("/").split("/")[-1]
        return f"知乎问题 #{qid}"

    def hunt_direct_url(self, url: str) -> List[ZhihuQuestion]:
        """Create a question card directly from a Zhihu question URL.

        Used when the user pastes a URL they were invited to answer.
        Fetches the page title and description so the card displays readable info.

        Args:
            url: A zhihu.com/question/<id> URL.

        Returns:
            Single-element list containing the ZhihuQuestion.
        """
        clean_url = re.sub(r'[>\'")\].,;]+$', '', url).lstrip("<").split("?")[0].split("#")[0].rstrip("/")
        title, description = self._fetch_question_info(clean_url)
        logger.info(f"Direct URL hunt: {clean_url!r} → title={title!r}, desc={len(description or '')} chars")
        return [ZhihuQuestion(title=title, url=clean_url, description=description)]

    def _fetch_question_info(self, url: str) -> tuple[str, Optional[str]]:
        """Fetch the question title and description via Playwright.

        Falls back to a URL-derived label if Playwright is unavailable or fails.

        Args:
            url: Cleaned question URL.

        Returns:
            Tuple of (title, description). Description may be None.
        """
        state_file = Path(__file__).resolve().parents[3] / "data" / "zhihu_state.json"
        try:
            from playwright.sync_api import sync_playwright
            from playwright_stealth import Stealth
            import json as _json

            with sync_playwright() as p:
                browser = p.chromium.launch(headless=True)
                context = browser.new_context(
                    user_agent=(
                        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                        "AppleWebKit/537.36 (KHTML, like Gecko) "
                        "Chrome/122.0.0.0 Safari/537.36"
                    )
                )
                # Load saved cookies if available
                if state_file.exists():
                    data = _json.loads(state_file.read_text(encoding="utf-8"))
                    cookies = data.get("cookies", [])
                    if cookies:
                        context.add_cookies(cookies)

                page = context.new_page()
                Stealth().apply_stealth_sync(page)
                page.goto(url, wait_until="domcontentloaded", timeout=15000)
                page.wait_for_timeout(2000)

                # Extract title
                title = ""
                title_el = page.query_selector("h1.QuestionHeader-title")
                if title_el:
                    title = title_el.inner_text().strip()
                if not title:
                    title = page.title() or ""
                    title = title.split(" - 知乎")[0].strip()
                # Strip "(XX 条消息)" notification prefix from tab title
                title = re.sub(r'^\(\d+ 条消息\)\s*', '', title)

                # Extract question description
                description = None
                for selector in [
                    "div.QuestionRichText",
                    "div.QuestionDetail-main .RichText",
                    "meta[name='description']",
                ]:
                    desc_el = page.query_selector(selector)
                    if desc_el:
                        if selector.startswith("meta"):
                            description = desc_el.get_attribute("content") or ""
                        else:
                            description = desc_el.inner_text().strip()
                        if description:
                            description = description[:2000]
                            break

                browser.close()

                if title and title != "知乎":
                    return title, description
        except Exception as e:
            logger.debug(f"Playwright question fetch failed for {url}: {e}")

        # Fallback: use the question ID
        qid = url.rstrip("/").split("/")[-1]
        return f"知乎问题 #{qid}", None


    def hunt_direct_url(self, url: str) -> List[ZhihuQuestion]:
        """Create a question card directly from a Zhihu question URL.

        Used when the user pastes a URL they were invited to answer.
        Fetches the page title and description so the card displays readable info.

        Args:
            url: A zhihu.com/question/<id> URL.

        Returns:
            Single-element list containing the ZhihuQuestion.
        """
        clean_url = re.sub(r'[>\'")\].,;]+$', '', url).lstrip("<").split("?")[0].split("#")[0].rstrip("/")
        title, description = self._fetch_question_info(clean_url)
        logger.info(f"Direct URL hunt: {clean_url!r} → title={title!r}, desc={len(description or '')} chars")
        return [ZhihuQuestion(title=title, url=clean_url, description=description)]

    def _fetch_question_info(self, url: str) -> tuple[str, Optional[str]]:
        """Fetch the question title and description via Playwright.

        Falls back to a URL-derived label if Playwright is unavailable or fails.

        Args:
            url: Cleaned question URL.

        Returns:
            Tuple of (title, description). Description may be None.
        """
        state_file = Path(__file__).resolve().parents[3] / "data" / "zhihu_state.json"
        try:
            from playwright.sync_api import sync_playwright
            from playwright_stealth import Stealth
            import json as _json

            with sync_playwright() as p:
                browser = p.chromium.launch(headless=True)
                context = browser.new_context(
                    user_agent=(
                        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                        "AppleWebKit/537.36 (KHTML, like Gecko) "
                        "Chrome/122.0.0.0 Safari/537.36"
                    )
                )
                # Load saved cookies if available
                if state_file.exists():
                    data = _json.loads(state_file.read_text(encoding="utf-8"))
                    cookies = data.get("cookies", [])
                    if cookies:
                        context.add_cookies(cookies)

                page = context.new_page()
                Stealth().apply_stealth_sync(page)
                page.goto(url, wait_until="domcontentloaded", timeout=15000)
                page.wait_for_timeout(2000)

                # Extract title
                title = ""
                title_el = page.query_selector("h1.QuestionHeader-title")
                if title_el:
                    title = title_el.inner_text().strip()
                if not title:
                    title = page.title() or ""
                    title = title.split(" - 知乎")[0].strip()
                # Strip "(XX 条消息)" notification prefix from tab title
                title = re.sub(r'^\(\d+ 条消息\)\s*', '', title)

                # Extract question description
                description = None
                for selector in [
                    "div.QuestionRichText",
                    "div.QuestionDetail-main .RichText",
                    "meta[name='description']",
                ]:
                    desc_el = page.query_selector(selector)
                    if desc_el:
                        if selector.startswith("meta"):
                            description = desc_el.get_attribute("content") or ""
                        else:
                            description = desc_el.inner_text().strip()
                        if description:
                            description = description[:2000]
                            break

                browser.close()

                if title and title != "知乎":
                    return title, description
        except Exception as e:
            logger.debug(f"Playwright question fetch failed for {url}: {e}")

        # Fallback: use the question ID
        qid = url.rstrip("/").split("/")[-1]
        return f"知乎问题 #{qid}", None

"""Zhihu Hunter — vault scanning, question discovery, and answer drafting.

Workflow:
    1. scan_and_hunt()  →  scans recent vault files, extracts keywords,
                           searches Tavily for matching Zhihu questions.
    2. draft_answer()   →  uses existing RAG + ZhihuGenerator to produce
                           a Zhihu-style answer draft ready for review.
"""

import os
import re
import requests
from datetime import datetime, timezone, timedelta
from pathlib import Path
from typing import List, Optional

from pydantic import BaseModel, Field

from health.utils.logging_config import setup_logger
from slack_bot.llm.gemini import GeminiLLM
from slack_bot.obsidian.generators import ZhihuGenerator
from slack_bot.obsidian.indexer import ObsidianIndexer
from slack_bot.obsidian.vector_store import ChromaVectorStore

logger = setup_logger(__name__)

# ── Vault directories to watch for new/updated content ────────────────────────
# Paths are relative to vault_path root.
WATCH_DIRS: List[str] = [
    "notes",
    "Article",
    "auto_report/deep-dive",
    "CLAW/deepfact",
    "Clippings",
]

# Article is always included regardless of time; Revised files are prioritized.
_ARTICLE_DIR = "Article"

# Short names users can type to target a specific directory.
DIR_SHORTCUTS: dict[str, str] = {
    "notes":      "notes",
    "article":    "Article",
    "clippings":  "Clippings",
    "claw":       "CLAW/deepfact",
    "deepfact":   "CLAW/deepfact",
    "deep-dive":  "auto_report/deep-dive",
}

_MAX_FILES_PER_DIR   = 4    # most-recent files per directory (keeps dir diversity)
_MAX_FILES_ARTICLE   = 8    # Article gets more slots (higher priority)
_MAX_KEYWORDS        = 10   # keywords extracted from vault scan
_MAX_QUESTIONS_TOTAL = 12   # total Zhihu questions surfaced per hunt
# Must contain /question/<id>; answer/comment subpages are excluded in _find_questions
_MIN_QUESTION_URL_RE = re.compile(r'zhihu\.com/question/\d+')

# ── Data Models ────────────────────────────────────────────────────────────────

class ZhihuQuestion(BaseModel):
    """A Zhihu question candidate surfaced by the hunter."""

    title: str
    url: str
    snippet: Optional[str] = None
    keywords: List[str] = Field(default_factory=list)
    found_at: str = Field(
        default_factory=lambda: datetime.now(tz=timezone.utc).isoformat()
    )
    description: Optional[str] = None   # question body/detail text from Zhihu page
    source_file: Optional[str] = None  # vault-relative path, set by scan_single_file()
    outline: Optional[str] = None       # user-provided answer logic/structure (direct URL mode)

class AnswerDraft(BaseModel):
    """A generated answer draft awaiting human review."""

    question: ZhihuQuestion
    content: str
    generated_at: str = Field(
        default_factory=lambda: datetime.now(tz=timezone.utc).isoformat()
    )
    rag_sources: List[str] = Field(default_factory=list)
    vault_file: Optional[str] = None  # absolute path set after saving to vault

# ── Main Class ─────────────────────────────────────────────────────────────────

class ZhihuHunter:
    """Discovers Zhihu questions from vault content and drafts answers.

    Example:
        hunter = ZhihuHunter(vault_path, vector_store, indexer)
        questions = hunter.scan_and_hunt(since_days=3)
        draft = hunter.draft_answer(questions[0])
    """

    def __init__(
        self,
        vault_path: Path,
        vector_store: ChromaVectorStore,
        indexer: ObsidianIndexer,
    ) -> None:
        """Initialize the hunter.

        Args:
            vault_path: Root path of the Obsidian vault.
            vector_store: ChromaDB vector store for semantic retrieval.
            indexer: ObsidianIndexer for keyword-based search and writing samples.
        """
        self.vault_path = vault_path
        self.vector_store = vector_store
        self.indexer = indexer
        self.llm = GeminiLLM()

    # ── Public API ─────────────────────────────────────────────────────────────

    def scan_and_hunt(
        self,
        since_days: int = 7,
        dirs: Optional[List[str]] = None,
        topic_hint: str = "",
    ) -> List[ZhihuQuestion]:
        """Scan vault files and surface matching Zhihu questions.

        Args:
            since_days: Age threshold for non-Article directories.
            dirs: Restrict scan to these vault dirs (relative paths). None = all WATCH_DIRS.
            topic_hint: Optional focus hint injected into keyword extraction prompt.

        Returns:
            List of ZhihuQuestion candidates (up to _MAX_QUESTIONS_TOTAL).
        """
        scan_desc = f"dirs={dirs or 'all'}" + (f", topic='{topic_hint}'" if topic_hint else "")
        logger.info(f"ZhihuHunter: scanning vault ({scan_desc})")

        keywords = self._scan_vault_for_keywords(since_days, dirs=dirs, topic_hint=topic_hint)
        if not keywords:
            logger.info("No keywords extracted from vault files")
            return []

        logger.info(f"Extracted keywords: {keywords}")
        questions = self._find_questions(keywords)
        logger.info(f"Found {len(questions)} Zhihu questions")
        return questions

    def scan_single_file(self, rel_path: str) -> List[ZhihuQuestion]:
        """Extract keywords from one vault file and find matching Zhihu questions.

        The returned questions carry ``source_file`` set to ``rel_path`` so that
        ``draft_answer()`` will ground the generated answer in that article.

        Args:
            rel_path: Path relative to vault_path (e.g. "Article/8-xxx-Revised.md").

        Returns:
            List of ZhihuQuestion candidates with source_file populated.

        Raises:
            FileNotFoundError: If the file does not exist under the vault root.
        """
        file_path = self.vault_path / rel_path
        if not file_path.exists():
            raise FileNotFoundError(f"文件不存在：{file_path}")

        text = file_path.read_text(encoding="utf-8", errors="ignore")
        text = re.sub(r'^---\n.*?\n---\n', '', text, flags=re.DOTALL).strip()
        logger.info(f"scan_single_file: {rel_path} ({len(text)} chars)")

        keywords = self._extract_keywords_with_llm(text[:5000])
        if not keywords:
            logger.info("No keywords extracted from single file")
            return []

        logger.info(f"Keywords from file: {keywords}")
        questions = self._find_questions(keywords)

        for q in questions:
            q.source_file = rel_path

        return questions

    def scan_glob_files(self, pattern: str) -> List[ZhihuQuestion]:
        """Find vault files matching a glob pattern and hunt Zhihu questions.

        All matched files are combined for keyword extraction, so the questions
        reflect the full set of content.  The most-recently-modified matched file
        is stored as ``source_file`` to ground the answer draft.

        Args:
            pattern: Glob pattern relative to vault_path
                     (e.g. "Article/*openclaw*-Revised.md").

        Returns:
            List of ZhihuQuestion candidates with source_file set.

        Raises:
            FileNotFoundError: If no files match the pattern.
        """
        matched = sorted(
            self.vault_path.glob(pattern),
            key=lambda p: p.stat().st_mtime,
            reverse=True,
        )
        if not matched:
            raise FileNotFoundError(f"没有文件匹配：{pattern}")

        names = [p.name for p in matched]
        logger.info(f"scan_glob_files: '{pattern}' → {len(matched)} files: {names}")

        # Combine content from all matched files (per-file budget so no one file
        # dominates) and extract keywords from the aggregate.
        parts: List[str] = []
        per_file_budget = 1500 if len(matched) == 1 else 800
        for fp in matched:
            try:
                text = fp.read_text(encoding="utf-8", errors="ignore")
                text = re.sub(r'^---\n.*?\n---\n', '', text, flags=re.DOTALL).strip()
                parts.append(f"[{fp.name}]\n{text[:per_file_budget]}")
            except OSError as e:
                logger.debug(f"Could not read {fp}: {e}")

        combined = "\n\n---\n\n".join(parts)
        keywords = self._extract_keywords_with_llm(combined[:8000])
        if not keywords:
            logger.info("No keywords extracted from glob-matched files")
            return []

        logger.info(f"Keywords from glob: {keywords}")
        questions = self._find_questions(keywords)

        # Primary source = most-recently-modified matched file
        primary = str(matched[0].relative_to(self.vault_path))
        for q in questions:
            q.source_file = primary

        return questions

    def hunt_direct_url(self, url: str) -> List[ZhihuQuestion]:
        """Create a question card directly from a Zhihu question URL.

        Used when the user pastes a URL they were invited to answer.
        Fetches the page title so the card displays a readable name.

        Args:
            url: A zhihu.com/question/<id> URL.

        Returns:
            Single-element list containing the ZhihuQuestion.
        """
        clean_url = re.sub(r'[>\'")\].,;]+$', '', url).lstrip("<").split("?")[0].split("#")[0].rstrip("/")
        title = self._fetch_question_title(clean_url)
        logger.info(f"Direct URL hunt: {clean_url!r} → title={title!r}")
        return [ZhihuQuestion(title=title, url=clean_url)]

    def _fetch_question_title(self, url: str) -> str:
        """Fetch the question title via Playwright (with saved session cookies).

        Falls back to a URL-derived label if Playwright is unavailable or fails.

        Args:
            url: Cleaned question URL.

        Returns:
            Question title string, or a fallback derived from the URL.
        """
        state_file = Path(__file__).resolve().parents[3] / "data" / "zhihu_state.json"
        try:
            from playwright.sync_api import sync_playwright
            from playwright_stealth import Stealth
            import json as _json

            with sync_playwright() as p:
                browser = p.chromium.launch(headless=True)
                context = browser.new_context(
                    user_agent=(
                        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                        "AppleWebKit/537.36 (KHTML, like Gecko) "
                        "Chrome/122.0.0.0 Safari/537.36"
                    )
                )
                # Load saved cookies if available
                if state_file.exists():
                    data = _json.loads(state_file.read_text(encoding="utf-8"))
                    cookies = data.get("cookies", [])
                    if cookies:
                        context.add_cookies(cookies)

                page = context.new_page()
                Stealth().apply_stealth_sync(page)
                page.goto(url, wait_until="domcontentloaded", timeout=15000)
                page.wait_for_timeout(2000)

                title = page.title()
                browser.close()

                if title:
                    # Page title format: "问题标题 - 知乎" or just "知乎"
                    title = title.split(" - 知乎")[0].strip()
                    if title and title != "知乎":
                        return title
        except Exception as e:
            logger.debug(f"Playwright title fetch failed for {url}: {e}")

        # Fallback: use the question ID
        qid = url.rstrip("/").split("/")[-1]
        return f"知乎问题 #{qid}"

    def _fetch_recent_web_context(self, query: str) -> str:
        """Search the web for recent articles/news about the query topic.

        Results are injected into the answer prompt so it reads like it
        comes from someone who's actively following the latest developments.

        Args:
            query: Topic or question title to search.

        Returns:
            Formatted string of recent snippets, or empty string on failure.
        """
        api_key = os.environ.get("TAVILY_API_KEY")
        if not api_key:
            return ""
        try:
            from tavily import TavilyClient
            client = TavilyClient(api_key=api_key)
            results = client.search(
                query=query,
                search_depth="basic",
                max_results=4,
                days=90,
                exclude_domains=["zhihu.com"],
            )
            items = results.get("results", [])
            if not items:
                return ""
            parts = [
                f"- {r['title']} ({r['url'].split('/')[2]}): {r.get('content', '')[:250]}"
                for r in items
            ]
            return "\n".join(parts)
        except Exception as e:
            logger.debug(f"Recent web context fetch failed: {e}")
            return ""

    def draft_answer(self, question: ZhihuQuestion, feedback: str = "") -> AnswerDraft:
        """Generate a Zhihu-style answer draft for a question.

        Delegates to the existing ZhihuGenerator (reuses prompt + style guide).
        The RAG context is enriched by both semantic (ChromaDB) and
        keyword-based (ObsidianIndexer) retrieval.

        Args:
            question: ZhihuQuestion to answer.
            feedback: Optional revision instructions from the user (e.g. "更口语化一些").

        Returns:
            AnswerDraft with generated content and source list.
        """
        logger.info(f"Drafting answer for: {question.title}" + (f" [feedback: {feedback[:40]}]" if feedback else ""))

        # Collect RAG sources for transparency
        rag_sources: List[str] = []

        semantic_results = self.vector_store.search_knowledge(question.title, top_k=3)
        for r in semantic_results:
            rag_sources.append(Path(r.source_path).name)

        keyword_results = self.indexer.search(question.title, limit=5)
        for note in keyword_results:
            first_line = note.split("\n")[0].strip("# ").strip()
            if first_line:
                rag_sources.append(first_line)

        # If the question was pinned to a specific source file, load it as the
        # primary article so the answer stays grounded in its viewpoints.
        primary_article = ""
        if question.source_file:
            fp = self.vault_path / question.source_file
            try:
                raw = fp.read_text(encoding="utf-8", errors="ignore")
                raw = re.sub(r'^---\n.*?\n---\n', '', raw, flags=re.DOTALL).strip()
                primary_article = raw[:4000]
                rag_sources.insert(0, Path(question.source_file).name)
                logger.info(f"Loaded primary article: {question.source_file} ({len(primary_article)} chars)")
            except OSError as e:
                logger.warning(f"Could not read source file {question.source_file}: {e}")

        # Delegate to existing ZhihuGenerator for consistent style
        generator = ZhihuGenerator(self.indexer, self.vector_store)
        prompt_input = question.title
        if question.snippet:
            prompt_input += f" | 背景：{question.snippet}"

        # User-provided outline takes highest priority — structure the answer strictly
        # around it, pulling matching content from the knowledge base for each point.
        if question.outline:
            prompt_input += (
                f"\n\n用户提供的回答思路（必须严格按照这个逻辑框架展开，"
                f"从知识库中找到对应的内容来支撑每个论点，不要自由发挥框架结构）：\n{question.outline}"
            )
            logger.info(f"Injected user outline: {question.outline[:60]!r}")

        if primary_article:
            prompt_input += (
                f"\n\n主要参考文章（请基于此文的观点和论据来回答，"
                f"可直接引用其中的分析和结论）：\n{primary_article}"
            )

        # Inject recent web context so the answer feels up-to-date
        recent_context = self._fetch_recent_web_context(question.title)
        if recent_context:
            prompt_input += (
                f"\n\n近期相关资讯（最近90天内的动态，用来补充最新工具/发布/讨论，"
                f"让回答更有时效感，自然地融入即可，不要刻意罗列）：\n{recent_context}"
            )
            logger.info(f"Injected recent web context ({len(recent_context)} chars)")

        if feedback:
            prompt_input += f"\n\n用户修改意见：{feedback}"

        content, _ = generator.chat(prompt_input, history=[])

        return AnswerDraft(
            question=question,
            content=content,
            rag_sources=list(dict.fromkeys(rag_sources)),  # deduplicate, keep order
        )

    # ── Vault Scanning ─────────────────────────────────────────────────────────

    def _scan_vault_for_keywords(
        self,
        since_days: int,
        dirs: Optional[List[str]] = None,
        topic_hint: str = "",
    ) -> List[str]:
        """Collect vault files and extract topic keywords via LLM.

        Args:
            since_days: Age threshold for non-Article directories.
            dirs: Dirs to scan; None = all WATCH_DIRS.
            topic_hint: Optional focus instruction for keyword extraction.

        Returns:
            Deduplicated list of topic keywords (up to _MAX_KEYWORDS).
        """
        cutoff = datetime.now(tz=timezone.utc) - timedelta(days=since_days)
        files = self._collect_recent_files(cutoff, dirs=dirs)

        if not files:
            return []

        # Article-only scans get a larger per-file budget (higher priority content)
        scan_dirs = dirs or WATCH_DIRS
        per_file_budget = 800 if scan_dirs == [_ARTICLE_DIR] else 500
        combined = self._read_files_summary(files, max_chars_per_file=per_file_budget)
        return self._extract_keywords_with_llm(combined, topic_hint=topic_hint)

    def _collect_recent_files(
        self,
        cutoff: datetime,
        dirs: Optional[List[str]] = None,
    ) -> List[Path]:
        """Walk vault directories and return candidate files.

        Article directory:
          - No time filter (always included regardless of cutoff)
          - Revised files sorted first, then by mtime descending
          - Up to _MAX_FILES_ARTICLE slots
        Other directories:
          - Only files modified after cutoff
          - Sorted by mtime descending
          - Up to _MAX_FILES_PER_DIR slots

        Args:
            cutoff: Freshness threshold for non-Article directories.
            dirs: Directories to scan (relative paths). None = all WATCH_DIRS.

        Returns:
            List of Path objects.
        """
        scan_dirs = dirs if dirs is not None else WATCH_DIRS
        collected: List[Path] = []

        for rel_dir in scan_dirs:
            watch_dir = self.vault_path / rel_dir
            if not watch_dir.is_dir():
                logger.debug(f"Watch dir not found, skipping: {watch_dir}")
                continue

            # (mtime, is_revised, path)
            candidates: List[tuple[float, bool, Path]] = []
            for md_file in watch_dir.rglob("*.md"):
                try:
                    mtime = md_file.stat().st_mtime
                    if rel_dir != _ARTICLE_DIR:
                        file_dt = datetime.fromtimestamp(mtime, tz=timezone.utc)
                        if file_dt < cutoff:
                            continue
                    is_revised = md_file.name.lower().endswith("-revised.md")
                    candidates.append((mtime, is_revised, md_file))
                except OSError:
                    continue

            if rel_dir == _ARTICLE_DIR:
                # Revised files first, then newest; give double slots
                candidates.sort(key=lambda t: (0 if t[1] else 1, -t[0]))
                max_files = _MAX_FILES_ARTICLE
            else:
                candidates.sort(key=lambda t: -t[0])
                max_files = _MAX_FILES_PER_DIR

            collected.extend(p for _, _, p in candidates[:max_files])

        logger.debug(f"Collected {len(collected)} files from vault (dirs={scan_dirs})")
        return collected

    def _read_files_summary(
        self,
        files: List[Path],
        max_chars: int = 8000,
        max_chars_per_file: int = 500,
    ) -> str:
        """Read and concatenate file contents up to a character budget.

        Each file is capped at max_chars_per_file so that no single large file
        exhausts the total budget and crowds out other directories.

        Args:
            files: List of markdown file paths.
            max_chars: Total character budget across all files.
            max_chars_per_file: Per-file cap to ensure directory diversity.

        Returns:
            Concatenated content string with file-name headers.
        """
        parts: List[str] = []
        remaining = max_chars

        for f in files:
            if remaining <= 0:
                break
            try:
                text = f.read_text(encoding="utf-8", errors="ignore")
                # Strip YAML frontmatter
                text = re.sub(r'^---\n.*?\n---\n', '', text, flags=re.DOTALL).strip()
                chunk = text[:min(max_chars_per_file, remaining)]
                parts.append(f"[{f.name}]\n{chunk}")
                remaining -= len(chunk)
            except OSError as e:
                logger.debug(f"Could not read {f}: {e}")

        return "\n\n---\n\n".join(parts)

    def _extract_keywords_with_llm(
        self,
        combined_text: str,
        topic_hint: str = "",
    ) -> List[str]:
        """Use LLM to distill topic keywords from aggregated vault content.

        Args:
            combined_text: Concatenated recent vault content.
            topic_hint: Optional focus instruction (e.g. "agent开发").

        Returns:
            List of keyword strings (up to _MAX_KEYWORDS).
        """
        topic_instruction = (
            f"\n- 重点关注与「{topic_hint}」相关的话题，优先提取这个方向的关键词"
            if topic_hint else ""
        )
        prompt = f"""你是一位知乎内容运营专家。请从以下笔记内容中提炼出适合在知乎搜索问题的关键词。

要求：
- 输出 {_MAX_KEYWORDS} 个以内的关键词，每行一个，无序号无符号
- 关键词要在知乎上有真实讨论热度，即普通中国用户会关心并提问的话题领域
- 避免过于偏门的专有名词（如具体项目代号、小众产品名），要用它所属的上一级概念
  例：「OpenClaw」→「AI智能体安全」；「BadJs」→「微信监控机制」；「Moon Monitor」→「健康监测设备」
- 不要太泛（如"AI""技术""健康"），也不要太窄（如只有一个工具代码名称）
- 优先选择最近讨论最多、最有深度的话题{topic_instruction}
- 只输出关键词列表，不要解释

=== 笔记内容 ===
{combined_text[:5000]}
"""
        try:
            response, _ = self.llm.generate_response(prompt, [])
            keywords = [
                line.strip()
                for line in response.strip().splitlines()
                if line.strip() and not line.strip().startswith("#")
            ]
            return keywords[:_MAX_KEYWORDS]
        except Exception as e:
            logger.error(f"Keyword extraction failed: {e}")
            return []

    # ── Question Discovery ─────────────────────────────────────────────────────

    def _find_questions(self, keywords: List[str]) -> List[ZhihuQuestion]:
        """Search Tavily for Zhihu questions matching each keyword.

        Collects candidates from ALL keywords before truncating so that later
        keywords (which can cover different facets) are not silently dropped.

        Args:
            keywords: List of topic keywords to search.

        Returns:
            Deduplicated ZhihuQuestion list (up to _MAX_QUESTIONS_TOTAL).
        """
        from tavily import TavilyClient

        api_key = os.environ.get("TAVILY_API_KEY")
        if not api_key:
            raise RuntimeError("TAVILY_API_KEY not set")

        client = TavilyClient(api_key=api_key)
        seen_urls: set[str] = set()
        questions: List[ZhihuQuestion] = []

        for kw in keywords:
            try:
                results = self._tavily_search(client, kw)
                for item in results:
                    url = item.get("url", "")
                    if not _MIN_QUESTION_URL_RE.search(url):
                        continue  # skip non-question URLs (e.g. zhihu.com/people/...)
                    # Normalize: extract bare URL to strip invisible chars, then remove params
                    _m = re.search(r'https?://\S+', url)
                    if not _m:
                        continue
                    clean_url = re.sub(r'[>\'")\].,;]+$', '', _m.group(0)).split("?")[0].split("#")[0].rstrip("/")
                    # Skip answer/comment subpages — we want the question itself
                    if re.search(r'/answer/|/comment/', clean_url):
                        continue
                    if clean_url in seen_urls:
                        continue
                    seen_urls.add(clean_url)
                    questions.append(
                        ZhihuQuestion(
                            title=item.get("title", kw),
                            url=clean_url,
                            snippet=item.get("content", "")[:200] or None,
                            keywords=[kw],
                        )
                    )
            except Exception as e:
                logger.warning(f"Tavily search failed for keyword '{kw}': {e}")

        logger.info(f"Found {len(questions)} Zhihu questions (capped at {_MAX_QUESTIONS_TOTAL})")
        return questions[:_MAX_QUESTIONS_TOTAL]

    def _tavily_search(self, client, keyword: str) -> List[dict]:
        """Run a single Tavily search scoped to zhihu.com/question.

        Args:
            client: Authenticated TavilyClient instance.
            keyword: Search keyword.

        Returns:
            List of result dicts from Tavily (url, title, content, score).
        """
        query = f"site:zhihu.com/question {keyword}"
        logger.debug(f"Tavily search: {query}")
        response = client.search(
            query=query,
            search_depth="basic",
            max_results=8,
            include_domains=["zhihu.com"],
            days=365,   # prefer questions with recent activity (higher chance of top rank)
        )
        return response.get("results", [])

-        Fetches the page title so the card displays a readable name.

+        Fetches the page title and description so the card displays readable info.

-        title = self._fetch_question_title(clean_url)

-        logger.info(f"Direct URL hunt: {clean_url!r} → title={title!r}")

-        return [ZhihuQuestion(title=title, url=clean_url)]

+        title, description = self._fetch_question_info(clean_url)

+        logger.info(f"Direct URL hunt: {clean_url!r} → title={title!r}, desc={len(description or '')} chars")

+        return [ZhihuQuestion(title=title, url=clean_url, description=description)]

-    def _fetch_question_title(self, url: str) -> str:

-        """Fetch the question title via Playwright (with saved session cookies).

+    def _fetch_question_info(self, url: str) -> tuple[str, Optional[str]]:

+        """Fetch the question title and description via Playwright.

-            Question title string, or a fallback derived from the URL.

+            Tuple of (title, description). Description may be None.

+                title_el = page.query_selector("h1.QuestionHeader-title")

+                    title = title_el.inner_text().strip()

+                    title = page.title() or ""

+                    title = title.split(" - 知乎")[0].strip()

+                # Strip "(XX 条消息)" notification prefix from tab title

+                title = re.sub(r'^\(\d+ 条消息\)\s*', '', title)

+                # Extract question description

+                    "div.QuestionRichText",

+                    "div.QuestionDetail-main .RichText",

+                    "meta[name='description']",

+                    desc_el = page.query_selector(selector)

+                        if selector.startswith("meta"):

+                            description = desc_el.get_attribute("content") or ""

+                            description = desc_el.inner_text().strip()

+                        if description:

+                            description = description[:2000]

-                    # Page title format: "问题标题 - 知乎" or just "知乎"

-                    title = title.split(" - 知乎")[0].strip()

-                    if title and title != "知乎":

+                if title and title != "知乎":

+                    return title, description

-            logger.debug(f"Playwright title fetch failed for {url}: {e}")

+            logger.debug(f"Playwright question fetch failed for {url}: {e}")


**改动 3: `draft_answer()` 注入问题描述**


        # Delegate to existing ZhihuGenerator for consistent style
        generator = ZhihuGenerator(self.indexer, self.vector_store)
        prompt_input = question.title
        if question.snippet:
            prompt_input += f" | 背景：{question.snippet}"

        # Delegate to existing ZhihuGenerator for consistent style
        generator = ZhihuGenerator(self.indexer, self.vector_store)
        prompt_input = question.title
        if question.description:
            prompt_input += f"\n\n问题详细描述：{question.description}"
        if question.snippet:
            prompt_input += f" | 背景：{question.snippet}"