     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→


Bash

grep -rn "fetch.*question\|get.*description\|get.*content\|question.*description" /root/projects/butler/slack_bot/zhihu/ 2>/dev/null | grep -v ".pyc" | head -30


/root/projects/butler/slack_bot/zhihu/zhihu_hunter.py:252:        title = self._fetch_question_title(clean_url)
/root/projects/butler/slack_bot/zhihu/zhihu_hunter.py:256:    def _fetch_question_title(self, url: str) -> str:
/root/projects/butler/slack_bot/zhihu/zhihu_hunter.py:338:                f"- {r['title']} ({r['url'].split('/')[2]}): {r.get('content', '')[:250]}"
/root/projects/butler/slack_bot/zhihu/zhihu_hunter.py:411:        recent_context = self._fetch_recent_web_context(question.title)
/root/projects/butler/slack_bot/zhihu/zhihu_hunter.py:454:        # Article-only scans get a larger per-file budget (higher priority content)
/root/projects/butler/slack_bot/zhihu/zhihu_hunter.py:646:                            snippet=item.get("content", "")[:200] or None,


ls -la /root/projects/butler/data/ 2>/dev/null; echo "---"; cat /root/projects/butler/.gitignore 2>/dev/null | grep -i "zhihu\|cookie\|state" || echo "No .gitignore entries found for Zhihu"


total 12
drwxr-xr-x  3  501 games   54 Feb  6 15:21 .
drwxr-xr-x 20 root root  4096 Mar 10 13:43 ..
drwxr-xr-x  9  501 games 4096 Mar 16 11:33 health
-rw-r--r--  1  501 games  233 Feb  6 15:21 proxy_health_history.jsonl
---
No .gitignore entries found for Zhihu


grep -n "zhihu\|Zhihu" /root/projects/butler/slack_bot/obsidian/dispatcher.py -i | head -30


8:from slack_bot.obsidian.generators import WritingAssistant, ReplyGenerator, DecisionSupport, SearchAnalyzer, DeAIReviser, ZhihuGenerator
46:    MODE_ZHIHU = "zhihu"
48:    MODE_ZHIHU_HUNTER = "zhihu-hunter"
50:    VALID_MODES = {MODE_WRITE, MODE_REPLY, MODE_DECIDE, MODE_SEARCH, MODE_DEAI, MODE_ZHIHU, MODE_NOTE, MODE_ZHIHU_HUNTER}
73:            self.MODE_ZHIHU: ZhihuGenerator(self.indexer, self.vector_store),
79:        # Initialize Zhihu Hunter components
80:        from slack_bot.zhihu.zhihu_hunter import ZhihuHunter
81:        from slack_bot.zhihu.zhihu_playwright_engine import ZhihuPlaywrightEngine
82:        from slack_bot.zhihu.slack_interactive_gateway import SlackInteractiveGateway
84:        zhihu_notify_channel = os.environ.get("ZHIHU_NOTIFY_CHANNEL", "")
85:        self.zhihu_hunter = ZhihuHunter(
90:        _zhihu_engine = ZhihuPlaywrightEngine(
92:            notify_channel=zhihu_notify_channel,
94:        self.zhihu_gateway = SlackInteractiveGateway(
96:            hunter=self.zhihu_hunter,
97:            engine=_zhihu_engine,
98:            notify_channel=zhihu_notify_channel,
133:            self._reply(channel_id, "⚠ No mode selected. Use `mode write/reply/decide/search/deai/zhihu/note/zhihu-hunter` to start.", response_ts)
146:        # MODE_ZHIHU_HUNTER: scan vault for keywords, surface Zhihu question candidates
156:        #   https://www.zhihu.com/question/... → direct URL: answer this specific question
159:        if current_mode == self.MODE_ZHIHU_HUNTER:
160:            from slack_bot.zhihu.zhihu_hunter import DIR_SHORTCUTS
168:                    questions = self.zhihu_hunter.scan_and_hunt(since_days=7)
170:                elif words and 'zhihu.com/question/' in words[0]:
179:                    questions = self.zhihu_hunter.hunt_direct_url(url)
182:                    logger.info(f"ZhihuHunter: direct URL {url!r} → {questions[0].title!r}" +
193:                    questions = self.zhihu_hunter.scan_glob_files(pattern)
194:                    logger.info(f"ZhihuHunter: glob '{pattern}' → {len(questions)} questions")
205:                    questions = self.zhihu_hunter.scan_single_file(rel_path)
206:                    logger.info(f"ZhihuHunter: single-file scan '{rel_path}' → {len(questions)} questions")


   156→        #   https://www.zhihu.com/question/... → direct URL: answer this specific question
   157→        #
   158→        # Dir shortcuts: notes / article / clippings / claw / deepfact / deep-dive
   159→        if current_mode == self.MODE_ZHIHU_HUNTER:
   160→            from slack_bot.zhihu.zhihu_hunter import DIR_SHORTCUTS
   161→            try:
   162→                trigger_words = {"开始", "hunt", "扫描", "start", "go"}
   163→                words = [w for w in text.strip().split() if w.lower() not in trigger_words]
   164→
   165→                if not words:
   166→                    # Bare trigger → full auto scan
   167→                    self._reply(channel_id, "🔍 正在扫描知乎候选问题（所有目录）...", response_ts)
   168→                    questions = self.zhihu_hunter.scan_and_hunt(since_days=7)
   169→
   170→                elif words and 'zhihu.com/question/' in words[0]:
   171→                    # Direct URL paste — user was invited or found a question to answer.
   172→                    # Any text after the URL is treated as an optional answer outline.
   173→                    # Strip trailing Slack/HTML junk chars (>, ", etc.) from the URL.
   174→                    import re as _re
   175→                    url = _re.sub(r'[>\'")\].,;]+$', '', words[0])
   176→                    outline = " ".join(words[1:])[:500] if len(words) > 1 else ""
   177→                    hint = f"（已记录回答思路）" if outline else ""
   178→                    self._reply(channel_id, f"🔍 获取问题标题，准备生成草稿{hint}...", response_ts)
   179→                    questions = self.zhihu_hunter.hunt_direct_url(url)
   180→                    if outline and questions:
   181→                        questions[0].outline = outline
   182→                    logger.info(f"ZhihuHunter: direct URL {url!r} → {questions[0].title!r}" +
   183→                                (f" outline={outline[:40]!r}" if outline else ""))
   184→
   185→                elif words and ('*' in words[0] or '?' in words[0]):
   186→                    # Glob pattern → combine all matched files for keyword extraction
   187→                    pattern = words[0]
   188→                    self._reply(
   189→                        channel_id,
   190→                        f"🔍 匹配 `{pattern}` 中的文件，提取观点搜索知乎问题...",
   191→                        response_ts,
   192→                    )
   193→                    questions = self.zhihu_hunter.scan_glob_files(pattern)
   194→                    logger.info(f"ZhihuHunter: glob '{pattern}' → {len(questions)} questions")
   195→
   196→                elif words and ('/' in words[0] or words[0].endswith('.md')):
   197→                    # Single-file mode: extract questions from one specific file,
   198→                    # and pass source_file so answers stay grounded in that article.
   199→                    rel_path = words[0]
   200→                    self._reply(
   201→                        channel_id,
   202→                        f"🔍 从 `{rel_path}` 提取观点，搜索匹配知乎问题...",
   203→                        response_ts,
   204→                    )
   205→                    questions = self.zhihu_hunter.scan_single_file(rel_path)
   206→                    logger.info(f"ZhihuHunter: single-file scan '{rel_path}' → {len(questions)} questions")
   207→
   208→                elif words[0].lower() in DIR_SHORTCUTS:
   209→                    # Dir-scoped scan with optional topic hint
   210→                    rel_dir = DIR_SHORTCUTS[words[0].lower()]
   211→                    topic_hint = " ".join(words[1:])
   212→                    hint_desc = f"，聚焦：{topic_hint}" if topic_hint else ""
   213→                    self._reply(
   214→                        channel_id,
   215→                        f"🔍 扫描 {rel_dir} 目录{hint_desc}，请稍候...",
   216→                        response_ts,
   217→                    )
   218→                    questions = self.zhihu_hunter.scan_and_hunt(
   219→                        since_days=7, dirs=[rel_dir], topic_hint=topic_hint
   220→                    )
   221→
   222→                else:
   223→                    # Direct keywords → skip vault scan entirely
   224→                    self._reply(channel_id, f"🔍 使用指定关键词搜索：{' · '.join(words)}", response_ts)
   225→                    questions = self.zhihu_hunter._find_questions(words)
   226→                    logger.info(f"ZhihuHunter: manual keywords {words} → {len(questions)} questions")
   227→
   228→                self.zhihu_gateway.post_question_cards(questions, channel=channel_id)
   229→            except Exception as e:
   230→                logger.error(f"Zhihu hunt failed: {e}", exc_info=True)
   231→                self._reply(channel_id, f"❌ 扫描失败：{e}", response_ts)
   232→            return
   233→
   234→        # Get history
   235→        history = storage.get_context()
   236→        generator = self.generators[current_mode]
   237→
   238→        try:
   239→            logger.info(f"Generating Obsidian response in mode: {current_mode}")
   240→            # The chat method handles first turn logic and returns updated history
   241→            # But wait, our generators' chat method appends to history internally.
   242→            # In our Slack setup, we want to append the result to ContextStorage.
   243→
   244→            # Note: generator.chat(text, history) returns (response, new_history_list)
   245→            # new_history_list contains [...history, {user: rich_prompt}, {assistant: response}]
   246→            response_text, updated_history = generator.chat(text, history)
   247→
   248→            # Since generator.chat already did the LLM call and returned history,
   249→            # we just need to save the ONLY NEW messages to our storage.
   250→            # However, during FIRST TURN, text is replaced by rich_prompt.
   251→
   252→            # For simplicity: completely overwrite storage with updated_history
   253→            # ContextStorage doesn't support overwrite easily, but we can clear and re-add.
   254→            # Better: Modify ObsidianContextStorage to support set_context.
   255→


   519→class ZhihuGenerator(BaseGenerator):
   520→    """Generate Zhihu-style answers based on style guide and user's knowledge base."""
   521→
   522→    def chat(self, user_input: str, history: List[Dict]) -> Tuple[str, List[Dict]]:
   523→
   524→        is_first_turn = (len(history) == 0)
   525→        built_rich_prompt = False  # tracks whether we ran the full question->draft pipeline
   526→
   527→        # Pre-load rule files (needed for both generation and second-pass review)
   528→        zhihu_bot_style = self._read_file("zhihu-bot-style.md")
   529→        powell_revise_guide = self._read_file("POWELL_REVISE.md")
   530→
   531→        # Treat as a new question if:
   532→        # - history is empty (genuine first turn), OR
   533→        # - user input contains "|" (explicit "Question | Core Ideas" format), OR
   534→        # - user input starts with known question triggers like "回答这个问题"
   535→        is_new_question = (
   536→            is_first_turn
   537→            or "|" in user_input
   538→            or user_input.strip().startswith("回答这个问题")
   539→        )
   540→
   541→        if is_new_question:
   542→            # === First Turn: Build Rich Prompt ===
   543→            # Expected input format: "Question | Core Ideas"
   544→            parts = user_input.split("|", 1)
   545→            question = parts[0].strip()
   546→            core_ideas = parts[1].strip() if len(parts) > 1 else ""
   547→
   548→            # 1. Load Style Guide (zhihu-style.md serves as writing standard, NOT as material)
   549→            zhihu_style_guide = self._read_file("zhihu-style.md")
   550→            # Bot-specific overrides (plain-text output, no bracket explanations, etc.)
   551→            if zhihu_bot_style:
   552→                zhihu_style_guide = zhihu_style_guide + "\n\n" + zhihu_bot_style
   553→
   554→            # 2. RAG from Obsidian vault for actual content material
   555→            rag_notes = self.indexer.search(question, limit=5)
   556→            rag_text = "\n\n".join(rag_notes)
   557→
   558→            # 3. Load methodology
   559→            methodology = self._read_file("methodology.md")
   560→
   561→            semantic_context = self._semantic_search(question, top_k=3)
   562→
   563→            prompt = f"""You are answering a Zhihu question. Your goal is to write an authentic, experience-driven answer that sounds like a real human, not AI-generated.
   564→
   565→=== YOUR IDENTITY & METHODOLOGY ===
   566→{methodology}
   567→
   568→=== ANTI-AI-TONE GUIDE (APPLY WHILE WRITING, NOT JUST AT THE END) ===
   569→{powell_revise_guide}
   570→
   571→=== ZHIHU STYLE GUIDE (FOLLOW THESE WRITING PRINCIPLES) ===
   572→{zhihu_style_guide}
   573→
   574→CRITICAL: The style guide above defines HOW to write (tone, structure, language patterns).
   575→Do NOT treat zhihu-sample content as factual material. It's ONLY for style reference.
   576→
   577→=== RELEVANT KNOWLEDGE FROM YOUR OBSIDIAN VAULT (USE AS ACTUAL MATERIAL) ===
   578→{rag_text}
   579→
   580→{semantic_context}
   581→
   582→=== THE QUESTION ===
   583→{question}
   584→
   585→=== YOUR CORE IDEAS ===
   586→{core_ideas}
   587→
   588→=== TASK ===
   589→1. Answer the question based on YOUR knowledge (from Obsidian vault) and core ideas
   590→2. FOLLOW THE ANTI-AI-TONE GUIDE FIRST, then the Zhihu style guide
   591→3. Share real experiences, examples, and insights from the vault material
   592→4. Write in Chinese (unless question implies English)
   593→5. Minimize first person "我": use it sparingly, not as filler. Observations and judgments can stand without "我认为" or "我觉得" as prefix.
   594→6. Be direct and opinionated. Attach a concrete evaluation to every fact or feature you mention (e.g. "暴力但有效", "极度脆弱"). Pure feature listing is forbidden.
   595→7. AVOID AT ALL COSTS: AI tone, empty buzzwords, hollow phrases, and 宏大叙事 (不可逆的浪潮, 范式转移, 时代大背景).
   596→8. If recent web context is provided, weave it in naturally — like someone who reads a lot online
   597→9. STRICT: Never add bracket explanations for any term. Write "大语言模型" not "大语言模型（LLM）". If a term needs explaining, use a sentence, not parentheses.
   598→10. No public account names, no self-promotion, no引流 of any kind — this is the highest priority rule.
   599→11. STRICT: Never reveal personal identity or professional background. Do not mention employer, team, job title, product name, or any detail that could identify who you are.
   600→12. STRICT: Never reference past projects, articles, or talks by name.
   601→13. No poetry, quotes, or "升华" endings. No首尾呼应 (repeating the opening point at the end). Stop when the point is made.
   602→14. LENGTH: 500–1000 Chinese characters. Cut a point, not quality.
   603→
   604→=== BEFORE SUBMITTING, CHECK THESE ===
   605→- Is there any AI-generated tone or phrasing?
   606→- Are there any buzzwords or jargon that need to be replaced with plain language?
   607→- Is the answer direct and opinionated, not neutral or "balanced"?
   608→- Does it sound like a real human wrote it, not a machine?
   609→
   610→Begin writing the answer:
   611→"""
   612→
   613→            logger.info(f"Generating Zhihu answer for: {question[:50]}...")
   614→            actual_user_message = prompt
   615→            built_rich_prompt = True
   616→
   617→        else:
   618→            # === Follow-up Turn: User gives refinement instructions (e.g. "make it shorter") ===


Perfect! Now I have a comprehensive understanding of the Zhihu integration. Let me create a final summary document:

## Summary: Zhihu Question Content Fetching Architecture

I've thoroughly analyzed the project's Zhihu integration. Here are my key findings:

### 1. **The `_fetch_question_title()` Method** (`/root/projects/butler/slack_bot/zhihu/zhihu_hunter.py`, lines 256-307)

This method fetches **only the question title**, not the full description. Here's what it does:

```python
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.
    """
```

**Key behaviors:**
- Uses Playwright with `playwright-stealth` to avoid bot detection
- Sets Windows user-agent: `Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/122.0.0.0`
- Loads saved cookies from `/root/projects/butler/data/zhihu_state.json` if available
- Navigates to the question URL with `wait_until="domcontentloaded"` and 15-second timeout
- Waits 2 seconds for React to render
- Extracts the page `title()` which comes in format: `"问题标题 - 知乎"`
- Strips `" - 知乎"` suffix to get clean title
- **Falls back to `知乎问题 #{question_id}`** if Playwright fails

### 2. **Playwright-based Scraping** (`/root/projects/butler/slack_bot/zhihu/zhihu_playwright_engine.py`)

This is a comprehensive Zhihu browser automation engine that handles:

**Authentication:**
- Persistent cookies stored in `/root/projects/butler/data/zhihu_state.json` (JSON format)
- QR-code login flow with Slack notifications (sends QR code to user)
- Automatic session validation before operations
- Stealth mode to bypass anti-bot detection

**Answer Publishing Pipeline:**
1. Navigate to Zhihu home page first (to "warm up" the session)
2. Navigate to question URL with timeout handling
3. Detect 404 pages and login redirects
4. Find and click "写回答" (write answer) button with multiple CSS selector strategies
5. Fill answer text using three strategies (in order):
   - `execCommand('insertText')` - most reliable for Draft.js
   - Synthetic ClipboardEvent paste
   - Keyboard.type() with 8ms delay per character
6. Find and click publish button
7. Extract answer URL from published page

**Key selectors for DOM manipulation:**
- QR code: `.SignFlow-qrcode img`, `.LoginQrcode img`, canvas variants
- Write answer button: multiple selectors + JavaScript TreeWalker fallback (handles zero-width characters)
- Editor: `.DraftEditor-content[contenteditable='true']`, `.RichText [contenteditable='true']`, etc.
- Publish button: `button:has-text('发布回答')`, `.AnswerForm button:has-text('发布')`, etc.

### 3. **What's NOT Implemented: Full Question Content Fetching**

**Currently, there is NO method to fetch the full question description/details.** The flow only captures:

1. **From Tavily search results** (lines 626-675):
   - Title (from search result)
   - URL
   - Snippet (up to 200 chars from `item.get("content")`)

2. **From direct URL** (line 252):
   - Title only (via `_fetch_question_title()`)
   - URL

3. **What's available but not used:**
   - The `ZhihuQuestion` model (lines 61-72) has an optional `snippet` field
   - Tavily returns `content` which is used as the snippet
   - There's NO field for full question description

### 4. **Cookie Management**

- **Location:** `/root/projects/butler/data/zhihu_state.json`
- **Format:** JSON with `{"cookies": [...], "saved_at": timestamp}`
- **Loaded automatically** by Playwright in both `_fetch_question_title()` (line 283-287) and `publish_answer()` (line 141)
- **NO environment variable** like `ZHIHU_COOKIE` - it's only file-based
- **Only config:** `ZHIHU_NOTIFY_CHANNEL=D0AACF9G4J0` in `.env` (for Slack notifications)

### 5. **Architecture Flow**

```
User inputs URL/keywords
    ↓
ZhihuHunter.hunt_direct_url() or scan_and_hunt()
    ↓
_fetch_question_title() [Playwright] → title only
    ↓
Tavily API search → title + snippet (200 chars)
    ↓
ZhihuQuestion model (with optional snippet field)
    ↓
SlackInteractiveGateway.post_question_cards() → Slack Block Kit UI
    ↓
User clicks "生成草稿" (Generate Draft)
    ↓
ZhihuHunter.draft_answer() [uses title + snippet + vault RAG, NOT full question]
    ↓
Generate answer draft
    ↓
User clicks "确认发布" (Confirm Publish)
    ↓
ZhihuPlaywrightEngine.publish_answer() → Playwright automation
```

### 6. **Key Limitations**

1. **Only title + snippet available** - the full question description is never fetched
2. **Snippet is limited to 200 chars** - from Tavily, not from Zhihu directly
3. **No direct Zhihu API access** - relies on web scraping via Playwright
4. **Can't fetch without valid session** - Zhihu blocks headless browsers accessing full content without authentication
5. **No method to extract question details** from the Playwright page after loading

### 7. **What Might Be Failing**

If you're trying to fetch full question content, the issues could be:

1. **Invalid/expired cookies** - `zhihu_state.json` may be outdated (Zhihu cookies expire)
2. **Zhihu DOM changed** - selectors in lines 25-72 of `zhihu_playwright_engine.py` may be stale
3. **Anti-bot detection** - even with stealth mode, Zhihu's anti-scraping may block Playwright
4. **Missing question description field** - no field exists in `ZhihuQuestion` model to store it
5. **Playwright timeout** - 15-second timeout in `_fetch_question_title()` may be insufficient

### File Paths Summary

- **Main hunter:** `/root/projects/butler/slack_bot/zhihu/zhihu_hunter.py` (676 lines)
- **Playwright engine:** `/root/projects/butler/slack_bot/zhihu/zhihu_playwright_engine.py` (706 lines)
- **Interactive Slack gateway:** `/root/projects/butler/slack_bot/zhihu/slack_interactive_gateway.py` (655 lines)
- **Obsidian dispatcher integration:** `/root/projects/butler/slack_bot/obsidian/dispatcher.py` (starts at line 159 for Zhihu mode)
- **Zhihu style guides:** `/root/projects/butler/zhihu-bot-style.md`, `/root/projects/butler/zhihu-style.md`
- **Configuration:** `.env` contains `ZHIHU_NOTIFY_CHANNEL` only
- **Cookies:** `/root/projects/butler/data/zhihu_state.json` (only created after first QR login)

agentId: a398cb930968a869e (for resuming to continue this agent's work if needed)
<usage>total_tokens: 68383
tool_uses: 23
duration_ms: 885636</usage>

Prompt:
I need to understand how this project fetches Zhihu question content. Please find and read:

1. The `_fetch_question_title()` method in `/root/projects/butler/slack_bot/zhihu/zhihu_hunter.py` - read the full method
2. Any Playwright-related code for Zhihu page scraping (check `zhihu_playwright_engine.py`)
3. Any cookie handling or authentication logic for Zhihu
4. Check if there's any method that fetches the FULL question description (not just title)

Also check for any config related to Zhihu cookies (look for cookie files, env vars like ZHIHU_COOKIE, etc.)

Be thorough - I need to understand exactly how the bot interacts with Zhihu's website and what might be failing.


Let me check the cookie state file and also look at the specific `_fetch_question_title` method code, plus try to run Playwright to see what actually happens when accessing a Zhihu page.


让我检查 cookie 状态和实际抓取情况。


ls -la /root/projects/butler/data/zhihu_state.json 2>/dev/null && python3 -c "
import json
with open('/root/projects/butler/data/zhihu_state.json') as f:
    data = json.load(f)
print('Keys:', list(data.keys()))
print('Saved at:', data.get('saved_at', 'N/A'))
cookies = data.get('cookies', [])
print(f'Cookie count: {len(cookies)}')
# Check expiry of cookies
import time
now = time.time()
for c in cookies[:5]:
    exp = c.get('expires', 0)
    name = c.get('name', '?')
    expired = exp < now if exp > 0 else 'no-expiry'
    print(f'  {name}: expires={exp}, expired={expired}')
" 2>&1 || echo "No zhihu_state.json found"

Check Zhihu cookie state file


No zhihu_state.json found


   250→        """
   251→        clean_url = re.sub(r'[>\'")\].,;]+$', '', url).split("?")[0].split("#")[0].rstrip("/")
   252→        title = self._fetch_question_title(clean_url)
   253→        logger.info(f"Direct URL hunt: {clean_url!r} → title={title!r}")
   254→        return [ZhihuQuestion(title=title, url=clean_url)]
   255→
   256→    def _fetch_question_title(self, url: str) -> str:
   257→        """Fetch the question title via Playwright (with saved session cookies).
   258→
   259→        Falls back to a URL-derived label if Playwright is unavailable or fails.
   260→
   261→        Args:
   262→            url: Cleaned question URL.
   263→
   264→        Returns:
   265→            Question title string, or a fallback derived from the URL.
   266→        """
   267→        state_file = Path(__file__).resolve().parents[3] / "data" / "zhihu_state.json"
   268→        try:
   269→            from playwright.sync_api import sync_playwright
   270→            from playwright_stealth import Stealth
   271→            import json as _json
   272→
   273→            with sync_playwright() as p:
   274→                browser = p.chromium.launch(headless=True)
   275→                context = browser.new_context(
   276→                    user_agent=(
   277→                        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
   278→                        "AppleWebKit/537.36 (KHTML, like Gecko) "
   279→                        "Chrome/122.0.0.0 Safari/537.36"
   280→                    )
   281→                )
   282→                # Load saved cookies if available
   283→                if state_file.exists():
   284→                    data = _json.loads(state_file.read_text(encoding="utf-8"))
   285→                    cookies = data.get("cookies", [])
   286→                    if cookies:
   287→                        context.add_cookies(cookies)
   288→
   289→                page = context.new_page()
   290→                Stealth().apply_stealth_sync(page)
   291→                page.goto(url, wait_until="domcontentloaded", timeout=15000)
   292→                page.wait_for_timeout(2000)
   293→
   294→                title = page.title()
   295→                browser.close()
   296→
   297→                if title:
   298→                    # Page title format: "问题标题 - 知乎" or just "知乎"
   299→                    title = title.split(" - 知乎")[0].strip()
   300→                    if title and title != "知乎":
   301→                        return title
   302→        except Exception as e:
   303→            logger.debug(f"Playwright title fetch failed for {url}: {e}")
   304→
   305→        # Fallback: use the question ID
   306→        qid = url.rstrip("/").split("/")[-1]
   307→        return f"知乎问题 #{qid}"
   308→
   309→    def _fetch_recent_web_context(self, query: str) -> str:

        """
        clean_url = re.sub(r'[>\'")\].,;]+$', '', url).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:


Key findings:
1. **No cookies file exists** (`zhihu_state.json` not found) - this means the bot is accessing Zhihu without authentication!
2. The `_fetch_question_title()` method:
   - Uses Playwright with stealth
   - Tries to load cookies from `data/zhihu_state.json` (which doesn't exist)
   - Only extracts `page.title()` - just the browser tab title
   - Falls back to `知乎问题 #{question_id}` if it fails

Without cookies, Zhihu will likely:

- Show a limited version of the page
- Or redirect to a login page
- Or show a CAPTCHA/verification page

Let me actually run the Playwright code to see what happens when accessing the Zhihu URL.


关键发现：**cookie 文件不存在**。让我直接用 Playwright 测试一下抓取效果。


python3 -c "
from playwright.sync_api import sync_playwright
from playwright_stealth import Stealth

url = 'https://www.zhihu.com/question/2016845353517806681'
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'
    )
    page = context.new_page()
    Stealth().apply_stealth_sync(page)
    page.goto(url, wait_until='domcontentloaded', timeout=15000)
    page.wait_for_timeout(3000)

    title = page.title()
    print(f'Page title: {title!r}')

    # Try to get page content
    body_text = page.inner_text('body')
    print(f'Body text (first 500 chars): {body_text[:500]!r}')

    # Check for login redirect or captcha
    current_url = page.url
    print(f'Current URL: {current_url}')

    browser.close()
" 2>&1

Test Playwright Zhihu page fetch


Error: Exit code 1
Traceback (most recent call last):
  File "<string>", line 2, in <module>
ModuleNotFoundError: No module named 'playwright'


pip list 2>/dev/null | grep -i -E "playwright|stealth|selenium"

Check if Playwright is installed


(Bash completed with no output)


source venv/bin/activate 2>/dev/null && pip list 2>/dev/null | grep -i -E "playwright|stealth" ; which playwright 2>/dev/null; ls venv/lib/python*/site-packages/ 2>/dev/null | grep -i playwright

Check Playwright in project venv


playwright                               1.58.0
playwright-stealth                       2.0.2
/root/projects/butler/venv/bin/playwright
playwright
playwright-1.58.0.dist-info
playwright_stealth
playwright_stealth-2.0.2.dist-info


source venv/bin/activate && python3 -c "
from playwright.sync_api import sync_playwright
from playwright_stealth import Stealth

url = 'https://www.zhihu.com/question/2016845353517806681'
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'
    )
    page = context.new_page()
    Stealth().apply_stealth_sync(page)
    page.goto(url, wait_until='domcontentloaded', timeout=15000)
    page.wait_for_timeout(3000)

    title = page.title()
    print(f'Page title: {title!r}')

    # Check for login redirect or captcha
    current_url = page.url
    print(f'Current URL: {current_url}')

    # Get page HTML to check what Zhihu returns
    html = page.content()
    print(f'HTML length: {len(html)}')

    # Check for common blocking signs
    if '验证' in html or 'captcha' in html.lower():
        print('DETECTED: Captcha/verification page')
    if '登录' in html and 'SignFlow' in html:
        print('DETECTED: Login required page')
    if 'zse-ck' in html:
        print('DETECTED: Anti-bot JS challenge (zse-ck)')

    # Try to get question title element
    try:
        q_title = page.query_selector('h1.QuestionHeader-title')
        if q_title:
            print(f'Question title element: {q_title.inner_text()!r}')
        else:
            print('Question title element NOT found')
    except Exception as e:
        print(f'Error finding title element: {e}')

    # Try to get visible body text
    try:
        body = page.inner_text('body')
        print(f'Body text (first 800): {body[:800]!r}')
    except:
        print('Could not get body text')

    browser.close()
" 2>&1

Test Playwright Zhihu fetch from venv


Page title: '什么是云计算平台 MaaS？云大厂的护城河究竟是模型能力，还是其生态的高粘性？ - 知乎'
Current URL: https://www.zhihu.com/question/2016845353517806681
HTML length: 192405
DETECTED: Captcha/verification page
DETECTED: Login required page
Question title element: '什么是云计算平台 MaaS？云大厂的护城河究竟是模型能力，还是其生态的高粘性？'
Body text (first 800): '关注\n推荐\n热榜\n专栏\n圈子\nNew\n付费咨询\n知学堂\n\u200b\n直答\n切换模式\n登录/注册\n什么是云计算平台 MaaS？云大厂的护城河究竟是模型能力，还是其生态的高粘性？\n关注问题\n\u200b\n写回答\n登录/注册\n云平台\n云计算平台\nAI算力\nAI云计算\nMaaS平台\n什么是云计算平台 MaaS？云大厂的护城河究竟是模型能力，还是其生态的高粘性？\n圆桌收录\nAI 投资进化论\n在人工智能技术高速发展的今天，模型即服务（MaaS）作为连接AI模型与用户的关键环节，本应借助技术的优势为用户提供便捷的AI服务，从而获取丰厚的收益。…显示全部 \u200b\n关注者\n2\n被浏览\n18\n关注问题\u200b\n写回答\n\u200b\n邀请回答\n\u200b\n好问题\n\u200b\n添加评论\n\u200b\n分享\n\u200b\n1 个回答\n默认排序\n白鲸王AI直达号\u200b\n计算机技术与软件专业技术资格证持证人\n\u200b\n 关注\n一、 什么是云计算平台 MaaS？\n\nMaaS (Model as a Service，模型即服务) 是继 IaaS（基础设施）、PaaS（平台）、SaaS（软件）之后的第四种云计算服务模式。\n\n核心逻辑：云厂商预先训练好大型模型（如大语言模型、图像生成模型等），通过 API 接口或集成开发平台，将其能力开放给开发者和企业。\n对于用户的价值：用户不再需要投入数亿资金去买显卡、洗数据、练模型，只需要像开关水龙头一样，按需调用模型能力，并按使用量（Token）付费。\n产业链位置：MaaS 处于 AI 时代的“咽喉”位置。上接算力（IaaS），下接应用（SaaS）。\n二、 云大厂的护城河：模型能力 vs 生态粘性\n\n两者其实就像“硬币的两面”。也可看作是“矛”与“盾”的关系：\n\n1. 模型能力：入场的“门票”（矛）\n\n在 AI 竞争的初期，模型能力（参数量、逻辑推理能力、长文本处理能力）是绝对的护城河。\n\nDeepSeek 的冲击：DeepSeek 证明了通过算法优化可'


Now I see the problem clearly!

The Zhihu question is: **"什么是云计算平台 MaaS？云大厂的护城河究竟是模型能力，还是其生态的高粘性？"**

This is about **MaaS (Model as a Service) and cloud platform competitive advantages** - NOT about intermittent fasting!

But the bot generated an answer about **间歇性禁食** (intermittent fasting). So the bot completely got the wrong topic!

Wait, that doesn't make sense. Let me re-read the user's message:

The user is pointing out that the bot's answer is wrong and providing the Zhihu link along with the expected structure: definition, advantages, and suggestions. But the bot responded about intermittent fasting instead of MaaS platforms. This is a complete mismatch between what was asked and what was answered.

Let me trace through what happened: the bot should have fetched the question from that URL, which is clearly about MaaS and the competitive advantages of cloud platforms, but somehow generated content about an entirely different topic. The outline the user provided was meant to guide the answer structure, but the bot went off track completely. I'm realizing the user is pointing out that the intermittent fasting answer they received is incorrect, and they're providing a Zhihu URL about MaaS as the reference for what they actually wanted answered. The confusion stems from the bot generating content about the wrong topic entirely—they asked about Mobility as a Service but got a response about dietary practices instead. Looking at this more carefully, the user is showing me a Slack message where someone pointed out that a bot's response was incorrect. The message includes a Zhihu link with instructions to answer about definition, advantages, and suggestions, but the bot responded with content about intermittent fasting instead of addressing whatever the actual question was about. I'm trying to understand what went wrong with the bot's response and why it generated content on the wrong topic entirely. So the question is definitely about MaaS based on the URL, but the bot answered about intermittent fasting instead — that's a complete mismatch. The user confirmed it's wrong, and then mentioned they tried using zhihu-hunter mode but still couldn't fetch the post content, which suggests the bot failed to properly retrieve the question and just hallucinated an answer. I'm wondering if the IF response was from a different question the user tested earlier, and now they're showing me this new MaaS URL where the same fetching problem occurred. So the user showed me two separate issues: one about an IF answer with factual errors, and another about the bot failing to fetch the full content from the MaaS Zhihu post. I've confirmed the bot can grab the question title via Playwright, but it's hitting a wall with the full description—likely because there's no authentication cookie and Zhihu is throwing up verification prompts. Looking at my test results, Playwright can still access Zhihu and retrieve the page title successfully, which suggests anti-scraping measures haven't completely blocked access. The real issue is that the bot code only extracts the title using `page.title()` and never attempts to fetch the full question description or body content. Without authentication cookies, the bot is hitting content restrictions that prevent it from pulling the detailed post information the user expects.

When the user reports they can't get post content in zhihu-hunter mode, they're likely seeing the fallback "知乎问题 #xxx" message instead of an actual title, which would point to a Playwright failure on their end. But since my test shows Playwright works fine on this server, the issue might be that the bot's Playwright browser binaries aren't properly installed in their environment.


发现了！知乎问题实际是：**"什么是云计算平台 MaaS？云大厂的护城河究竟是模型能力，还是其生态的高粘性？"** — 跟间歇性禁食完全无关。

让我检查一下 Playwright 浏览器是否装好，以及 bot 运行环境下的实际情况。


source /root/projects/butler/venv/bin/activate && playwright install --list-devices 2>&1 | head -5; echo "---"; playwright --version 2>&1; echo "---"; ls -la ~/.cache/ms-playwright/ 2>/dev/null || echo "No playwright browsers cache"; echo "---"; playwright install chromium --dry-run 2>&1 | head -5

Check Playwright browser installation status


error: unknown option '--list-devices'
---
Version 1.58.0
---
total 0
drwxr-xr-x 6 root root  96 Mar  3 19:16 .
drwxr-xr-x 7 root root  83 Mar 10 10:21 ..
drwxr-xr-x 3 root root  87 Feb 24 18:21 chromium-1208
drwxr-xr-x 3 root root 102 Feb 24 18:21 chromium_headless_shell-1208
drwxr-xr-x 2 root root 109 Feb 24 18:21 ffmpeg-1011
drwxr-xr-x 2 root root 102 Mar  3 19:16 .links
---
Chrome for Testing 145.0.7632.6 (playwright chromium v1208)
  Install location:    /root/.cache/ms-playwright/chromium-1208
  Download url:        https://cdn.playwright.dev/chrome-for-testing-public/145.0.7632.6/linux64/chrome-linux64.zip

FFmpeg (playwright ffmpeg v1011)


OK, Playwright browsers are installed. And my earlier test showed that Playwright CAN fetch the Zhihu page. So the issue isn't Playwright or anti-scraping per se.

Let me now check the bot logs to see what happened when the user tried to use zhihu-hunter mode. Also, let me re-think the problem.

The user's answer is about "间歇性禁食" (intermittent fasting), but the Zhihu question is about "MaaS" (Model as a Service). This is a completely different topic. So:

1. Either the IF answer was generated for a DIFFERENT question (not this MaaS one)

2. Or something went terribly wrong in the pipeline

Looking at the user's message again, "回答定义，优势和建议" might be describing what the answer covers—definition, advantages, and suggestions—rather than being an instruction. The URL they provided points to the actual Zhihu question they're asking about.

But wait, I'm confused about why an IF answer would be generated for a MaaS question in the first place. Let me reconsider: maybe the IF answer was for a separate IF question entirely, and the user is now asking me to look at a different MaaS question URL. But then "这个回答是错误的呀" doesn't quite fit that interpretation either.

Actually, I should stop overthinking this and focus on what the user's second message is actually asking: they're using the zhihu-hunter mode and still can't retrieve the post content, so they want me to check whether Zhihu has strengthened its anti-scraping measures.

The user tried the bot's zhihu-hunter mode but it failed to fetch the content. My Playwright test shows the title can be extracted, but the full question body isn't being retrieved. I should look at the bot logs to see what errors occurred.


Playwright 和浏览器都正常。让我检查 bot 日志看看实际报错了什么。


find /root/projects/butler -name "*.log" -mtime -1 2>/dev/null | head -10; echo "---"; ls -la /root/projects/butler/logs/ 2>/dev/null || echo "No logs dir"; echo "---"; find /root/projects/butler -path "*/data/*.log" -o -path "*/log*" -name "*.log" 2>/dev/null | head -10

Find bot log files


/root/projects/butler/logs/daily_sync.log
/root/projects/butler/logs/health.log
/root/projects/butler/logs/health_sync.log
/root/projects/butler/logs/obsidian.log
/root/projects/butler/logs/weekly_report.log
/root/projects/butler/logs/cron_daily_sync.log
/root/projects/butler/logs/memory_reminder.log
/root/projects/butler/logs/cron_weekly_report.log
---
total 2436
drwxr-xr-x  2  501 games   4096 Mar  9 11:00 .
drwxr-xr-x 20 root root    4096 Mar 10 13:43 ..
-rw-r--r--  1 root root  127515 Mar 16 08:00 cron_daily_sync.log
-rw-r--r--  1 root root    5306 Mar 16 11:00 cron_weekly_report.log
-rw-r--r--  1  501 games  38217 Mar 16 08:00 daily_sync.log
-rw-r--r--  1  501 games 820951 Mar 16 11:34 health.log
-rw-r--r--  1  501 games 859274 Mar 16 14:12 health_sync.log
-rw-r--r--  1 root root   12170 Mar  3 13:57 init_vault_indexer.log
-rw-r--r--  1 root root    9068 Mar 16 01:00 memory_reminder.log
-rw-r--r--  1  501 games 436034 Mar 16 14:45 obsidian.log
-rw-r--r--  1  501 games 146730 Feb 28 13:55 shell.log
-rw-r--r--  1  501 games   3256 Feb  5 20:51 slack_bot.log
-rw-r--r--  1 root root    3830 Mar 16 11:00 weekly_report.log
---
/root/projects/butler/logs/daily_sync.log
/root/projects/butler/logs/health.log
/root/projects/butler/logs/health_sync.log
/root/projects/butler/logs/obsidian.log
/root/projects/butler/logs/shell.log
/root/projects/butler/logs/slack_bot.log
/root/projects/butler/logs/weekly_report.log
/root/projects/butler/logs/cron_daily_sync.log
/root/projects/butler/logs/init_vault_indexer.log
/root/projects/butler/logs/memory_reminder.log


tail -200 /root/projects/butler/logs/obsidian.log | grep -i -E "zhihu|hunter|playwright|fetch|title|direct|question|error|fail" -A 2

Check recent Zhihu-related log entries


2026-03-16 13:10:34 - slack_bot.obsidian.generators - INFO - Running second-pass rule enforcement on Zhihu draft...
2026-03-16 13:10:48 - slack_bot.context.storage - INFO - Cleared context for D0AACF9G4J0
2026-03-16 13:30:58 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
--
2026-03-16 13:31:00 - slack_bot.zhihu.slack_interactive_gateway - INFO - ZhihuHunter: Slack action handlers registered
2026-03-16 13:31:00 - __main__ - INFO - ⚡ Obsidian Bot is connected to Slack!
⚡ Bolt app is running!
2026-03-16 13:31:12 - __main__ - INFO - Obsidian Bot received from U0A8ZCK7RJM: <https://www.zhihu.com/question/201647704514890581...
2026-03-16 13:31:12 - slack_bot.obsidian.dispatcher - INFO - Generating Obsidian response in mode: zhihu
2026-03-16 13:31:12 - slack_bot.obsidian.generators - INFO - Follow-up Zhihu instruction: <https://www.zhihu.com/question/2016477045148905811> 回答这个问题的逻辑是：肯定是需要专门准备的，因为它的权限和ai幻觉问题，专门准备有不同的方式（虚拟机/闲置电脑/云服务器等），云上是最好的，而且未来也是云上最好，因为云上具备数据的稳定性、可迁移性和随时可以把工作流变成saas
2026-03-16 13:31:19 - slack_bot.context.storage - INFO - Cleared context for D0AACF9G4J0
2026-03-16 13:32:53 - __main__ - INFO - Obsidian Bot received from U0A8ZCK7RJM: mode zhihu...
2026-03-16 13:32:54 - slack_bot.context.storage - INFO - Cleared context for D0AACF9G4J0
2026-03-16 13:33:10 - __main__ - INFO - Obsidian Bot received from U0A8ZCK7RJM: <https://www.zhihu.com/question/201647704514890581...
2026-03-16 13:33:10 - slack_bot.obsidian.dispatcher - INFO - Generating Obsidian response in mode: zhihu
2026-03-16 13:33:14 - slack_bot.obsidian.generators - INFO - Generating Zhihu answer for: <https://www.zhihu.com/question/201647704514890581...
2026-03-16 13:33:25 - slack_bot.obsidian.generators - INFO - Running second-pass rule enforcement on Zhihu draft...
2026-03-16 13:33:35 - slack_bot.context.storage - INFO - Cleared context for D0AACF9G4J0
2026-03-16 13:43:12 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
--
2026-03-16 13:43:14 - slack_bot.zhihu.slack_interactive_gateway - INFO - ZhihuHunter: Slack action handlers registered
2026-03-16 13:43:14 - __main__ - INFO - ⚡ Obsidian Bot is connected to Slack!
⚡ Bolt app is running!
2026-03-16 13:43:21 - __main__ - INFO - Obsidian Bot received from U0A8ZCK7RJM: mode zhihu...
2026-03-16 13:43:22 - slack_bot.context.storage - INFO - Cleared context for D0AACF9G4J0
2026-03-16 13:44:18 - __main__ - INFO - Obsidian Bot received from U0A8ZCK7RJM: <https://www.zhihu.com/question/201647704514890581...
2026-03-16 13:44:19 - slack_bot.obsidian.dispatcher - INFO - Generating Obsidian response in mode: zhihu
2026-03-16 13:44:23 - slack_bot.obsidian.generators - INFO - Generating Zhihu answer for: <https://www.zhihu.com/question/201647704514890581...
2026-03-16 13:44:33 - slack_bot.obsidian.generators - INFO - Running second-pass rule enforcement on Zhihu draft...
2026-03-16 13:44:43 - slack_bot.context.storage - INFO - Cleared context for D0AACF9G4J0
2026-03-16 13:50:09 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
--
2026-03-16 13:50:11 - slack_bot.zhihu.slack_interactive_gateway - INFO - ZhihuHunter: Slack action handlers registered
2026-03-16 13:50:11 - __main__ - INFO - ⚡ Obsidian Bot is connected to Slack!
⚡ Bolt app is running!
2026-03-16 13:50:20 - __main__ - INFO - Obsidian Bot received from U0A8ZCK7RJM: mode zhihu...
2026-03-16 13:50:20 - slack_bot.context.storage - INFO - Cleared context for D0AACF9G4J0
2026-03-16 13:50:31 - __main__ - INFO - Obsidian Bot received from U0A8ZCK7RJM: <https://www.zhihu.com/question/201647704514890581...
2026-03-16 13:50:32 - slack_bot.obsidian.dispatcher - INFO - Generating Obsidian response in mode: zhihu
2026-03-16 13:50:32 - slack_bot.obsidian.generators - INFO - Generating Zhihu answer for: <https://www.zhihu.com/question/201647704514890581...
2026-03-16 13:50:45 - slack_bot.obsidian.generators - INFO - Running second-pass rule enforcement on Zhihu draft...
2026-03-16 13:50:51 - slack_bot.context.storage - INFO - Cleared context for D0AACF9G4J0
2026-03-16 13:59:58 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
--
2026-03-16 14:00:00 - slack_bot.zhihu.slack_interactive_gateway - INFO - ZhihuHunter: Slack action handlers registered
2026-03-16 14:00:00 - __main__ - INFO - ⚡ Obsidian Bot is connected to Slack!
⚡ Bolt app is running!
2026-03-16 14:00:09 - __main__ - INFO - Obsidian Bot received from U0A8ZCK7RJM: mode zhihu...
2026-03-16 14:00:09 - slack_bot.context.storage - INFO - Cleared context for D0AACF9G4J0
2026-03-16 14:00:18 - __main__ - INFO - Obsidian Bot received from U0A8ZCK7RJM: <https://www.zhihu.com/question/201647704514890581...
2026-03-16 14:00:19 - slack_bot.obsidian.dispatcher - INFO - Generating Obsidian response in mode: zhihu
2026-03-16 14:00:22 - slack_bot.obsidian.generators - INFO - Generating Zhihu answer for: <https://www.zhihu.com/question/201647704514890581...
2026-03-16 14:00:34 - slack_bot.obsidian.generators - INFO - Running second-pass rule enforcement on Zhihu draft...
2026-03-16 14:00:40 - slack_bot.context.storage - INFO - Cleared context for D0AACF9G4J0
2026-03-16 14:12:41 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
--
2026-03-16 14:12:43 - slack_bot.zhihu.slack_interactive_gateway - INFO - ZhihuHunter: Slack action handlers registered
2026-03-16 14:12:43 - __main__ - INFO - ⚡ Obsidian Bot is connected to Slack!
⚡ Bolt app is running!
2026-03-16 14:12:52 - __main__ - INFO - Obsidian Bot received from U0A8ZCK7RJM: mode zhihu...
2026-03-16 14:12:52 - slack_bot.context.storage - INFO - Cleared context for D0AACF9G4J0
2026-03-16 14:13:05 - __main__ - INFO - Obsidian Bot received from U0A8ZCK7RJM: <https://www.zhihu.com/question/201647704514890581...
2026-03-16 14:13:06 - slack_bot.obsidian.dispatcher - INFO - Generating Obsidian response in mode: zhihu
2026-03-16 14:13:16 - slack_bot.obsidian.generators - INFO - Generating Zhihu answer for: <https://www.zhihu.com/question/201647704514890581...
2026-03-16 14:13:27 - slack_bot.obsidian.generators - INFO - Running second-pass rule enforcement on Zhihu draft...
2026-03-16 14:13:33 - slack_bot.context.storage - INFO - Cleared context for D0AACF9G4J0
2026-03-16 14:22:32 - __main__ - INFO - Obsidian Bot received from U0A8ZCK7RJM: <https://www.zhihu.com/question/201684535351780668...
2026-03-16 14:22:32 - slack_bot.obsidian.dispatcher - INFO - Generating Obsidian response in mode: zhihu
2026-03-16 14:22:32 - slack_bot.obsidian.generators - INFO - Follow-up Zhihu instruction: <https://www.zhihu.com/question/2016845353517806681> 回答定义，优势和建议
2026-03-16 14:22:43 - slack_bot.context.storage - INFO - Cleared context for D0AACF9G4J0
2026-03-16 14:42:49 - __main__ - INFO - Obsidian Bot received from U0A8ZCK7RJM: mode...
2026-03-16 14:42:50 - slack_bot.obsidian.dispatcher - INFO - Generating Obsidian response in mode: zhihu
2026-03-16 14:42:50 - slack_bot.obsidian.generators - INFO - Follow-up Zhihu instruction: mode
2026-03-16 14:42:55 - slack_bot.context.storage - INFO - Cleared context for D0AACF9G4J0
2026-03-16 14:43:07 - __main__ - INFO - Obsidian Bot received from U0A8ZCK7RJM: mode help...
2026-03-16 14:43:14 - __main__ - INFO - Obsidian Bot received from U0A8ZCK7RJM: mode zhihu-hunter...
2026-03-16 14:43:14 - slack_bot.context.storage - INFO - Cleared context for D0AACF9G4J0
2026-03-16 14:43:26 - __main__ - INFO - Obsidian Bot received from U0A8ZCK7RJM: <https://www.zhihu.com/question/201684535351780668...
2026-03-16 14:43:28 - slack_bot.zhihu.zhihu_hunter - INFO - Direct URL hunt: '<https://www.zhihu.com/question/2016845353517806681' → title='知乎问题 #2016845353517806681'
2026-03-16 14:43:28 - slack_bot.obsidian.dispatcher - INFO - ZhihuHunter: direct URL '<https://www.zhihu.com/question/2016845353517806681' → '知乎问题 #2016845353517806681' outline='回答定义，优势和建议'
2026-03-16 14:44:44 - slack_bot.zhihu.zhihu_hunter - INFO - Drafting answer for: 知乎问题 #2016845353517806681
2026-03-16 14:44:46 - slack_bot.llm.gemini - INFO - Using OpenRouter: https://openrouter.ai/api
2026-03-16 14:44:46 - slack_bot.llm.gemini - INFO - Initialized Gemini model: google/gemini-3-flash-preview
2026-03-16 14:44:46 - slack_bot.zhihu.zhihu_hunter - INFO - Injected user outline: '回答定义，优势和建议'
2026-03-16 14:44:49 - slack_bot.zhihu.zhihu_hunter - INFO - Injected recent web context (1140 chars)
2026-03-16 14:44:52 - slack_bot.obsidian.generators - INFO - Generating Zhihu answer for: 知乎问题 #2016845353517806681

用户提供的回答思路（必须严格按照这个逻辑框架展...
2026-03-16 14:45:03 - slack_bot.obsidian.generators - INFO - Running second-pass rule enforcement on Zhihu draft...
2026-03-16 14:45:09 - slack_bot.zhihu.slack_interactive_gateway - INFO - Draft saved to vault: /root/vault/obsidian_vault/obsidian/obsidian/zhihu/2026-03-16-知乎问题-2016845353517806681.md


Now I see the FULL picture from the logs!

**Root Cause Found:**

1. At 14:22:32, the user sent `<https://www.zhihu.com/question/2016845353517806681> 回答定义，优势和建议` in **mode zhihu** (not zhihu-hunter!)
2. The bot treated this as a **follow-up instruction** (not a new question) because there was already history from the previous IF question!
   - Log: `Follow-up Zhihu instruction: <https://www.zhihu.com/question/2016845353517806681> 回答定义，优势和建议`

3. Since it was treated as a follow-up to the earlier IF conversation, the LLM kept discussing IF instead of switching to the new Zhihu question.
4. Later at 14:43:26, the user switched to **mode zhihu-hunter** with the same URL, but when the hunter tried to fetch the page title at 14:43:28, it failed and fell back to a generic label, with the URL appearing truncated in the logs.
5. I'm noticing the URL has a `<` prefix—Slack wraps URLs in angle brackets, and the URL cleaning logic didn't remove them, which caused the fetch to fail since it was passing a malformed URL to the title-fetching function. I see the issue now — both the regex in the dispatcher and in `hunt_direct_url()` only strip trailing characters like `>`, leaving the leading `<` intact. So when `_fetch_question_title()` receives the URL, it still has that malformed leading bracket, and Playwright fails to navigate to it.

Looking at the logs confirms this: the URL being passed around is `<https://www.zhihu.com/question/2016845353517806681` with the leading angle bracket still there. The Slack format wraps URLs as `<URL>`, so I need to strip both the leading and trailing brackets, not just the trailing ones.


找到根因了！**不是知乎加强了反爬虫，是 Slack URL 格式没清理干净。**

看日志里的关键行：

```
14:43:28 - Direct URL hunt: '<https://www.zhihu.com/question/2016845353517806681' → title='知乎问题 #2016845353517806681'
```

URL 前面多了个 `<`！Slack 把 URL 包装成 `<URL>` 格式，代码只清理了尾部的 `>`，没有清理头部的 `<`，导致 Playwright 打开了一个畸形 URL，直接 fallback 到 `知乎问题 #xxx`。

而且还有第二个问题——你的间歇性禁食回答也是同一个 bug 引起的。看日志：

```
14:22:32 - Follow-up Zhihu instruction: <https://www.zhihu.com/question/2016845353517806681> 回答定义，优势和建议
```

你当时在 `mode zhihu`（不是 zhihu-hunter），而且之前有 IF 问题的 history。Bot 把你的 MaaS 问题当成了**上一轮 IF 对话的 follow-up**，所以 LLM 继续围绕间歇性禁食在聊。

总结两个 bug：

1. **URL 清理不完整**：`<https://...>` 只 strip 了尾部 `>`，头部 `<` 没删
2. **新问题识别遗漏**：`is_new_question` 没检测到输入包含 URL，错误地当成 follow-up

要现在修复吗？修复很简单：
- URL 清理加 `lstrip("<")`
- `is_new_question` 判断加上 URL 检测


修复


TaskCreate

Fix Slack URL `<>` stripping in dispatcher and zhihu_hunter

Slack wraps URLs in `<URL>` format. The current URL cleaning only strips trailing `>` but not leading `<`. Fix in two places:
1. dispatcher.py line 175: `url = _re.sub(r'[>\'")\].,;]+$', '', words[0])` — add leading `<` strip
2. zhihu_hunter.py line 251: `clean_url = re.sub(r'[>\'")\].,;]+$', '', url)` — add leading `<` strip


Fix ZhihuGenerator is_new_question to detect URL input

In generators.py ZhihuGenerator.chat(), the `is_new_question` check doesn't detect when a URL is pasted as input. If there's existing history from a previous question, the URL gets treated as a follow-up instruction instead of a new question. Add URL detection (e.g. `zhihu.com/question/` in user_input) to the is_new_question condition.


Task #3 created successfully: Fix ZhihuGenerator is_new_question to detect URL input


Task #4 created successfully: Fix Slack URL `<>` stripping in dispatcher and zhihu_hunter


Updated task #4 status


先修 URL 清理问题。


   170→                elif words and 'zhihu.com/question/' in words[0]:
   171→                    # Direct URL paste — user was invited or found a question to answer.
   172→                    # Any text after the URL is treated as an optional answer outline.
   173→                    # Strip trailing Slack/HTML junk chars (>, ", etc.) from the URL.
   174→                    import re as _re
   175→                    url = _re.sub(r'[>\'")\].,;]+$', '', words[0])
   176→                    outline = " ".join(words[1:])[:500] if len(words) > 1 else ""
   177→                    hint = f"（已记录回答思路）" if outline else ""
   178→                    self._reply(channel_id, f"🔍 获取问题标题，准备生成草稿{hint}...", response_ts)
   179→                    questions = self.zhihu_hunter.hunt_direct_url(url)
   180→                    if outline and questions:
   181→                        questions[0].outline = outline
   182→                    logger.info(f"ZhihuHunter: direct URL {url!r} → {questions[0].title!r}" +
   183→                                (f" outline={outline[:40]!r}" if outline else ""))
   184→

                elif words and 'zhihu.com/question/' in words[0]:
                    # Direct URL paste — user was invited or found a question to answer.
                    # Any text after the URL is treated as an optional answer outline.
                    # Strip trailing Slack/HTML junk chars (>, ", etc.) from the URL.
                    import re as _re
                    url = _re.sub(r'[>\'")\].,;]+$', '', words[0])
                    outline = " ".join(words[1:])[:500] if len(words) > 1 else ""
                    hint = f"（已记录回答思路）" if outline else ""
                    self._reply(channel_id, f"🔍 获取问题标题，准备生成草稿{hint}...", response_ts)
                    questions = self.zhihu_hunter.hunt_direct_url(url)
                    if outline and questions:
                        questions[0].outline = outline
                    logger.info(f"ZhihuHunter: direct URL {url!r} → {questions[0].title!r}" +
                                (f" outline={outline[:40]!r}" if outline else ""))


   245→        Args:
   246→            url: A zhihu.com/question/<id> URL.
   247→
   248→        Returns:
   249→            Single-element list containing the ZhihuQuestion.
   250→        """
   251→        clean_url = re.sub(r'[>\'")\].,;]+$', '', url).split("?")[0].split("#")[0].rstrip("/")
   252→        title = self._fetch_question_title(clean_url)
   253→        logger.info(f"Direct URL hunt: {clean_url!r} → title={title!r}")
   254→        return [ZhihuQuestion(title=title, url=clean_url)]
   255→
   256→    def _fetch_question_title(self, url: str) -> str:
   257→        """Fetch the question title via Playwright (with saved session cookies).
   258→
   259→        Falls back to a URL-derived label if Playwright is unavailable or fails.

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

        Returns:
            Single-element list containing the ZhihuQuestion.
        """
        clean_url = re.sub(r'[>\'")\].,;]+$', '', url).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.