{
  "canonical_name": "vasylenko/markfetch",
  "compilation_id": "pack_bbc38230dbdc4ffb80fa45ef8de95c7d",
  "created_at": "2026-05-15T03:17:04.634144+00:00",
  "created_by": "project-pack-compiler",
  "feedback": {
    "carrier_selection_notes": [
      "viable_asset_types=mcp_config, recipe, host_instruction, eval, preflight",
      "recommended_asset_types=mcp_config, recipe, host_instruction, eval, preflight"
    ],
    "evidence_delta": {
      "confirmed_claims": [
        "identity_anchor_present",
        "capability_and_host_targets_present",
        "install_path_declared_or_better"
      ],
      "missing_required_fields": [],
      "must_verify_forwarded": [
        "Run or inspect `npm i -g markfetch` in an isolated environment.",
        "Confirm the project exposes the claimed capability to at least one target host."
      ],
      "quickstart_execution_scope": "allowlisted_sandbox_smoke",
      "sandbox_command": "npm i -g markfetch",
      "sandbox_container_image": "node:22-slim",
      "sandbox_execution_backend": "docker",
      "sandbox_planner_decision": "deterministic_isolated_install",
      "sandbox_validation_id": "sbx_68d591c3193f451280c23122928ef2c6"
    },
    "feedback_event_type": "project_pack_compilation_feedback",
    "learning_candidate_reasons": [],
    "template_gaps": []
  },
  "identity": {
    "canonical_id": "project_f2a92ee4f0af5e4010add90875cdae99",
    "canonical_name": "vasylenko/markfetch",
    "homepage_url": null,
    "license": "unknown",
    "repo_url": "https://github.com/vasylenko/markfetch",
    "slug": "markfetch",
    "source_packet_id": "phit_a4fc82107707459d877159657cf79a72",
    "source_validation_id": "dval_340184719b7f4ddea815de0bc4647491"
  },
  "merchandising": {
    "best_for": "需要工具连接与集成能力，并使用 mcp_host的用户",
    "github_forks": 0,
    "github_stars": 0,
    "one_liner_en": "Tiny CLI and MCP server: fetch an URL -- return clean markdown. Built for AI agents.",
    "one_liner_zh": "Tiny CLI and MCP server: fetch an URL -- return clean markdown. Built for AI agents.",
    "primary_category": {
      "category_id": "tool-integrations",
      "confidence": "high",
      "name_en": "Tool Integrations",
      "name_zh": "工具连接与集成",
      "reason": "matched_keywords:mcp, server, github"
    },
    "target_user": "使用 mcp_host 等宿主 AI 的用户",
    "title_en": "markfetch",
    "title_zh": "markfetch 能力包",
    "visible_tags": [
      {
        "label_en": "Browser Agents",
        "label_zh": "浏览器 Agent",
        "source": "repo_evidence_project_characteristics",
        "tag_id": "product_domain-browser-agents",
        "type": "product_domain"
      },
      {
        "label_en": "Web Task Automation",
        "label_zh": "网页任务自动化",
        "source": "repo_evidence_project_characteristics",
        "tag_id": "user_job-web-task-automation",
        "type": "user_job"
      },
      {
        "label_en": "Structured Extraction",
        "label_zh": "结构化提取",
        "source": "repo_evidence_project_characteristics",
        "tag_id": "core_capability-structured-extraction",
        "type": "core_capability"
      },
      {
        "label_en": "Node-based Workflow",
        "label_zh": "节点式流程编排",
        "source": "repo_evidence_project_characteristics",
        "tag_id": "workflow_pattern-node-based-workflow",
        "type": "workflow_pattern"
      },
      {
        "label_en": "Local-first",
        "label_zh": "本地优先",
        "source": "repo_evidence_project_characteristics",
        "tag_id": "selection_signal-local-first",
        "type": "selection_signal"
      }
    ]
  },
  "packet_id": "phit_a4fc82107707459d877159657cf79a72",
  "page_model": {
    "artifacts": {
      "artifact_slug": "markfetch",
      "files": [
        "PROJECT_PACK.json",
        "QUICK_START.md",
        "PROMPT_PREVIEW.md",
        "HUMAN_MANUAL.md",
        "AI_CONTEXT_PACK.md",
        "BOUNDARY_RISK_CARD.md",
        "PITFALL_LOG.md",
        "REPO_INSPECTION.json",
        "REPO_INSPECTION.md",
        "CAPABILITY_CONTRACT.json",
        "EVIDENCE_INDEX.json",
        "CLAIM_GRAPH.json"
      ],
      "required_files": [
        "PROJECT_PACK.json",
        "QUICK_START.md",
        "PROMPT_PREVIEW.md",
        "HUMAN_MANUAL.md",
        "AI_CONTEXT_PACK.md",
        "BOUNDARY_RISK_CARD.md",
        "PITFALL_LOG.md",
        "REPO_INSPECTION.json"
      ]
    },
    "detail": {
      "capability_source": "Project Hit Packet + DownstreamValidationResult",
      "commands": [
        {
          "command": "npm i -g markfetch",
          "label": "Node.js / npm · 官方安装入口",
          "source": "https://github.com/vasylenko/markfetch#readme",
          "verified": true
        }
      ],
      "display_tags": [
        "浏览器 Agent",
        "网页任务自动化",
        "结构化提取",
        "节点式流程编排",
        "本地优先"
      ],
      "eyebrow": "工具连接与集成",
      "glance": [
        {
          "body": "判断自己是不是目标用户。",
          "label": "最适合谁",
          "value": "需要工具连接与集成能力，并使用 mcp_host的用户"
        },
        {
          "body": "先理解能力边界，再决定是否继续。",
          "label": "核心价值",
          "value": "Tiny CLI and MCP server: fetch an URL -- return clean markdown. Built for AI agents."
        },
        {
          "body": "未完成验证前保持审慎。",
          "label": "继续前",
          "value": "publish to Doramagic.ai project surfaces"
        }
      ],
      "guardrail_source": "Boundary & Risk Card",
      "guardrails": [
        {
          "body": "Prompt Preview 只展示流程，不证明项目已安装或运行。",
          "label": "Check 1",
          "value": "不要把试用当真实运行"
        },
        {
          "body": "mcp_host",
          "label": "Check 2",
          "value": "确认宿主兼容"
        },
        {
          "body": "publish to Doramagic.ai project surfaces",
          "label": "Check 3",
          "value": "先隔离验证"
        }
      ],
      "mode": "mcp_config, recipe, host_instruction, eval, preflight",
      "pitfall_log": {
        "items": [
          {
            "body": "GitHub 社区证据显示该项目存在一个安装相关的待验证问题：v0.4.1",
            "category": "安装坑",
            "evidence": [
              "community_evidence:github | cevd_749b65614f7b40e0b524f4e932cd4aca | https://github.com/vasylenko/markfetch/releases/tag/v0.4.1 | 来源讨论提到 node 相关条件，需在安装/试用前复核。"
            ],
            "severity": "medium",
            "suggested_check": "来源显示可能已有修复、规避或版本变化，说明书中必须标注适用版本。",
            "title": "来源证据：v0.4.1",
            "user_impact": "可能增加新用户试用和生产接入成本。"
          },
          {
            "body": "README/documentation is current enough for a first validation pass.",
            "category": "能力坑",
            "evidence": [
              "capability.assumptions | github_repo:1234238440 | https://github.com/vasylenko/markfetch | README/documentation is current enough for a first validation pass."
            ],
            "severity": "medium",
            "suggested_check": "将假设转成下游验证清单。",
            "title": "能力判断依赖假设",
            "user_impact": "假设不成立时，用户拿不到承诺的能力。"
          },
          {
            "body": "未记录 last_activity_observed。",
            "category": "维护坑",
            "evidence": [
              "evidence.maintainer_signals | github_repo:1234238440 | https://github.com/vasylenko/markfetch | last_activity_observed missing"
            ],
            "severity": "medium",
            "suggested_check": "补 GitHub 最近 commit、release、issue/PR 响应信号。",
            "title": "维护活跃度未知",
            "user_impact": "新项目、停更项目和活跃项目会被混在一起，推荐信任度下降。"
          },
          {
            "body": "no_demo",
            "category": "安全/权限坑",
            "evidence": [
              "downstream_validation.risk_items | github_repo:1234238440 | https://github.com/vasylenko/markfetch | no_demo; severity=medium"
            ],
            "severity": "medium",
            "suggested_check": "进入安全/权限治理复核队列。",
            "title": "下游验证发现风险项",
            "user_impact": "下游已经要求复核，不能在页面中弱化。"
          },
          {
            "body": "no_demo",
            "category": "安全/权限坑",
            "evidence": [
              "risks.scoring_risks | github_repo:1234238440 | https://github.com/vasylenko/markfetch | no_demo; severity=medium"
            ],
            "severity": "medium",
            "suggested_check": "把风险写入边界卡，并确认是否需要人工复核。",
            "title": "存在评分风险",
            "user_impact": "风险会影响是否适合普通用户安装。"
          },
          {
            "body": "issue_or_pr_quality=unknown。",
            "category": "维护坑",
            "evidence": [
              "evidence.maintainer_signals | github_repo:1234238440 | https://github.com/vasylenko/markfetch | issue_or_pr_quality=unknown"
            ],
            "severity": "low",
            "suggested_check": "抽样最近 issue/PR，判断是否长期无人处理。",
            "title": "issue/PR 响应质量未知",
            "user_impact": "用户无法判断遇到问题后是否有人维护。"
          },
          {
            "body": "release_recency=unknown。",
            "category": "维护坑",
            "evidence": [
              "evidence.maintainer_signals | github_repo:1234238440 | https://github.com/vasylenko/markfetch | release_recency=unknown"
            ],
            "severity": "low",
            "suggested_check": "确认最近 release/tag 和 README 安装命令是否一致。",
            "title": "发布节奏不明确",
            "user_impact": "安装命令和文档可能落后于代码，用户踩坑概率升高。"
          }
        ],
        "source": "ProjectPitfallLog + ProjectHitPacket + validation + community signals",
        "summary": "发现 7 个潜在踩坑项，其中 0 个为 high/blocking；最高优先级：安装坑 - 来源证据：v0.4.1。",
        "title": "踩坑日志"
      },
      "snapshot": {
        "contributors": 1,
        "forks": 0,
        "license": "unknown",
        "note": "站点快照，非实时质量证明；用于开工前背景判断。",
        "stars": 0
      },
      "source_url": "https://github.com/vasylenko/markfetch",
      "steps": [
        {
          "body": "不安装项目，先体验能力节奏。",
          "code": "preview",
          "title": "先试 Prompt"
        },
        {
          "body": "理解输入、输出、失败模式和边界。",
          "code": "manual",
          "title": "读说明书"
        },
        {
          "body": "把上下文交给宿主 AI 继续工作。",
          "code": "context",
          "title": "带给 AI"
        },
        {
          "body": "进入主力环境前先完成安装入口与风险边界验证。",
          "code": "verify",
          "title": "沙箱验证"
        }
      ],
      "subtitle": "Tiny CLI and MCP server: fetch an URL -- return clean markdown. Built for AI agents.",
      "title": "markfetch 能力包",
      "trial_prompt": "# markfetch - Prompt Preview\n\n> 复制下面这段 Prompt 到你常用的 AI，先试一次，不需要安装。\n> 它的目标是让你直接体验这个项目的服务方式，而不是阅读项目介绍。\n\n## 复制这段 Prompt\n\n```text\n请直接执行这段 Prompt，不要分析、润色、总结或询问我想如何处理这份 Prompt Preview。\n\n你现在扮演 markfetch 的“安装前体验版”。\n这不是项目介绍、不是评价报告、不是 README 总结。你的任务是让我用最小成本体验它的核心服务。\n\n我的试用任务：我想用它完成一个真实的工具连接与集成任务。\n我常用的宿主 AI：MCP Client\n\n【体验目标】\n围绕我的真实任务，现场演示这个项目如何把输入转成 示例引导, 判断线索。重点是让我感受到工作方式，而不是给我项目背景。\n\n【业务流约束】\n- 你必须像一个正在提供服务的项目能力包，而不是像一个讲解员。\n- 每一轮只推进一个步骤；提出问题后必须停下来等我回答。\n- 每一步都必须让我感受到一个具体服务动作：澄清、整理、规划、检查、判断或收尾。\n- 每一步都要说明：当前目标、你需要我提供什么、我回答后你会产出什么。\n- 不要安装、不要运行命令、不要写代码、不要声称测试通过、不要声称已经修改文件。\n- 需要真实安装或宿主加载后才能验证的内容，必须明确说“这一步需要安装后验证”。\n- 如果我说“用示例继续”，你可以用虚构示例推进，但仍然不能声称真实执行。\n\n【可体验服务能力】\n- 安装前能力预览: Tiny CLI and MCP server: fetch an URL -- return clean markdown. Built for AI agents. 输入：用户任务, 当前 AI 对话上下文；输出：示例引导, 判断线索。\n\n【必须安装后才可验证的能力】\n- 命令行启动或安装流程: 项目文档中存在可执行命令，真实使用需要在本地或宿主环境中运行这些命令。 输入：终端环境, 包管理器, 项目依赖；输出：安装结果, 列表/更新/运行结果。\n\n【核心服务流】\n请严格按这个顺序带我体验。不要一次性输出完整流程：\n1. overview：项目概述。围绕“项目概述”模拟一次用户任务，不展示安装或运行结果。\n2. system-architecture：系统架构。围绕“系统架构”模拟一次用户任务，不展示安装或运行结果。\n3. cli-usage：命令行界面。围绕“命令行界面”模拟一次用户任务，不展示安装或运行结果。\n4. mcp-server：MCP 服务器。围绕“MCP 服务器”模拟一次用户任务，不展示安装或运行结果。\n5. http-fingerprint：HTTP 指纹与请求模拟。围绕“HTTP 指纹与请求模拟”模拟一次用户任务，不展示安装或运行结果。\n\n【核心能力体验剧本】\n每一步都必须按“输入 -> 服务动作 -> 中间产物”执行。不要只说流程名：\n1. overview\n输入：用户提供的“项目概述”相关信息。\n服务动作：模拟项目在这一步的核心判断和整理方式。\n中间产物：一个可检查的小结果。\n\n2. system-architecture\n输入：用户提供的“系统架构”相关信息。\n服务动作：模拟项目在这一步的核心判断和整理方式。\n中间产物：一个可检查的小结果。\n\n3. cli-usage\n输入：用户提供的“命令行界面”相关信息。\n服务动作：模拟项目在这一步的核心判断和整理方式。\n中间产物：一个可检查的小结果。\n\n4. mcp-server\n输入：用户提供的“MCP 服务器”相关信息。\n服务动作：模拟项目在这一步的核心判断和整理方式。\n中间产物：一个可检查的小结果。\n\n5. http-fingerprint\n输入：用户提供的“HTTP 指纹与请求模拟”相关信息。\n服务动作：模拟项目在这一步的核心判断和整理方式。\n中间产物：一个可检查的小结果。\n\n【项目服务规则】\n这些规则决定你如何服务用户。不要解释规则本身，而要在每一步执行时遵守：\n- 先确认用户任务、输入材料和成功标准，再模拟项目能力。\n- 每一步都必须形成可检查的小产物，并等待用户确认后再继续。\n- 凡是需要安装、调用工具或访问外部服务的能力，都必须标记为安装后验证。\n\n【每一步的服务约束】\n- Step 1 / overview：Step 1 必须围绕“项目概述”形成一个小中间产物，并等待用户确认。\n- Step 2 / system-architecture：Step 2 必须围绕“系统架构”形成一个小中间产物，并等待用户确认。\n- Step 3 / cli-usage：Step 3 必须围绕“命令行界面”形成一个小中间产物，并等待用户确认。\n- Step 4 / mcp-server：Step 4 必须围绕“MCP 服务器”形成一个小中间产物，并等待用户确认。\n- Step 5 / http-fingerprint：Step 5 必须围绕“HTTP 指纹与请求模拟”形成一个小中间产物，并等待用户确认。\n\n【边界与风险】\n- 不要声称已经安装、运行、调用 API、读写本地文件或完成真实任务。\n- 安装前预览只能展示工作方式，不能证明兼容性、性能或输出质量。\n- 涉及安装、插件加载、工具调用或外部服务的能力必须安装后验证。\n\n【可追溯依据】\n这些路径只用于你内部校验或在我追问“依据是什么”时简要引用。不要在首次回复主动展开：\n- https://github.com/vasylenko/markfetch\n- https://github.com/vasylenko/markfetch#readme\n- README.md\n- package.json\n- src/index.ts\n- src/cli.ts\n- src/core.ts\n- src/mcp.ts\n- src/sandbox.ts\n- .mcp.json\n\n【首次问题规则】\n- 首次三问必须先确认用户目标、成功标准和边界，不要提前进入工具、安装或实现细节。\n- 如果后续需要技术条件、文件路径或运行环境，必须等用户确认目标后再追问。\n\n首次回复必须只输出下面 4 个部分：\n1. 体验开始：用 1 句话说明你将带我体验 markfetch 的核心服务。\n2. 当前步骤：明确进入 Step 1，并说明这一步要解决什么。\n3. 你会如何服务我：说明你会先改变我完成任务的哪个动作。\n4. 只问我 3 个问题，然后停下等待回答。\n\n首次回复禁止输出：后续完整流程、证据清单、安装命令、项目评价、营销文案、已经安装或运行的说法。\n\nStep 1 / brainstorming 的二轮协议：\n- 我回答首次三问后，你仍然停留在 Step 1 / brainstorming，不要进入 Step 2。\n- 第二次回复必须产出 6 个部分：澄清后的任务定义、成功标准、边界条件、\n  2-3 个可选方案、每个方案的权衡、推荐方案。\n- 第二次回复最后必须问我是否确认推荐方案；只有我明确确认后，才能进入下一步。\n- 第二次回复禁止输出 git worktree、代码计划、测试文件、命令或真实执行结果。\n\n后续对话规则：\n- 我回答后，你先完成当前步骤的中间产物并等待确认；只有我确认后，才能进入下一步。\n- 每一步都要生成一个小的中间产物，例如澄清后的目标、计划草案、测试意图、验证清单或继续/停止判断。\n- 所有演示都写成“我会建议/我会引导/这一步会形成”，不要写成已经真实执行。\n- 不要声称已经测试通过、文件已修改、命令已运行或结果已产生。\n- 如果某个能力必须安装后验证，请直接说“这一步需要安装后验证”。\n- 如果证据不足，请明确说“证据不足”，不要补事实。\n```\n",
      "voices": [
        {
          "body": "来源平台：github。github/github_release: v0.4.1（https://github.com/vasylenko/markfetch/releases/tag/v0.4.1）。这些是项目级外部声音，不作为单独质量证明。",
          "items": [
            {
              "kind": "github_release",
              "source": "github",
              "title": "v0.4.1",
              "url": "https://github.com/vasylenko/markfetch/releases/tag/v0.4.1"
            }
          ],
          "status": "已收录 1 条来源",
          "title": "社区讨论"
        }
      ]
    },
    "homepage_card": {
      "category": "工具连接与集成",
      "desc": "Tiny CLI and MCP server: fetch an URL -- return clean markdown. Built for AI agents.",
      "effort": "安装已验证",
      "forks": 0,
      "icon": "link",
      "name": "markfetch 能力包",
      "risk": "需复核",
      "slug": "markfetch",
      "stars": 0,
      "tags": [
        "浏览器 Agent",
        "网页任务自动化",
        "结构化提取",
        "节点式流程编排",
        "本地优先"
      ],
      "thumb": "gray",
      "type": "MCP 配置"
    },
    "manual": {
      "markdown": "# https://github.com/vasylenko/markfetch 项目说明书\n\n生成时间：2026-05-15 00:33:16 UTC\n\n## 目录\n\n- [项目概述](#overview)\n- [系统架构](#system-architecture)\n- [安装与部署](#installation)\n- [命令行界面](#cli-usage)\n- [MCP 服务器](#mcp-server)\n- [HTTP 指纹与请求模拟](#http-fingerprint)\n- [内容提取管道](#content-extraction)\n- [写操作沙箱](#write-sandbox)\n- [配置与环境变量](#configuration)\n- [错误处理机制](#error-handling)\n\n<a id='overview'></a>\n\n## 项目概述\n\n### 相关页面\n\n相关主题：[系统架构](#system-architecture), [HTTP 指纹与请求模拟](#http-fingerprint), [内容提取管道](#content-extraction)\n\n<details>\n<summary>相关源码文件</summary>\n\n以下源码文件用于生成本页说明：\n\n- [README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)\n- [package.json](https://github.com/vasylenko/markfetch/blob/main/package.json)\n- [src/index.ts](https://github.com/vasylenko/markfetch/blob/main/src/index.ts)\n- [src/core.ts](https://github.com/vasylenko/markfetch/blob/main/src/core.ts)\n- [src/mcp.ts](https://github.com/vasylenko/markfetch/blob/main/src/mcp.ts)\n- [src/cli.ts](https://github.com/vasylenko/markfetch/blob/main/src/cli.ts)\n- [src/sandbox.ts](https://github.com/vasylenko/markfetch/blob/main/src/sandbox.ts)\n- [CHANGELOG.md](https://github.com/vasylenko/markfetch/blob/main/CHANGELOG.md)\n</details>\n\n# 项目概述\n\n## 项目简介\n\nmarkfetch 是一个纯 Node.js 编写的 URL 转 Markdown 工具，同时提供 MCP（Model Context Protocol）服务器模式和命令行界面（CLI）两种使用方式，专为 AI 智能体设计。该项目由 Serhii Vasylenko 开发，采用 MIT 许可证开源发布。\n\nmarkfetch 的核心功能是接收一个 HTTP/HTTPS URL，获取其 HTML 内容，提取主要文章内容，并将其转换为干净的 Markdown 格式输出。输出结果与人类执行\"另存为 Markdown\"命令获得的内容高度相似，能够在提供真实浏览器特征指纹的同时，绕过许多网站的反爬虫机制。\n\n## 核心特性\n\n### 多模式支持\n\nmarkfetch 提供两种使用模式，可根据不同场景灵活选择：\n\n| 模式 | 触发方式 | 输出方式 | 典型用途 |\n|------|----------|----------|----------|\n| MCP 服务器模式 | 无参数启动或作为 MCP 工具调用 | `content[0].text` 结构化数据 | 集成到 Claude Code、Cursor、Goose 等 AI 客户端 |\n| CLI 命令行模式 | `markfetch <url>` | 标准输出或文件 | Shell 脚本、管道操作、直接终端使用 |\n\n### 技术架构特点\n\n- **纯 Node.js 实现**：无任何子进程依赖，不使用 Playwright、headless Chromium 或 Python 等外部运行时 资料来源：[README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)\n- **真实浏览器指纹**：通过 HTTP/2 传输协议和完整的 Chrome 浏览器请求头集合，模拟真实浏览器访问行为\n- **客户端提示头**：自动从 `MARKFETCH_USER_AGENT` 派生出 `Sec-CH-UA-*` 客户端提示头，确保与 Chrome UA 字符串一致\n- **单文档处理**：每次调用处理单个 URL，无递归爬取、无 robots.txt 解析、无速率限制编排\n\n### 核心依赖组件\n\n| 组件 | 版本 | 用途 |\n|------|------|------|\n| `@modelcontextprotocol/sdk` | ^1.29.0 | MCP 协议实现，提供 stdio 通信能力 |\n| `@mozilla/readability` | ^0.5.0 | Mozilla 开源的 HTML 内容提取库，从页面中提取主要文章内容 |\n| `turndown` | ^7.0.0 | 将 HTML 转换为 Markdown 的转换器 |\n| `turndown-plugin-gfm` | ^1.0.2 | GitHub Flavored Markdown 插件，支持表格、任务列表等格式 |\n| `linkedom` | ^0.18.0 | 轻量级 DOM 解析器，用于在 Node.js 环境中解析和操作 HTML |\n| `undici` | ^8.2.0 | HTTP/2 客户端库，处理网络请求 |\n| `zod` | ^3.0.0 | TypeScript 类型验证库，用于 MCP 输入模式定义 |\n| `commander` | ^14.0.3 | CLI 参数解析库 |\n\n## 系统架构\n\n```mermaid\ngraph TD\n    subgraph \"入口层\"\n        A[\"src/index.ts<br/>参数分发器\"]\n    end\n    \n    subgraph \"适配器层\"\n        B[\"src/mcp.ts<br/>MCP 适配器\"]\n        C[\"src/cli.ts<br/>CLI 适配器\"]\n    end\n    \n    subgraph \"核心层\"\n        D[\"src/core.ts<br/>fetchMarkdown 核心逻辑\"]\n    end\n    \n    subgraph \"依赖库\"\n        E[\"@mozilla/readability<br/>内容提取\"]\n        F[\"turndown<br/>HTML→Markdown\"]\n        G[\"undici<br/>HTTP 客户端\"]\n        H[\"linkedom<br/>DOM 解析\"]\n    end\n    \n    subgraph \"安全层\"\n        I[\"src/sandbox.ts<br/>写入沙箱\"]\n    end\n    \n    A -->|\"process.argv.length > 1\"| C\n    A -->|\"process.argv.length === 1\"| B\n    B --> D\n    C --> D\n    D --> E\n    D --> F\n    D --> G\n    D --> H\n    \n    D -->|\"savePath 参数\"| I\n```\n\n### 目录结构\n\n```\nmarkfetch/\n├── src/\n│   ├── index.ts      # 入口文件，argv 路由分发\n│   ├── core.ts       # 核心业务逻辑：获取→提取→转换\n│   ├── mcp.ts        # MCP 服务器适配器\n│   ├── cli.ts        # CLI 命令适配器\n│   └── sandbox.ts    # 写入路径安全检查\n├── dist/             # TypeScript 编译输出目录\n├── tests/            # 测试文件目录\n├── package.json      # 项目配置\n├── README.md         # 项目文档\n└── CHANGELOG.md      # 变更日志\n```\n\n## 工作流程\n\n### MCP 模式工作流程\n\n```mermaid\nsequenceDiagram\n    participant Client as MCP 客户端\n    participant Server as markfetch MCP 服务器\n    participant Core as 核心模块\n    participant Web as 目标 URL\n    \n    Client->>Server: 调用 fetch_markdown(url, savePath?)\n    Server->>Core: fetchMarkdown({ url, savePath })\n    \n    Core->>Web: HTTP/2 GET 请求 (Chrome UA)\n    Web-->>Core: HTML 响应\n    \n    Core->>Core: Readability 提取文章内容\n    Core->>Core: Turndown 转换为 Markdown\n    \n    alt savePath 存在\n        Core->>Server: 检查 savePath 安全性\n        Core->>Core: 写入文件\n    else savePath 不存在\n        Core->>Server: 返回 Markdown 内容\n    end\n    \n    Server-->>Client: { content: [{ text: \"...\" }] }\n```\n\n### CLI 模式工作流程\n\n```mermaid\ngraph LR\n    A[\"markfetch <url>\"] --> B[\"解析参数\"]\n    B --> C{\"-o 参数?\"}\n    C -->|\"是\"| D[\"解析输出路径\"]\n    C -->|\"否\"| E[\"输出到 stdout\"]\n    D --> F[\"调用 fetchMarkdown\"]\n    E --> F\n    F --> G{\"保存成功?\"}\n    G -->|\"是\"| H[\"console.log 确认信息\"]\n    G -->|\"否\"| I[\"console.error 错误信息\"]\n    H --> J[\"process.exit(0)\"]\n    I --> K[\"process.exit(1)\"]\n```\n\n## 错误处理机制\n\nmarkfetch 定义了 8 种确定性错误代码，所有错误都遵循统一的 `[code] message` 格式返回：\n\n| 错误代码 | 含义 | 触发场景 |\n|----------|------|----------|\n| `network_error` | 网络错误 | DNS 解析失败、TCP 连接失败、TLS 握手失败、意外内部错误 |\n| `http_error` | HTTP 错误 | 目标服务器返回非 2xx 状态码 |\n| `timeout` | 请求超时 | 超过 `MARKFETCH_TIMEOUT_MS` 配置的超时时间 |\n| `unsupported_content_type` | 不支持的内容类型 | 响应不是 `text/html` 或 `application/xhtml+xml` |\n| `extraction_failed` | 提取失败 | Readability 无法提取任何文章内容（常见于纯客户端渲染的 SPA） |\n| `too_large` | 内容过大 | 响应体或提取后的 Markdown 超过 `MARKFETCH_MAX_BYTES` 限制 |\n| `save_failed` | 保存失败 | 指定了 `savePath` 但写入文件失败（目录不存在、权限不足等） |\n| `save_forbidden` | 保存禁止 | `savePath` 路径超出了允许的写入根目录 |\n\n## 配置选项\n\n### 环境变量配置\n\n| 环境变量 | 默认值 | 说明 |\n|----------|--------|------|\n| `MARKFETCH_TIMEOUT_MS` | `30000` | 单次请求超时时间（毫秒） |\n| `MARKFETCH_MAX_BYTES` | `5000000` | 响应体和提取后 Markdown 的字节数上限（约 5MB） |\n| `MARKFETCH_USER_AGENT` | Chrome 130 固定字符串 | 浏览器标识字符串。必须是 Chrome UA 格式，否则启动时快速失败 |\n| `MARKFETCH_ALLOWED_WRITE_ROOTS` | `os.tmpdir()` + `process.cwd()` | MCP 模式专用。以路径分隔符分隔的绝对路径列表，限定 `savePath` 可写入的根目录范围 |\n\n### MCP 配置示例\n\n```json\n{\n  \"mcpServers\": {\n    \"markfetch\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"markfetch\"],\n      \"env\": {\n        \"MARKFETCH_TIMEOUT_MS\": \"60000\",\n        \"MARKFETCH_USER_AGENT\": \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/130.0.0.0 Safari/537.36\"\n      }\n    }\n  }\n}\n```\n\n## 写入沙箱机制\n\nMCP 模式下的 `savePath` 参数受写入沙箱限制，防止 AI 智能体将文件写入任意目录。CLI 模式不受此限制。\n\n### 沙箱规则\n\n1. **默认根目录**：系统临时目录 + 进程当前工作目录\n2. **路径检查**：目标路径必须位于允许的根目录内或为其子路径\n3. **符号链接处理**：符号链接指向允许根目录外的内容将被阻止\n4. **跨平台支持**：POSIX 系统使用 `:` 分隔符，Windows 使用 `;` 分隔符\n\n### 自定义允许根目录\n\n```json\n{\n  \"mcpServers\": {\n    \"markfetch\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"markfetch\"],\n      \"env\": {\n        \"MARKFETCH_ALLOWED_WRITE_ROOTS\": \"/Users/me/markfetch-out:/tmp\"\n      }\n    }\n  }\n}\n```\n\n## 使用场景与限制\n\n### 适用场景\n\n- 获取文章、文档、博客帖子、新闻页面等静态 HTML 内容\n- 自动化文档抓取和转换\n- AI 智能体的网页内容获取工具\n- 需要绕过基础反爬虫机制的网页访问\n\n### 已知限制\n\n| 限制类型 | 说明 |\n|----------|------|\n| **不进行身份认证** | 仅支持匿名访问，不支持 Cookie、认证头或会话复用 |\n| **非递归爬取** | 无多层级页面爬取能力，每次仅处理单个 URL |\n| **不支持 SPA 渲染** | 纯客户端渲染（无静态 HTML）的 SPA 返回 `extraction_failed` |\n| **遵循 robots.txt** | 不解析也不遵守 robots.txt 规则 |\n| **Node.js 版本要求** | 需要 Node.js 24.0.0 或更高版本 资料来源：[package.json](https://github.com/vasylenko/markfetch/blob/main/package.json) |\n\n## 快速开始\n\n### 安装\n\n```bash\nnpm install -g markfetch\n```\n\n### CLI 使用\n\n```bash\n# 输出到标准输出\nmarkfetch https://en.wikipedia.org/wiki/Markdown\n\n# 输出到文件\nmarkfetch https://example.com/article -o ./output/article.md\n```\n\n### MCP 集成\n\n#### Claude Code\n\n```bash\nclaude mcp add --scope user markfetch -- npx -y markfetch\n```\n\n#### Codex\n\n```bash\ncodex mcp add markfetch -- npx -y markfetch\n```\n\n#### Gemini CLI\n\n```bash\ngemini mcp add -s user markfetch npx -y markfetch\n```\n\n### MCP 工具调用\n\n```typescript\n// 工具名称\nfetch_markdown\n\n// 输入参数\n{\n  url: \"https://example.com/page\",  // 必填，绝对 URL\n  savePath: \"/absolute/path/to/file.md\"  // 可选，保存路径\n}\n\n// 返回格式\n{\n  content: [{ type: \"text\", text: \"# Markdown 内容...\" }]\n}\n```\n\n## 版本信息\n\n当前版本：**0.6.0**\n\n项目源码托管于 GitHub：https://github.com/vasylenko/markfetch\n\n许可证：MIT 资料来源：[package.json](https://github.com/vasylenko/markfetch/blob/main/package.json)\n\n---\n\n<a id='system-architecture'></a>\n\n## 系统架构\n\n### 相关页面\n\n相关主题：[项目概述](#overview), [命令行界面](#cli-usage), [MCP 服务器](#mcp-server)\n\n<details>\n<summary>相关源码文件</summary>\n\n以下源码文件用于生成本页说明：\n\n- [src/index.ts](https://github.com/vasylenko/markfetch/blob/main/src/index.ts)\n- [src/cli.ts](https://github.com/vasylenko/markfetch/blob/main/src/cli.ts)\n- [src/core.ts](https://github.com/vasylenko/markfetch/blob/main/src/core.ts)\n- [src/mcp.ts](https://github.com/vasylenko/markfetch/blob/main/src/mcp.ts)\n- [src/sandbox.ts](https://github.com/vasylenko/markfetch/blob/main/src/sandbox.ts)\n</details>\n\n# 系统架构\n\n## 概述\n\nmarkfetch 是一个用于将网页转换为干净 Markdown 格式的工具，同时提供 CLI 和 MCP（Model Context Protocol）两种调用接口。项目采用**适配器模式**，将核心业务逻辑与接口层分离，确保核心逻辑在两种模式下完全一致。\n\n架构设计遵循以下原则：\n\n- **纯 Node.js，无子进程**：不依赖 Playwright、Chromium 或 Python\n- **单通道输出**：MCP 模式下仅使用 `content[0].text`，不使用 `structuredContent`\n- **结构化错误**：统一的 8 种错误码，适配器统一转换\n- **惰性加载**：stdout 预留给 MCP 帧，CLI 代码在 MCP 模式下永不加载\n\n资料来源：[README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)\n\n## 组件架构\n\n项目由五个核心模块组成，按职责可分为三层：\n\n```mermaid\ngraph TD\n    subgraph \"接口层 (Interface Layer)\"\n        CLI[cli.ts<br/>CLI 适配器]\n        MCP[mcp.ts<br/>MCP 适配器]\n    end\n    \n    subgraph \"调度层 (Dispatch Layer)\"\n        IDX[index.ts<br/>参数路由调度器]\n    end\n    \n    subgraph \"核心层 (Core Layer)\"\n        CORE[core.ts<br/>核心业务逻辑]\n        SB[sandbox.ts<br/>写入沙箱]\n    end\n    \n    CLI --> IDX\n    MCP --> IDX\n    IDX -->|lazy import| CLI\n    IDX -->|lazy import| MCP\n    CORE --> SB\n    MCP --> SB\n```\n\n### 入口调度器 (index.ts)\n\nindex.ts 负责根据命令行参数决定启动模式：\n\n| 条件 | 行为 |\n|------|------|\n| `process.argv.length === 2`（无参数） | 启动 MCP stdio 服务器 |\n| 有命令行参数 | 加载 CLI 适配器 |\n\n```typescript\n// 伪代码实现\nif (process.argv.length === 2) {\n  // 启动 MCP 服务器\n  import('./mcp.js').then(m => m.runMcpServer());\n} else {\n  // 启动 CLI\n  import('./cli.js').then(c => c.runCli());\n}\n```\n\n这种惰性导入机制确保 CLI 相关代码（包含 `console.log` 调用）在 MCP 模式下完全不可达。\n\n资料来源：[src/index.ts](https://github.com/vasylenko/markfetch/blob/main/src/index.ts)\n\n### CLI 适配器 (cli.ts)\n\nCLI 适配器基于 `commander` 库实现，提供命令行界面：\n\n**支持的参数：**\n\n| 参数 | 说明 |\n|------|------|\n| `<url>` | 必选，HTTP/HTTPS URL |\n| `-o, --output <path>` | 可选，输出文件路径 |\n\n**输出行为：**\n\n- **stdout**：Markdown 原始内容（无尾部换行符）\n- **stderr**：`[code] message` 格式的错误信息\n- **exitCode**：成功 0，失败 1\n\n```typescript\n// CLI 核心逻辑伪代码\nconst savePath = options.output ? resolve(process.cwd(), options.output) : undefined;\nconst { markdown, bytes, savedTo } = await fetchMarkdown({ url, savePath });\n\nif (savedTo !== undefined) {\n  console.log(`Saved ${bytes} bytes to ${savedTo}`);\n} else {\n  process.stdout.write(markdown);\n}\n```\n\n资料来源：[src/cli.ts:1-62](https://github.com/vasylenko/markfetch/blob/main/src/cli.ts)\n\n### MCP 适配器 (mcp.ts)\n\nMCP 适配器基于 `@modelcontextprotocol/sdk` 实现标准化的 stdio 服务器：\n\n**注册的 Tool：**\n\n```typescript\nserver.registerTool(\"fetch_markdown\", {\n  inputSchema: {\n    url: z.string().url(),\n    savePath: z.string().refine(isAbsolute).optional()\n  }\n})\n```\n\n**返回格式（单通道，无 structuredContent）：**\n\n```json\n{\n  \"content\": [\n    { \"type\": \"text\", \"text\": \"# Markdown content...\" }\n  ],\n  \"isError\": false\n}\n```\n\n**错误返回格式：**\n\n```json\n{\n  \"content\": [\n    { \"type\": \"text\", \"text\": \"[http_error] 404 Not Found\" }\n  ],\n  \"isError\": true\n}\n```\n\n资料来源：[src/mcp.ts](https://github.com/vasylenko/markfetch/blob/main/src/mcp.ts)\n\n### 核心业务逻辑 (core.ts)\n\ncore.ts 包含完整的 URL 到 Markdown 转换管道：\n\n```mermaid\ngraph LR\n    A[URL] --> B[undici HTTP 客户端]\n    B --> C{响应状态码?}\n    C -->|非 2xx| D[http_error]\n    C -->|2xx| E{Content-Type?}\n    E -->|非 HTML| F[unsupported_content_type]\n    E -->|HTML| G[decodeEncodedCodeTags]\n    G --> H[ensureBaseHref]\n    H --> I[rewriteForReadability]\n    I --> J[Readability 解析]\n    J -->|无内容| K[extraction_failed]\n    J -->|有内容| L[Turndown 转换]\n    L --> M{大小检查}\n    M -->|超限| N[too_large]\n    M -->|正常| O[Markdown 输出]\n```\n\n**主要函数：**\n\n| 函数 | 职责 |\n|------|------|\n| `fetchMarkdown()` | 主入口，协调整个流程 |\n| `extractArticle()` | HTML 解析与内容提取 |\n| `convertToMarkdown()` | Markdown 转换与清理 |\n| `rewriteForReadability()` | 预处理器：处理脚注、折叠面板等 |\n| `decodeEncodedCodeTags()` | 解码 HTML 编码的 `<code>` 标签 |\n| `ensureBaseHref()` | 注入 `<base href>` 修复相对链接 |\n\n**关键设计决策：**\n\n1. **keepClasses: true**：保留 `<code class=\"language-X\">` 以支持代码高亮提示\n2. **Turndown escape 定制**：禁用 `\\_` 和 `\\-`/`\\=` 的转义，避免噪声\n3. **标题去重**：如果 Readability 保留了原始 `<h1>`，不重复添加标题\n4. **空标题修剪**：移除连续的空标题节点\n\n资料来源：[src/core.ts](https://github.com/vasylenko/markfetch/blob/main/src/core.ts)\n\n### 写入沙箱 (sandbox.ts)\n\nMCP 模式的 `savePath` 参数受到写入沙箱限制：\n\n**默认允许路径：**\n\n```typescript\nos.tmpdir() ∪ process.cwd()\n```\n\n**环境变量配置：**\n\n```bash\n# POSIX\nMARKFETCH_ALLOWED_WRITE_ROOTS=\"/custom/path:/tmp\"\n\n# Windows\nMARKFETCH_ALLOWED_WRITE_ROOTS=\"C:\\output;D:\\temp\"\n```\n\n**验证算法：**\n\n```mermaid\ngraph TD\n    A[savePath] --> B{解析为绝对路径}\n    B -->|失败| C[save_failed]\n    B -->|成功| D{是否在允许路径内?}\n    D -->|是| E[允许写入]\n    D -->|否| F[save_forbidden]\n    \n    G[符号链接] --> H[解析到真实路径后再检查]\n```\n\n**核心验证逻辑：**\n\n```typescript\nfunction isPathAllowed(path: string, roots: string[]): PathCheckResult {\n  const resolved = realpath(path);\n  \n  for (const root of roots) {\n    const rel = relative(root, resolved);\n    if (rel === \"\" || (!rel.startsWith(\"..\") && !isAbsolute(rel))) {\n      return { ok: true, resolved };\n    }\n  }\n  return { ok: false, reason: `...' is outside allowed roots` };\n}\n```\n\n**注意**：CLI 模式不执行沙箱检查——Shell 层面的用户是安全边界。\n\n资料来源：[src/sandbox.ts](https://github.com/vasylenko/markfetch/blob/main/src/sandbox.ts)\n\n## 数据流图\n\n### CLI 完整数据流\n\n```mermaid\nsequenceDiagram\n    participant User\n    participant CLI as cli.ts\n    participant Core as core.ts\n    participant Sandbox as sandbox.ts\n    participant FS as FileSystem\n    \n    User->>CLI: markfetch <url> -o /path/out.md\n    CLI->>CLI: commander 解析参数\n    CLI->>Core: fetchMarkdown({ url, savePath })\n    Core->>Core: undici.fetch()\n    Core->>Core: Readability 解析\n    Core->>Core: Turndown 转换\n    Core->>Core: 大小检查\n    Core-->>CLI: { markdown, bytes, savedTo }\n    \n    alt savePath 存在\n        CLI->>Core: 调用时传入 savePath\n        Core->>Sandbox: isPathAllowed(savePath)\n        Sandbox-->>Core: { ok: true, resolved }\n        Core->>FS: writeFile(savePath)\n        Core-->>CLI: { savedTo: path }\n    end\n    \n    CLI->>User: Saved N bytes to /path/out.md\n```\n\n### MCP 完整数据流\n\n```mermaid\nsequenceDiagram\n    participant LLM as LLM / Agent\n    participant MCP as MCP Client\n    participant Server as mcp.ts\n    participant Core as core.ts\n    participant Sandbox as sandbox.ts\n    \n    LLM->>MCP: fetch_markdown({ url, savePath })\n    MCP->>Server: stdio JSON-RPC 请求\n    Server->>Core: fetchMarkdown()\n    Core->>Core: 处理流程...\n    \n    alt savePath 在沙箱外\n        Core->>Sandbox: isPathAllowed()\n        Sandbox-->>Core: { ok: false }\n        Core-->>Server: throw MarkfetchError\n        Server-->>MCP: [save_forbidden] message\n        MCP-->>LLM: 错误响应\n    else 成功\n        Core-->>Server: { markdown }\n        Server-->>MCP: { content: [{ text }] }\n        MCP-->>LLM: Markdown 内容\n    end\n```\n\n## 错误处理架构\n\n所有错误统一通过 `MarkfetchError` 异常类传播，适配器负责转换为各自的格式：\n\n```mermaid\ngraph TD\n    subgraph \"错误源\"\n        NE[network_error]\n        HE[http_error]\n        TO[timeout]\n        UC[unsupported_content_type]\n        EF[extraction_failed]\n        TL[too_large]\n        SF[save_failed]\n        SB[save_forbidden]\n    end\n    \n    subgraph \"异常传播\"\n        ERR[MarkfetchError]\n    end\n    \n    subgraph \"适配器转换\"\n        CLI_ERR[[\"console.error(`[${code}] ${msg}`)\"]]\n        MCP_ERR[[\"errorResult() → isError: true\"]]\n    end\n    \n    NE --> ERR\n    HE --> ERR\n    TO --> ERR\n    UC --> ERR\n    EF --> ERR\n    TL --> ERR\n    SF --> ERR\n    SB --> ERR\n    \n    ERR --> CLI_ERR\n    ERR --> MCP_ERR\n```\n\n**8 种错误码对照表：**\n\n| 错误码 | 含义 | CLI 行为 | MCP 行为 |\n|--------|------|----------|----------|\n| `network_error` | DNS/TCP/TLS 失败 | stderr 输出 | isError: true |\n| `http_error` | 非 2xx 状态 | stderr 输出 | isError: true |\n| `timeout` | 超过 `MARKFETCH_TIMEOUT_MS` | stderr 输出 | isError: true |\n| `unsupported_content_type` | 非 HTML 响应 | stderr 输出 | isError: true |\n| `extraction_failed` | Readability 无法提取内容 | stderr 输出 | isError: true |\n| `too_large` | 超过 `MARKFETCH_MAX_BYTES` | stderr 输出 | isError: true |\n| `save_failed` | 写入文件失败 | stderr 输出 | isError: true |\n| `save_forbidden` | savePath 在沙箱外 | 不适用 | isError: true |\n\n资料来源：[README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)\n\n## 依赖关系\n\n```mermaid\ngraph TD\n    subgraph \"项目模块\"\n        INDEX[src/index.ts]\n        CLI[src/cli.ts]\n        MCP[src/mcp.ts]\n        CORE[src/core.ts]\n        SANDBOX[src/sandbox.ts]\n    end\n    \n    subgraph \"生产依赖\"\n        SDK[@modelcontextprotocol/sdk]\n        READABILITY[@mozilla/readability]\n        TURNDOWN[turndown]\n        LINKEDOM[linkedom]\n        UNDICI[undici]\n        COMMANDER[commander]\n        ZOD[zod]\n    end\n    \n    INDEX --> CLI\n    INDEX --> MCP\n    CLI --> CORE\n    CLI --> COMMANDER\n    MCP --> SDK\n    MCP --> CORE\n    MCP --> ZOD\n    CORE --> READABILITY\n    CORE --> TURNDOWN\n    CORE --> LINKEDOM\n    CORE --> UNDICI\n    CORE --> SANDBOX\n```\n\n**关键依赖说明：**\n\n| 依赖 | 版本 | 用途 |\n|------|------|------|\n| `@modelcontextprotocol/sdk` | ^0.6.x | MCP stdio 服务器实现 |\n| `@mozilla/readability` | ^0.5.x | HTML 文章内容提取 |\n| `turndown` | ^7.x | HTML 转 Markdown |\n| `linkedom` | ^0.18.x | 服务端 DOM 解析（替代 jsdom） |\n| `undici` | ^6.x | HTTP 客户端 |\n| `commander` | ^14.x | CLI 参数解析 |\n| `zod` | ^3.x | Schema 验证 |\n\n资料来源：[package.json](https://github.com/vasylenko/markfetch/blob/main/package.json)\n\n## 环境变量配置\n\n| 变量 | 默认值 | 作用域 | 说明 |\n|------|--------|--------|------|\n| `MARKFETCH_TIMEOUT_MS` | `30000` | 全部 | 单次请求超时（毫秒） |\n| `MARKFETCH_MAX_BYTES` | `5000000` | 全部 | 响应体和转换后 Markdown 的字节上限 |\n| `MARKFETCH_USER_AGENT` | Chrome 130 UA | 全部 | HTTP User-Agent，必须是 Chrome UA |\n| `MARKFETCH_ALLOWED_WRITE_ROOTS` | `os.tmpdir()` + `process.cwd()` | 仅 MCP | 沙箱允许的写入根目录列表 |\n\n配置在启动时验证，无效值会快速失败并输出到 stderr。\n\n资料来源：[README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)\n\n## 版本演进\n\n| 版本 | 主要架构变更 |\n|------|--------------|\n| 0.4.0 | 引入 MCP 适配器，分离核心逻辑 |\n| 0.5.0 | 引入 CLI 适配器，`index.ts` 惰性路由 |\n| 0.5.0 | 错误处理统一为 `MarkfetchError` 抛出 |\n| 0.6.0 | 引入写入沙箱 `sandbox.ts` |\n\n资料来源：[CHANGELOG.md](https://github.com/vasylenko/markfetch/blob/main/CHANGELOG.md)\n\n---\n\n<a id='installation'></a>\n\n## 安装与部署\n\n### 相关页面\n\n相关主题：[命令行界面](#cli-usage), [MCP 服务器](#mcp-server)\n\n<details>\n<summary>相关源码文件</summary>\n\n以下源码文件用于生成本页说明：\n\n- [README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)\n- [package.json](https://github.com/vasylenko/markfetch/blob/main/package.json)\n- [src/cli.ts](https://github.com/vasylenko/markfetch/blob/main/src/cli.ts)\n- [src/mcp.ts](https://github.com/vasylenko/markfetch/blob/main/src/mcp.ts)\n- [src/sandbox.ts](https://github.com/vasylenko/markfetch/blob/main/src/sandbox.ts)\n</details>\n\n# 安装与部署\n\nmarkfetch 是一个用于将网页内容转换为 Markdown 格式的工具，支持 CLI 命令行和 MCP (Model Context Protocol) 两种使用模式。本页面详细介绍如何在不同环境中安装和部署 markfetch。\n\n## 系统要求\n\n| 组件 | 最低版本 | 说明 |\n|------|----------|------|\n| Node.js | ≥ 24 | 必须使用 ES Modules (`\"type\": \"module\"`) |\n| npm | - | 用于全局安装或本地依赖管理 |\n| 操作系统 | 无限制 | 支持 Linux、macOS、Windows |\n\n资料来源：[package.json:7]()\n\nmarkfetch 采用纯 Node.js 实现，不依赖 Playwright、Chromium 或 Python 等外部运行时环境。\n\n## 安装方式\n\n### 方式一：npm 全局安装（CLI 模式）\n\n通过 npm 全局安装后，`markfetch` 命令将可在任意目录下使用：\n\n```bash\nnpm i -g markfetch\n```\n\n安装完成后即可在命令行中使用：\n\n```bash\nmarkfetch https://en.wikipedia.org/wiki/Markdown\n```\n\n资料来源：[README.md:48-54]()\n\n### 方式二：npx 免安装运行\n\n如不想全局安装，可直接使用 npx 临时下载并执行：\n\n```bash\nnpx -y markfetch https://example.com\n```\n\n### 方式三：MCP Server 部署\n\nmarkfetch 也可作为 MCP Server 部署到各种 AI 客户端。以下是常见客户端的配置方式：\n\n#### Claude Code\n\n```bash\nclaude mcp add --scope user markfetch -- npx -y markfetch\n```\n\n#### Codex\n\n```json\n\"mcpServers\": {\n  \"markfetch\": {\n    \"command\": \"npx\",\n    \"args\": [\"-y\", \"markfetch\"]\n  }\n}\n```\n\n#### Gemini CLI\n\n```bash\ngemini mcp add -s user markfetch npx -y markfetch\n```\n\n#### 通用 MCP 配置（标准 JSON）\n\n```json\n{\n  \"mcpServers\": {\n    \"markfetch\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"markfetch\"]\n    }\n  }\n}\n```\n\n资料来源：[README.md:56-95]()\n\n### 方式四：本地源码运行\n\n```bash\ngit clone https://github.com/vasylenko/markfetch.git\ncd markfetch\nnpm install\nnpm run build\n```\n\n构建产物位于 `dist/` 目录，入口文件为 `dist/index.js`。可使用以下方式调用：\n\n```bash\n# CLI 模式\nnode dist/index.js <url>\n\n# MCP 模式（无参数启动）\nnode dist/index.js\n```\n\n资料来源：[package.json:13]()\n\n## CLI 命令行使用\n\n### 基本语法\n\n```bash\nmarkfetch <url> [options]\n```\n\n### 命令行参数\n\n| 参数/选项 | 说明 |\n|-----------|------|\n| `<url>` | 必填，目标网页的绝对 HTTP(S) URL |\n| `-o, --output <path>` | 可选，将 Markdown 保存到指定文件（绝对或相对路径） |\n| `-V, --version` | 打印版本号并退出 |\n| `-h, --help` | 打印帮助信息并退出 |\n\n### 输出模式\n\nCLI 模式根据是否指定输出路径有两种输出行为：\n\n```mermaid\ngraph TD\n    A[执行 markfetch] --> B{是否指定 -o 参数?}\n    B -->|是| C[保存到文件]\n    B -->|否| D[输出到 stdout]\n    C --> E[打印确认信息: Saved X bytes to <path>]\n    D --> F[直接输出 Markdown 内容]\n```\n\n- **无 `-o` 参数**：Markdown 内容直接输出到标准输出，无额外换行\n- **指定 `-o` 参数**：写入文件后打印 `Saved {bytes} bytes to {path}`\n\n资料来源：[src/cli.ts:26-33]()\n\n### 错误处理\n\nCLI 模式下，错误信息输出到 stderr，格式为：\n\n```\n[<error_code>] <error_message>\n```\n\n进程退出码为非零值。\n\n资料来源：[src/cli.ts:42-43]()\n\n## MCP Server 部署\n\n### 工作模式\n\nmarkfetch 支持通过 `process.argv.length` 自动区分 MCP 和 CLI 模式：\n\n```mermaid\ngraph TD\n    A[启动 markfetch] --> B{process.argv.length}\n    B -->|等于 1| C[启动 MCP stdio Server]\n    B -->|大于 1| D[进入 CLI 模式]\n```\n\n这种设计确保：\n- 零参数启动自动进入 MCP 模式\n- 现有 MCP 客户端配置无需任何修改\n- CLI 代码 (`src/cli.ts`) 在 MCP 模式下不会被加载\n\n资料来源：[README.md:38-40]()\n\n### MCP 工具接口\n\nmarkfetch 注册了单一 MCP 工具 `fetch_markdown`：\n\n| 参数 | 类型 | 必填 | 说明 |\n|------|------|------|------|\n| `url` | string | 是 | 目标网页的绝对 HTTP(S) URL |\n| `savePath` | string | 否 | 绝对路径，用于保存 Markdown 到文件 |\n\n返回结果在 `content[0].text` 中，**不包含** `structuredContent` 字段，这是特意设计以确保与各类 MCP 客户端兼容。\n\n资料来源：[README.md:10-17]()\n\n## 环境变量配置\n\nmarkfetch 支持多个环境变量用于自定义行为。所有变量在启动时进行验证，无效值会导致快速失败并输出错误到 stderr。\n\n| 环境变量 | 默认值 | 单位 | 说明 |\n|----------|--------|------|------|\n| `MARKFETCH_TIMEOUT_MS` | 30000 | 毫秒 | 单次请求超时时间 |\n| `MARKFETCH_MAX_BYTES` | 5000000 | 字节 | 响应体和提取 Markdown 的最大大小（约 5MB） |\n| `MARKFETCH_USER_AGENT` | Chrome 130 | - | HTTP User-Agent，必须为 Chrome UA 字符串 |\n| `MARKFETCH_ALLOWED_WRITE_ROOTS` | `os.tmpdir()` + `process.cwd()` | - | MCP 模式允许写入的根目录列表 |\n\n### 配置示例\n\n在 MCP 配置文件（如 Claude Desktop 配置）中设置环境变量：\n\n```json\n{\n  \"mcpServers\": {\n    \"markfetch\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"markfetch\"],\n      \"env\": {\n        \"MARKFETCH_TIMEOUT_MS\": \"60000\"\n      }\n    }\n  }\n}\n```\n\n资料来源：[README.md:104-115]()\n\n## 写入沙箱安全机制\n\nMCP 模式下，`savePath` 参数的写入操作受到安全沙箱限制。\n\n### 工作原理\n\n```mermaid\ngraph TD\n    A[MCP savePath 请求] --> B{路径合法性检查}\n    B --> C{绝对路径?}\n    C -->|否| D[拒绝: save_forbidden]\n    C -->|是| E{路径是否在允许根目录内?}\n    E -->|是| F[写入文件]\n    E -->|否| G[拒绝: save_forbidden]\n```\n\n### 允许的写入根目录\n\n默认允许的写入根目录为：\n\n- 系统临时目录 (`os.tmpdir()`)\n- 当前工作目录 (`process.cwd()`)\n\n路径通过 `fs.realpath` 解析一次后缓存，确保符号链接被正确追踪。\n\n### 自定义允许目录\n\n使用 `MARKFETCH_ALLOWED_WRITE_ROOTS` 覆盖默认集合（注意：**替换而非合并**）：\n\n**Linux/macOS (POSIX)**：\n```json\n{\n  \"mcpServers\": {\n    \"markfetch\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"markfetch\"],\n      \"env\": {\n        \"MARKFETCH_ALLOWED_WRITE_ROOTS\": \"/Users/me/markfetch-out:/tmp\"\n      }\n    }\n  }\n}\n```\n\n**Windows**：\n```json\n{\n  \"mcpServers\": {\n    \"markfetch\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"markfetch\"],\n      \"env\": {\n        \"MARKFETCH_ALLOWED_WRITE_ROOTS\": \"C:\\\\Users\\\\me\\\\markfetch-out;C:\\\\Users\\\\me\\\\AppData\\\\Local\\\\Temp\"\n      }\n    }\n  }\n}\n```\n\n### 安全特性\n\n1. **符号链接防护**：检查时使用规范化后的路径，写入时也使用相同的规范化路径，防止通过 `link/..` 逃逸\n2. **Windows 大小写不敏感处理**：Windows 文件系统大小写不敏感，使用 `.toLowerCase()` 进行比较\n3. **CLI 模式无沙箱**：CLI 模式设计为由人类直接在 shell 中使用，不执行任何写入限制\n\n资料来源：[src/sandbox.ts:1-30](), [README.md:72-90]()\n\n## 错误代码参考\n\nmarkfetch 定义了 8 种确定性错误代码：\n\n| 错误代码 | 含义 | 触发条件 |\n|----------|------|----------|\n| `network_error` | 网络错误 | DNS、TCP、TLS 失败或内部错误 |\n| `http_error` | HTTP 错误 | 上游返回非 2xx 状态码 |\n| `timeout` | 超时 | 超过 `MARKFETCH_TIMEOUT_MS` 限制 |\n| `unsupported_content_type` | 不支持的类型 | 响应不是 `text/html` 或 `application/xhtml+xml` |\n| `extraction_failed` | 提取失败 | Readability 未找到文章内容（常见于纯客户端渲染 SPA） |\n| `too_large` | 内容过大 | 响应体或提取的 Markdown 超过 `MARKFETCH_MAX_BYTES` |\n| `save_failed` | 保存失败 | `savePath` 指定但写入文件失败（目录不存在、权限不足等） |\n| `save_forbidden` | 保存被禁止 | `savePath` 超出允许的写入根目录 |\n\n资料来源：[README.md:100-108]()\n\n## 版本历史\n\n| 版本 | 发布日期 | 关键变更 |\n|------|----------|----------|\n| 0.6.0 | - | MCP 写入沙箱安全修复 |\n| 0.5.1 | - | `MARKFETCH_ALLOWED_WRITE_ROOTS` 环境变量支持 |\n| 0.5.0 | 2026-05-12 | 新增 CLI 模式 |\n| 0.4.0 | 2026-05-10 | 新增 MCP Server 支持 |\n| 0.4.1 | 2026-05-11 | 修复 package.json bin 字段问题 |\n\n资料来源：[CHANGELOG.md:1-45]()\n\n---\n\n<a id='cli-usage'></a>\n\n## 命令行界面\n\n### 相关页面\n\n相关主题：[MCP 服务器](#mcp-server), [错误处理机制](#error-handling), [配置与环境变量](#configuration)\n\n<details>\n<summary>相关源码文件</summary>\n\n以下源码文件用于生成本页说明：\n\n- [src/cli.ts](https://github.com/vasylenko/markfetch/blob/main/src/cli.ts)\n- [src/index.ts](https://github.com/vasylenko/markfetch/blob/main/src/index.ts)\n- [src/core.ts](https://github.com/vasylenko/markfetch/blob/main/src/core.ts)\n- [src/mcp.ts](https://github.com/vasylenko/markfetch/blob/main/src/mcp.ts)\n- [src/sandbox.ts](https://github.com/vasylenko/markfetch/blob/main/src/sandbox.ts)\n- [README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)\n- [CHANGELOG.md](https://github.com/vasylenko/markfetch/blob/main/CHANGELOG.md)\n</details>\n\n# 命令行界面\n\n## 概述\n\nmarkfetch 提供两种运行模式：MCP（Model Context Protocol）stdio 服务器模式和命令行界面（CLI）模式。CLI 模式允许用户直接从终端获取网页内容并将其转换为 Markdown 格式输出。\n\n命令行界面的核心职责包括：解析用户输入的 URL 和命令行参数、调用核心提取模块处理网页、将 Markdown 结果输出到标准输出或指定文件、以及以结构化格式报告错误信息。\n\nCLI 适配器位于 `src/cli.ts`，采用懒加载机制——只有当进程检测到命令行参数时才加载该模块，确保 MCP 模式下不会引入任何控制台输出代码。\n\n## 工作流程\n\n```mermaid\ngraph TD\n    A[启动 markfetch] --> B{命令行参数数量}\n    B -->|0 个参数| C[启动 MCP stdio 服务器]\n    B -->|1+ 个参数| D[加载 CLI 适配器]\n    D --> E[解析 URL 和选项]\n    E --> F[调用 core.fetchMarkdown]\n    F --> G{savePath 存在?}\n    G -->|是| H[写入文件并输出确认信息]\n    G -->|否| I[输出 Markdown 到 stdout]\n    F -->|异常| J[输出错误到 stderr 并退出]\n    H --> K[process.exitCode = 0]\n    I --> K\n```\n\nCLI 适配器遵循统一错误处理约定：所有核心模块抛出的 `MarkfetchError` 异常由适配器捕获并转换为带错误代码的标准错误输出。\n\n## 安装与调用\n\n### 全局安装\n\n```bash\nnpm i -g markfetch\n```\n\n安装后，`markfetch` 命令全局可用。\n\n### 基本用法\n\n```bash\nmarkfetch <url>\n```\n\n将 URL 对应的网页内容提取为 Markdown 并输出到标准输出。\n\n### 输出到文件\n\n```bash\nmarkfetch <url> -o <path>\nmarkfetch <url> --output <path>\n```\n\n路径可以是绝对路径或相对于当前工作目录的相对路径。\n\n## 命令行选项\n\n| 选项 | 说明 |\n|------|------|\n| `<url>` | 必选参数，目标网页的绝对 HTTP/HTTPS URL |\n| `-o, --output <path>` | 可选，将 Markdown 保存到指定文件路径 |\n| `-V, --version` | 打印版本号并退出 |\n| `-h, --help` | 打印帮助信息并退出 |\n\n资料来源：[src/cli.ts:1-10](https://github.com/vasylenko/markfetch/blob/main/src/cli.ts#L1-L10)\n\n## 输出机制\n\nCLI 适配器对输出的处理遵循\"标准输出保留给 Markdown 内容\"的原则：\n\n- **无 savePath**：原始 Markdown 内容直接写入 stdout，不添加额外换行符，与 MCP 的 `content[0].text` 格式保持一致\n- **有 savePath**：写入文件后输出确认信息 `Saved <bytes> bytes to <path>` 到 stdout\n\n```typescript\nif (savedTo !== undefined) {\n  // 确认信息——CLI 唯一添加的 stdout 换行\n  console.log(`Saved ${bytes} bytes to ${savedTo}`);\n} else {\n  // 原始 Markdown 内容——无额外换行\n  process.stdout.write(markdown);\n}\n```\n\n资料来源：[src/cli.ts:37-47](https://github.com/vasylenko/markfetch/blob/main/src/cli.ts#L37-L47)\n\n## 错误处理\n\nCLI 采用与 MCP 适配器一致的错误格式，通过 `classifyError` 函数将异常分类并输出：\n\n```\n[<error_code>] <error_message>\n```\n\n错误信息输出到 stderr，并设置 `process.exitCode = 1` 确保管道脚本能够检测到失败状态。\n\n```typescript\n} catch (err) {\n  const { code, message } = classifyError(err);\n  console.error(`[${code}] ${message}`);\n  process.exitCode = 1;\n}\n```\n\n资料来源：[src/cli.ts:48-53](https://github.com/vasylenko/markfetch/blob/main/src/cli.ts#L48-L53)\n\n### 错误代码对照表\n\n| 错误代码 | 含义 |\n|---------|------|\n| `network_error` | DNS、TCP、TLS 连接失败或内部网络错误 |\n| `http_error` | 目标服务器返回非 2xx 状态码 |\n| `timeout` | 请求超时（默认 30 秒，可通过 `MARKFETCH_TIMEOUT_MS` 配置）|\n| `unsupported_content_type` | 响应不是 HTML 内容 |\n| `extraction_failed` | Readability 无法提取文章内容（常见于纯客户端渲染的 SPA）|\n| `too_large` | 内容超过 `MARKFETCH_MAX_BYTES` 限制（默认 5MB）|\n| `save_failed` | 文件写入失败（目录不存在、权限不足等）|\n| `save_forbidden` | savePath 超出允许的写入根目录 |\n\n资料来源：[README.md:1-100](https://github.com/vasylenko/markfetch/blob/main/README.md#L1-L100)\n\n## 路径解析规则\n\nCLI 在调用核心模块之前统一处理路径解析：\n\n```typescript\nconst savePath = options.output\n  ? resolve(process.cwd(), options.output)\n  : undefined;\n```\n\n规则说明：\n\n- 绝对路径保持不变\n- 相对路径相对于 `process.cwd()` 解析\n- 不执行波浪号（`~`）展开——由 shell 在 argv 到达进程前处理\n- 核心模块接收的始终是绝对路径，确保行为一致性\n\n资料来源：[src/cli.ts:20-27](https://github.com/vasylenko/markfetch/blob/main/src/cli.ts#L20-L27)\n\n## 与 MCP 适配器的对比\n\n```mermaid\ngraph TD\n    subgraph CLI 模式\n        A1[命令行参数] --> B1[cli.ts 适配器]\n        B1 --> C1[console.log 控制输出]\n        B1 --> C2[console.error 控制错误]\n    end\n    subgraph MCP 模式\n        A2[stdio 帧] --> B2[mcp.ts 适配器]\n        B2 --> C3[MCP JSON-RPC 响应]\n        B2 --> C4[structuredContent 格式]\n    end\n    subgraph 共享核心\n        D[core.fetchMarkdown]\n        E[MarkfetchError]\n        F[extractArticle]\n    end\n    B1 --> D\n    B2 --> D\n    C1 -.不使用.-> C3\n    C2 -.不使用.-> C4\n```\n\n关键差异：\n\n| 特性 | CLI 模式 | MCP 模式 |\n|------|---------|---------|\n| 交互协议 | 终端 stdin/stdout | stdio JSON-RPC |\n| 输出格式 | 原始 Markdown | MCP content 数组 |\n| 沙箱写入 | 无限制 | 受 `MARKFETCH_ALLOWED_WRITE_ROOTS` 限制 |\n| 控制台输出 | 可用 | 不可用 |\n\nCLI 模式没有沙箱限制——shell 用户是安全边界，因此允许写入任意路径。MCP 模式由于由语言模型驱动，引入写入沙箱以防止路径遍历攻击。\n\n资料来源：[src/mcp.ts:1-50](https://github.com/vasylenko/markfetch/blob/main/src/mcp.ts#L1-L50)\n资料来源：[src/sandbox.ts:1-50](https://github.com/vasylenko/markfetch/blob/main/src/sandbox.ts#L1-L50)\n\n## 分发器机制\n\n`src/index.ts` 负责根据进程参数数量决定加载哪个适配器：\n\n```typescript\n// 懒加载分发逻辑\n// process.argv.length <= 1: 仅 node 命令，无参数\n// process.argv.length == 2: 仅命令名（如 'node markfetch'），启动 MCP\n// process.argv.length > 2: 有额外参数，启动 CLI\n```\n\n这种设计确保：\n- 零参数启动时始终进入 MCP 模式，保持现有配置的兼容性\n- 任何命令行参数触发 CLI 模式\n- 适配器模块懒加载，MCP 模式下永不引入 CLI 代码路径\n\n资料来源：[src/index.ts](https://github.com/vasylenko/markfetch/blob/main/src/index.ts)\n\n## 环境变量配置\n\nCLI 与 MCP 共享以下环境变量：\n\n| 变量名 | 默认值 | 说明 |\n|--------|--------|------|\n| `MARKFETCH_TIMEOUT_MS` | `30000` | 单次请求超时（毫秒）|\n| `MARKFETCH_MAX_BYTES` | `5000000` | 响应体和提取 Markdown 的字节上限 |\n| `MARKFETCH_USER_AGENT` | Chrome 130 UA 字符串 | HTTP User-Agent 头 |\n\n注意：`MARKFETCH_ALLOWED_WRITE_ROOTS` 仅在 MCP 模式下生效，CLI 模式不受此限制。\n\n资料来源：[README.md:100-150](https://github.com/vasylenko/markfetch/blob/main/README.md#L100-L150)\n\n## 典型使用场景\n\n### 管道处理\n\n```bash\nmarkfetch https://example.com/article | grep -A5 \"## Introduction\"\n```\n\n### 保存长文档\n\n```bash\nmarkfetch https://docs.example.com/guide -o /tmp/guide.md\n```\n\n### 批量脚本集成\n\n```bash\n#!/bin/bash\nfor url in \"${urls[@]}\"; do\n  markfetch \"$url\" -o \"/output/$(basename \"$url\").md\" || echo \"Failed: $url\" >&2\ndone\n```\n\n## 版本历史\n\n- **0.5.0** (2026-05-12)：新增 CLI 模式，采用 commander.js 进行参数解析\n- **0.6.0**：当前版本，CLI 与 MCP 双模式稳定运行\n\n资料来源：[CHANGELOG.md](https://github.com/vasylenko/markfetch/blob/main/CHANGELOG.md)\n\n---\n\n<a id='mcp-server'></a>\n\n## MCP 服务器\n\n### 相关页面\n\n相关主题：[命令行界面](#cli-usage), [写操作沙箱](#write-sandbox), [错误处理机制](#error-handling)\n\n<details>\n<summary>相关源码文件</summary>\n\n以下源码文件用于生成本页说明：\n\n- [src/mcp.ts](https://github.com/vasylenko/markfetch/blob/main/src/mcp.ts)\n- [src/index.ts](https://github.com/vasylenko/markfetch/blob/main/src/index.ts)\n- [src/cli.ts](https://github.com/vasylenko/markfetch/blob/main/src/cli.ts)\n- [src/core.ts](https://github.com/vasylenko/markfetch/blob/main/src/core.ts)\n- [src/sandbox.ts](https://github.com/vasylenko/markfetch/blob/main/src/sandbox.ts)\n- [package.json](https://github.com/vasylenko/markfetch/blob/main/package.json)\n- [README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)\n</details>\n\n# MCP 服务器\n\n## 概述\n\nmarkfetch 的 MCP 服务器是基于 Model Context Protocol (MCP) 的标准输入输出（stdio）服务器实现，提供 `fetch_markdown` 工具供 AI 代理使用。该服务器通过单一工具接口将网页内容转换为干净的 Markdown 格式，支持直接返回内容或写入文件系统。\n\n```mermaid\ngraph TD\n    A[MCP 客户端] -->|stdio| B[markfetch MCP 服务器]\n    B --> C[src/index.ts 分发器]\n    C -->|无参数| D[MCP 模式]\n    C -->|有参数| E[CLI 模式]\n    D --> F[src/mcp.ts]\n    E --> G[src/cli.ts]\n    F --> H[src/core.ts]\n    G --> H\n    H --> I[fetchMarkdown 核心逻辑]\n```\n\n## 架构设计\n\n### 双模式入口\n\nmarkfetch 采用单一二进制文件支持两种运行模式，通过 `process.argv.length` 在运行时自动判断： 资料来源：[src/index.ts:1-15]()\n\n| 模式 | 触发条件 | 入口文件 | 输出通道 |\n|------|----------|----------|----------|\n| MCP stdio 服务器 | 无命令行参数 | `src/mcp.ts` | stdout 用于 MCP 协议帧 |\n| CLI 工具 | 存在命令行参数 | `src/cli.ts` | stdout 用于 markdown 输出 |\n\n关键设计原则：**stdout 保留给 MCP 协议帧使用**，stderr 仅用于致命错误输出，确保 stdio 通道的纯净性。 资料来源：[src/cli.ts:45-48]()\n\n### MCP 服务器初始化\n\n服务器使用 `@modelcontextprotocol/sdk` 包提供的 `McpServer` 类进行初始化：\n\n```typescript\nconst server = new McpServer({ name: \"markfetch\", version: \"0.6.0\" });\n```\n\n版本号与 `package.json` 中的 `version` 字段保持一致，确保 MCP 客户端能获取准确的版本信息。 资料来源：[src/mcp.ts:12]()\n\n## 工具定义\n\n### fetch_markdown 工具\n\n`fetch_markdown` 是 markfetch 提供的唯一 MCP 工具，具有以下特性：\n\n| 特性 | 说明 |\n|------|------|\n| 返回通道 | `content[0].text` 单一通道，无 `structuredContent` |\n| 适用场景 | 文章、文档、博客帖子、新闻、参考页面 |\n| 限制 | 匿名获取，无认证支持 |\n\n#### 输入参数\n\n| 参数 | 类型 | 必填 | 说明 |\n|------|------|------|------|\n| `url` | `string` | 是 | 完整的 HTTP/HTTPS URL，服务器自动跟随重定向 |\n| `savePath` | `string` | 否 | 绝对路径，将 markdown 写入文件而非返回 |\n\n`url` 参数使用 Zod schema 进行验证：\n\n```typescript\nurl: z\n  .string()\n  .url()\n  .describe(\"Absolute http(s) URL of the page to fetch...\")\n```\n\n`savePath` 必须为绝对路径：\n\n```typescript\nsavePath: z\n  .string()\n  .refine(isAbsolute, \"savePath must be an absolute filesystem path\")\n  .optional()\n```\n\n资料来源：[src/mcp.ts:14-38]()\n\n#### 返回格式\n\n成功响应：\n\n```json\n{\n  \"content\": [\n    {\n      \"type\": \"text\",\n      \"text\": \"# Markdown Content Here...\"\n    }\n  ]\n}\n```\n\n错误响应：\n\n```json\n{\n  \"content\": [\n    {\n      \"type\": \"text\",\n      \"text\": \"[error_code] Error message\"\n    }\n  ],\n  \"isError\": true\n}\n```\n\n## 错误处理\n\n### 错误代码体系\n\nMCP 服务器返回 8 种确定性错误代码：\n\n| 错误代码 | 含义 | 触发条件 |\n|----------|------|----------|\n| `network_error` | 网络故障 | DNS/TCP/TLS 失败或内部错误 |\n| `http_error` | HTTP 错误 | 上游返回非 2xx 状态码 |\n| `timeout` | 请求超时 | 超过 `MARKFETCH_TIMEOUT_MS` 配置的时间 |\n| `unsupported_content_type` | 不支持的类型 | 响应不是 `text/html` 或 `application/xhtml+xml` |\n| `extraction_failed` | 提取失败 | Readability 无法提取文章内容（常见于纯客户端渲染的 SPA） |\n| `too_large` | 内容过大 | 响应体或提取的 markdown 超过 `MARKFETCH_MAX_BYTES` |\n| `save_failed` | 保存失败 | 写入文件失败（目录不存在、权限问题等） |\n| `save_forbidden` | 保存禁止 | `savePath` 超出允许的写入根目录 |\n\n资料来源：[src/mcp.ts:4-9]()\n\n### 错误结果生成\n\n```typescript\nfunction errorResult(code: ErrorCode, message: string) {\n  return {\n    content: [{ type: \"text\" as const, text: `[${code}] ${message}` }],\n    isError: true,\n  };\n}\n```\n\n所有错误统一通过 `errorResult()` 函数格式化，确保错误消息格式的一致性：`[错误代码] 错误描述`。 资料来源：[src/mcp.ts:4-9]()\n\n## 配置选项\n\nMCP 服务器支持以下环境变量配置：\n\n| 环境变量 | 默认值 | 说明 |\n|----------|--------|------|\n| `MARKFETCH_TIMEOUT_MS` | `30000` | 单次请求超时时间（毫秒） |\n| `MARKFETCH_MAX_BYTES` | `5000000` | 响应体和提取 markdown 的最大字节数 |\n| `MARKFETCH_USER_AGENT` | Chrome 130 固定字符串 | HTTP User-Agent，必须为 Chrome UA |\n| `MARKFETCH_ALLOWED_WRITE_ROOTS` | `os.tmpdir()` + `process.cwd()` | MCP 模式允许的写入根目录列表 |\n\n### 写入沙箱\n\nMCP 模式的 `savePath` 写入被限制在允许的根目录集合内。默认情况下包含系统临时目录和进程工作目录。 资料来源：[src/sandbox.ts:1-10]()\n\n```mermaid\ngraph LR\n    A[savePath] --> B{检查是否在允许根目录内}\n    B -->|是| C[允许写入]\n    B -->|否| D[返回 save_forbidden 错误]\n    \n    E[默认允许根目录] --> B\n    F[MARKFETCH_ALLOWED_WRITE_ROOTS] --> E\n```\n\n#### 自定义允许根目录\n\n```json\n{\n  \"mcpServers\": {\n    \"markfetch\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"markfetch\"],\n      \"env\": {\n        \"MARKFETCH_ALLOWED_WRITE_ROOTS\": \"/Users/me/markfetch-out:/tmp\"\n      }\n    }\n  }\n}\n```\n\n设置此变量会**完全替换**默认根目录，而非合并。如需保留默认值，必须显式包含。 资料来源：[README.md:Configuration]()\n\n#### 路径解析逻辑\n\n`sandbox.ts` 中的路径验证逻辑：\n\n1. 解析 `savePath` 为绝对路径\n2. 遍历所有允许根目录\n3. 检查目标路径是否在任一允许根目录内\n4. Windows 平台使用不区分大小写的比较\n\n```typescript\n// Win32 case-fold: filesystem is case-insensitive\nconst fold = process.platform === \"win32\"\n  ? (s: string) => s.toLowerCase()\n  : (s: string) => s;\n```\n\n资料来源：[src/sandbox.ts:32-36]()\n\n## MCP 客户端集成\n\n### Claude Code\n\n```bash\nclaude mcp add --scope user markfetch -- npx -y markfetch\n```\n\n### Codex\n\n```json\n{\n  \"mcpServers\": {\n    \"markfetch\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"markfetch\"]\n    }\n  }\n}\n```\n\n### Gemini CLI\n\n```bash\ngemini mcp add -s user markfetch npx -y markfetch\n```\n\n### 本地路径配置\n\n对于需要使用本地构建版本的场景：\n\n```json\n{\n  \"mcpServers\": {\n    \"markfetch\": {\n      \"command\": \"node\",\n      \"args\": [\"/absolute/path/to/markfetch/dist/index.js\"]\n    }\n  }\n}\n```\n\n资料来源：[README.md:MCP install commands]()\n\n## 与 CLI 模式的区别\n\n| 特性 | MCP 服务器 | CLI 工具 |\n|------|------------|----------|\n| 调用方式 | stdio 协议 | 命令行参数 |\n| 输出通道 | `content[0].text` | stdout 直接输出 |\n| 写入沙箱 | 启用 | 禁用（无限制） |\n| 错误输出 | `isError: true` | stderr + 退出码 |\n| 相对路径处理 | 不支持（需绝对路径） | 支持（相对 cwd 解析） |\n\nCLI 模式中相对路径会被解析为绝对路径后再传给核心逻辑：\n\n```typescript\nconst savePath = options.output\n  ? resolve(process.cwd(), options.output)\n  : undefined;\n```\n\n资料来源：[src/cli.ts:13-20]()\n\n## 版本历史\n\n| 版本 | 发布日期 | MCP 相关变更 |\n|------|----------|--------------|\n| 0.6.0 | 当前版本 | 稳定版 MCP 实现 |\n| 0.5.0 | 2026-05-12 | 引入 CLI 模式，源文件重构为独立模块 |\n| 0.4.0 | 2026-05-10 | 初始 MCP 工具 `fetch_markdown` |\n\n0.4.0 版本引入了原始的 MCP 实现，0.5.0 版本将源码重构为 `src/mcp.ts`（MCP 适配器）、`src/cli.ts`（CLI 适配器）和 `src/core.ts`（核心管道 + 错误处理）的分离结构，同时保持了 MCP 消费者的公共 API 完全兼容。 资料来源：[CHANGELOG.md:版本历史]()\n\n## 安全考量\n\n### MCP 模式的安全设计\n\n1. **写入沙箱隔离**：MCP 工具由语言模型驱动，可能被页面内容引导，因此 `savePath` 限制在可配置的允许目录内\n2. **CLI 模式无沙箱**：命令行由人类直接使用，作为安全边界，不施加写入限制\n3. **Symlink 防护**：符号链接指向允许根目录外的路径会被阻止 资料来源：[README.md:Write sandbox]()\n\n### User-Agent 要求\n\n`MARKFETCH_USER_AGENT` 必须为 Chrome 用户代理字符串。非 Chrome 字符串会在启动时快速失败，防止因客户端提示不匹配导致的问题。 资料来源：[README.md:Configuration]()\n\n---\n\n<a id='http-fingerprint'></a>\n\n## HTTP 指纹与请求模拟\n\n### 相关页面\n\n相关主题：[项目概述](#overview), [内容提取管道](#content-extraction)\n\n<details>\n<summary>相关源码文件</summary>\n\n以下源码文件用于生成本页说明：\n\n- [src/core.ts](https://github.com/vasylenko/markfetch/blob/main/src/core.ts)\n- [README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)\n- [src/mcp.ts](https://github.com/vasylenko/markfetch/blob/main/src/mcp.ts)\n- [src/cli.ts](https://github.com/vasylenko/markfetch/blob/main/src/cli.ts)\n- [src/sandbox.ts](https://github.com/vasylenko/markfetch/blob/main/src/sandbox.ts)\n- [package.json](https://github.com/vasylenko/markfetch/blob/main/package.json)\n</details>\n\n# HTTP 指纹与请求模拟\n\n## 概述\n\nmarkfetch 在发起 HTTP 请求时模拟真实浏览器的指纹特征，以绕过网站的反爬虫机制。其核心设计理念是使每个请求在 HTTP 协议层面与真实 Chrome 浏览器发出的请求无法区分，从而在不支持 JavaScript 渲染的环境中也能获取到完整的页面内容。\n\nmarkfetch 使用 **HTTP/2 传输层** 结合 **一致的 Chrome 请求头集合**，并通过 `MARKFETCH_USER_AGENT` 环境变量动态生成 `Sec-CH-UA-*` 客户端提示头，确保请求指纹的真实性与可配置性。\n\n资料来源：[README.md:47]()\n\n## 核心机制\n\n### HTTP/2 传输\n\nmarkfetch 默认使用 HTTP/2 协议进行网络请求。相比 HTTP/1.1，HTTP/2 的多路复用、头部压缩和服务器推送特性使得请求模式更接近现代浏览器的实际行为。\n\n### Chrome 请求头集\n\n项目预置了一套完整的 Chrome 请求头集合，覆盖了以下关键头字段：\n\n| 头字段类型 | 说明 |\n|---|---|\n| `User-Agent` | Chrome 130 版本标识 |\n| `Sec-CH-UA-*` | 客户端提示头，基于 User-Agent 动态派生 |\n| `Accept` | 内容协商头 |\n| `Accept-Language` | 语言偏好 |\n| `Accept-Encoding` | 压缩算法支持 |\n\n这套头字段组合确保了请求在 TLS 握手后的 HTTP 层与真实 Chrome 浏览器保持一致。\n\n## 配置项\n\n### 环境变量配置\n\n| 变量名 | 默认值 | 用途 |\n|---|---|---|\n| `MARKFETCH_TIMEOUT_MS` | `30000` | 单次请求超时时间（毫秒） |\n| `MARKFETCH_MAX_BYTES` | `5000000` | 响应体与提取后 markdown 的字节数上限 |\n| `MARKFETCH_USER_AGENT` | Chrome 130 固定版本字符串 | 覆盖默认 UA，必须为 Chrome UA 格式 |\n\n`MARKFETCH_USER_AGENT` 的值在进程启动时派生 `Sec-CH-UA-*` 客户端提示。如果传入非 Chrome 格式的字符串，程序会在启动时快速失败并在 stderr 输出错误。\n\n资料来源：[README.md:88-93]()\n\n### 配置示例\n\n```json\n{\n  \"mcpServers\": {\n    \"markfetch\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"markfetch\"],\n      \"env\": {\n        \"MARKFETCH_USER_AGENT\": \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36\",\n        \"MARKFETCH_TIMEOUT_MS\": \"60000\"\n      }\n    }\n  }\n}\n```\n\n## 请求流程\n\n```mermaid\ngraph TD\n    A[入口: fetchMarkdown] --> B[验证环境变量]\n    B --> C{配置有效?}\n    C -->|无效| D[启动时快速失败]\n    C -->|有效| E[构造 HTTP/2 请求]\n    E --> F[附加 Chrome 请求头集]\n    F --> G[基于 UA 派生 Sec-CH-UA-*]\n    G --> H[发送请求到目标 URL]\n    H --> I{响应状态}\n    I -->|2xx| J[进入内容提取流程]\n    I -->|非 2xx| K[抛出 http_error]\n    J --> L[解码 HTML 实体]\n    L --> M[注入 base href]\n    M --> N[解析 DOM 树]\n    N --> O[Readability 提取]\n    O --> P[Turndown 转 Markdown]\n```\n\n## 基础 URL 注入机制\n\n由于 linkedom 解析器在文档没有 `<base href>` 时会将相对路径保留为 `/wiki/...` 形式，markfetch 在 `extractArticle` 函数中通过 `ensureBaseHref` 注入重定向后的规范 URL，确保后续的相对链接能够被正确解析。\n\n```typescript\nfunction ensureBaseHref(html: string, url: string): string {\n  const safeUrl = url.replaceAll(\"&\", \"&amp;\").replaceAll('\"', \"&quot;\");\n  const stripped = html.replaceAll(/<base\\s[^>]*>/gi, \"\");\n  if (/<head\\b[^>]*>/i.test(stripped)) {\n    return stripped.replace(\n      /<head\\b([^>]*)>/i,\n      `<head$1><base href=\"${safeUrl}\">`,\n    );\n  }\n  if (/<html\\b[^>]*>/i.test(stripped)) {\n    return stripped.replace(\n      /<html\\b([^>]*)/i,\n      `<html$1><head><base href=\"${safeUrl}\"></head>`,\n    );\n  }\n  return stripped;\n}\n```\n\n资料来源：[src/core.ts:18-33]()\n\n该函数会：\n\n1. 对 URL 中的特殊字符进行 HTML 实体转义\n2. 移除页面中已有的 `<base>` 标签（上游 URL 更具权威性）\n3. 将规范 URL 注入到 `<head>` 或 `<html>` 标签中\n\n## 请求适配层\n\n### MCP 适配器\n\nMCP 适配器 (`src/mcp.ts`) 接收已验证的输入（URL 语法由适配器的 schema 校验，savePath 为绝对路径），并将错误统一映射为 `MarkfetchError`。\n\n```typescript\nconst server = new McpServer({ name: \"markfetch\", version: \"0.6.0\" });\n\nserver.registerTool(\n  \"fetch_markdown\",\n  {\n    description:\n      \"Fetch a single public HTTP/S URL and return its main article content as clean markdown...\",\n    inputSchema: {\n      url: z.string().url().describe(\"Absolute http(s) URL...\"),\n      savePath: z.string().refine(isAbsolute, \"savePath must be an absolute filesystem path\").optional()\n    }\n  }\n);\n```\n\n资料来源：[src/mcp.ts:22-38]()\n\n### CLI 适配器\n\nCLI 适配器 (`src/cli.ts`) 将相对输出路径解析为绝对路径后再传递给核心模块：\n\n```typescript\nconst savePath = options.output\n  ? resolve(process.cwd(), options.output)\n  : undefined;\n```\n\nCLI 模式下的错误通过 stderr 输出，格式为 `[code] message`，并设置 `process.exitCode = 1`。\n\n资料来源：[src/cli.ts:28-32]()\n\n## 错误码体系\n\nmarkfetch 定义了 8 种确定性错误码，用于标识请求和处理过程中的各类失败场景：\n\n| 错误码 | 含义 | 触发条件 |\n|---|---|---|\n| `network_error` | DNS/TCP/TLS 故障或内部错误 | 网络层异常 |\n| `http_error` | 上游返回非 2xx 状态 | HTTP 响应状态码 ≥ 400 |\n| `timeout` | 超过 `MARKFETCH_TIMEOUT_MS` | 请求超时 |\n| `unsupported_content_type` | 响应不是 HTML 类型 | Content-Type 非 `text/html` 或 `application/xhtml+xml` |\n| `extraction_failed` | Readability 未提取到内容 | 纯客户端渲染的 SPA |\n| `too_large` | 超过 `MARKFETCH_MAX_BYTES` | 响应体或 markdown 超限 |\n| `save_failed` | 文件写入失败 | 目录不存在或权限不足 |\n| `save_forbidden` | 路径超出允许的写入根目录 | MCP 模式下路径未通过沙箱校验 |\n\n资料来源：[README.md:74-82]()\n\n## 与其他方案的对比\n\n| 方案 | 真实浏览器指纹 | Reader-View 提取 | 结构化错误 | 零配置 |\n|---|---|---|---|---|\n| 内置 Agent fetch 工具 | ✗ | ✗ | ✗ | ✓ |\n| 通用 Playwright/Puppeteer | ✓ | ✗ | ✗ | ✗ |\n| mcp-server-fetch (Python) | ✗ | 基础 | ✗ | ✗ |\n| CloudFlare /markdown | ✗ | ✓ | ✗ | ✗ |\n| **markfetch** | **✓** | **✓** | **✓** | **✓** |\n\nmarkfetch 的独特优势在于同时实现了真实浏览器指纹和 Reader-View 风格的内容提取，而无需运行无头浏览器。\n\n资料来源：[README.md:55-62]()\n\n## 设计原则\n\n### 单进程架构\n\nmarkfetch 采用纯 Node.js 实现，不依赖 Playwright、headless Chromium 或 Python 子进程。这使得：\n\n- 启动开销极低\n- 内存占用可控\n- 适合在 MCP stdio 服务器等受限环境中运行\n\n### Stdio 清洁原则\n\n- **stdout** 保留给 MCP 协议帧\n- **stderr** 仅用于致命错误\n- 无日志输出、无 ANSI 转义码\n\n### MCP 与 CLI 的行为一致性\n\n两种调用方式使用相同的核心模块，唯一的差异在于：\n\n- MCP 模式启用写入沙箱 (`save_forbidden` 错误码)\n- CLI 模式完全不受限（人类用户是安全边界）\n\n资料来源：[README.md:49-54]()\n\n## 版本历史\n\n| 版本 | 变更内容 |\n|---|---|\n| 0.4.0 | 引入 HTTP/2 传输和 Chrome 请求头集，`MARKFETCH_USER_AGENT` 环境变量 |\n| 0.5.0 | 新增 CLI 模式，保持 MCP 和 CLI 行为一致 |\n| 0.6.0 | 完善错误码体系和环境变量验证 |\n\n资料来源：[CHANGELOG.md:1-15]()\n\n---\n\n<a id='content-extraction'></a>\n\n## 内容提取管道\n\n### 相关页面\n\n相关主题：[HTTP 指纹与请求模拟](#http-fingerprint), [错误处理机制](#error-handling)\n\n<details>\n<summary>相关源码文件</summary>\n\n以下源码文件用于生成本页说明：\n\n- [src/core.ts](https://github.com/vasylenko/markfetch/blob/main/src/core.ts)\n- [src/mcp.ts](https://github.com/vasylenko/markfetch/blob/main/src/mcp.ts)\n- [src/cli.ts](https://github.com/vasylenko/markfetch/blob/main/src/cli.ts)\n- [src/sandbox.ts](https://github.com/vasylenko/markfetch/blob/main/src/sandbox.ts)\n- [package.json](https://github.com/vasylenko/markfetch/blob/main/package.json)\n- [README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)\n- [CHANGELOG.md](https://github.com/vasylenko/markfetch/blob/main/CHANGELOG.md)\n</details>\n\n# 内容提取管道\n\n## 概述\n\n内容提取管道（Content Extraction Pipeline）是 markfetch 项目的核心模块，负责将任意 HTTP/HTTPS URL 的 HTML 内容转换为干净的 Markdown 格式。该管道是纯 Node.js 实现，不依赖 Playwright 或无头浏览器，通过 HTTP/2 传输和完整的 Chrome 请求头集实现真实浏览器指纹模拟。\n\n资料来源：[README.md:1-15]()\n\n## 架构总览\n\nmarkfetch 的内容提取管道遵循经典的\"获取-解析-转换\"三阶段架构。整个管道从 URL 输入开始，依次经过网络请求层、内容解析层和 Markdown 转换层，最终输出结构化的 Markdown 文档。\n\n```mermaid\ngraph TD\n    A[URL 输入] --> B[HTTP/2 网络请求]\n    B --> C{响应状态码}\n    C -->|2xx| D[HTML 内容]\n    C -->|非2xx| E[http_error]\n    D --> F[内容类型检测]\n    F -->|text/html| G[解码编码标签]\n    F -->|非HTML| I[unsupported_content_type]\n    G --> H[注入 Base Href]\n    H --> J[Readability 解析]\n    J --> K{提取结果}\n    K -->|有内容| L[转换为 Markdown]\n    K -->|无内容| M[extraction_failed]\n    L --> N[大小检查]\n    N -->|未超限| O[返回结果]\n    N -->|超出限制| P[too_large]\n    O --> Q{savePath}\n    Q -->|指定路径| R[沙盒写入]\n    Q -->|标准输出| S[stdout 输出]\n```\n\n资料来源：[src/core.ts:1-100]()\n\n## 核心组件\n\n### 核心模块结构\n\nmarkfetch 的核心提取逻辑集中在 `src/core.ts` 中，包含以下关键函数：\n\n| 函数名 | 职责 | 返回值 |\n|--------|------|--------|\n| `decodeEncodedCodeTags()` | 解码 HTML 实体编码的代码标签 | `string` |\n| `ensureBaseHref()` | 注入 `<base>` 标签确保相对路径正确解析 | `string` |\n| `rewriteForReadability()` | 重写 DOM 结构以优化提取效果 | `void` |\n| `extractArticle()` | 使用 Readability 提取文章内容 | `{title, content} \\| null` |\n| `convertToMarkdown()` | 将 HTML 转换为 Markdown | `string` |\n\n资料来源：[src/core.ts:1-150]()\n\n### 依赖库\n\n| 库名 | 版本 | 用途 |\n|------|------|------|\n| `@mozilla/readability` | 最新稳定版 | 从 HTML 中提取主要文章内容 |\n| `turndown` | 最新稳定版 | 将 HTML DOM 转换为 Markdown |\n| `linkedom` | 最新稳定版 | 轻量级 DOM 解析器 |\n| `undici` | 最新稳定版 | HTTP/2 客户端 |\n\n资料来源：[package.json:1-30]()\n\n## 管道各阶段详解\n\n### 阶段一：HTML 实体解码\n\n```typescript\nfunction decodeEncodedCodeTags(html: string): string {\n  return html.replaceAll(\n    /&lt;(\\/?(?:code|pre)(?:\\s[^&]*?)?\\/?)&gt;/g,\n    (_, tag) => `<${tag}>`,\n  );\n}\n```\n\n此函数专门处理 HTML 实体编码的 `<code>` 和 `<pre>` 标签。由于代码块在转换过程中需要保留原始格式，markfetch 需要将这些被实体编码的标签转换回标准 HTML 标签形式。\n\n正则表达式说明：\n- `&lt;` - 匹配左尖括号的实体编码形式\n- `\\/?` - 可选的闭合斜杠\n- `(?:code|pre)` - 匹配 `code` 或 `pre` 标签名\n- `(?:\\s[^&]*?)?` - 可选的属性部分\n- `&gt;` - 匹配右尖括号的实体编码形式\n\n资料来源：[src/core.ts:10-18]()\n\n### 阶段二：Base Href 注入\n\n```typescript\nfunction ensureBaseHref(html: string, url: string): string {\n  const safeUrl = url.replaceAll(\"&\", \"&amp;\").replaceAll('\"', \"&quot;\");\n  const stripped = html.replaceAll(/<base\\s[^>]*>/gi, \"\");\n  if (/<head\\b[^>]*>/i.test(stripped)) {\n    return stripped.replace(\n      /<head\\b([^>]*)>/i,\n      `<head$1><base href=\"${safeUrl}\">`,\n    );\n  }\n  if (/<html\\b[^>]*>/i.test(stripped)) {\n    return stripped.replace(\n      /<html\\b([^>]*)>/i,\n      `<html$1><head><base href=\"${safeUrl}\"></head>`,\n    );\n  }\n  return stripped;\n}\n```\n\n此函数的目的是确保相对链接和图片路径能够正确解析。由于 linkedom 解析器不会自动填充 `baseURI`，导致相对路径（如 `/wiki/...`）无法正确转换为绝对 URL。管道通过以下步骤解决此问题：\n\n1. 移除页面原有的 `<base>` 标签（避免冲突）\n2. 在 `<head>` 标签内注入新的 `<base href>` 指向最终重定向后的 URL\n3. 如果没有 `<head>` 标签，则在 `<html>` 标签后创建新的 `<head>`\n\n资料来源：[src/core.ts:25-50]()\n\n### 阶段三：DOM 重写优化\n\n```typescript\nfunction rewriteForReadability(document: Document): void {\n  // 处理脚注aside元素\n  const footnoteAsides = document.querySelectorAll(\n    'aside.footnote-brackets, ' +\n    'aside[role=\"doc-endnotes\"], aside[role=\"doc-footnote\"], aside[role=\"doc-footnotes\"]',\n  );\n  \n  // 处理details/summary折叠元素\n  for (const el of Array.from(document.querySelectorAll(\"details\"))) {\n    const parent = el.parentNode;\n    if (!parent) continue;\n    while (el.firstChild) parent.insertBefore(el.firstChild, el);\n    el.remove();\n  }\n  \n  // 处理 MediaWiki 样式标题容器\n  for (const el of Array.from(document.querySelectorAll(\"div.mw-heading\"))) {\n    const heading = el.querySelector(\"h1, h2, h3, h4, h5, h6\");\n    if (!heading) continue;\n    el.parentNode?.replaceChild(heading, el);\n  }\n}\n```\n\n此函数对 DOM 结构进行预处理，使 Readability 能够更准确地提取主要内容：\n\n| 处理类型 | 选择器 | 处理方式 |\n|----------|--------|----------|\n| 脚注区域 | `aside.footnote-*` | 替换为 `<section>` 以保留内容 |\n| 折叠元素 | `details` | 展开内容并移除容器 |\n| 标题容器 | `div.mw-heading` | 提换为纯标题元素 |\n\n资料来源：[src/core.ts:75-115]()\n\n### 阶段四：Readability 内容提取\n\n```typescript\nfunction extractArticle(\n  html: string,\n  url: string,\n): { title: string; content: string } | null {\n  const decoded = decodeEncodedCodeTags(html);\n  const withBase = ensureBaseHref(decoded, url);\n  const { document } = parseHTML(withBase);\n  rewriteForReadability(document);\n  \n  const article = new Readability(document, {\n    keepClasses: true,\n  }).parse();\n  \n  if (!article?.content?.trim()) return null;\n  return { title: (article.title ?? \"\").trim(), content: article.content };\n}\n```\n\nReadability 是 Mozilla 开发的专门用于从网页中提取主要文章内容的库。markfetch 的配置使用 `keepClasses: true` 选项，目的是保留 `<code>` 元素的 `class=\"language-X\"` 属性，使 turndown 能够在代码块中输出语言提示标记。\n\n资料来源：[src/core.ts:118-145]()\n\n### 阶段五：Markdown 转换\n\n```typescript\nfunction convertToMarkdown(article: {\n  title: string;\n  content: string;\n}): string {\n  const body = TURNDOWN.turndown(article.content);\n  \n  // 如果 Readability 保留了页面的 <h1>，不重复添加标题\n  const contentLeadsWithH1 = /^\\s*<h1[\\s>]/i.test(article.content);\n  let result = article.title && !contentLeadsWithH1\n    ? `# ${article.title}\\n\\n${body}`\n    : body;\n  \n  // 移除空标题\n  result = pruneEmptyHeadings(result);\n  \n  // 处理代码块语言提示\n  result = patchCodeFenceLanguageHints(result);\n  \n  return result;\n}\n```\n\nTurndown 配置了自定义的转义规则，以解决以下问题：\n\n1. **行内下划线保护**：Markdown 中行内代码外的下划线通常需要转义，但 markfetch 保留了未转义的形式以提高可读性\n2. **标题下划线处理**：CommonMark 的 setext 标题使用 `=` 或 `-` 字符，但后面紧跟字母数字字符的情况不是标题，应保留原样\n\n资料来源：[src/core.ts:50-75]()\n\n## 错误处理机制\n\n### 错误代码表\n\n| 错误代码 | 含义 | 触发条件 |\n|----------|------|----------|\n| `network_error` | 网络层故障 | DNS 解析、TCP 连接、TLS 握手失败 |\n| `http_error` | HTTP 协议错误 | 服务器返回非 2xx 状态码 |\n| `timeout` | 请求超时 | 超过 `MARKFETCH_TIMEOUT_MS` 配置的时间 |\n| `unsupported_content_type` | 不支持的类型 | 响应不是 `text/html` 或 `application/xhtml+xml` |\n| `extraction_failed` | 提取失败 | Readability 返回空内容（典型于纯客户端渲染 SPA） |\n| `too_large` | 内容过大 | 响应体或提取后的 Markdown 超过 `MARKFETCH_MAX_BYTES` |\n| `save_failed` | 保存失败 | 指定了 `savePath` 但写入文件失败 |\n| `save_forbidden` | 写入被禁止 | `savePath` 超出允许的写入根目录 |\n\n资料来源：[README.md:60-80]()\n\n### 错误传播流程\n\n```mermaid\ngraph LR\n    A[核心模块抛出] --> B[MarkfetchError]\n    B --> C[MCP 适配器]\n    B --> D[CLI 适配器]\n    C --> E[errorResult 格式化]\n    D --> F[classifyError 处理]\n    E --> G[返回给 LLM]\n    F --> H[stderr 输出]\n    H --> I[非零退出码]\n```\n\n资料来源：[src/mcp.ts:1-50]() 和 [src/cli.ts:1-50]()\n\n## 配置参数\n\n### 环境变量配置\n\n| 变量名 | 默认值 | 用途 |\n|--------|--------|------|\n| `MARKFETCH_TIMEOUT_MS` | `30000` | 单次请求超时时间（毫秒） |\n| `MARKFETCH_MAX_BYTES` | `5000000` | 响应体和提取 Markdown 的最大字节数 |\n| `MARKFETCH_USER_AGENT` | Chrome 130 UA 字符串 | HTTP User-Agent 头 |\n| `MARKFETCH_ALLOWED_WRITE_ROOTS` | `os.tmpdir()` + `process.cwd()` | MCP 模式下允许写入的根目录列表 |\n\n所有配置变量在启动时进行验证，无效值会立即失败并输出到 stderr，避免产生难以调试的运行时错误。\n\n资料来源：[README.md:55-65]()\n\n## 适配器模式\n\nmarkfetch 采用适配器模式同时支持 CLI 和 MCP 两种调用方式。入口文件 `src/index.ts` 根据 `process.argv.length` 延迟加载对应的适配器：\n\n```typescript\n// 伪代码示例\nif (process.argv.length > 1) {\n  // CLI 模式\n  import('./cli.js');\n} else {\n  // MCP 模式\n  import('./mcp.js');\n}\n```\n\n这种设计确保了\"stdout 保留给 MCP 帧\"这一不变量是结构性的——CLI 代码在 MCP 模式下永远不会加载，因此不存在通过 `console.log` 污染输出的可能性。\n\n资料来源：[CHANGELOG.md:40-60]()\n\n## 安全沙盒\n\nMCP 模式下的文件写入操作受到沙盒限制，防止恶意提示词诱导 LLM 将文件写入任意位置：\n\n```mermaid\ngraph TD\n    A[savePath 参数] --> B{绝对路径检查}\n    B -->|否| C[返回 save_forbidden]\n    B -->|是| D[符号链接解析]\n    D --> E{路径限制检查}\n    E -->|在允许范围内| F[写入文件]\n    E -->|超出允许范围| G[返回 save_forbidden]\n```\n\n沙盒默认允许写入 `os.tmpdir()` 和 `process.cwd()` 目录。可以通过 `MARKFETCH_ALLOWED_WRITE_ROOTS` 环境变量覆盖（注意：覆盖是替换而非合并）。\n\n资料来源：[src/sandbox.ts:1-80]()\n\n## 性能特性\n\n### 内存效率\n\nmarkfetch 采用流式处理策略：\n- 响应体直接流入解析器，不经过完整的内存缓冲\n- DOM 操作在 linkedom 轻量级解析器中完成，相比原生 DOM 节省内存\n- Markdown 转换采用增量处理，避免一次性加载整个文档树\n\n### 大小限制\n\n`MARKFETCH_MAX_BYTES` 限制应用于两个阶段：\n1. 原始响应体大小\n2. 转换后的 Markdown 大小\n\n如果任一阶段超出限制，管道返回 `too_large` 错误而非返回不完整的内容。\n\n资料来源：[README.md:50-55]()\n\n## 版本历史\n\n| 版本 | 变更内容 |\n|------|----------|\n| 0.4.0 | 初始 MCP 工具实现，引入 Readability + Turndown 管道 |\n| 0.4.1 | 修复 `bin` 入口路径问题，改进文档 |\n| 0.5.0 | 新增 CLI 模式，引入适配器架构 |\n| 0.6.0 | 新增沙盒写入限制，8 种确定性错误码体系完成 |\n\n资料来源：[CHANGELOG.md:1-100]()\n\n---\n\n<a id='write-sandbox'></a>\n\n## 写操作沙箱\n\n### 相关页面\n\n相关主题：[MCP 服务器](#mcp-server), [配置与环境变量](#configuration)\n\n<details>\n<summary>相关源码文件</summary>\n\n以下源码文件用于生成本页说明：\n\n- [src/sandbox.ts](https://github.com/vasylenko/markfetch/blob/main/src/sandbox.ts)\n- [src/mcp.ts](https://github.com/vasylenko/markfetch/blob/main/src/mcp.ts)\n- [README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)\n</details>\n\n# 写操作沙箱\n\n## 概述\n\n写操作沙箱（Write Sandbox）是 markfetch 项目中为 MCP（Model Context Protocol）模式设计的文件系统写入安全机制。其核心功能是限制 MCP 工具的 `savePath` 参数只能写入预定义的允许目录范围内，防止语言模型通过路径遍历（如 `../../etc/passwd`）将文件写入敏感位置。\n\n该沙箱**仅在 MCP 模式下生效**，CLI 模式不执行任何沙箱检查，因为在命令行环境中人类用户本身就是安全边界。\n\n资料来源：[README.md:沙箱设计说明]()\n\n## 架构设计\n\n### 组件关系\n\n```mermaid\ngraph TD\n    A[MCP 客户端调用 fetch_markdown] --> B{是否提供 savePath?}\n    B -->|否| Z[直接返回 markdown]\n    B -->|是| C[沙箱校验模块]\n    C --> D[路径解析]\n    C --> E[符号链接展开]\n    C --> F[边界检查]\n    D --> G{是否在允许范围内?}\n    G -->|是| H[写入文件]\n    G -->|否| I[返回 save_forbidden 错误]\n    \n    J[环境变量配置] --> C\n    K[默认根目录] --> C\n```\n\n### 核心模块\n\n| 模块 | 文件位置 | 职责 |\n|------|----------|------|\n| 沙箱校验核心 | `src/sandbox.ts` | 实现路径合法性检查逻辑 |\n| MCP 适配器 | `src/mcp.ts` | 在工具调用前调用沙箱校验 |\n| 环境配置解析 | `src/mcp.ts` | 读取并验证 `MARKFETCH_ALLOWED_WRITE_ROOTS` |\n\n资料来源：[src/sandbox.ts:1-50](), [src/mcp.ts:构建允许根目录]()\n\n## 工作流程\n\n### 写入校验流程\n\n```mermaid\nsequenceDiagram\n    participant MCP as MCP 客户端\n    participant Server as markfetch Server\n    participant Sandbox as 沙箱模块\n    participant FS as 文件系统\n    \n    MCP->>Server: fetch_markdown(url, savePath=\"/tmp/out.md\")\n    Server->>Sandbox: validatePath(\"/tmp/out.md\")\n    Sandbox->>Sandbox: realpath(\"/tmp/out.md\")\n    Sandbox->>Sandbox: 获取 allowedRoots\n    Sandbox->>Sandbox: 检查相对路径\n    alt 路径在允许范围内\n        Sandbox-->>Server: { ok: true, resolved: \"/private/tmp/out.md\" }\n        Server->>FS: writeFile(resolved)\n        FS-->>Server: 写入成功\n        Server-->>MCP: { content: [...], isError: false }\n    else 路径超出范围\n        Sandbox-->>Server: { ok: false, reason: \"...\" }\n        Server-->>MCP: { content: \"[save_forbidden] ...\", isError: true }\n    end\n```\n\n### 默认允许根目录\n\n启动时，markfetch 会自动解析并记录两个默认写根目录：\n\n| 默认根目录 | 获取方式 | 说明 |\n|------------|----------|------|\n| 系统临时目录 | `os.tmpdir()` | 通常为 `/tmp`（POSIX）或 `C:\\Users\\xxx\\AppData\\Local\\Temp`（Windows） |\n| 当前工作目录 | `process.cwd()` | markfetch 进程启动时的目录 |\n\n两个路径都会通过 `fs.realpath` 展开符号链接，确保路径规范唯一。\n\n资料来源：[src/mcp.ts:构建 allowedRoots](), [README.md:默认值说明]()\n\n## 路径校验算法\n\n### 核心检查逻辑\n\n沙箱的路径校验算法位于 `src/sandbox.ts`，主要步骤如下：\n\n```mermaid\ngraph TD\n    A[接收 savePath] --> B[realpath 解析符号链接]\n    B --> C{Win32 平台?}\n    C -->|是| D[转换为小写比较]\n    C -->|否| E[保持原大小写]\n    D --> F[遍历 allowedRoots]\n    E --> F\n    F --> G{计算相对路径}\n    G --> H{rel === ''?}\n    H -->|是| J[允许 - 目标即为根目录]\n    G --> K{rel 不以 '..' 开头且非绝对路径?}\n    K -->|是| J\n    K -->|否| L[拒绝 - 路径超出边界]\n    L --> M[返回 ok: false]\n    J --> N[返回 ok: true]\n```\n\n### Windows 大小写处理\n\nWindows 文件系统大小写不敏感，但 `fs.realpath` 不会自动规范化大小写。为防止 `/Users/Me/../me/` 这类绕过检查，沙箱在 Windows 平台会将路径和小写的根目录都转换为小写进行比较。\n\n```typescript\nconst fold = process.platform === \"win32\"\n  ? (s: string) => s.toLowerCase()\n  : (s: string) => s;\n```\n\n资料来源：[src/sandbox.ts:Win32 case-fold 处理]()\n\n## 配置选项\n\n### 环境变量\n\n| 环境变量 | 默认值 | 说明 |\n|----------|--------|------|\n| `MARKFETCH_ALLOWED_WRITE_ROOTS` | `os.tmpdir() + ':' + process.cwd()` | 冒号（POSIX）或分号（Windows）分隔的绝对路径列表 |\n\n### 配置规则\n\n1. **替换而非合并**：设置该变量会**完全替换**默认值，不会追加\n2. **必须为绝对路径**：每个路径必须是绝对路径，相对路径会导致启动失败\n3. **目录必须存在**：路径指向的目录必须在启动时存在，否则失败\n4. **平台分隔符**：\n   - POSIX（Linux/macOS）：使用冒号 `:` 分隔\n   - Windows：使用分号 `;` 分隔\n\n### 配置示例\n\n**Linux/macOS 配置：**\n```json\n{\n  \"mcpServers\": {\n    \"markfetch\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"markfetch\"],\n      \"env\": {\n        \"MARKFETCH_ALLOWED_WRITE_ROOTS\": \"/Users/me/markfetch-out:/tmp\"\n      }\n    }\n  }\n}\n```\n\n**Windows 配置：**\n```json\n{\n  \"mcpServers\": {\n    \"markfetch\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"markfetch\"],\n      \"env\": {\n        \"MARKFETCH_ALLOWED_WRITE_ROOTS\": \"C:\\\\Users\\\\me\\\\markfetch-out;C:\\\\Users\\\\me\\\\AppData\\\\Local\\\\Temp\"\n      }\n    }\n  }\n}\n```\n\n资料来源：[README.md:环境变量配置说明]()\n\n## 符号链接处理\n\n### 安全修复历史\n\n早期版本存在符号链接逃逸漏洞：检查时对 `<sandbox>/link/../out.md` 进行路径规范化后看似合法，但写入时路径从左到右解析符号链接，导致实际写入位置超出沙箱。\n\n**修复方案**：将 `realpath` 解析后的规范路径直接传递给 `writeFile`，确保检查路径和写入路径完全一致。\n\n| 版本 | 问题 | 影响 |\n|------|------|------|\n| 修复前 | 检查规范化路径，但写入原始路径 | `link/..` 可逃逸到沙箱外 |\n| 修复后 | 检查路径即为写入路径 | 无逃逸可能 |\n\n资料来源：[CHANGELOG.md:符号链接逃逸修复说明]()\n\n## 错误处理\n\n### 错误码\n\n| 错误码 | 触发条件 | 返回内容示例 |\n|--------|----------|--------------|\n| `save_forbidden` | `savePath` 解析后不在允许的写根目录内 | `[save_forbidden] '/etc/passwd' is outside the allowed write roots: ['/tmp', '/project']` |\n\n### 返回数据结构\n\n```typescript\n// 允许的路径\n{\n  ok: true,\n  resolved: \"/private/tmp/out.md\"  // realpath 展开后的规范路径\n}\n\n// 拒绝的路径\n{\n  ok: false,\n  reason: \"'/etc/passwd' is outside the allowed write roots: ['/tmp', '/project']\"\n}\n```\n\n注意：确认消息中仍会回显原始 `savePath`，以确保在 tmpdir 本身是符号链接的主机（如 macOS `/var` → `/private/var`）上消息的稳定性。\n\n资料来源：[src/sandbox.ts:返回值结构](), [src/mcp.ts:错误转换]()\n\n## MCP 工具 schema 约束\n\nMCP 适配器在 `src/mcp.ts` 中使用 Zod 对 `savePath` 参数进行预校验：\n\n```typescript\nsavePath: z\n  .string()\n  .refine(isAbsolute, \"savePath must be an absolute filesystem path\")\n  .optional()\n```\n\n这确保了：\n1. `savePath` 必须是字符串\n2. 字符串必须是绝对路径（`isAbsolute` 校验）\n3. 参数可选（未提供时直接返回 markdown）\n\n如果路径不是绝对路径，校验会直接失败，不会进入沙箱检查阶段。\n\n资料来源：[src/mcp.ts:savePath schema 定义]()\n\n## CLI 模式差异\n\n| 特性 | MCP 模式 | CLI 模式 |\n|------|----------|----------|\n| 沙箱检查 | ✅ 启用 | ❌ 禁用 |\n| 路径要求 | 必须为绝对路径 | 可为相对路径（相对 cwd） |\n| 符号链接检查 | ✅ 启用 | ❌ 禁用 |\n| 错误码 | `save_forbidden` | 直接拒绝写入 |\n\nCLI 模式的无限制设计基于以下假设：命令行环境中的人类用户是天然的安全边界，可以自行判断路径的适当性。\n\n资料来源：[README.md:CLI 模式说明](), [README.md:沙箱设计说明]()\n\n## 安全考量\n\n### 设计原则\n\n1. **最小权限原则**：默认只允许写入临时目录和当前工作目录\n2. **显式优于隐式**：环境变量会替换而非合并默认配置\n3. **fail-fast**：配置错误在启动时立即失败，而非运行时\n4. **路径规范化**：所有路径经过 `realpath` 展开，防止符号链接攻击\n\n### 潜在风险\n\n- **并发写入冲突**：多个请求写入相同文件名时，后写覆盖前写（无锁保护）\n- **临时目录清理**：系统清理 `/tmp` 可能导致未保存的文件丢失\n- **符号链接竞态**：在路径检查和写入之间存在微小时间窗口，攻击难度极高但理论上可能\n\n资料来源：[README.md:安全说明](), [src/sandbox.ts:安全设计]()\n\n---\n\n<a id='configuration'></a>\n\n## 配置与环境变量\n\n### 相关页面\n\n相关主题：[命令行界面](#cli-usage), [MCP 服务器](#mcp-server), [写操作沙箱](#write-sandbox)\n\n<details>\n<summary>相关源码文件</summary>\n\n以下源码文件用于生成本页说明：\n\n- [src/core.ts](https://github.com/vasylenko/markfetch/blob/main/src/core.ts)\n- [src/mcp.ts](https://github.com/vasylenko/markfetch/blob/main/src/mcp.ts)\n- [src/cli.ts](https://github.com/vasylenko/markfetch/blob/main/src/cli.ts)\n- [src/sandbox.ts](https://github.com/vasylenko/markfetch/blob/main/src/sandbox.ts)\n- [package.json](https://github.com/vasylenko/markfetch/blob/main/package.json)\n</details>\n\n# 配置与环境变量\n\nmarkfetch 通过环境变量提供运行时配置能力，支持自定义超时、响应大小限制、用户代理以及写入沙箱策略。这些配置项在服务启动时进行验证，确保在处理请求前就能捕获无效配置。\n\n## 环境变量总览\n\nmarkfetch 提供以下四个环境变量用于运行时配置：\n\n| 变量名 | 默认值 | 说明 |\n|--------|--------|------|\n| `MARKFETCH_TIMEOUT_MS` | `30000` | 单次请求超时时间（毫秒） |\n| `MARKFETCH_MAX_BYTES` | `5000000` | 响应体和提取后 Markdown 的最大字节数 |\n| `MARKFETCH_USER_AGENT` | Chrome 130 固定字符串 | HTTP 请求的用户代理 |\n| `MARKFETCH_ALLOWED_WRITE_ROOTS` | `os.tmpdir()` + `process.cwd()` | MCP 模式下允许写入的根目录列表 |\n\n所有环境变量在服务启动时进行验证，无效值会快速失败并将错误输出到 stderr，避免在请求处理时才产生混淆的错误信息。资料来源：[README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)\n\n## 超时配置\n\n`MARKFETCH_TIMEOUT_MS` 控制单个 HTTP 请求的最大等待时间。当请求超过设定时间未完成时，返回 `timeout` 错误码。资料来源：[README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)\n\n```json\n{\n  \"mcpServers\": {\n    \"markfetch\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"markfetch\"],\n      \"env\": {\n        \"MARKFETCH_TIMEOUT_MS\": \"60000\"\n      }\n    }\n  }\n}\n```\n\n增加超时值适用于网络环境较差或目标服务器响应缓慢的场景。\n\n## 响应大小限制\n\n`MARKFETCH_MAX_BYTES` 设置响应体和最终提取的 Markdown 内容的大小上限。当任一阶段超过此限制时，返回 `too_large` 错误码。资料来源：[README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)\n\n```\nMARKFETCH_MAX_BYTES=5000000\n```\n\n默认值约 5MB，适用于绝大多数网页场景。超大型文档建议使用 `savePath` 参数将结果直接写入磁盘。\n\n## 用户代理配置\n\n`MARKFETCH_USER_AGENT` 用于自定义 HTTP 请求头中的 User-Agent 字段。默认值是固定的 Chrome 130 字符串，用于模拟真实浏览器指纹。资料来源：[README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)\n\n```json\n{\n  \"env\": {\n    \"MARKFETCH_ALLOWED_WRITE_ROOTS\": \"/Users/me/markfetch-out:/tmp\"\n  }\n}\n```\n\nmarkfetch 会从 `MARKFETCH_USER_AGENT` 推导出 `Sec-CH-UA-*` 客户端提示头。非 Chrome 浏览器的 UA 字符串会启动时快速失败。\n\n## 写入沙箱（Write Sandbox）\n\n写入沙箱是 MCP 模式下特有的安全机制，用于限制 `savePath` 参数可以写入的目录范围。CLI 模式下不实施沙箱限制，因为命令行用户本身就是安全边界。资料来源：[README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)\n\n### 默认允许的写入根目录\n\n默认情况下，允许写入的根目录为：\n\n- 系统临时目录（`os.tmpdir()`）\n- 当前工作目录（`process.cwd()`）\n\n两个路径在服务启动时通过 `fs.realpath` 解析一次，后续路径验证使用解析后的绝对路径。\n\n### 自定义允许根目录\n\n通过 `MARKFETCH_ALLOWED_WRITE_ROOTS` 可以覆盖默认的允许根目录列表：\n\n| 平台 | 路径分隔符 | 示例 |\n|------|-----------|------|\n| POSIX | `:` | `/Users/me/markfetch-out:/tmp` |\n| Windows | `;` | `C:\\Users\\me\\markfetch-out;C:\\Users\\me\\AppData\\Local\\Temp` |\n\n配置此变量会**完全替换**默认值，不会合并。因此如果需要保留临时目录访问权限，必须显式列出。资料来源：[README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)\n\n```json\n{\n  \"mcpServers\": {\n    \"markfetch\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"markfetch\"],\n      \"env\": {\n        \"MARKFETCH_ALLOWED_WRITE_ROOTS\": \"/Users/me/markfetch-out:/tmp\"\n      }\n    }\n  }\n}\n```\n\n### 沙箱验证逻辑\n\n沙箱验证在 `src/sandbox.ts` 中实现，核心逻辑如下：资料来源：[src/sandbox.ts](https://github.com/vasylenko/markfetch/blob/main/src/sandbox.ts)\n\n```typescript\n// 将 savePath 重新附加到可能的根目录\nconst reattached = isAbsolute(savePath)\n  ? savePath\n  : join(resolvedAncestor, ...trailing);\n\n// Windows 平台大小写不敏感，使用小写比较\nconst fold = process.platform === \"win32\"\n  ? (s: string) => s.toLowerCase()\n  : (s: string) => s;\n\n// 检查重附加后的路径是否在允许的根目录范围内\nfor (const root of roots) {\n  const rel = relative(fold(root), foldedTarget);\n  if (rel === \"\") return { ok: true, resolved: reattached };\n  if (!rel.startsWith(\"..\") && !isAbsolute(rel)) {\n    return { ok: true, resolved: reattached };\n  }\n}\n```\n\n### 验证失败处理\n\n当 `savePath` 解析后不在允许的根目录范围内时：\n\n- MCP 模式返回 `save_forbidden` 错误码\n- CLI 模式不受沙箱限制，无此检查\n\n```typescript\nreturn {\n  ok: false,\n  reason: `'${reattached}' is outside the allowed write roots: [${roots.map((r) => `'${r}'`).join(\", \")}]`\n};\n```\n\n### 符号链接处理\n\n沙箱机制会阻止指向允许根目录外的符号链接。每个路径都通过 `fs.realpath` 解析后再验证。资料来源：[README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)\n\n### 配置验证\n\n沙箱配置在服务启动时进行验证：\n\n- 路径必须是绝对路径\n- 目录必须存在\n- 格式错误会在启动时快速失败并输出到 stderr\n\n## 配置传递方式\n\n### MCP 模式\n\nMCP 客户端通过配置文件的 `env` 块传递环境变量：资料来源：[src/mcp.ts](https://github.com/vasylenko/markfetch/blob/main/src/mcp.ts)\n\n```json\n{\n  \"mcpServers\": {\n    \"markfetch\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"markfetch\"],\n      \"env\": {\n        \"MARKFETCH_TIMEOUT_MS\": \"60000\",\n        \"MARKFETCH_MAX_BYTES\": \"10000000\",\n        \"MARKFETCH_USER_AGENT\": \"Mozilla/5.0 ...\",\n        \"MARKFETCH_ALLOWED_WRITE_ROOTS\": \"/custom/path\"\n      }\n    }\n  }\n}\n```\n\n### CLI 模式\n\nCLI 模式下可直接通过 shell 环境变量设置：\n\n```bash\nexport MARKFETCH_TIMEOUT_MS=60000\nmarkfetch https://example.com\n```\n\n或在一行内设置：\n\n```bash\nMARKFETCH_TIMEOUT_MS=60000 markfetch https://example.com\n```\n\n## 架构流程图\n\n```mermaid\ngraph TD\n    A[服务启动] --> B{环境变量验证}\n    B -->|有效| C[启动 MCP/CLI 服务]\n    B -->|无效| D[输出错误到 stderr<br/>进程退出]\n    \n    C --> E[接收请求]\n    E --> F{MCP 模式?}\n    F -->|是| G[检查 savePath 沙箱]\n    F -->|否| H[直接处理请求]\n    \n    G -->|在允许范围内| H\n    G -->|超出范围| I[返回 save_forbidden]\n    \n    H --> J[执行 fetch_markdown]\n    J --> K{超时?}\n    J --> L{响应大小?}\n    J --> M{内容类型?}\n    \n    K -->|是| N[返回 timeout]\n    L -->|是| O[返回 too_large]\n    M -->|否| P[返回 unsupported_content_type]\n    \n    J --> Q[提取文章内容]\n    Q --> R{提取成功?}\n    R -->|否| S[返回 extraction_failed]\n    R -->|是| T[转换为 Markdown]\n    \n    T --> U{保存到文件?}\n    U -->|是| V[写入 savePath]\n    U -->|否| W[返回 content[0].text]\n    \n    V --> X[返回确认信息]\n```\n\n## 错误码与配置关系\n\n| 错误码 | 相关配置 | 说明 |\n|--------|----------|------|\n| `timeout` | `MARKFETCH_TIMEOUT_MS` | 请求超时 |\n| `too_large` | `MARKFETCH_MAX_BYTES` | 响应或内容超出限制 |\n| `save_failed` | 路径写入权限 | 写入文件失败 |\n| `save_forbidden` | `MARKFETCH_ALLOWED_WRITE_ROOTS` | 路径超出沙箱范围 |\n\n资料来源：[README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)\n\n## 最佳实践建议\n\n1. **生产环境**：建议设置合理的 `MARKFETCH_TIMEOUT_MS`（如 30000-60000ms）和 `MARKFETCH_MAX_BYTES`（如 5-10MB）\n\n2. **MCP 部署**：显式设置 `MARKFETCH_ALLOWED_WRITE_ROOTS`，明确允许的输出目录\n\n3. **安全考虑**：不要在 MCP 模式下完全移除沙箱限制，保持最小权限原则\n\n4. **Windows 平台**：使用 `;` 作为分隔符，并确保路径格式正确（反斜杠或正斜杠均可）\n\n5. **调试场景**：可以临时增大超时和大小限制来排查问题\n\n---\n\n<a id='error-handling'></a>\n\n## 错误处理机制\n\n### 相关页面\n\n相关主题：[命令行界面](#cli-usage), [MCP 服务器](#mcp-server), [内容提取管道](#content-extraction)\n\n<details>\n<summary>相关源码文件</summary>\n\n以下源码文件用于生成本页说明：\n\n- [src/core.ts](https://github.com/vasylenko/markfetch/blob/main/src/core.ts)\n- [src/cli.ts](https://github.com/vasylenko/markfetch/blob/main/src/cli.ts)\n- [src/mcp.ts](https://github.com/vasylenko/markfetch/blob/main/src/mcp.ts)\n- [src/sandbox.ts](https://github.com/vasylenko/markfetch/blob/main/src/sandbox.ts)\n- [README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)\n- [CHANGELOG.md](https://github.com/vasylenko/markfetch/blob/main/CHANGELOG.md)\n</details>\n\n# 错误处理机制\n\n## 概述\n\nmarkfetch 采用统一的错误处理架构，确保在 CLI 和 MCP 两种运行模式下都能提供一致的、机器可读的诊断信息。所有错误均通过预定义的错误代码标识，支持确定性错误处理，便于调用方进行逻辑分支和日志记录。\n\n核心设计原则：\n\n- **确定性错误代码**：8 种标准化错误代码覆盖所有故障场景\n- **统一错误类型**：`MarkfetchError` 类在 core 层统一抛出\n- **适配器隔离**：CLI 和 MCP 适配器各自负责错误格式转换\n- **启动时验证**：环境变量在进程启动时即进行校验，失败快速报错\n\n资料来源：[README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)\n\n---\n\n## 错误代码体系\n\n### 错误代码一览表\n\n| 错误代码 | 含义 | 触发条件 |\n|---------|------|----------|\n| `network_error` | 网络故障 | DNS 解析失败、TCP 连接失败、TLS 握手错误，或 fetcher 内部未预期错误 |\n| `http_error` | HTTP 错误 | 上游服务器返回非 2xx 状态码 |\n| `timeout` | 请求超时 | 超过 `MARKFETCH_TIMEOUT_MS` 配置的超时时间 |\n| `unsupported_content_type` | 不支持的 Content-Type | 响应不是 `text/html` 或 `application/xhtml+xml` |\n| `extraction_failed` | 内容提取失败 | Readability 算法无法提取文章内容（典型于纯客户端渲染 SPA） |\n| `too_large` | 响应过大 | 响应体或提取的 markdown 超过 `MARKFETCH_MAX_BYTES` |\n| `save_failed` | 文件保存失败 | 指定了 `savePath` 但写入失败（目录不存在、权限不足等） |\n| `save_forbidden` | 写入路径禁止 | `savePath` 超出允许的写入根目录范围 |\n\n资料来源：[README.md:错误代码表](https://github.com/vasylenko/markfetch/blob/main/README.md)\n\n### 错误格式\n\n所有错误均遵循统一格式：`[<错误代码>] <错误消息>`\n\n```\n[network_error] getaddrinfo ENOTFOUND example.invalid\n[http_error] HTTP 403 Forbidden\n[timeout] Per-request timeout exceeded (30000ms)\n[unsupported_content_type] application/json\n[extraction_failed] No article content found\n[too_large] Response body (52428800 bytes) exceeds MARKFETCH_MAX_BYTES (5242880)\n[save_failed] write EPERM /root/restricted.txt\n[save_forbidden] '/etc/passwd' is outside the allowed write roots\n```\n\n资料来源：[src/cli.ts:错误输出格式](https://github.com/vasylenko/markfetch/blob/main/src/cli.ts)\n\n---\n\n## 架构设计\n\n### 错误处理流程图\n\n```mermaid\ngraph TD\n    A[请求入口] --> B{运行模式}\n    B -->|MCP| C[mcp.ts 适配器]\n    B -->|CLI| D[cli.ts 适配器]\n    \n    C --> E[调用 core.fetchMarkdown]\n    D --> E\n    \n    E --> F{处理结果}\n    F -->|成功| G[返回 Markdown]\n    F -->|失败| H[抛出 MarkfetchError]\n    \n    H --> I{MCP 适配器}\n    H --> J{CLI 适配器}\n    \n    I --> K[构建 errorResult]\n    J --> L[classifyError 分类]\n    \n    K --> M[返回 MCP 错误响应<br/>isError: true]\n    L --> N[console.error 输出<br/>process.exitCode = 1]\n    \n    style H fill:#ff6b6b\n    style K fill:#feca57\n    style L fill:#feca57\n    style M fill:#48dbfb\n    style N fill:#48dbfb\n```\n\n### 核心组件职责\n\n| 组件 | 文件 | 职责 |\n|------|------|------|\n| MarkfetchError | src/core.ts | 统一的错误类型，携带错误代码和消息 |\n| errorResult | src/mcp.ts | 构建 MCP 格式的错误响应 |\n| classifyError | src/core.ts | 对异常进行分类，提取代码和消息 |\n| 写入沙箱验证 | src/sandbox.ts | 验证 savePath 是否在允许范围内 |\n\n资料来源：[src/core.ts](https://github.com/vasylenko/markfetch/blob/main/src/core.ts) 和 [src/mcp.ts](https://github.com/vasylenko/markfetch/blob/main/src/mcp.ts)\n\n---\n\n## 统一错误类型\n\n### MarkfetchError 类\n\n```typescript\n// src/core.ts 中的核心实现\nclass MarkfetchError extends Error {\n  constructor(\n    public readonly code: ErrorCode,\n    message: string\n  ) {\n    super(message);\n    this.name = \"MarkfetchError\";\n  }\n}\n```\n\n`MarkfetchError` 是项目的核心异常类型，继承自 `Error`，包含：\n\n- **code**：标准化的错误代码（ErrorCode 枚举）\n- **message**：人类可读的错误描述\n\n资料来源：[src/core.ts](https://github.com/vasylenko/markfetch/blob/main/src/core.ts)\n\n### 错误代码枚举\n\n```typescript\ntype ErrorCode =\n  | \"network_error\"\n  | \"http_error\"\n  | \"timeout\"\n  | \"unsupported_content_type\"\n  | \"extraction_failed\"\n  | \"too_large\"\n  | \"save_failed\"\n  | \"save_forbidden\";\n```\n\n资料来源：[src/core.ts](https://github.com/vasylenko/markfetch/blob/main/src/core.ts)\n\n### 错误分类器\n\n```typescript\n// src/core.ts\nfunction classifyError(err: unknown): { code: string; message: string } {\n  if (err instanceof MarkfetchError) {\n    return { code: err.code, message: err.message };\n  }\n  // 处理未知异常\n  return { code: \"network_error\", message: String(err) };\n}\n```\n\n资料来源：[src/core.ts:classifyError](https://github.com/vasylenko/markfetch/blob/main/src/core.ts)\n\n---\n\n## MCP 适配器错误处理\n\n### MCP 错误响应格式\n\nMCP 适配器通过 `errorResult` 函数构建符合 MCP 协议的错误响应：\n\n```typescript\n// src/mcp.ts\nfunction errorResult(code: ErrorCode, message: string) {\n  return {\n    content: [{ type: \"text\" as const, text: `[${code}] ${message}` }],\n    isError: true,\n  };\n}\n```\n\n响应结构：\n\n| 字段 | 类型 | 说明 |\n|------|------|------|\n| content | Array | 包含单个文本消息的数组 |\n| content[0].type | `\"text\"` | 固定值 |\n| content[0].text | string | 格式化的错误信息 `[code] message` |\n| isError | `true` | 标识为错误响应 |\n\n资料来源：[src/mcp.ts:errorResult](https://github.com/vasylenko/markfetch/blob/main/src/mcp.ts)\n\n### MCP 工具注册\n\n```typescript\nserver.registerTool(\n  \"fetch_markdown\",\n  {\n    description: \"...\",\n    inputSchema: {\n      url: z.string().url(),\n      savePath: z.string().refine(isAbsolute).optional(),\n    },\n  },\n  async ({ url, savePath }) => {\n    try {\n      const { markdown, bytes, savedTo } = await fetchMarkdown({ url, savePath });\n      return {\n        content: [{ type: \"text\", text: markdown }],\n        meta?: { bytes, savedTo },\n      };\n    } catch (err) {\n      const { code, message } = classifyError(err);\n      return errorResult(code, message);\n    }\n  }\n);\n```\n\n资料来源：[src/mcp.ts:工具注册](https://github.com/vasylenko/markfetch/blob/main/src/mcp.ts)\n\n---\n\n## CLI 适配器错误处理\n\n### CLI 错误输出\n\nCLI 适配器使用 `classifyError` 函数对异常进行分类，然后将结果输出到 stderr：\n\n```typescript\n// src/cli.ts\ntry {\n  const { markdown, bytes, savedTo } = await fetchMarkdown({ url, savePath });\n  if (savedTo !== undefined) {\n    console.log(`Saved ${bytes} bytes to ${savedTo}`);\n  } else {\n    process.stdout.write(markdown);\n  }\n} catch (err) {\n  const { code, message } = classifyError(err);\n  console.error(`[${code}] ${message}`);\n  process.exitCode = 1;\n}\n```\n\nCLI 错误处理特点：\n\n- **stderr 专用**：错误信息写入 stderr，不干扰 stdout 的 markdown 输出\n- **进程退出码**：设置 `process.exitCode = 1` 确保管道脚本能检测到失败\n- **优雅关闭**：使用 `exitCode` 而非 `exit()`，确保输出缓冲区排空\n\n资料来源：[src/cli.ts:错误处理](https://github.com/vasylenko/markfetch/blob/main/src/cli.ts)\n\n---\n\n## 写入沙箱错误\n\n### save_forbidden 错误机制\n\n当 MCP 调用指定 `savePath` 时，系统会验证目标路径是否在允许的写入根目录范围内：\n\n```typescript\n// src/sandbox.ts\nexport function validateSavePath(\n  savePath: string,\n  roots: string[]\n): { ok: true; resolved: string } | { ok: false; reason: string } {\n  // 解析绝对路径\n  const resolved = resolve(savePath);\n  \n  // 检查是否在允许的根目录下\n  for (const root of roots) {\n    const rel = relative(fold(root), foldedTarget);\n    if (rel === \"\" || (!rel.startsWith(\"..\") && !isAbsolute(rel))) {\n      return { ok: true, resolved };\n    }\n  }\n  \n  return {\n    ok: false,\n    reason: `'${resolved}' is outside the allowed write roots: [${roots.join(\", \")}]`,\n  };\n}\n```\n\n验证规则：\n\n| 检查项 | 说明 |\n|--------|------|\n| 绝对路径 | savePath 必须为绝对路径（由 schema 的 `isAbsolute` 约束） |\n| 根目录匹配 | 解析后的路径必须位于允许的根目录下 |\n| 符号链接 | 指向外部的符号链接会被阻止 |\n\n资料来源：[src/sandbox.ts](https://github.com/vasylenko/markfetch/blob/main/src/sandbox.ts)\n\n### 允许的写入根目录\n\n| 环境变量 | 默认值 | 说明 |\n|----------|--------|------|\n| `MARKFETCH_ALLOWED_WRITE_ROOTS` | `os.tmpdir()` + `process.cwd()` | MCP 专用，可覆盖默认值 |\n\n平台差异：\n\n- **POSIX**：路径分隔符为 `:`\n- **Windows**：路径分隔符为 `;`\n\n资料来源：[README.md:Write sandbox](https://github.com/vasylenko/markfetch/blob/main/README.md)\n\n---\n\n## 环境变量验证\n\n### 启动时验证机制\n\nmarkfetch 在进程启动时对所有环境变量进行校验，无效配置会快速失败：\n\n| 环境变量 | 默认值 | 校验规则 |\n|----------|--------|----------|\n| `MARKFETCH_TIMEOUT_MS` | `30000` | 正整数，最大 300000 |\n| `MARKFETCH_MAX_BYTES` | `5000000` | 正整数，最大 104857600 |\n| `MARKFETCH_USER_AGENT` | Chrome 130 UA | 必须是 Chrome User-Agent 字符串 |\n| `MARKFETCH_ALLOWED_WRITE_ROOTS` | 参见上表 | 所有路径必须为绝对路径且存在 |\n\n验证失败时：\n\n- 错误信息输出到 stderr\n- 进程立即退出（非零退出码）\n- 不执行任何请求处理\n\n资料来源：[README.md:Configuration](https://github.com/vasylenko/markfetch/blob/main/README.md) 和 [CHANGELOG.md:0.6.0](https://github.com/vasylenko/markfetch/blob/main/CHANGELOG.md)\n\n---\n\n## 错误恢复策略\n\n### 调用方建议\n\n```mermaid\ngraph LR\n    A[调用 fetch_markdown] --> B{isError?}\n    B -->|是| C[解析错误代码]\n    B -->|否| D[处理 markdown]\n    \n    C --> E{错误代码分支}\n    E -->|network_error| F[重试 / 记录日志]\n    E -->|http_error| G[检查 URL 有效性]\n    E -->|timeout| H[增加 MARKFETCH_TIMEOUT_MS]\n    E -->|unsupported_content_type| I[确认 URL 是否为 HTML]\n    E -->|extraction_failed| J[尝试直接访问 API]\n    E -->|too_large| K[增加 MARKFETCH_MAX_BYTES]\n    E -->|save_failed| L[检查目录权限]\n    E -->|save_forbidden| M[使用允许的路径]\n```\n\n### 错误处理建议\n\n| 错误代码 | 建议操作 |\n|----------|----------|\n| `network_error` | 检查网络连接，等待后重试 |\n| `http_error` | 验证 URL 是否有效，检查网站是否允许访问 |\n| `timeout` | 增大 `MARKFETCH_TIMEOUT_MS` 环境变量 |\n| `unsupported_content_type` | 确认目标 URL 返回 HTML 内容 |\n| `extraction_failed` | 该页面可能是纯 JS 渲染，暂无解决方案 |\n| `too_large` | 增大 `MARKFETCH_MAX_BYTES` 或使用 `savePath` 写入文件 |\n| `save_failed` | 检查目录存在性和写入权限 |\n| `save_forbidden` | 使用允许的根目录路径，或设置 `MARKFETCH_ALLOWED_WRITE_ROOTS` |\n\n---\n\n## 版本演进\n\n### 0.6.0（当前版本）\n\n统一错误处理架构完成：\n\n- 3 处内联 `return errorResult(...)` 站点改为抛出 `MarkfetchError`\n- CLI 和 MCP 适配器统一捕获并转换错误\n- 错误消息格式保持一致\n\n资料来源：[CHANGELOG.md:0.6.0](https://github.com/vasylenko/markfetch/blob/main/CHANGELOG.md)\n\n### 0.4.0\n\n引入 `save_forbidden` 错误代码和写入沙箱机制：\n\n- MCP `savePath` schema 改用 `z.string().refine(path.isAbsolute)`\n- 不符合规范的路径直接返回 `save_forbidden`\n\n资料来源：[CHANGELOG.md:0.4.0](https://github.com/vasylenko/markfetch/blob/main/CHANGELOG.md)\n\n### 0.3.0\n\n确立 7 种基础错误代码：\n\n- 完整的错误代码体系首次定义\n- 环境变量验证机制引入\n\n资料来源：[CHANGELOG.md:0.3.0](https://github.com/vasylenko/markfetch/blob/main/CHANGELOG.md)\n\n---\n\n## 总结\n\nmarkfetch 的错误处理机制具有以下核心特征：\n\n1. **8 种确定性错误代码**：覆盖所有可能的故障场景\n2. **统一格式**：`[<代码>] <消息>` 便于解析和日志处理\n3. **双通道输出**：MCP 使用 `isError: true` 响应，CLI 使用 stderr\n4. **启动时验证**：环境变量错误提前暴露\n5. **写入安全**：MCP 模式下的路径沙箱保护\n\n这套机制确保了工具在各种调用场景下都能提供一致、可预测的错误反馈。\n\n---\n\n---\n\n## Doramagic 踩坑日志\n\n项目：vasylenko/markfetch\n\n摘要：发现 7 个潜在踩坑项，其中 0 个为 high/blocking；最高优先级：安装坑 - 来源证据：v0.4.1。\n\n## 1. 安装坑 · 来源证据：v0.4.1\n\n- 严重度：medium\n- 证据强度：source_linked\n- 发现：GitHub 社区证据显示该项目存在一个安装相关的待验证问题：v0.4.1\n- 对用户的影响：可能增加新用户试用和生产接入成本。\n- 建议检查：来源显示可能已有修复、规避或版本变化，说明书中必须标注适用版本。\n- 防护动作：不得脱离来源链接放大为确定性结论；需要标注适用版本和复核状态。\n- 证据：community_evidence:github | cevd_749b65614f7b40e0b524f4e932cd4aca | https://github.com/vasylenko/markfetch/releases/tag/v0.4.1 | 来源讨论提到 node 相关条件，需在安装/试用前复核。\n\n## 2. 能力坑 · 能力判断依赖假设\n\n- 严重度：medium\n- 证据强度：source_linked\n- 发现：README/documentation is current enough for a first validation pass.\n- 对用户的影响：假设不成立时，用户拿不到承诺的能力。\n- 建议检查：将假设转成下游验证清单。\n- 防护动作：假设必须转成验证项；没有验证结果前不能写成事实。\n- 证据：capability.assumptions | github_repo:1234238440 | https://github.com/vasylenko/markfetch | README/documentation is current enough for a first validation pass.\n\n## 3. 维护坑 · 维护活跃度未知\n\n- 严重度：medium\n- 证据强度：source_linked\n- 发现：未记录 last_activity_observed。\n- 对用户的影响：新项目、停更项目和活跃项目会被混在一起，推荐信任度下降。\n- 建议检查：补 GitHub 最近 commit、release、issue/PR 响应信号。\n- 防护动作：维护活跃度未知时，推荐强度不能标为高信任。\n- 证据：evidence.maintainer_signals | github_repo:1234238440 | https://github.com/vasylenko/markfetch | last_activity_observed missing\n\n## 4. 安全/权限坑 · 下游验证发现风险项\n\n- 严重度：medium\n- 证据强度：source_linked\n- 发现：no_demo\n- 对用户的影响：下游已经要求复核，不能在页面中弱化。\n- 建议检查：进入安全/权限治理复核队列。\n- 防护动作：下游风险存在时必须保持 review/recommendation 降级。\n- 证据：downstream_validation.risk_items | github_repo:1234238440 | https://github.com/vasylenko/markfetch | no_demo; severity=medium\n\n## 5. 安全/权限坑 · 存在评分风险\n\n- 严重度：medium\n- 证据强度：source_linked\n- 发现：no_demo\n- 对用户的影响：风险会影响是否适合普通用户安装。\n- 建议检查：把风险写入边界卡，并确认是否需要人工复核。\n- 防护动作：评分风险必须进入边界卡，不能只作为内部分数。\n- 证据：risks.scoring_risks | github_repo:1234238440 | https://github.com/vasylenko/markfetch | no_demo; severity=medium\n\n## 6. 维护坑 · issue/PR 响应质量未知\n\n- 严重度：low\n- 证据强度：source_linked\n- 发现：issue_or_pr_quality=unknown。\n- 对用户的影响：用户无法判断遇到问题后是否有人维护。\n- 建议检查：抽样最近 issue/PR，判断是否长期无人处理。\n- 防护动作：issue/PR 响应未知时，必须提示维护风险。\n- 证据：evidence.maintainer_signals | github_repo:1234238440 | https://github.com/vasylenko/markfetch | issue_or_pr_quality=unknown\n\n## 7. 维护坑 · 发布节奏不明确\n\n- 严重度：low\n- 证据强度：source_linked\n- 发现：release_recency=unknown。\n- 对用户的影响：安装命令和文档可能落后于代码，用户踩坑概率升高。\n- 建议检查：确认最近 release/tag 和 README 安装命令是否一致。\n- 防护动作：发布节奏未知或过期时，安装说明必须标注可能漂移。\n- 证据：evidence.maintainer_signals | github_repo:1234238440 | https://github.com/vasylenko/markfetch | release_recency=unknown\n\n<!-- canonical_name: vasylenko/markfetch; human_manual_source: deepwiki_human_wiki -->\n",
      "markdown_key": "markfetch",
      "pages": "draft",
      "source_refs": [
        {
          "evidence_id": "github_repo:1234238440",
          "kind": "repo",
          "supports_claim_ids": [
            "claim_identity",
            "claim_distribution",
            "claim_capability"
          ],
          "url": "https://github.com/vasylenko/markfetch"
        },
        {
          "evidence_id": "art_af64b5f930b64736aa1d4abc1e690f07",
          "kind": "docs",
          "supports_claim_ids": [
            "claim_identity",
            "claim_distribution",
            "claim_capability"
          ],
          "url": "https://github.com/vasylenko/markfetch#readme"
        }
      ],
      "summary": "DeepWiki/Human Wiki 完整输出，末尾追加 Discovery Agent 踩坑日志。",
      "title": "markfetch 说明书",
      "toc": [
        "https://github.com/vasylenko/markfetch 项目说明书",
        "目录",
        "项目概述",
        "项目简介",
        "核心特性",
        "系统架构",
        "工作流程",
        "错误处理机制",
        "Doramagic 踩坑日志"
      ]
    }
  },
  "quality_gate": {
    "blocking_gaps": [],
    "category_confidence": "medium",
    "compile_status": "ready_for_review",
    "five_assets_present": true,
    "install_sandbox_verified": true,
    "missing_evidence": [],
    "next_action": "publish to Doramagic.ai project surfaces",
    "prompt_preview_boundary_ok": true,
    "publish_status": "publishable",
    "quick_start_verified": true,
    "repo_clone_verified": true,
    "repo_commit": "c4732aae41c009a052a824c3b8402d43b1aa3302",
    "repo_inspection_error": null,
    "repo_inspection_files": [
      "package.json",
      "README.md",
      "docs/SPEC.md",
      "src/index.ts",
      "src/mcp.ts",
      "src/cli.ts",
      "src/sandbox.ts",
      "src/core.ts"
    ],
    "repo_inspection_verified": true,
    "review_reasons": [
      "community_discussion_evidence_below_public_threshold"
    ],
    "tag_count_ok": true,
    "unsupported_claims": []
  },
  "schema_version": "0.1",
  "user_assets": {
    "ai_context_pack": {
      "asset_id": "ai_context_pack",
      "filename": "AI_CONTEXT_PACK.md",
      "markdown": "# markfetch - Doramagic AI Context Pack\n\n> 定位：安装前体验与判断资产。它帮助宿主 AI 有一个好的开始，但不代表已经安装、执行或验证目标项目。\n\n## 充分原则\n\n- **充分原则，不是压缩原则**：AI Context Pack 应该充分到让宿主 AI 在开工前理解项目价值、能力边界、使用入口、风险和证据来源；它可以分层组织，但不以最短摘要为目标。\n- **压缩策略**：只压缩噪声和重复内容，不压缩会影响判断和开工质量的上下文。\n\n## 给宿主 AI 的使用方式\n\n你正在读取 Doramagic 为 markfetch 编译的 AI Context Pack。请把它当作开工前上下文：帮助用户理解适合谁、能做什么、如何开始、哪些必须安装后验证、风险在哪里。不要声称你已经安装、运行或执行了目标项目。\n\n## Claim 消费规则\n\n- **事实来源**：Repo Evidence + Claim/Evidence Graph；Human Wiki 只提供显著性、术语和叙事结构。\n- **事实最低状态**：`supported`\n- `supported`：可以作为项目事实使用，但回答中必须引用 claim_id 和证据路径。\n- `weak`：只能作为低置信度线索，必须要求用户继续核实。\n- `inferred`：只能用于风险提示或待确认问题，不能包装成项目事实。\n- `unverified`：不得作为事实使用，应明确说证据不足。\n- `contradicted`：必须展示冲突来源，不得替用户强行选择一个版本。\n\n## 它最适合谁\n\n- **正在使用 Claude/Codex/Cursor/Gemini 等宿主 AI 的开发者**：README 或插件配置提到多个宿主 AI。 证据：`README.md` Claim：`clm_0002` supported 0.86\n\n## 它能做什么\n\n- **命令行启动或安装流程**（需要安装后验证）：项目文档中存在可执行命令，真实使用需要在本地或宿主环境中运行这些命令。 证据：`README.md` Claim：`clm_0001` supported 0.86\n\n## 怎么开始\n\n- `npm i -g markfetch` 证据：`README.md` Claim：`clm_0003` supported 0.86, `clm_0008` supported 0.86\n- `claude mcp add --scope user markfetch -- npx -y markfetch` 证据：`README.md` Claim：`clm_0004` supported 0.86\n- `npx -y markfetch https://example.com/article` 证据：`README.md` Claim：`clm_0005` supported 0.86, `clm_0006` supported 0.86, `clm_0007` supported 0.86\n- `npx -y markfetch https://example.com/article -o article.md` 证据：`README.md` Claim：`clm_0006` supported 0.86\n- `npx -y markfetch https://example.com/article | pandoc -o article.pdf` 证据：`README.md` Claim：`clm_0007` supported 0.86\n- `npm i -g markfetch         # then anywhere: markfetch <url>` 证据：`README.md` Claim：`clm_0008` supported 0.86\n- `npm i -D markfetch         # then in package.json scripts: \"markfetch <url>\"` 证据：`README.md` Claim：`clm_0009` supported 0.86\n\n## 继续前判断卡\n\n- **当前建议**：先做权限沙盒试用\n- **为什么**：项目存在安装命令、宿主配置或本地写入线索，不建议直接进入主力环境，应先在隔离环境试装。\n\n### 30 秒判断\n\n- **现在怎么做**：先做权限沙盒试用\n- **最小安全下一步**：先跑 Prompt Preview；若仍要安装，只在隔离环境试装\n- **先别相信**：工具权限边界不能在安装前相信。\n- **继续会触碰**：命令执行、本地环境或项目文件、宿主 AI 上下文\n\n### 现在可以相信\n\n- **适合人群线索：正在使用 Claude/Codex/Cursor/Gemini 等宿主 AI 的开发者**（supported）：有 supported claim 或项目证据支撑，但仍不等于真实安装效果。 证据：`README.md` Claim：`clm_0002` supported 0.86\n- **能力存在：命令行启动或安装流程**（supported）：可以相信项目包含这类能力线索；是否适合你的具体任务仍要试用或安装后验证。 证据：`README.md` Claim：`clm_0001` supported 0.86\n- **存在 Quick Start / 安装命令线索**（supported）：可以相信项目文档出现过启动或安装入口；不要因此直接在主力环境运行。 证据：`README.md` Claim：`clm_0003` supported 0.86, `clm_0008` supported 0.86\n\n### 现在还不能相信\n\n- **工具权限边界不能在安装前相信。**（unverified）：MCP/tool 类项目通常会触碰文件、网络、浏览器或外部 API，必须真实检查权限和日志。\n- **真实输出质量不能在安装前相信。**（unverified）：Prompt Preview 只能展示引导方式，不能证明真实项目中的结果质量。\n- **宿主 AI 版本兼容性不能在安装前相信。**（unverified）：Claude、Cursor、Codex、Gemini 等宿主加载规则和版本差异必须在真实环境验证。\n- **不会污染现有宿主 AI 行为，不能直接相信。**（inferred）：Skill、plugin、AGENTS/CLAUDE/GEMINI 指令可能改变宿主 AI 的默认行为。\n- **可安全回滚不能默认相信。**（unverified）：除非项目明确提供卸载和恢复说明，否则必须先在隔离环境验证。\n- **真实安装后是否与用户当前宿主 AI 版本兼容？**（unverified）：兼容性只能通过实际宿主环境验证。\n- **项目输出质量是否满足用户具体任务？**（unverified）：安装前预览只能展示流程和边界，不能替代真实评测。\n- **安装命令是否需要网络、权限或全局写入？**（unverified）：这影响企业环境和个人环境的安装风险。 证据：`README.md`\n\n### 继续会触碰什么\n\n- **命令执行**：包管理器、网络下载、本地插件目录、项目配置或用户主目录。 原因：运行第一条命令就可能产生环境改动；必须先判断是否值得跑。 证据：`README.md`\n- **本地环境或项目文件**：安装结果、插件缓存、项目配置或本地依赖目录。 原因：安装前无法证明写入范围和回滚方式，需要隔离验证。 证据：`README.md`\n- **宿主 AI 上下文**：AI Context Pack、Prompt Preview、Skill 路由、风险规则和项目事实。 原因：导入上下文会影响宿主 AI 后续判断，必须避免把未验证项包装成事实。\n\n### 最小安全下一步\n\n- **先跑 Prompt Preview**：用安装前交互式试用判断工作方式是否匹配，不需要授权或改环境。（适用：任何项目都适用，尤其是输出质量未知时。）\n- **只在隔离目录或测试账号试装**：避免安装命令污染主力宿主 AI、真实项目或用户主目录。（适用：存在命令执行、插件配置或本地写入线索时。）\n- **安装后只验证一个最小任务**：先验证加载、兼容、输出质量和回滚，再决定是否深用。（适用：准备从试用进入真实工作流时。）\n\n### 退出方式\n\n- **保留安装前状态**：记录原始宿主配置和项目状态，后续才能判断是否可恢复。\n- **记录安装命令和写入路径**：没有明确卸载说明时，至少要知道哪些目录或配置需要手动清理。\n- **如果没有回滚路径，不进入主力环境**：不可回滚是继续前阻断项，不应靠信任或运气继续。\n\n## 哪些只能预览\n\n- 解释项目适合谁和能做什么\n- 基于项目文档演示典型对话流程\n- 帮助用户判断是否值得安装或继续研究\n\n## 哪些必须安装后验证\n\n- 真实安装 Skill、插件或 CLI\n- 执行脚本、修改本地文件或访问外部服务\n- 验证真实输出质量、性能和兼容性\n\n## 边界与风险判断卡\n\n- **把安装前预览误认为真实运行**：用户可能高估项目已经完成的配置、权限和兼容性验证。 处理方式：明确区分 prompt_preview_can_do 与 runtime_required。 Claim：`clm_0010` inferred 0.45\n- **命令执行会修改本地环境**：安装命令可能写入用户主目录、宿主插件目录或项目配置。 处理方式：先在隔离环境或测试账号中运行。 证据：`README.md` Claim：`clm_0011` supported 0.86\n- **待确认**：真实安装后是否与用户当前宿主 AI 版本兼容？。原因：兼容性只能通过实际宿主环境验证。\n- **待确认**：项目输出质量是否满足用户具体任务？。原因：安装前预览只能展示流程和边界，不能替代真实评测。\n- **待确认**：安装命令是否需要网络、权限或全局写入？。原因：这影响企业环境和个人环境的安装风险。\n\n## 开工前工作上下文\n\n### 加载顺序\n\n- 先读取 how_to_use.host_ai_instruction，建立安装前判断资产的边界。\n- 读取 claim_graph_summary，确认事实来自 Claim/Evidence Graph，而不是 Human Wiki 叙事。\n- 再读取 intended_users、capabilities 和 quick_start_candidates，判断用户是否匹配。\n- 需要执行具体任务时，优先查 role_skill_index，再查 evidence_index。\n- 遇到真实安装、文件修改、网络访问、性能或兼容性问题时，转入 risk_card 和 boundaries.runtime_required。\n\n### 任务路由\n\n- **命令行启动或安装流程**：先说明这是安装后验证能力，再给出安装前检查清单。 边界：必须真实安装或运行后验证。 证据：`README.md` Claim：`clm_0001` supported 0.86\n\n### 上下文规模\n\n- 文件总数：42\n- 重要文件覆盖：31/42\n- 证据索引条目：31\n- 角色 / Skill 条目：12\n\n### 证据不足时的处理\n\n- **missing_evidence**：说明证据不足，要求用户提供目标文件、README 段落或安装后验证记录；不要补全事实。\n- **out_of_scope_request**：说明该任务超出当前 AI Context Pack 证据范围，并建议用户先查看 Human Manual 或真实安装后验证。\n- **runtime_request**：给出安装前检查清单和命令来源，但不要替用户执行命令或声称已执行。\n- **source_conflict**：同时展示冲突来源，标记为待核实，不要强行选择一个版本。\n\n## Prompt Recipes\n\n### 适配判断\n\n- 目标：判断这个项目是否适合用户当前任务。\n- 预期输出：适配结论、关键理由、证据引用、安装前可预览内容、必须安装后验证内容、下一步建议。\n\n```text\n请基于 markfetch 的 AI Context Pack，先问我 3 个必要问题，然后判断它是否适合我的任务。回答必须包含：适合谁、能做什么、不能做什么、是否值得安装、证据来自哪里。所有项目事实必须引用 evidence_refs、source_paths 或 claim_id。\n```\n\n### 安装前体验\n\n- 目标：让用户在安装前感受核心工作流，同时避免把预览包装成真实能力或营销承诺。\n- 预期输出：一段带边界标签的体验剧本、安装后验证清单和谨慎建议；不含真实运行承诺或强营销表述。\n\n```text\n请把 markfetch 当作安装前体验资产，而不是已安装工具或真实运行环境。\n\n请严格输出四段：\n1. 先问我 3 个必要问题。\n2. 给出一段“体验剧本”：用 [安装前可预览]、[必须安装后验证]、[证据不足] 三种标签展示它可能如何引导工作流。\n3. 给出安装后验证清单：列出哪些能力只有真实安装、真实宿主加载、真实项目运行后才能确认。\n4. 给出谨慎建议：只能说“值得继续研究/试装”“先补充信息后再判断”或“不建议继续”，不得替项目背书。\n\n硬性边界：\n- 不要声称已经安装、运行、执行测试、修改文件或产生真实结果。\n- 不要写“自动适配”“确保通过”“完美适配”“强烈建议安装”等承诺性表达。\n- 如果描述安装后的工作方式，必须使用“如果安装成功且宿主正确加载 Skill，它可能会……”这种条件句。\n- 体验剧本只能写成“示例台词/假设流程”：使用“可能会询问/可能会建议/可能会展示”，不要写“已写入、已生成、已通过、正在运行、正在生成”。\n- Prompt Preview 不负责给安装命令；如用户准备试装，只能提示先阅读 Quick Start 和 Risk Card，并在隔离环境验证。\n- 所有项目事实必须来自 supported claim、evidence_refs 或 source_paths；inferred/unverified 只能作风险或待确认项。\n\n```\n\n### 角色 / Skill 选择\n\n- 目标：从项目里的角色或 Skill 中挑选最匹配的资产。\n- 预期输出：候选角色或 Skill 列表，每项包含适用场景、证据路径、风险边界和是否需要安装后验证。\n\n```text\n请读取 role_skill_index，根据我的目标任务推荐 3-5 个最相关的角色或 Skill。每个推荐都要说明适用场景、可能输出、风险边界和 evidence_refs。\n```\n\n### 风险预检\n\n- 目标：安装或引入前识别环境、权限、规则冲突和质量风险。\n- 预期输出：环境、权限、依赖、许可、宿主冲突、质量风险和未知项的检查清单。\n\n```text\n请基于 risk_card、boundaries 和 quick_start_candidates，给我一份安装前风险预检清单。不要替我执行命令，只说明我应该检查什么、为什么检查、失败会有什么影响。\n```\n\n### 宿主 AI 开工指令\n\n- 目标：把项目上下文转成一次对话开始前的宿主 AI 指令。\n- 预期输出：一段边界明确、证据引用明确、适合复制给宿主 AI 的开工前指令。\n\n```text\n请基于 markfetch 的 AI Context Pack，生成一段我可以粘贴给宿主 AI 的开工前指令。这段指令必须遵守 not_runtime=true，不能声称项目已经安装、运行或产生真实结果。\n```\n\n\n## 角色 / Skill 索引\n\n- 共索引 12 个角色 / Skill / 项目文档条目。\n\n- **markfetch**（project_doc）：Reader View for AI agents and your shell. Fetch any URL, get back clean markdown — with a real Chrome's request fingerprint, not curl's. 激活提示：当用户需要理解项目结构、安装方式或边界时参考。 证据：`README.md`\n- **markfetch — SPEC**（project_doc）：Errors throw MarkfetchError uniformly from core; adapters catch once. Codes: network error , http error , timeout , unsupported content type , extraction failed , too large , save failed ; plus save forbidden , emitted by the MCP adapter only before fetchMarkdown runs — see \"Asymmetric write sandbox\" under Core Decisions . CLI emits code message to stderr and exits 1; MCP emits { isError: true, content: { text: \" co… 激活提示：当用户需要理解项目结构、安装方式或边界时参考。 证据：`docs/SPEC.md`\n- **Changelog**（project_doc）：All notable changes to this project are documented in this file. 激活提示：当用户需要理解项目结构、安装方式或边界时参考。 证据：`CHANGELOG.md`\n- **Escape policy fixture**（project_doc）：The protocol uses a fixed Huffman code https://en.wikipedia.org/wiki/Huffman coding -based header compression algorithm to keep responses bandwidth-efficient. The phrase above mirrors a real pattern observed on Wikipedia: a link followed immediately by a hyphenated suffix in the next text node. 激活提示：当用户需要理解项目结构、安装方式或边界时参考。 证据：`tests/fixtures/01-escape-policy-mid-prose.expected.md`\n- **Citation bracket fixture**（project_doc）：HTTP/2 was developed by the IETF working group \\ 1\\ http://mock/ cite 1 based on Google's earlier SPDY protocol \\ 2\\ http://mock/ cite 2 . The standardisation document is RFC 7540, later obsoleted by RFC 9113. 激活提示：当用户需要理解项目结构、安装方式或边界时参考。 证据：`tests/fixtures/02-citation-bracket-link.expected.md`\n- **Informational responses http://mock/ informational responses**（project_doc）：Informational responses http://mock/ informational responses 激活提示：当用户需要理解项目结构、安装方式或边界时参考。 证据：`tests/fixtures/03-anchor-chrome-mdn-style.expected.md`\n- **json — JSON encoder and decoder ¶ http://mock/ module-json \"Link to this heading\"**（project_doc）：json — JSON encoder and decoder ¶ http://mock/ module-json \"Link to this heading\" 激活提示：当用户需要理解项目结构、安装方式或边界时参考。 证据：`tests/fixtures/04-sphinx-permalink.expected.md`\n- **Worldwide race to trace passengers from hantavirus-hit cruise ship**（project_doc）：Worldwide race to trace passengers from hantavirus-hit cruise ship 激活提示：当用户需要理解项目结构、安装方式或边界时参考。 证据：`tests/fixtures/05-no-h1-bbc-style.expected.md`\n- **Multi-line table cell fixture**（project_doc）：The conversion table below contains cells with bullet lists and multi-line content. CommonMark pipe-tables cannot express these structurally; the converter must either fall back to raw HTML or degrade gracefully without producing a broken pipe-table. 激活提示：当用户需要理解项目结构、安装方式或边界时参考。 证据：`tests/fixtures/06-multi-line-table-cell.expected.md`\n- **Intraword underscore fixture**（project_doc）：Function signatures often italicise parameter names, producing fragments like json.dump obj, fp, \\ , skipkeys=False, ensure ascii=True, \\ \\ kw in rendered docs. CommonMark's left-flanking-delimiter rule means an underscore flanked by alphanumerics on both sides cannot open emphasis, so escaping it is unnecessary noise. 激活提示：当用户需要理解项目结构、安装方式或边界时参考。 证据：`tests/fixtures/07-intraword-underscore.expected.md`\n- **Code fence language fixture**（project_doc）：Many documentation generators emit syntax-highlighted code blocks with a language hint encoded in the inner code element's class attribute. Common patterns include language-python , lang-js , and Highlight.js's hljs language-typescript . This fixture exercises whether markfetch preserves the language hint when emitting the fenced markdown code block. 激活提示：当用户需要理解项目结构、安装方式或边界时参考。 证据：`tests/fixtures/08-code-fence-language.expected.md`\n- **Baseline clean article**（project_doc）：This fixture represents the head-of-distribution use case: an editorial article with a single H1, a few H2 sections, plain paragraphs, one inline link to example.com https://example.com/ , and a small unordered list. Nothing here exercises any edge case under repair. 激活提示：当用户需要理解项目结构、安装方式或边界时参考。 证据：`tests/fixtures/09-baseline-clean-article.expected.md`\n\n## 证据索引\n\n- 共索引 31 条证据。\n\n- **markfetch**（documentation）：Reader View for AI agents and your shell. Fetch any URL, get back clean markdown — with a real Chrome's request fingerprint, not curl's. 证据：`README.md`\n- **Package**（package_manifest）：{ \"name\": \"markfetch\", \"version\": \"0.6.0\", \"description\": \"Fetch a URL, return clean markdown. MCP server and CLI for AI agents.\", \"license\": \"MIT\", \"author\": { \"name\": \"Serhii Vasylenko\", \"email\": \"serhii@vasylenko.info\", \"url\": \"https://devdosvid.blog\" }, \"type\": \"module\", \"private\": false, \"engines\": { \"node\": \" =24\" }, \"bin\": { \"markfetch\": \"dist/index.js\" }, \"files\": \"dist\", \"LICENSE\", \"README.md\" , \"keywords\": \"mcp\", \"mcp-server\", \"model-context-protocol\", \"markdown\", \"fetch\", \"html-to-markdown\", \"scraping\", \"readability\", \"ai-agent\", \"claude\" , \"repository\": { \"type\": \"git\", \"url\": \"git+https://github.com/vasylenko/markfetch.git\" }, \"bugs\": { \"url\": \"https://github.com/vasylenko/mark… 证据：`package.json`\n- **License**（source_file）：Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files the \"Software\" , to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 证据：`LICENSE`\n- **markfetch — SPEC**（documentation）：Errors throw MarkfetchError uniformly from core; adapters catch once. Codes: network error , http error , timeout , unsupported content type , extraction failed , too large , save failed ; plus save forbidden , emitted by the MCP adapter only before fetchMarkdown runs — see \"Asymmetric write sandbox\" under Core Decisions . CLI emits code message to stderr and exits 1; MCP emits { isError: true, content: { text: \" code message\" } } . 证据：`docs/SPEC.md`\n- **Changelog**（documentation）：All notable changes to this project are documented in this file. 证据：`CHANGELOG.md`\n- **Escape policy fixture**（documentation）：The protocol uses a fixed Huffman code https://en.wikipedia.org/wiki/Huffman coding -based header compression algorithm to keep responses bandwidth-efficient. The phrase above mirrors a real pattern observed on Wikipedia: a link followed immediately by a hyphenated suffix in the next text node. 证据：`tests/fixtures/01-escape-policy-mid-prose.expected.md`\n- **Citation bracket fixture**（documentation）：HTTP/2 was developed by the IETF working group \\ 1\\ http://mock/ cite 1 based on Google's earlier SPDY protocol \\ 2\\ http://mock/ cite 2 . The standardisation document is RFC 7540, later obsoleted by RFC 9113. 证据：`tests/fixtures/02-citation-bracket-link.expected.md`\n- **Informational responses http://mock/ informational responses**（documentation）：Informational responses http://mock/ informational responses 证据：`tests/fixtures/03-anchor-chrome-mdn-style.expected.md`\n- **json — JSON encoder and decoder ¶ http://mock/ module-json \"Link to this heading\"**（documentation）：json — JSON encoder and decoder ¶ http://mock/ module-json \"Link to this heading\" 证据：`tests/fixtures/04-sphinx-permalink.expected.md`\n- **Worldwide race to trace passengers from hantavirus-hit cruise ship**（documentation）：Worldwide race to trace passengers from hantavirus-hit cruise ship 证据：`tests/fixtures/05-no-h1-bbc-style.expected.md`\n- **Multi-line table cell fixture**（documentation）：The conversion table below contains cells with bullet lists and multi-line content. CommonMark pipe-tables cannot express these structurally; the converter must either fall back to raw HTML or degrade gracefully without producing a broken pipe-table. 证据：`tests/fixtures/06-multi-line-table-cell.expected.md`\n- **Intraword underscore fixture**（documentation）：Function signatures often italicise parameter names, producing fragments like json.dump obj, fp, \\ , skipkeys=False, ensure ascii=True, \\ \\ kw in rendered docs. CommonMark's left-flanking-delimiter rule means an underscore flanked by alphanumerics on both sides cannot open emphasis, so escaping it is unnecessary noise. 证据：`tests/fixtures/07-intraword-underscore.expected.md`\n- **Code fence language fixture**（documentation）：Many documentation generators emit syntax-highlighted code blocks with a language hint encoded in the inner code element's class attribute. Common patterns include language-python , lang-js , and Highlight.js's hljs language-typescript . This fixture exercises whether markfetch preserves the language hint when emitting the fenced markdown code block. 证据：`tests/fixtures/08-code-fence-language.expected.md`\n- **Baseline clean article**（documentation）：This fixture represents the head-of-distribution use case: an editorial article with a single H1, a few H2 sections, plain paragraphs, one inline link to example.com https://example.com/ , and a small unordered list. Nothing here exercises any edge case under repair. 证据：`tests/fixtures/09-baseline-clean-article.expected.md`\n- **.Mcp**（structured_config）：{ \"mcpServers\": { \"markfetch\": { \"command\": \"npx\", \"args\": \"tsx\", \"src/index.ts\" } } } 证据：`.mcp.json`\n- **Tsconfig**（structured_config）：{ \"compilerOptions\": { \"target\": \"ES2022\", \"module\": \"NodeNext\", \"moduleResolution\": \"NodeNext\", \"strict\": true, \"outDir\": \"dist\", \"rootDir\": \"src\", \"esModuleInterop\": true, \"skipLibCheck\": true, \"declaration\": false, \"resolveJsonModule\": true, \"forceConsistentCasingInFileNames\": true }, \"include\": \"src/ / \" } 证据：`tsconfig.json`\n- **Keep text files LF on all platforms. Windows runners would otherwise**（source_file）：Keep text files LF on all platforms. Windows runners would otherwise autocrlf .md fixtures to CRLF and break snapshot tests. text=auto eol=lf 证据：`.gitattributes`\n- **.gitignore**（source_file）：node modules/ dist/ .log .DS Store .tgz 证据：`.gitignore`\n- **Source, tests, and TS build config not needed at runtime; dist/ ships instead**（source_file）：Source, tests, and TS build config not needed at runtime; dist/ ships instead src/ tests/ .ts ! .d.ts tsconfig.json 证据：`.npmignore`\n- **Postbuild**（source_file）：// Sets execute bit on dist/index.js so the shebang-based launch works — // both when npm links the bin entry npm/npx exec the linked target // and when running ./dist/index.js directly. tsc preserves the shebang // but doesn't chmod its outputs. import { chmodSync } from \"node:fs\"; 证据：`scripts/postbuild.mjs`\n- **Cli**（source_file）：// CLI adapter. Imported lazily by index.ts when any argument is present // bare invocation routes to mcp.ts instead, preserving the existing MCP // server contract for every client config that doesn't pass args . // // Output channels: // - stdout: markdown body no -o OR \"Saved N bytes to \" confirmation // with -o . The markdown is written via process.stdout.write so its // own trailing whitespace is preserved verbatim — same bytes as the MCP // adapter would emit in content 0 .text. // - stderr: \" code message\" on any error path. Exits with non-zero code. // The project principle \"no ANSI escapes\" extends here — keep stderr // plain so shell pipelines can grep / split on the code prefix. 证据：`src/cli.ts`\n- **Core**（source_file）：// Pure pipeline + error types. Imported by both adapters mcp.ts and cli.ts . // Invariants: // - This module MUST NOT write to stdout or stderr. The MCP adapter relies on // stdout staying empty any non-JSON-RPC byte corrupts the protocol frame ; // the CLI adapter owns its own output channel. Errors are thrown, never // printed. // - This module MUST NOT import from @modelcontextprotocol/sdk or commander. // Keeping core transport-agnostic is what lets the dispatcher in index.ts // lazy-load only the adapter that's actually needed. 证据：`src/core.ts`\n- **!/usr/bin/env node**（source_file）：// Argv-discriminated dispatcher. // // process.argv.length === 2 means the user provided zero arguments // argv 0 is the path to node, argv 1 is this script path . That's the // shape every MCP client uses when spawning a server — so bare invocation // routes to the MCP adapter and preserves every existing client config. // // Any extra arg a URL, --help , --version , -o , even an unknown flag // routes to the CLI adapter, which uses commander to parse and validate. // // The dynamic import \"./mcp.js\" vs import \"./cli.js\" is intentional: // it ensures the MCP path never loads commander, and the CLI path never // loads @modelcontextprotocol/sdk. More importantly, it makes the stdout // inva… 证据：`src/index.ts`\n- **Mcp**（source_file）：// MCP adapter. Imported lazily by index.ts when invoked with zero arguments // the standard MCP client spawn shape . Wraps the unified fetchMarkdown // from core in the MCP tool-content shape and connects over stdio. // // Invariant: nothing in this module — or anything reachable from it — may // write to stdout. Stdout is the JSON-RPC frame channel; arbitrary writes // corrupt protocol framing and the client disconnects. Errors are returned // inside the MCP {isError: true, content: ... } envelope, not printed. // Stderr is also reserved project principle: stderr is fatal-only — every // per-request error round-trips through errorResult , never through logging. 证据：`src/mcp.ts`\n- **Sandbox**（source_file）：// Write-path containment for the MCP adapter. MCP's caller is a language // model — possibly steered by the page it just fetched — so this module // bounds the filesystem paths it can write to. CLI is intentionally // unbounded human at the shell is the security boundary ; only MCP uses // this module. // // Invariants: // - Leaf module: no imports from siblings, unit-testable in isolation. // - No console. — buildAllowedRoots throws escapes module init in // mcp.ts, surfaces on stderr ; checkPath returns a discriminated union. // - No hardcoded platform paths; every platform-dependent value comes // from a Node API. 证据：`src/sandbox.ts`\n- **Helpers**（source_file）：// Shared test helpers extracted from cli.test.ts / server.test.ts / e2e.test.ts // / snapshots.test.ts to remove copy-paste duplication. Not a test file itself // — the runner pattern tsx --test tests/ .test.ts see package.json excludes // this file by name. 证据：`tests/_helpers.ts`\n- **Cli.Test**（source_file）：// CLI tests. Run the dispatcher via tsx src/index.ts as a real // subprocess so we observe exit codes, stdout, and stderr — the things // shell consumers actually depend on. The MCP SDK Client is irrelevant // here; this is a plain CLI surface. import { test } from \"node:test\"; import assert from \"node:assert/strict\"; import { execFile } from \"node:child process\"; import { promisify } from \"node:util\"; import { mkdtemp, readFile, rm, stat } from \"node:fs/promises\"; import { tmpdir } from \"node:os\"; import { join, resolve as resolvePath } from \"node:path\"; import { startMock, HAPPY FIXTURE, TSX LOADER URL } from \"./ helpers.js\"; 证据：`tests/cli.test.ts`\n- **E2E.Test**（source_file）：// E2E tests against the BUILT JS output node dist/index.js , not the dev // source. server.test.ts already exercises the full surface via tsx; this file // verifies that tsc output is itself correct and runnable. If server.test.ts // passes but this file fails, the bug lives in the build pipeline, not the // runtime logic. import { test, before } from \"node:test\"; import assert from \"node:assert/strict\"; import { execFile, execSync } from \"node:child process\"; import { promisify } from \"node:util\"; import { Client } from \"@modelcontextprotocol/sdk/client/index.js\"; import { StdioClientTransport } from \"@modelcontextprotocol/sdk/client/stdio.js\"; import { mkdtemp, readFile, rm } from \"node:… 证据：`tests/e2e.test.ts`\n- **Sandbox.Test**（source_file）：// Unit tests for src/sandbox.ts — narrow path-edge-cases that are painful // to validate via the integration boundary in server.test.ts ../ traversal, // prefix-overlap, multi-entry env split, fail-fast variants without an // integration analog, win32 case-fold . All other sandbox behaviors are // covered by T9–T13 in server.test.ts. 证据：`tests/sandbox.test.ts`\n- **Server.Test**（source_file）：import { test } from \"node:test\"; import assert from \"node:assert/strict\"; import { Client } from \"@modelcontextprotocol/sdk/client/index.js\"; import { StdioClientTransport } from \"@modelcontextprotocol/sdk/client/stdio.js\"; import { mkdtemp, readFile, stat, access, writeFile, rm, mkdir, symlink, } from \"node:fs/promises\"; import { tmpdir } from \"node:os\"; import { join, parse } from \"node:path\"; import { startMock, textOf, HAPPY FIXTURE, spawnClient, assertSchemaRejection, spawnAndCaptureExit, } from \"./ helpers.js\"; 证据：`tests/server.test.ts`\n- **Snapshots.Test**（source_file）：import { test, before, after } from \"node:test\"; import assert from \"node:assert/strict\"; import { readFile, readdir, writeFile } from \"node:fs/promises\"; import { dirname, join } from \"node:path\"; import { fileURLToPath } from \"node:url\"; import { spawnClient, startMock } from \"./ helpers.js\"; 证据：`tests/snapshots.test.ts`\n\n## 宿主 AI 必须遵守的规则\n\n- **把本资产当作开工前上下文，而不是运行环境。**：AI Context Pack 只包含证据化项目理解，不包含目标项目的可执行状态。 证据：`README.md`, `package.json`, `LICENSE`\n- **回答用户时区分可预览内容与必须安装后才能验证的内容。**：安装前体验的消费者价值来自降低误装和误判，而不是伪装成真实运行。 证据：`README.md`, `package.json`, `LICENSE`\n\n## 用户开工前应该回答的问题\n\n- 你准备在哪个宿主 AI 或本地环境中使用它？\n- 你只是想先体验工作流，还是准备真实安装？\n- 你最在意的是安装成本、输出质量、还是和现有规则的冲突？\n\n## 验收标准\n\n- 所有能力声明都能回指到 evidence_refs 中的文件路径。\n- AI_CONTEXT_PACK.md 没有把预览包装成真实运行。\n- 用户能在 3 分钟内看懂适合谁、能做什么、如何开始和风险边界。\n\n---\n\n## Doramagic Context Augmentation\n\n下面内容用于强化 Repomix/AI Context Pack 主体。Human Manual 只提供阅读骨架；踩坑日志会被转成宿主 AI 必须遵守的工作约束。\n\n## Human Manual 骨架\n\n使用规则：这里只是项目阅读路线和显著性信号，不是事实权威。具体事实仍必须回到 repo evidence / Claim Graph。\n\n宿主 AI 硬性规则：\n- 不得把页标题、章节顺序、摘要或 importance 当作项目事实证据。\n- 解释 Human Manual 骨架时，必须明确说它只是阅读路线/显著性信号。\n- 能力、安装、兼容性、运行状态和风险判断必须引用 repo evidence、source path 或 Claim Graph。\n\n- **项目概述**：importance `high`\n  - source_paths: README.md, package.json, src/index.ts\n- **系统架构**：importance `high`\n  - source_paths: src/index.ts, src/cli.ts, src/core.ts, src/mcp.ts, src/sandbox.ts\n- **安装与部署**：importance `high`\n  - source_paths: README.md, package.json\n- **命令行界面**：importance `high`\n  - source_paths: src/cli.ts, src/index.ts\n- **MCP 服务器**：importance `high`\n  - source_paths: src/mcp.ts, src/index.ts, .mcp.json\n- **HTTP 指纹与请求模拟**：importance `high`\n  - source_paths: src/core.ts\n- **内容提取管道**：importance `high`\n  - source_paths: src/core.ts, docs/SPEC.md\n- **写操作沙箱**：importance `medium`\n  - source_paths: src/sandbox.ts, src/mcp.ts\n\n## Repo Inspection Evidence / 源码检查证据\n\n- repo_clone_verified: true\n- repo_inspection_verified: true\n- repo_commit: `c4732aae41c009a052a824c3b8402d43b1aa3302`\n- inspected_files: `package.json`, `README.md`, `docs/SPEC.md`, `src/index.ts`, `src/mcp.ts`, `src/cli.ts`, `src/sandbox.ts`, `src/core.ts`\n\n宿主 AI 硬性规则：\n- 没有 repo_clone_verified=true 时，不得声称已经读过源码。\n- 没有 repo_inspection_verified=true 时，不得把 README/docs/package 文件判断写成事实。\n- 没有 quick_start_verified=true 时，不得声称 Quick Start 已跑通。\n\n## Doramagic Pitfall Constraints / 踩坑约束\n\n这些规则来自 Doramagic 发现、验证或编译过程中的项目专属坑点。宿主 AI 必须把它们当作工作约束，而不是普通说明文字。\n\n### Constraint 1: 来源证据：v0.4.1\n\n- Trigger: GitHub 社区证据显示该项目存在一个安装相关的待验证问题：v0.4.1\n- Host AI rule: 来源显示可能已有修复、规避或版本变化，说明书中必须标注适用版本。\n- Why it matters: 可能增加新用户试用和生产接入成本。\n- Evidence: community_evidence:github | cevd_749b65614f7b40e0b524f4e932cd4aca | https://github.com/vasylenko/markfetch/releases/tag/v0.4.1 | 来源讨论提到 node 相关条件，需在安装/试用前复核。\n- Hard boundary: 不要把这个坑点包装成已解决、已验证或可忽略，除非后续验证证据明确证明它已经关闭。\n\n### Constraint 2: 能力判断依赖假设\n\n- Trigger: README/documentation is current enough for a first validation pass.\n- Host AI rule: 将假设转成下游验证清单。\n- Why it matters: 假设不成立时，用户拿不到承诺的能力。\n- Evidence: capability.assumptions | github_repo:1234238440 | https://github.com/vasylenko/markfetch | README/documentation is current enough for a first validation pass.\n- Hard boundary: 不要把这个坑点包装成已解决、已验证或可忽略，除非后续验证证据明确证明它已经关闭。\n\n### Constraint 3: 维护活跃度未知\n\n- Trigger: 未记录 last_activity_observed。\n- Host AI rule: 补 GitHub 最近 commit、release、issue/PR 响应信号。\n- Why it matters: 新项目、停更项目和活跃项目会被混在一起，推荐信任度下降。\n- Evidence: evidence.maintainer_signals | github_repo:1234238440 | https://github.com/vasylenko/markfetch | last_activity_observed missing\n- Hard boundary: 不要把这个坑点包装成已解决、已验证或可忽略，除非后续验证证据明确证明它已经关闭。\n\n### Constraint 4: 下游验证发现风险项\n\n- Trigger: no_demo\n- Host AI rule: 进入安全/权限治理复核队列。\n- Why it matters: 下游已经要求复核，不能在页面中弱化。\n- Evidence: downstream_validation.risk_items | github_repo:1234238440 | https://github.com/vasylenko/markfetch | no_demo; severity=medium\n- Hard boundary: 不要把这个坑点包装成已解决、已验证或可忽略，除非后续验证证据明确证明它已经关闭。\n\n### Constraint 5: 存在评分风险\n\n- Trigger: no_demo\n- Host AI rule: 把风险写入边界卡，并确认是否需要人工复核。\n- Why it matters: 风险会影响是否适合普通用户安装。\n- Evidence: risks.scoring_risks | github_repo:1234238440 | https://github.com/vasylenko/markfetch | no_demo; severity=medium\n- Hard boundary: 不要把这个坑点包装成已解决、已验证或可忽略，除非后续验证证据明确证明它已经关闭。\n\n### Constraint 6: issue/PR 响应质量未知\n\n- Trigger: issue_or_pr_quality=unknown。\n- Host AI rule: 抽样最近 issue/PR，判断是否长期无人处理。\n- Why it matters: 用户无法判断遇到问题后是否有人维护。\n- Evidence: evidence.maintainer_signals | github_repo:1234238440 | https://github.com/vasylenko/markfetch | issue_or_pr_quality=unknown\n- Hard boundary: 不要把这个坑点包装成已解决、已验证或可忽略，除非后续验证证据明确证明它已经关闭。\n\n### Constraint 7: 发布节奏不明确\n\n- Trigger: release_recency=unknown。\n- Host AI rule: 确认最近 release/tag 和 README 安装命令是否一致。\n- Why it matters: 安装命令和文档可能落后于代码，用户踩坑概率升高。\n- Evidence: evidence.maintainer_signals | github_repo:1234238440 | https://github.com/vasylenko/markfetch | release_recency=unknown\n- Hard boundary: 不要把这个坑点包装成已解决、已验证或可忽略，除非后续验证证据明确证明它已经关闭。\n",
      "summary": "给宿主 AI 的上下文和工作边界。",
      "title": "AI Context Pack / 带给我的 AI"
    },
    "boundary_risk_card": {
      "asset_id": "boundary_risk_card",
      "filename": "BOUNDARY_RISK_CARD.md",
      "markdown": "# Boundary & Risk Card / 安装前决策卡\n\n项目：vasylenko/markfetch\n\n## Doramagic 试用结论\n\n当前结论：可以进入发布前推荐检查；首次使用仍应从最小权限、临时目录和可回滚配置开始。\n\n## 用户现在可以做\n\n- 可以先阅读 Human Manual，理解项目目的和主要工作流。\n- 可以复制 Prompt Preview 做安装前体验；这只验证交互感，不代表真实运行。\n- 可以把官方 Quick Start 命令放到隔离环境中验证，不要直接进主力环境。\n\n## 现在不要做\n\n- 不要把 Prompt Preview 当成项目实际运行结果。\n- 不要把 metadata-only validation 当成沙箱安装验证。\n- 不要把未验证能力写成“已支持、已跑通、可放心安装”。\n- 不要在首次试用时交出生产数据、私人文件、真实密钥或主力配置目录。\n\n## 安装前检查\n\n- 宿主 AI 是否匹配：mcp_host\n- 官方安装入口状态：已发现官方入口\n- 是否在临时目录、临时宿主或容器中验证：必须是\n- 是否能回滚配置改动：必须能\n- 是否需要 API Key、网络访问、读写文件或修改宿主配置：未确认前按高风险处理\n- 是否记录了安装命令、实际输出和失败日志：必须记录\n\n## 当前阻塞项\n\n- review_required: community_discussion_evidence_below_public_threshold\n\n## 项目专属踩坑\n\n- 来源证据：v0.4.1（medium）：可能增加新用户试用和生产接入成本。 建议检查：来源显示可能已有修复、规避或版本变化，说明书中必须标注适用版本。\n- 能力判断依赖假设（medium）：假设不成立时，用户拿不到承诺的能力。 建议检查：将假设转成下游验证清单。\n- 维护活跃度未知（medium）：新项目、停更项目和活跃项目会被混在一起，推荐信任度下降。 建议检查：补 GitHub 最近 commit、release、issue/PR 响应信号。\n- 下游验证发现风险项（medium）：下游已经要求复核，不能在页面中弱化。 建议检查：进入安全/权限治理复核队列。\n- 存在评分风险（medium）：风险会影响是否适合普通用户安装。 建议检查：把风险写入边界卡，并确认是否需要人工复核。\n\n## 风险与权限提示\n\n- no_demo: medium\n\n## 证据缺口\n\n- 暂未发现结构化证据缺口。\n",
      "summary": "安装、权限、验证和推荐前风险。",
      "title": "Boundary & Risk Card / 边界与风险卡"
    },
    "human_manual": {
      "asset_id": "human_manual",
      "filename": "HUMAN_MANUAL.md",
      "markdown": "# https://github.com/vasylenko/markfetch 项目说明书\n\n生成时间：2026-05-15 00:33:16 UTC\n\n## 目录\n\n- [项目概述](#overview)\n- [系统架构](#system-architecture)\n- [安装与部署](#installation)\n- [命令行界面](#cli-usage)\n- [MCP 服务器](#mcp-server)\n- [HTTP 指纹与请求模拟](#http-fingerprint)\n- [内容提取管道](#content-extraction)\n- [写操作沙箱](#write-sandbox)\n- [配置与环境变量](#configuration)\n- [错误处理机制](#error-handling)\n\n<a id='overview'></a>\n\n## 项目概述\n\n### 相关页面\n\n相关主题：[系统架构](#system-architecture), [HTTP 指纹与请求模拟](#http-fingerprint), [内容提取管道](#content-extraction)\n\n<details>\n<summary>相关源码文件</summary>\n\n以下源码文件用于生成本页说明：\n\n- [README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)\n- [package.json](https://github.com/vasylenko/markfetch/blob/main/package.json)\n- [src/index.ts](https://github.com/vasylenko/markfetch/blob/main/src/index.ts)\n- [src/core.ts](https://github.com/vasylenko/markfetch/blob/main/src/core.ts)\n- [src/mcp.ts](https://github.com/vasylenko/markfetch/blob/main/src/mcp.ts)\n- [src/cli.ts](https://github.com/vasylenko/markfetch/blob/main/src/cli.ts)\n- [src/sandbox.ts](https://github.com/vasylenko/markfetch/blob/main/src/sandbox.ts)\n- [CHANGELOG.md](https://github.com/vasylenko/markfetch/blob/main/CHANGELOG.md)\n</details>\n\n# 项目概述\n\n## 项目简介\n\nmarkfetch 是一个纯 Node.js 编写的 URL 转 Markdown 工具，同时提供 MCP（Model Context Protocol）服务器模式和命令行界面（CLI）两种使用方式，专为 AI 智能体设计。该项目由 Serhii Vasylenko 开发，采用 MIT 许可证开源发布。\n\nmarkfetch 的核心功能是接收一个 HTTP/HTTPS URL，获取其 HTML 内容，提取主要文章内容，并将其转换为干净的 Markdown 格式输出。输出结果与人类执行\"另存为 Markdown\"命令获得的内容高度相似，能够在提供真实浏览器特征指纹的同时，绕过许多网站的反爬虫机制。\n\n## 核心特性\n\n### 多模式支持\n\nmarkfetch 提供两种使用模式，可根据不同场景灵活选择：\n\n| 模式 | 触发方式 | 输出方式 | 典型用途 |\n|------|----------|----------|----------|\n| MCP 服务器模式 | 无参数启动或作为 MCP 工具调用 | `content[0].text` 结构化数据 | 集成到 Claude Code、Cursor、Goose 等 AI 客户端 |\n| CLI 命令行模式 | `markfetch <url>` | 标准输出或文件 | Shell 脚本、管道操作、直接终端使用 |\n\n### 技术架构特点\n\n- **纯 Node.js 实现**：无任何子进程依赖，不使用 Playwright、headless Chromium 或 Python 等外部运行时 资料来源：[README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)\n- **真实浏览器指纹**：通过 HTTP/2 传输协议和完整的 Chrome 浏览器请求头集合，模拟真实浏览器访问行为\n- **客户端提示头**：自动从 `MARKFETCH_USER_AGENT` 派生出 `Sec-CH-UA-*` 客户端提示头，确保与 Chrome UA 字符串一致\n- **单文档处理**：每次调用处理单个 URL，无递归爬取、无 robots.txt 解析、无速率限制编排\n\n### 核心依赖组件\n\n| 组件 | 版本 | 用途 |\n|------|------|------|\n| `@modelcontextprotocol/sdk` | ^1.29.0 | MCP 协议实现，提供 stdio 通信能力 |\n| `@mozilla/readability` | ^0.5.0 | Mozilla 开源的 HTML 内容提取库，从页面中提取主要文章内容 |\n| `turndown` | ^7.0.0 | 将 HTML 转换为 Markdown 的转换器 |\n| `turndown-plugin-gfm` | ^1.0.2 | GitHub Flavored Markdown 插件，支持表格、任务列表等格式 |\n| `linkedom` | ^0.18.0 | 轻量级 DOM 解析器，用于在 Node.js 环境中解析和操作 HTML |\n| `undici` | ^8.2.0 | HTTP/2 客户端库，处理网络请求 |\n| `zod` | ^3.0.0 | TypeScript 类型验证库，用于 MCP 输入模式定义 |\n| `commander` | ^14.0.3 | CLI 参数解析库 |\n\n## 系统架构\n\n```mermaid\ngraph TD\n    subgraph \"入口层\"\n        A[\"src/index.ts<br/>参数分发器\"]\n    end\n    \n    subgraph \"适配器层\"\n        B[\"src/mcp.ts<br/>MCP 适配器\"]\n        C[\"src/cli.ts<br/>CLI 适配器\"]\n    end\n    \n    subgraph \"核心层\"\n        D[\"src/core.ts<br/>fetchMarkdown 核心逻辑\"]\n    end\n    \n    subgraph \"依赖库\"\n        E[\"@mozilla/readability<br/>内容提取\"]\n        F[\"turndown<br/>HTML→Markdown\"]\n        G[\"undici<br/>HTTP 客户端\"]\n        H[\"linkedom<br/>DOM 解析\"]\n    end\n    \n    subgraph \"安全层\"\n        I[\"src/sandbox.ts<br/>写入沙箱\"]\n    end\n    \n    A -->|\"process.argv.length > 1\"| C\n    A -->|\"process.argv.length === 1\"| B\n    B --> D\n    C --> D\n    D --> E\n    D --> F\n    D --> G\n    D --> H\n    \n    D -->|\"savePath 参数\"| I\n```\n\n### 目录结构\n\n```\nmarkfetch/\n├── src/\n│   ├── index.ts      # 入口文件，argv 路由分发\n│   ├── core.ts       # 核心业务逻辑：获取→提取→转换\n│   ├── mcp.ts        # MCP 服务器适配器\n│   ├── cli.ts        # CLI 命令适配器\n│   └── sandbox.ts    # 写入路径安全检查\n├── dist/             # TypeScript 编译输出目录\n├── tests/            # 测试文件目录\n├── package.json      # 项目配置\n├── README.md         # 项目文档\n└── CHANGELOG.md      # 变更日志\n```\n\n## 工作流程\n\n### MCP 模式工作流程\n\n```mermaid\nsequenceDiagram\n    participant Client as MCP 客户端\n    participant Server as markfetch MCP 服务器\n    participant Core as 核心模块\n    participant Web as 目标 URL\n    \n    Client->>Server: 调用 fetch_markdown(url, savePath?)\n    Server->>Core: fetchMarkdown({ url, savePath })\n    \n    Core->>Web: HTTP/2 GET 请求 (Chrome UA)\n    Web-->>Core: HTML 响应\n    \n    Core->>Core: Readability 提取文章内容\n    Core->>Core: Turndown 转换为 Markdown\n    \n    alt savePath 存在\n        Core->>Server: 检查 savePath 安全性\n        Core->>Core: 写入文件\n    else savePath 不存在\n        Core->>Server: 返回 Markdown 内容\n    end\n    \n    Server-->>Client: { content: [{ text: \"...\" }] }\n```\n\n### CLI 模式工作流程\n\n```mermaid\ngraph LR\n    A[\"markfetch <url>\"] --> B[\"解析参数\"]\n    B --> C{\"-o 参数?\"}\n    C -->|\"是\"| D[\"解析输出路径\"]\n    C -->|\"否\"| E[\"输出到 stdout\"]\n    D --> F[\"调用 fetchMarkdown\"]\n    E --> F\n    F --> G{\"保存成功?\"}\n    G -->|\"是\"| H[\"console.log 确认信息\"]\n    G -->|\"否\"| I[\"console.error 错误信息\"]\n    H --> J[\"process.exit(0)\"]\n    I --> K[\"process.exit(1)\"]\n```\n\n## 错误处理机制\n\nmarkfetch 定义了 8 种确定性错误代码，所有错误都遵循统一的 `[code] message` 格式返回：\n\n| 错误代码 | 含义 | 触发场景 |\n|----------|------|----------|\n| `network_error` | 网络错误 | DNS 解析失败、TCP 连接失败、TLS 握手失败、意外内部错误 |\n| `http_error` | HTTP 错误 | 目标服务器返回非 2xx 状态码 |\n| `timeout` | 请求超时 | 超过 `MARKFETCH_TIMEOUT_MS` 配置的超时时间 |\n| `unsupported_content_type` | 不支持的内容类型 | 响应不是 `text/html` 或 `application/xhtml+xml` |\n| `extraction_failed` | 提取失败 | Readability 无法提取任何文章内容（常见于纯客户端渲染的 SPA） |\n| `too_large` | 内容过大 | 响应体或提取后的 Markdown 超过 `MARKFETCH_MAX_BYTES` 限制 |\n| `save_failed` | 保存失败 | 指定了 `savePath` 但写入文件失败（目录不存在、权限不足等） |\n| `save_forbidden` | 保存禁止 | `savePath` 路径超出了允许的写入根目录 |\n\n## 配置选项\n\n### 环境变量配置\n\n| 环境变量 | 默认值 | 说明 |\n|----------|--------|------|\n| `MARKFETCH_TIMEOUT_MS` | `30000` | 单次请求超时时间（毫秒） |\n| `MARKFETCH_MAX_BYTES` | `5000000` | 响应体和提取后 Markdown 的字节数上限（约 5MB） |\n| `MARKFETCH_USER_AGENT` | Chrome 130 固定字符串 | 浏览器标识字符串。必须是 Chrome UA 格式，否则启动时快速失败 |\n| `MARKFETCH_ALLOWED_WRITE_ROOTS` | `os.tmpdir()` + `process.cwd()` | MCP 模式专用。以路径分隔符分隔的绝对路径列表，限定 `savePath` 可写入的根目录范围 |\n\n### MCP 配置示例\n\n```json\n{\n  \"mcpServers\": {\n    \"markfetch\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"markfetch\"],\n      \"env\": {\n        \"MARKFETCH_TIMEOUT_MS\": \"60000\",\n        \"MARKFETCH_USER_AGENT\": \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/130.0.0.0 Safari/537.36\"\n      }\n    }\n  }\n}\n```\n\n## 写入沙箱机制\n\nMCP 模式下的 `savePath` 参数受写入沙箱限制，防止 AI 智能体将文件写入任意目录。CLI 模式不受此限制。\n\n### 沙箱规则\n\n1. **默认根目录**：系统临时目录 + 进程当前工作目录\n2. **路径检查**：目标路径必须位于允许的根目录内或为其子路径\n3. **符号链接处理**：符号链接指向允许根目录外的内容将被阻止\n4. **跨平台支持**：POSIX 系统使用 `:` 分隔符，Windows 使用 `;` 分隔符\n\n### 自定义允许根目录\n\n```json\n{\n  \"mcpServers\": {\n    \"markfetch\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"markfetch\"],\n      \"env\": {\n        \"MARKFETCH_ALLOWED_WRITE_ROOTS\": \"/Users/me/markfetch-out:/tmp\"\n      }\n    }\n  }\n}\n```\n\n## 使用场景与限制\n\n### 适用场景\n\n- 获取文章、文档、博客帖子、新闻页面等静态 HTML 内容\n- 自动化文档抓取和转换\n- AI 智能体的网页内容获取工具\n- 需要绕过基础反爬虫机制的网页访问\n\n### 已知限制\n\n| 限制类型 | 说明 |\n|----------|------|\n| **不进行身份认证** | 仅支持匿名访问，不支持 Cookie、认证头或会话复用 |\n| **非递归爬取** | 无多层级页面爬取能力，每次仅处理单个 URL |\n| **不支持 SPA 渲染** | 纯客户端渲染（无静态 HTML）的 SPA 返回 `extraction_failed` |\n| **遵循 robots.txt** | 不解析也不遵守 robots.txt 规则 |\n| **Node.js 版本要求** | 需要 Node.js 24.0.0 或更高版本 资料来源：[package.json](https://github.com/vasylenko/markfetch/blob/main/package.json) |\n\n## 快速开始\n\n### 安装\n\n```bash\nnpm install -g markfetch\n```\n\n### CLI 使用\n\n```bash\n# 输出到标准输出\nmarkfetch https://en.wikipedia.org/wiki/Markdown\n\n# 输出到文件\nmarkfetch https://example.com/article -o ./output/article.md\n```\n\n### MCP 集成\n\n#### Claude Code\n\n```bash\nclaude mcp add --scope user markfetch -- npx -y markfetch\n```\n\n#### Codex\n\n```bash\ncodex mcp add markfetch -- npx -y markfetch\n```\n\n#### Gemini CLI\n\n```bash\ngemini mcp add -s user markfetch npx -y markfetch\n```\n\n### MCP 工具调用\n\n```typescript\n// 工具名称\nfetch_markdown\n\n// 输入参数\n{\n  url: \"https://example.com/page\",  // 必填，绝对 URL\n  savePath: \"/absolute/path/to/file.md\"  // 可选，保存路径\n}\n\n// 返回格式\n{\n  content: [{ type: \"text\", text: \"# Markdown 内容...\" }]\n}\n```\n\n## 版本信息\n\n当前版本：**0.6.0**\n\n项目源码托管于 GitHub：https://github.com/vasylenko/markfetch\n\n许可证：MIT 资料来源：[package.json](https://github.com/vasylenko/markfetch/blob/main/package.json)\n\n---\n\n<a id='system-architecture'></a>\n\n## 系统架构\n\n### 相关页面\n\n相关主题：[项目概述](#overview), [命令行界面](#cli-usage), [MCP 服务器](#mcp-server)\n\n<details>\n<summary>相关源码文件</summary>\n\n以下源码文件用于生成本页说明：\n\n- [src/index.ts](https://github.com/vasylenko/markfetch/blob/main/src/index.ts)\n- [src/cli.ts](https://github.com/vasylenko/markfetch/blob/main/src/cli.ts)\n- [src/core.ts](https://github.com/vasylenko/markfetch/blob/main/src/core.ts)\n- [src/mcp.ts](https://github.com/vasylenko/markfetch/blob/main/src/mcp.ts)\n- [src/sandbox.ts](https://github.com/vasylenko/markfetch/blob/main/src/sandbox.ts)\n</details>\n\n# 系统架构\n\n## 概述\n\nmarkfetch 是一个用于将网页转换为干净 Markdown 格式的工具，同时提供 CLI 和 MCP（Model Context Protocol）两种调用接口。项目采用**适配器模式**，将核心业务逻辑与接口层分离，确保核心逻辑在两种模式下完全一致。\n\n架构设计遵循以下原则：\n\n- **纯 Node.js，无子进程**：不依赖 Playwright、Chromium 或 Python\n- **单通道输出**：MCP 模式下仅使用 `content[0].text`，不使用 `structuredContent`\n- **结构化错误**：统一的 8 种错误码，适配器统一转换\n- **惰性加载**：stdout 预留给 MCP 帧，CLI 代码在 MCP 模式下永不加载\n\n资料来源：[README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)\n\n## 组件架构\n\n项目由五个核心模块组成，按职责可分为三层：\n\n```mermaid\ngraph TD\n    subgraph \"接口层 (Interface Layer)\"\n        CLI[cli.ts<br/>CLI 适配器]\n        MCP[mcp.ts<br/>MCP 适配器]\n    end\n    \n    subgraph \"调度层 (Dispatch Layer)\"\n        IDX[index.ts<br/>参数路由调度器]\n    end\n    \n    subgraph \"核心层 (Core Layer)\"\n        CORE[core.ts<br/>核心业务逻辑]\n        SB[sandbox.ts<br/>写入沙箱]\n    end\n    \n    CLI --> IDX\n    MCP --> IDX\n    IDX -->|lazy import| CLI\n    IDX -->|lazy import| MCP\n    CORE --> SB\n    MCP --> SB\n```\n\n### 入口调度器 (index.ts)\n\nindex.ts 负责根据命令行参数决定启动模式：\n\n| 条件 | 行为 |\n|------|------|\n| `process.argv.length === 2`（无参数） | 启动 MCP stdio 服务器 |\n| 有命令行参数 | 加载 CLI 适配器 |\n\n```typescript\n// 伪代码实现\nif (process.argv.length === 2) {\n  // 启动 MCP 服务器\n  import('./mcp.js').then(m => m.runMcpServer());\n} else {\n  // 启动 CLI\n  import('./cli.js').then(c => c.runCli());\n}\n```\n\n这种惰性导入机制确保 CLI 相关代码（包含 `console.log` 调用）在 MCP 模式下完全不可达。\n\n资料来源：[src/index.ts](https://github.com/vasylenko/markfetch/blob/main/src/index.ts)\n\n### CLI 适配器 (cli.ts)\n\nCLI 适配器基于 `commander` 库实现，提供命令行界面：\n\n**支持的参数：**\n\n| 参数 | 说明 |\n|------|------|\n| `<url>` | 必选，HTTP/HTTPS URL |\n| `-o, --output <path>` | 可选，输出文件路径 |\n\n**输出行为：**\n\n- **stdout**：Markdown 原始内容（无尾部换行符）\n- **stderr**：`[code] message` 格式的错误信息\n- **exitCode**：成功 0，失败 1\n\n```typescript\n// CLI 核心逻辑伪代码\nconst savePath = options.output ? resolve(process.cwd(), options.output) : undefined;\nconst { markdown, bytes, savedTo } = await fetchMarkdown({ url, savePath });\n\nif (savedTo !== undefined) {\n  console.log(`Saved ${bytes} bytes to ${savedTo}`);\n} else {\n  process.stdout.write(markdown);\n}\n```\n\n资料来源：[src/cli.ts:1-62](https://github.com/vasylenko/markfetch/blob/main/src/cli.ts)\n\n### MCP 适配器 (mcp.ts)\n\nMCP 适配器基于 `@modelcontextprotocol/sdk` 实现标准化的 stdio 服务器：\n\n**注册的 Tool：**\n\n```typescript\nserver.registerTool(\"fetch_markdown\", {\n  inputSchema: {\n    url: z.string().url(),\n    savePath: z.string().refine(isAbsolute).optional()\n  }\n})\n```\n\n**返回格式（单通道，无 structuredContent）：**\n\n```json\n{\n  \"content\": [\n    { \"type\": \"text\", \"text\": \"# Markdown content...\" }\n  ],\n  \"isError\": false\n}\n```\n\n**错误返回格式：**\n\n```json\n{\n  \"content\": [\n    { \"type\": \"text\", \"text\": \"[http_error] 404 Not Found\" }\n  ],\n  \"isError\": true\n}\n```\n\n资料来源：[src/mcp.ts](https://github.com/vasylenko/markfetch/blob/main/src/mcp.ts)\n\n### 核心业务逻辑 (core.ts)\n\ncore.ts 包含完整的 URL 到 Markdown 转换管道：\n\n```mermaid\ngraph LR\n    A[URL] --> B[undici HTTP 客户端]\n    B --> C{响应状态码?}\n    C -->|非 2xx| D[http_error]\n    C -->|2xx| E{Content-Type?}\n    E -->|非 HTML| F[unsupported_content_type]\n    E -->|HTML| G[decodeEncodedCodeTags]\n    G --> H[ensureBaseHref]\n    H --> I[rewriteForReadability]\n    I --> J[Readability 解析]\n    J -->|无内容| K[extraction_failed]\n    J -->|有内容| L[Turndown 转换]\n    L --> M{大小检查}\n    M -->|超限| N[too_large]\n    M -->|正常| O[Markdown 输出]\n```\n\n**主要函数：**\n\n| 函数 | 职责 |\n|------|------|\n| `fetchMarkdown()` | 主入口，协调整个流程 |\n| `extractArticle()` | HTML 解析与内容提取 |\n| `convertToMarkdown()` | Markdown 转换与清理 |\n| `rewriteForReadability()` | 预处理器：处理脚注、折叠面板等 |\n| `decodeEncodedCodeTags()` | 解码 HTML 编码的 `<code>` 标签 |\n| `ensureBaseHref()` | 注入 `<base href>` 修复相对链接 |\n\n**关键设计决策：**\n\n1. **keepClasses: true**：保留 `<code class=\"language-X\">` 以支持代码高亮提示\n2. **Turndown escape 定制**：禁用 `\\_` 和 `\\-`/`\\=` 的转义，避免噪声\n3. **标题去重**：如果 Readability 保留了原始 `<h1>`，不重复添加标题\n4. **空标题修剪**：移除连续的空标题节点\n\n资料来源：[src/core.ts](https://github.com/vasylenko/markfetch/blob/main/src/core.ts)\n\n### 写入沙箱 (sandbox.ts)\n\nMCP 模式的 `savePath` 参数受到写入沙箱限制：\n\n**默认允许路径：**\n\n```typescript\nos.tmpdir() ∪ process.cwd()\n```\n\n**环境变量配置：**\n\n```bash\n# POSIX\nMARKFETCH_ALLOWED_WRITE_ROOTS=\"/custom/path:/tmp\"\n\n# Windows\nMARKFETCH_ALLOWED_WRITE_ROOTS=\"C:\\output;D:\\temp\"\n```\n\n**验证算法：**\n\n```mermaid\ngraph TD\n    A[savePath] --> B{解析为绝对路径}\n    B -->|失败| C[save_failed]\n    B -->|成功| D{是否在允许路径内?}\n    D -->|是| E[允许写入]\n    D -->|否| F[save_forbidden]\n    \n    G[符号链接] --> H[解析到真实路径后再检查]\n```\n\n**核心验证逻辑：**\n\n```typescript\nfunction isPathAllowed(path: string, roots: string[]): PathCheckResult {\n  const resolved = realpath(path);\n  \n  for (const root of roots) {\n    const rel = relative(root, resolved);\n    if (rel === \"\" || (!rel.startsWith(\"..\") && !isAbsolute(rel))) {\n      return { ok: true, resolved };\n    }\n  }\n  return { ok: false, reason: `...' is outside allowed roots` };\n}\n```\n\n**注意**：CLI 模式不执行沙箱检查——Shell 层面的用户是安全边界。\n\n资料来源：[src/sandbox.ts](https://github.com/vasylenko/markfetch/blob/main/src/sandbox.ts)\n\n## 数据流图\n\n### CLI 完整数据流\n\n```mermaid\nsequenceDiagram\n    participant User\n    participant CLI as cli.ts\n    participant Core as core.ts\n    participant Sandbox as sandbox.ts\n    participant FS as FileSystem\n    \n    User->>CLI: markfetch <url> -o /path/out.md\n    CLI->>CLI: commander 解析参数\n    CLI->>Core: fetchMarkdown({ url, savePath })\n    Core->>Core: undici.fetch()\n    Core->>Core: Readability 解析\n    Core->>Core: Turndown 转换\n    Core->>Core: 大小检查\n    Core-->>CLI: { markdown, bytes, savedTo }\n    \n    alt savePath 存在\n        CLI->>Core: 调用时传入 savePath\n        Core->>Sandbox: isPathAllowed(savePath)\n        Sandbox-->>Core: { ok: true, resolved }\n        Core->>FS: writeFile(savePath)\n        Core-->>CLI: { savedTo: path }\n    end\n    \n    CLI->>User: Saved N bytes to /path/out.md\n```\n\n### MCP 完整数据流\n\n```mermaid\nsequenceDiagram\n    participant LLM as LLM / Agent\n    participant MCP as MCP Client\n    participant Server as mcp.ts\n    participant Core as core.ts\n    participant Sandbox as sandbox.ts\n    \n    LLM->>MCP: fetch_markdown({ url, savePath })\n    MCP->>Server: stdio JSON-RPC 请求\n    Server->>Core: fetchMarkdown()\n    Core->>Core: 处理流程...\n    \n    alt savePath 在沙箱外\n        Core->>Sandbox: isPathAllowed()\n        Sandbox-->>Core: { ok: false }\n        Core-->>Server: throw MarkfetchError\n        Server-->>MCP: [save_forbidden] message\n        MCP-->>LLM: 错误响应\n    else 成功\n        Core-->>Server: { markdown }\n        Server-->>MCP: { content: [{ text }] }\n        MCP-->>LLM: Markdown 内容\n    end\n```\n\n## 错误处理架构\n\n所有错误统一通过 `MarkfetchError` 异常类传播，适配器负责转换为各自的格式：\n\n```mermaid\ngraph TD\n    subgraph \"错误源\"\n        NE[network_error]\n        HE[http_error]\n        TO[timeout]\n        UC[unsupported_content_type]\n        EF[extraction_failed]\n        TL[too_large]\n        SF[save_failed]\n        SB[save_forbidden]\n    end\n    \n    subgraph \"异常传播\"\n        ERR[MarkfetchError]\n    end\n    \n    subgraph \"适配器转换\"\n        CLI_ERR[[\"console.error(`[${code}] ${msg}`)\"]]\n        MCP_ERR[[\"errorResult() → isError: true\"]]\n    end\n    \n    NE --> ERR\n    HE --> ERR\n    TO --> ERR\n    UC --> ERR\n    EF --> ERR\n    TL --> ERR\n    SF --> ERR\n    SB --> ERR\n    \n    ERR --> CLI_ERR\n    ERR --> MCP_ERR\n```\n\n**8 种错误码对照表：**\n\n| 错误码 | 含义 | CLI 行为 | MCP 行为 |\n|--------|------|----------|----------|\n| `network_error` | DNS/TCP/TLS 失败 | stderr 输出 | isError: true |\n| `http_error` | 非 2xx 状态 | stderr 输出 | isError: true |\n| `timeout` | 超过 `MARKFETCH_TIMEOUT_MS` | stderr 输出 | isError: true |\n| `unsupported_content_type` | 非 HTML 响应 | stderr 输出 | isError: true |\n| `extraction_failed` | Readability 无法提取内容 | stderr 输出 | isError: true |\n| `too_large` | 超过 `MARKFETCH_MAX_BYTES` | stderr 输出 | isError: true |\n| `save_failed` | 写入文件失败 | stderr 输出 | isError: true |\n| `save_forbidden` | savePath 在沙箱外 | 不适用 | isError: true |\n\n资料来源：[README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)\n\n## 依赖关系\n\n```mermaid\ngraph TD\n    subgraph \"项目模块\"\n        INDEX[src/index.ts]\n        CLI[src/cli.ts]\n        MCP[src/mcp.ts]\n        CORE[src/core.ts]\n        SANDBOX[src/sandbox.ts]\n    end\n    \n    subgraph \"生产依赖\"\n        SDK[@modelcontextprotocol/sdk]\n        READABILITY[@mozilla/readability]\n        TURNDOWN[turndown]\n        LINKEDOM[linkedom]\n        UNDICI[undici]\n        COMMANDER[commander]\n        ZOD[zod]\n    end\n    \n    INDEX --> CLI\n    INDEX --> MCP\n    CLI --> CORE\n    CLI --> COMMANDER\n    MCP --> SDK\n    MCP --> CORE\n    MCP --> ZOD\n    CORE --> READABILITY\n    CORE --> TURNDOWN\n    CORE --> LINKEDOM\n    CORE --> UNDICI\n    CORE --> SANDBOX\n```\n\n**关键依赖说明：**\n\n| 依赖 | 版本 | 用途 |\n|------|------|------|\n| `@modelcontextprotocol/sdk` | ^0.6.x | MCP stdio 服务器实现 |\n| `@mozilla/readability` | ^0.5.x | HTML 文章内容提取 |\n| `turndown` | ^7.x | HTML 转 Markdown |\n| `linkedom` | ^0.18.x | 服务端 DOM 解析（替代 jsdom） |\n| `undici` | ^6.x | HTTP 客户端 |\n| `commander` | ^14.x | CLI 参数解析 |\n| `zod` | ^3.x | Schema 验证 |\n\n资料来源：[package.json](https://github.com/vasylenko/markfetch/blob/main/package.json)\n\n## 环境变量配置\n\n| 变量 | 默认值 | 作用域 | 说明 |\n|------|--------|--------|------|\n| `MARKFETCH_TIMEOUT_MS` | `30000` | 全部 | 单次请求超时（毫秒） |\n| `MARKFETCH_MAX_BYTES` | `5000000` | 全部 | 响应体和转换后 Markdown 的字节上限 |\n| `MARKFETCH_USER_AGENT` | Chrome 130 UA | 全部 | HTTP User-Agent，必须是 Chrome UA |\n| `MARKFETCH_ALLOWED_WRITE_ROOTS` | `os.tmpdir()` + `process.cwd()` | 仅 MCP | 沙箱允许的写入根目录列表 |\n\n配置在启动时验证，无效值会快速失败并输出到 stderr。\n\n资料来源：[README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)\n\n## 版本演进\n\n| 版本 | 主要架构变更 |\n|------|--------------|\n| 0.4.0 | 引入 MCP 适配器，分离核心逻辑 |\n| 0.5.0 | 引入 CLI 适配器，`index.ts` 惰性路由 |\n| 0.5.0 | 错误处理统一为 `MarkfetchError` 抛出 |\n| 0.6.0 | 引入写入沙箱 `sandbox.ts` |\n\n资料来源：[CHANGELOG.md](https://github.com/vasylenko/markfetch/blob/main/CHANGELOG.md)\n\n---\n\n<a id='installation'></a>\n\n## 安装与部署\n\n### 相关页面\n\n相关主题：[命令行界面](#cli-usage), [MCP 服务器](#mcp-server)\n\n<details>\n<summary>相关源码文件</summary>\n\n以下源码文件用于生成本页说明：\n\n- [README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)\n- [package.json](https://github.com/vasylenko/markfetch/blob/main/package.json)\n- [src/cli.ts](https://github.com/vasylenko/markfetch/blob/main/src/cli.ts)\n- [src/mcp.ts](https://github.com/vasylenko/markfetch/blob/main/src/mcp.ts)\n- [src/sandbox.ts](https://github.com/vasylenko/markfetch/blob/main/src/sandbox.ts)\n</details>\n\n# 安装与部署\n\nmarkfetch 是一个用于将网页内容转换为 Markdown 格式的工具，支持 CLI 命令行和 MCP (Model Context Protocol) 两种使用模式。本页面详细介绍如何在不同环境中安装和部署 markfetch。\n\n## 系统要求\n\n| 组件 | 最低版本 | 说明 |\n|------|----------|------|\n| Node.js | ≥ 24 | 必须使用 ES Modules (`\"type\": \"module\"`) |\n| npm | - | 用于全局安装或本地依赖管理 |\n| 操作系统 | 无限制 | 支持 Linux、macOS、Windows |\n\n资料来源：[package.json:7]()\n\nmarkfetch 采用纯 Node.js 实现，不依赖 Playwright、Chromium 或 Python 等外部运行时环境。\n\n## 安装方式\n\n### 方式一：npm 全局安装（CLI 模式）\n\n通过 npm 全局安装后，`markfetch` 命令将可在任意目录下使用：\n\n```bash\nnpm i -g markfetch\n```\n\n安装完成后即可在命令行中使用：\n\n```bash\nmarkfetch https://en.wikipedia.org/wiki/Markdown\n```\n\n资料来源：[README.md:48-54]()\n\n### 方式二：npx 免安装运行\n\n如不想全局安装，可直接使用 npx 临时下载并执行：\n\n```bash\nnpx -y markfetch https://example.com\n```\n\n### 方式三：MCP Server 部署\n\nmarkfetch 也可作为 MCP Server 部署到各种 AI 客户端。以下是常见客户端的配置方式：\n\n#### Claude Code\n\n```bash\nclaude mcp add --scope user markfetch -- npx -y markfetch\n```\n\n#### Codex\n\n```json\n\"mcpServers\": {\n  \"markfetch\": {\n    \"command\": \"npx\",\n    \"args\": [\"-y\", \"markfetch\"]\n  }\n}\n```\n\n#### Gemini CLI\n\n```bash\ngemini mcp add -s user markfetch npx -y markfetch\n```\n\n#### 通用 MCP 配置（标准 JSON）\n\n```json\n{\n  \"mcpServers\": {\n    \"markfetch\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"markfetch\"]\n    }\n  }\n}\n```\n\n资料来源：[README.md:56-95]()\n\n### 方式四：本地源码运行\n\n```bash\ngit clone https://github.com/vasylenko/markfetch.git\ncd markfetch\nnpm install\nnpm run build\n```\n\n构建产物位于 `dist/` 目录，入口文件为 `dist/index.js`。可使用以下方式调用：\n\n```bash\n# CLI 模式\nnode dist/index.js <url>\n\n# MCP 模式（无参数启动）\nnode dist/index.js\n```\n\n资料来源：[package.json:13]()\n\n## CLI 命令行使用\n\n### 基本语法\n\n```bash\nmarkfetch <url> [options]\n```\n\n### 命令行参数\n\n| 参数/选项 | 说明 |\n|-----------|------|\n| `<url>` | 必填，目标网页的绝对 HTTP(S) URL |\n| `-o, --output <path>` | 可选，将 Markdown 保存到指定文件（绝对或相对路径） |\n| `-V, --version` | 打印版本号并退出 |\n| `-h, --help` | 打印帮助信息并退出 |\n\n### 输出模式\n\nCLI 模式根据是否指定输出路径有两种输出行为：\n\n```mermaid\ngraph TD\n    A[执行 markfetch] --> B{是否指定 -o 参数?}\n    B -->|是| C[保存到文件]\n    B -->|否| D[输出到 stdout]\n    C --> E[打印确认信息: Saved X bytes to <path>]\n    D --> F[直接输出 Markdown 内容]\n```\n\n- **无 `-o` 参数**：Markdown 内容直接输出到标准输出，无额外换行\n- **指定 `-o` 参数**：写入文件后打印 `Saved {bytes} bytes to {path}`\n\n资料来源：[src/cli.ts:26-33]()\n\n### 错误处理\n\nCLI 模式下，错误信息输出到 stderr，格式为：\n\n```\n[<error_code>] <error_message>\n```\n\n进程退出码为非零值。\n\n资料来源：[src/cli.ts:42-43]()\n\n## MCP Server 部署\n\n### 工作模式\n\nmarkfetch 支持通过 `process.argv.length` 自动区分 MCP 和 CLI 模式：\n\n```mermaid\ngraph TD\n    A[启动 markfetch] --> B{process.argv.length}\n    B -->|等于 1| C[启动 MCP stdio Server]\n    B -->|大于 1| D[进入 CLI 模式]\n```\n\n这种设计确保：\n- 零参数启动自动进入 MCP 模式\n- 现有 MCP 客户端配置无需任何修改\n- CLI 代码 (`src/cli.ts`) 在 MCP 模式下不会被加载\n\n资料来源：[README.md:38-40]()\n\n### MCP 工具接口\n\nmarkfetch 注册了单一 MCP 工具 `fetch_markdown`：\n\n| 参数 | 类型 | 必填 | 说明 |\n|------|------|------|------|\n| `url` | string | 是 | 目标网页的绝对 HTTP(S) URL |\n| `savePath` | string | 否 | 绝对路径，用于保存 Markdown 到文件 |\n\n返回结果在 `content[0].text` 中，**不包含** `structuredContent` 字段，这是特意设计以确保与各类 MCP 客户端兼容。\n\n资料来源：[README.md:10-17]()\n\n## 环境变量配置\n\nmarkfetch 支持多个环境变量用于自定义行为。所有变量在启动时进行验证，无效值会导致快速失败并输出错误到 stderr。\n\n| 环境变量 | 默认值 | 单位 | 说明 |\n|----------|--------|------|------|\n| `MARKFETCH_TIMEOUT_MS` | 30000 | 毫秒 | 单次请求超时时间 |\n| `MARKFETCH_MAX_BYTES` | 5000000 | 字节 | 响应体和提取 Markdown 的最大大小（约 5MB） |\n| `MARKFETCH_USER_AGENT` | Chrome 130 | - | HTTP User-Agent，必须为 Chrome UA 字符串 |\n| `MARKFETCH_ALLOWED_WRITE_ROOTS` | `os.tmpdir()` + `process.cwd()` | - | MCP 模式允许写入的根目录列表 |\n\n### 配置示例\n\n在 MCP 配置文件（如 Claude Desktop 配置）中设置环境变量：\n\n```json\n{\n  \"mcpServers\": {\n    \"markfetch\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"markfetch\"],\n      \"env\": {\n        \"MARKFETCH_TIMEOUT_MS\": \"60000\"\n      }\n    }\n  }\n}\n```\n\n资料来源：[README.md:104-115]()\n\n## 写入沙箱安全机制\n\nMCP 模式下，`savePath` 参数的写入操作受到安全沙箱限制。\n\n### 工作原理\n\n```mermaid\ngraph TD\n    A[MCP savePath 请求] --> B{路径合法性检查}\n    B --> C{绝对路径?}\n    C -->|否| D[拒绝: save_forbidden]\n    C -->|是| E{路径是否在允许根目录内?}\n    E -->|是| F[写入文件]\n    E -->|否| G[拒绝: save_forbidden]\n```\n\n### 允许的写入根目录\n\n默认允许的写入根目录为：\n\n- 系统临时目录 (`os.tmpdir()`)\n- 当前工作目录 (`process.cwd()`)\n\n路径通过 `fs.realpath` 解析一次后缓存，确保符号链接被正确追踪。\n\n### 自定义允许目录\n\n使用 `MARKFETCH_ALLOWED_WRITE_ROOTS` 覆盖默认集合（注意：**替换而非合并**）：\n\n**Linux/macOS (POSIX)**：\n```json\n{\n  \"mcpServers\": {\n    \"markfetch\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"markfetch\"],\n      \"env\": {\n        \"MARKFETCH_ALLOWED_WRITE_ROOTS\": \"/Users/me/markfetch-out:/tmp\"\n      }\n    }\n  }\n}\n```\n\n**Windows**：\n```json\n{\n  \"mcpServers\": {\n    \"markfetch\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"markfetch\"],\n      \"env\": {\n        \"MARKFETCH_ALLOWED_WRITE_ROOTS\": \"C:\\\\Users\\\\me\\\\markfetch-out;C:\\\\Users\\\\me\\\\AppData\\\\Local\\\\Temp\"\n      }\n    }\n  }\n}\n```\n\n### 安全特性\n\n1. **符号链接防护**：检查时使用规范化后的路径，写入时也使用相同的规范化路径，防止通过 `link/..` 逃逸\n2. **Windows 大小写不敏感处理**：Windows 文件系统大小写不敏感，使用 `.toLowerCase()` 进行比较\n3. **CLI 模式无沙箱**：CLI 模式设计为由人类直接在 shell 中使用，不执行任何写入限制\n\n资料来源：[src/sandbox.ts:1-30](), [README.md:72-90]()\n\n## 错误代码参考\n\nmarkfetch 定义了 8 种确定性错误代码：\n\n| 错误代码 | 含义 | 触发条件 |\n|----------|------|----------|\n| `network_error` | 网络错误 | DNS、TCP、TLS 失败或内部错误 |\n| `http_error` | HTTP 错误 | 上游返回非 2xx 状态码 |\n| `timeout` | 超时 | 超过 `MARKFETCH_TIMEOUT_MS` 限制 |\n| `unsupported_content_type` | 不支持的类型 | 响应不是 `text/html` 或 `application/xhtml+xml` |\n| `extraction_failed` | 提取失败 | Readability 未找到文章内容（常见于纯客户端渲染 SPA） |\n| `too_large` | 内容过大 | 响应体或提取的 Markdown 超过 `MARKFETCH_MAX_BYTES` |\n| `save_failed` | 保存失败 | `savePath` 指定但写入文件失败（目录不存在、权限不足等） |\n| `save_forbidden` | 保存被禁止 | `savePath` 超出允许的写入根目录 |\n\n资料来源：[README.md:100-108]()\n\n## 版本历史\n\n| 版本 | 发布日期 | 关键变更 |\n|------|----------|----------|\n| 0.6.0 | - | MCP 写入沙箱安全修复 |\n| 0.5.1 | - | `MARKFETCH_ALLOWED_WRITE_ROOTS` 环境变量支持 |\n| 0.5.0 | 2026-05-12 | 新增 CLI 模式 |\n| 0.4.0 | 2026-05-10 | 新增 MCP Server 支持 |\n| 0.4.1 | 2026-05-11 | 修复 package.json bin 字段问题 |\n\n资料来源：[CHANGELOG.md:1-45]()\n\n---\n\n<a id='cli-usage'></a>\n\n## 命令行界面\n\n### 相关页面\n\n相关主题：[MCP 服务器](#mcp-server), [错误处理机制](#error-handling), [配置与环境变量](#configuration)\n\n<details>\n<summary>相关源码文件</summary>\n\n以下源码文件用于生成本页说明：\n\n- [src/cli.ts](https://github.com/vasylenko/markfetch/blob/main/src/cli.ts)\n- [src/index.ts](https://github.com/vasylenko/markfetch/blob/main/src/index.ts)\n- [src/core.ts](https://github.com/vasylenko/markfetch/blob/main/src/core.ts)\n- [src/mcp.ts](https://github.com/vasylenko/markfetch/blob/main/src/mcp.ts)\n- [src/sandbox.ts](https://github.com/vasylenko/markfetch/blob/main/src/sandbox.ts)\n- [README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)\n- [CHANGELOG.md](https://github.com/vasylenko/markfetch/blob/main/CHANGELOG.md)\n</details>\n\n# 命令行界面\n\n## 概述\n\nmarkfetch 提供两种运行模式：MCP（Model Context Protocol）stdio 服务器模式和命令行界面（CLI）模式。CLI 模式允许用户直接从终端获取网页内容并将其转换为 Markdown 格式输出。\n\n命令行界面的核心职责包括：解析用户输入的 URL 和命令行参数、调用核心提取模块处理网页、将 Markdown 结果输出到标准输出或指定文件、以及以结构化格式报告错误信息。\n\nCLI 适配器位于 `src/cli.ts`，采用懒加载机制——只有当进程检测到命令行参数时才加载该模块，确保 MCP 模式下不会引入任何控制台输出代码。\n\n## 工作流程\n\n```mermaid\ngraph TD\n    A[启动 markfetch] --> B{命令行参数数量}\n    B -->|0 个参数| C[启动 MCP stdio 服务器]\n    B -->|1+ 个参数| D[加载 CLI 适配器]\n    D --> E[解析 URL 和选项]\n    E --> F[调用 core.fetchMarkdown]\n    F --> G{savePath 存在?}\n    G -->|是| H[写入文件并输出确认信息]\n    G -->|否| I[输出 Markdown 到 stdout]\n    F -->|异常| J[输出错误到 stderr 并退出]\n    H --> K[process.exitCode = 0]\n    I --> K\n```\n\nCLI 适配器遵循统一错误处理约定：所有核心模块抛出的 `MarkfetchError` 异常由适配器捕获并转换为带错误代码的标准错误输出。\n\n## 安装与调用\n\n### 全局安装\n\n```bash\nnpm i -g markfetch\n```\n\n安装后，`markfetch` 命令全局可用。\n\n### 基本用法\n\n```bash\nmarkfetch <url>\n```\n\n将 URL 对应的网页内容提取为 Markdown 并输出到标准输出。\n\n### 输出到文件\n\n```bash\nmarkfetch <url> -o <path>\nmarkfetch <url> --output <path>\n```\n\n路径可以是绝对路径或相对于当前工作目录的相对路径。\n\n## 命令行选项\n\n| 选项 | 说明 |\n|------|------|\n| `<url>` | 必选参数，目标网页的绝对 HTTP/HTTPS URL |\n| `-o, --output <path>` | 可选，将 Markdown 保存到指定文件路径 |\n| `-V, --version` | 打印版本号并退出 |\n| `-h, --help` | 打印帮助信息并退出 |\n\n资料来源：[src/cli.ts:1-10](https://github.com/vasylenko/markfetch/blob/main/src/cli.ts#L1-L10)\n\n## 输出机制\n\nCLI 适配器对输出的处理遵循\"标准输出保留给 Markdown 内容\"的原则：\n\n- **无 savePath**：原始 Markdown 内容直接写入 stdout，不添加额外换行符，与 MCP 的 `content[0].text` 格式保持一致\n- **有 savePath**：写入文件后输出确认信息 `Saved <bytes> bytes to <path>` 到 stdout\n\n```typescript\nif (savedTo !== undefined) {\n  // 确认信息——CLI 唯一添加的 stdout 换行\n  console.log(`Saved ${bytes} bytes to ${savedTo}`);\n} else {\n  // 原始 Markdown 内容——无额外换行\n  process.stdout.write(markdown);\n}\n```\n\n资料来源：[src/cli.ts:37-47](https://github.com/vasylenko/markfetch/blob/main/src/cli.ts#L37-L47)\n\n## 错误处理\n\nCLI 采用与 MCP 适配器一致的错误格式，通过 `classifyError` 函数将异常分类并输出：\n\n```\n[<error_code>] <error_message>\n```\n\n错误信息输出到 stderr，并设置 `process.exitCode = 1` 确保管道脚本能够检测到失败状态。\n\n```typescript\n} catch (err) {\n  const { code, message } = classifyError(err);\n  console.error(`[${code}] ${message}`);\n  process.exitCode = 1;\n}\n```\n\n资料来源：[src/cli.ts:48-53](https://github.com/vasylenko/markfetch/blob/main/src/cli.ts#L48-L53)\n\n### 错误代码对照表\n\n| 错误代码 | 含义 |\n|---------|------|\n| `network_error` | DNS、TCP、TLS 连接失败或内部网络错误 |\n| `http_error` | 目标服务器返回非 2xx 状态码 |\n| `timeout` | 请求超时（默认 30 秒，可通过 `MARKFETCH_TIMEOUT_MS` 配置）|\n| `unsupported_content_type` | 响应不是 HTML 内容 |\n| `extraction_failed` | Readability 无法提取文章内容（常见于纯客户端渲染的 SPA）|\n| `too_large` | 内容超过 `MARKFETCH_MAX_BYTES` 限制（默认 5MB）|\n| `save_failed` | 文件写入失败（目录不存在、权限不足等）|\n| `save_forbidden` | savePath 超出允许的写入根目录 |\n\n资料来源：[README.md:1-100](https://github.com/vasylenko/markfetch/blob/main/README.md#L1-L100)\n\n## 路径解析规则\n\nCLI 在调用核心模块之前统一处理路径解析：\n\n```typescript\nconst savePath = options.output\n  ? resolve(process.cwd(), options.output)\n  : undefined;\n```\n\n规则说明：\n\n- 绝对路径保持不变\n- 相对路径相对于 `process.cwd()` 解析\n- 不执行波浪号（`~`）展开——由 shell 在 argv 到达进程前处理\n- 核心模块接收的始终是绝对路径，确保行为一致性\n\n资料来源：[src/cli.ts:20-27](https://github.com/vasylenko/markfetch/blob/main/src/cli.ts#L20-L27)\n\n## 与 MCP 适配器的对比\n\n```mermaid\ngraph TD\n    subgraph CLI 模式\n        A1[命令行参数] --> B1[cli.ts 适配器]\n        B1 --> C1[console.log 控制输出]\n        B1 --> C2[console.error 控制错误]\n    end\n    subgraph MCP 模式\n        A2[stdio 帧] --> B2[mcp.ts 适配器]\n        B2 --> C3[MCP JSON-RPC 响应]\n        B2 --> C4[structuredContent 格式]\n    end\n    subgraph 共享核心\n        D[core.fetchMarkdown]\n        E[MarkfetchError]\n        F[extractArticle]\n    end\n    B1 --> D\n    B2 --> D\n    C1 -.不使用.-> C3\n    C2 -.不使用.-> C4\n```\n\n关键差异：\n\n| 特性 | CLI 模式 | MCP 模式 |\n|------|---------|---------|\n| 交互协议 | 终端 stdin/stdout | stdio JSON-RPC |\n| 输出格式 | 原始 Markdown | MCP content 数组 |\n| 沙箱写入 | 无限制 | 受 `MARKFETCH_ALLOWED_WRITE_ROOTS` 限制 |\n| 控制台输出 | 可用 | 不可用 |\n\nCLI 模式没有沙箱限制——shell 用户是安全边界，因此允许写入任意路径。MCP 模式由于由语言模型驱动，引入写入沙箱以防止路径遍历攻击。\n\n资料来源：[src/mcp.ts:1-50](https://github.com/vasylenko/markfetch/blob/main/src/mcp.ts#L1-L50)\n资料来源：[src/sandbox.ts:1-50](https://github.com/vasylenko/markfetch/blob/main/src/sandbox.ts#L1-L50)\n\n## 分发器机制\n\n`src/index.ts` 负责根据进程参数数量决定加载哪个适配器：\n\n```typescript\n// 懒加载分发逻辑\n// process.argv.length <= 1: 仅 node 命令，无参数\n// process.argv.length == 2: 仅命令名（如 'node markfetch'），启动 MCP\n// process.argv.length > 2: 有额外参数，启动 CLI\n```\n\n这种设计确保：\n- 零参数启动时始终进入 MCP 模式，保持现有配置的兼容性\n- 任何命令行参数触发 CLI 模式\n- 适配器模块懒加载，MCP 模式下永不引入 CLI 代码路径\n\n资料来源：[src/index.ts](https://github.com/vasylenko/markfetch/blob/main/src/index.ts)\n\n## 环境变量配置\n\nCLI 与 MCP 共享以下环境变量：\n\n| 变量名 | 默认值 | 说明 |\n|--------|--------|------|\n| `MARKFETCH_TIMEOUT_MS` | `30000` | 单次请求超时（毫秒）|\n| `MARKFETCH_MAX_BYTES` | `5000000` | 响应体和提取 Markdown 的字节上限 |\n| `MARKFETCH_USER_AGENT` | Chrome 130 UA 字符串 | HTTP User-Agent 头 |\n\n注意：`MARKFETCH_ALLOWED_WRITE_ROOTS` 仅在 MCP 模式下生效，CLI 模式不受此限制。\n\n资料来源：[README.md:100-150](https://github.com/vasylenko/markfetch/blob/main/README.md#L100-L150)\n\n## 典型使用场景\n\n### 管道处理\n\n```bash\nmarkfetch https://example.com/article | grep -A5 \"## Introduction\"\n```\n\n### 保存长文档\n\n```bash\nmarkfetch https://docs.example.com/guide -o /tmp/guide.md\n```\n\n### 批量脚本集成\n\n```bash\n#!/bin/bash\nfor url in \"${urls[@]}\"; do\n  markfetch \"$url\" -o \"/output/$(basename \"$url\").md\" || echo \"Failed: $url\" >&2\ndone\n```\n\n## 版本历史\n\n- **0.5.0** (2026-05-12)：新增 CLI 模式，采用 commander.js 进行参数解析\n- **0.6.0**：当前版本，CLI 与 MCP 双模式稳定运行\n\n资料来源：[CHANGELOG.md](https://github.com/vasylenko/markfetch/blob/main/CHANGELOG.md)\n\n---\n\n<a id='mcp-server'></a>\n\n## MCP 服务器\n\n### 相关页面\n\n相关主题：[命令行界面](#cli-usage), [写操作沙箱](#write-sandbox), [错误处理机制](#error-handling)\n\n<details>\n<summary>相关源码文件</summary>\n\n以下源码文件用于生成本页说明：\n\n- [src/mcp.ts](https://github.com/vasylenko/markfetch/blob/main/src/mcp.ts)\n- [src/index.ts](https://github.com/vasylenko/markfetch/blob/main/src/index.ts)\n- [src/cli.ts](https://github.com/vasylenko/markfetch/blob/main/src/cli.ts)\n- [src/core.ts](https://github.com/vasylenko/markfetch/blob/main/src/core.ts)\n- [src/sandbox.ts](https://github.com/vasylenko/markfetch/blob/main/src/sandbox.ts)\n- [package.json](https://github.com/vasylenko/markfetch/blob/main/package.json)\n- [README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)\n</details>\n\n# MCP 服务器\n\n## 概述\n\nmarkfetch 的 MCP 服务器是基于 Model Context Protocol (MCP) 的标准输入输出（stdio）服务器实现，提供 `fetch_markdown` 工具供 AI 代理使用。该服务器通过单一工具接口将网页内容转换为干净的 Markdown 格式，支持直接返回内容或写入文件系统。\n\n```mermaid\ngraph TD\n    A[MCP 客户端] -->|stdio| B[markfetch MCP 服务器]\n    B --> C[src/index.ts 分发器]\n    C -->|无参数| D[MCP 模式]\n    C -->|有参数| E[CLI 模式]\n    D --> F[src/mcp.ts]\n    E --> G[src/cli.ts]\n    F --> H[src/core.ts]\n    G --> H\n    H --> I[fetchMarkdown 核心逻辑]\n```\n\n## 架构设计\n\n### 双模式入口\n\nmarkfetch 采用单一二进制文件支持两种运行模式，通过 `process.argv.length` 在运行时自动判断： 资料来源：[src/index.ts:1-15]()\n\n| 模式 | 触发条件 | 入口文件 | 输出通道 |\n|------|----------|----------|----------|\n| MCP stdio 服务器 | 无命令行参数 | `src/mcp.ts` | stdout 用于 MCP 协议帧 |\n| CLI 工具 | 存在命令行参数 | `src/cli.ts` | stdout 用于 markdown 输出 |\n\n关键设计原则：**stdout 保留给 MCP 协议帧使用**，stderr 仅用于致命错误输出，确保 stdio 通道的纯净性。 资料来源：[src/cli.ts:45-48]()\n\n### MCP 服务器初始化\n\n服务器使用 `@modelcontextprotocol/sdk` 包提供的 `McpServer` 类进行初始化：\n\n```typescript\nconst server = new McpServer({ name: \"markfetch\", version: \"0.6.0\" });\n```\n\n版本号与 `package.json` 中的 `version` 字段保持一致，确保 MCP 客户端能获取准确的版本信息。 资料来源：[src/mcp.ts:12]()\n\n## 工具定义\n\n### fetch_markdown 工具\n\n`fetch_markdown` 是 markfetch 提供的唯一 MCP 工具，具有以下特性：\n\n| 特性 | 说明 |\n|------|------|\n| 返回通道 | `content[0].text` 单一通道，无 `structuredContent` |\n| 适用场景 | 文章、文档、博客帖子、新闻、参考页面 |\n| 限制 | 匿名获取，无认证支持 |\n\n#### 输入参数\n\n| 参数 | 类型 | 必填 | 说明 |\n|------|------|------|------|\n| `url` | `string` | 是 | 完整的 HTTP/HTTPS URL，服务器自动跟随重定向 |\n| `savePath` | `string` | 否 | 绝对路径，将 markdown 写入文件而非返回 |\n\n`url` 参数使用 Zod schema 进行验证：\n\n```typescript\nurl: z\n  .string()\n  .url()\n  .describe(\"Absolute http(s) URL of the page to fetch...\")\n```\n\n`savePath` 必须为绝对路径：\n\n```typescript\nsavePath: z\n  .string()\n  .refine(isAbsolute, \"savePath must be an absolute filesystem path\")\n  .optional()\n```\n\n资料来源：[src/mcp.ts:14-38]()\n\n#### 返回格式\n\n成功响应：\n\n```json\n{\n  \"content\": [\n    {\n      \"type\": \"text\",\n      \"text\": \"# Markdown Content Here...\"\n    }\n  ]\n}\n```\n\n错误响应：\n\n```json\n{\n  \"content\": [\n    {\n      \"type\": \"text\",\n      \"text\": \"[error_code] Error message\"\n    }\n  ],\n  \"isError\": true\n}\n```\n\n## 错误处理\n\n### 错误代码体系\n\nMCP 服务器返回 8 种确定性错误代码：\n\n| 错误代码 | 含义 | 触发条件 |\n|----------|------|----------|\n| `network_error` | 网络故障 | DNS/TCP/TLS 失败或内部错误 |\n| `http_error` | HTTP 错误 | 上游返回非 2xx 状态码 |\n| `timeout` | 请求超时 | 超过 `MARKFETCH_TIMEOUT_MS` 配置的时间 |\n| `unsupported_content_type` | 不支持的类型 | 响应不是 `text/html` 或 `application/xhtml+xml` |\n| `extraction_failed` | 提取失败 | Readability 无法提取文章内容（常见于纯客户端渲染的 SPA） |\n| `too_large` | 内容过大 | 响应体或提取的 markdown 超过 `MARKFETCH_MAX_BYTES` |\n| `save_failed` | 保存失败 | 写入文件失败（目录不存在、权限问题等） |\n| `save_forbidden` | 保存禁止 | `savePath` 超出允许的写入根目录 |\n\n资料来源：[src/mcp.ts:4-9]()\n\n### 错误结果生成\n\n```typescript\nfunction errorResult(code: ErrorCode, message: string) {\n  return {\n    content: [{ type: \"text\" as const, text: `[${code}] ${message}` }],\n    isError: true,\n  };\n}\n```\n\n所有错误统一通过 `errorResult()` 函数格式化，确保错误消息格式的一致性：`[错误代码] 错误描述`。 资料来源：[src/mcp.ts:4-9]()\n\n## 配置选项\n\nMCP 服务器支持以下环境变量配置：\n\n| 环境变量 | 默认值 | 说明 |\n|----------|--------|------|\n| `MARKFETCH_TIMEOUT_MS` | `30000` | 单次请求超时时间（毫秒） |\n| `MARKFETCH_MAX_BYTES` | `5000000` | 响应体和提取 markdown 的最大字节数 |\n| `MARKFETCH_USER_AGENT` | Chrome 130 固定字符串 | HTTP User-Agent，必须为 Chrome UA |\n| `MARKFETCH_ALLOWED_WRITE_ROOTS` | `os.tmpdir()` + `process.cwd()` | MCP 模式允许的写入根目录列表 |\n\n### 写入沙箱\n\nMCP 模式的 `savePath` 写入被限制在允许的根目录集合内。默认情况下包含系统临时目录和进程工作目录。 资料来源：[src/sandbox.ts:1-10]()\n\n```mermaid\ngraph LR\n    A[savePath] --> B{检查是否在允许根目录内}\n    B -->|是| C[允许写入]\n    B -->|否| D[返回 save_forbidden 错误]\n    \n    E[默认允许根目录] --> B\n    F[MARKFETCH_ALLOWED_WRITE_ROOTS] --> E\n```\n\n#### 自定义允许根目录\n\n```json\n{\n  \"mcpServers\": {\n    \"markfetch\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"markfetch\"],\n      \"env\": {\n        \"MARKFETCH_ALLOWED_WRITE_ROOTS\": \"/Users/me/markfetch-out:/tmp\"\n      }\n    }\n  }\n}\n```\n\n设置此变量会**完全替换**默认根目录，而非合并。如需保留默认值，必须显式包含。 资料来源：[README.md:Configuration]()\n\n#### 路径解析逻辑\n\n`sandbox.ts` 中的路径验证逻辑：\n\n1. 解析 `savePath` 为绝对路径\n2. 遍历所有允许根目录\n3. 检查目标路径是否在任一允许根目录内\n4. Windows 平台使用不区分大小写的比较\n\n```typescript\n// Win32 case-fold: filesystem is case-insensitive\nconst fold = process.platform === \"win32\"\n  ? (s: string) => s.toLowerCase()\n  : (s: string) => s;\n```\n\n资料来源：[src/sandbox.ts:32-36]()\n\n## MCP 客户端集成\n\n### Claude Code\n\n```bash\nclaude mcp add --scope user markfetch -- npx -y markfetch\n```\n\n### Codex\n\n```json\n{\n  \"mcpServers\": {\n    \"markfetch\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"markfetch\"]\n    }\n  }\n}\n```\n\n### Gemini CLI\n\n```bash\ngemini mcp add -s user markfetch npx -y markfetch\n```\n\n### 本地路径配置\n\n对于需要使用本地构建版本的场景：\n\n```json\n{\n  \"mcpServers\": {\n    \"markfetch\": {\n      \"command\": \"node\",\n      \"args\": [\"/absolute/path/to/markfetch/dist/index.js\"]\n    }\n  }\n}\n```\n\n资料来源：[README.md:MCP install commands]()\n\n## 与 CLI 模式的区别\n\n| 特性 | MCP 服务器 | CLI 工具 |\n|------|------------|----------|\n| 调用方式 | stdio 协议 | 命令行参数 |\n| 输出通道 | `content[0].text` | stdout 直接输出 |\n| 写入沙箱 | 启用 | 禁用（无限制） |\n| 错误输出 | `isError: true` | stderr + 退出码 |\n| 相对路径处理 | 不支持（需绝对路径） | 支持（相对 cwd 解析） |\n\nCLI 模式中相对路径会被解析为绝对路径后再传给核心逻辑：\n\n```typescript\nconst savePath = options.output\n  ? resolve(process.cwd(), options.output)\n  : undefined;\n```\n\n资料来源：[src/cli.ts:13-20]()\n\n## 版本历史\n\n| 版本 | 发布日期 | MCP 相关变更 |\n|------|----------|--------------|\n| 0.6.0 | 当前版本 | 稳定版 MCP 实现 |\n| 0.5.0 | 2026-05-12 | 引入 CLI 模式，源文件重构为独立模块 |\n| 0.4.0 | 2026-05-10 | 初始 MCP 工具 `fetch_markdown` |\n\n0.4.0 版本引入了原始的 MCP 实现，0.5.0 版本将源码重构为 `src/mcp.ts`（MCP 适配器）、`src/cli.ts`（CLI 适配器）和 `src/core.ts`（核心管道 + 错误处理）的分离结构，同时保持了 MCP 消费者的公共 API 完全兼容。 资料来源：[CHANGELOG.md:版本历史]()\n\n## 安全考量\n\n### MCP 模式的安全设计\n\n1. **写入沙箱隔离**：MCP 工具由语言模型驱动，可能被页面内容引导，因此 `savePath` 限制在可配置的允许目录内\n2. **CLI 模式无沙箱**：命令行由人类直接使用，作为安全边界，不施加写入限制\n3. **Symlink 防护**：符号链接指向允许根目录外的路径会被阻止 资料来源：[README.md:Write sandbox]()\n\n### User-Agent 要求\n\n`MARKFETCH_USER_AGENT` 必须为 Chrome 用户代理字符串。非 Chrome 字符串会在启动时快速失败，防止因客户端提示不匹配导致的问题。 资料来源：[README.md:Configuration]()\n\n---\n\n<a id='http-fingerprint'></a>\n\n## HTTP 指纹与请求模拟\n\n### 相关页面\n\n相关主题：[项目概述](#overview), [内容提取管道](#content-extraction)\n\n<details>\n<summary>相关源码文件</summary>\n\n以下源码文件用于生成本页说明：\n\n- [src/core.ts](https://github.com/vasylenko/markfetch/blob/main/src/core.ts)\n- [README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)\n- [src/mcp.ts](https://github.com/vasylenko/markfetch/blob/main/src/mcp.ts)\n- [src/cli.ts](https://github.com/vasylenko/markfetch/blob/main/src/cli.ts)\n- [src/sandbox.ts](https://github.com/vasylenko/markfetch/blob/main/src/sandbox.ts)\n- [package.json](https://github.com/vasylenko/markfetch/blob/main/package.json)\n</details>\n\n# HTTP 指纹与请求模拟\n\n## 概述\n\nmarkfetch 在发起 HTTP 请求时模拟真实浏览器的指纹特征，以绕过网站的反爬虫机制。其核心设计理念是使每个请求在 HTTP 协议层面与真实 Chrome 浏览器发出的请求无法区分，从而在不支持 JavaScript 渲染的环境中也能获取到完整的页面内容。\n\nmarkfetch 使用 **HTTP/2 传输层** 结合 **一致的 Chrome 请求头集合**，并通过 `MARKFETCH_USER_AGENT` 环境变量动态生成 `Sec-CH-UA-*` 客户端提示头，确保请求指纹的真实性与可配置性。\n\n资料来源：[README.md:47]()\n\n## 核心机制\n\n### HTTP/2 传输\n\nmarkfetch 默认使用 HTTP/2 协议进行网络请求。相比 HTTP/1.1，HTTP/2 的多路复用、头部压缩和服务器推送特性使得请求模式更接近现代浏览器的实际行为。\n\n### Chrome 请求头集\n\n项目预置了一套完整的 Chrome 请求头集合，覆盖了以下关键头字段：\n\n| 头字段类型 | 说明 |\n|---|---|\n| `User-Agent` | Chrome 130 版本标识 |\n| `Sec-CH-UA-*` | 客户端提示头，基于 User-Agent 动态派生 |\n| `Accept` | 内容协商头 |\n| `Accept-Language` | 语言偏好 |\n| `Accept-Encoding` | 压缩算法支持 |\n\n这套头字段组合确保了请求在 TLS 握手后的 HTTP 层与真实 Chrome 浏览器保持一致。\n\n## 配置项\n\n### 环境变量配置\n\n| 变量名 | 默认值 | 用途 |\n|---|---|---|\n| `MARKFETCH_TIMEOUT_MS` | `30000` | 单次请求超时时间（毫秒） |\n| `MARKFETCH_MAX_BYTES` | `5000000` | 响应体与提取后 markdown 的字节数上限 |\n| `MARKFETCH_USER_AGENT` | Chrome 130 固定版本字符串 | 覆盖默认 UA，必须为 Chrome UA 格式 |\n\n`MARKFETCH_USER_AGENT` 的值在进程启动时派生 `Sec-CH-UA-*` 客户端提示。如果传入非 Chrome 格式的字符串，程序会在启动时快速失败并在 stderr 输出错误。\n\n资料来源：[README.md:88-93]()\n\n### 配置示例\n\n```json\n{\n  \"mcpServers\": {\n    \"markfetch\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"markfetch\"],\n      \"env\": {\n        \"MARKFETCH_USER_AGENT\": \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36\",\n        \"MARKFETCH_TIMEOUT_MS\": \"60000\"\n      }\n    }\n  }\n}\n```\n\n## 请求流程\n\n```mermaid\ngraph TD\n    A[入口: fetchMarkdown] --> B[验证环境变量]\n    B --> C{配置有效?}\n    C -->|无效| D[启动时快速失败]\n    C -->|有效| E[构造 HTTP/2 请求]\n    E --> F[附加 Chrome 请求头集]\n    F --> G[基于 UA 派生 Sec-CH-UA-*]\n    G --> H[发送请求到目标 URL]\n    H --> I{响应状态}\n    I -->|2xx| J[进入内容提取流程]\n    I -->|非 2xx| K[抛出 http_error]\n    J --> L[解码 HTML 实体]\n    L --> M[注入 base href]\n    M --> N[解析 DOM 树]\n    N --> O[Readability 提取]\n    O --> P[Turndown 转 Markdown]\n```\n\n## 基础 URL 注入机制\n\n由于 linkedom 解析器在文档没有 `<base href>` 时会将相对路径保留为 `/wiki/...` 形式，markfetch 在 `extractArticle` 函数中通过 `ensureBaseHref` 注入重定向后的规范 URL，确保后续的相对链接能够被正确解析。\n\n```typescript\nfunction ensureBaseHref(html: string, url: string): string {\n  const safeUrl = url.replaceAll(\"&\", \"&amp;\").replaceAll('\"', \"&quot;\");\n  const stripped = html.replaceAll(/<base\\s[^>]*>/gi, \"\");\n  if (/<head\\b[^>]*>/i.test(stripped)) {\n    return stripped.replace(\n      /<head\\b([^>]*)>/i,\n      `<head$1><base href=\"${safeUrl}\">`,\n    );\n  }\n  if (/<html\\b[^>]*>/i.test(stripped)) {\n    return stripped.replace(\n      /<html\\b([^>]*)/i,\n      `<html$1><head><base href=\"${safeUrl}\"></head>`,\n    );\n  }\n  return stripped;\n}\n```\n\n资料来源：[src/core.ts:18-33]()\n\n该函数会：\n\n1. 对 URL 中的特殊字符进行 HTML 实体转义\n2. 移除页面中已有的 `<base>` 标签（上游 URL 更具权威性）\n3. 将规范 URL 注入到 `<head>` 或 `<html>` 标签中\n\n## 请求适配层\n\n### MCP 适配器\n\nMCP 适配器 (`src/mcp.ts`) 接收已验证的输入（URL 语法由适配器的 schema 校验，savePath 为绝对路径），并将错误统一映射为 `MarkfetchError`。\n\n```typescript\nconst server = new McpServer({ name: \"markfetch\", version: \"0.6.0\" });\n\nserver.registerTool(\n  \"fetch_markdown\",\n  {\n    description:\n      \"Fetch a single public HTTP/S URL and return its main article content as clean markdown...\",\n    inputSchema: {\n      url: z.string().url().describe(\"Absolute http(s) URL...\"),\n      savePath: z.string().refine(isAbsolute, \"savePath must be an absolute filesystem path\").optional()\n    }\n  }\n);\n```\n\n资料来源：[src/mcp.ts:22-38]()\n\n### CLI 适配器\n\nCLI 适配器 (`src/cli.ts`) 将相对输出路径解析为绝对路径后再传递给核心模块：\n\n```typescript\nconst savePath = options.output\n  ? resolve(process.cwd(), options.output)\n  : undefined;\n```\n\nCLI 模式下的错误通过 stderr 输出，格式为 `[code] message`，并设置 `process.exitCode = 1`。\n\n资料来源：[src/cli.ts:28-32]()\n\n## 错误码体系\n\nmarkfetch 定义了 8 种确定性错误码，用于标识请求和处理过程中的各类失败场景：\n\n| 错误码 | 含义 | 触发条件 |\n|---|---|---|\n| `network_error` | DNS/TCP/TLS 故障或内部错误 | 网络层异常 |\n| `http_error` | 上游返回非 2xx 状态 | HTTP 响应状态码 ≥ 400 |\n| `timeout` | 超过 `MARKFETCH_TIMEOUT_MS` | 请求超时 |\n| `unsupported_content_type` | 响应不是 HTML 类型 | Content-Type 非 `text/html` 或 `application/xhtml+xml` |\n| `extraction_failed` | Readability 未提取到内容 | 纯客户端渲染的 SPA |\n| `too_large` | 超过 `MARKFETCH_MAX_BYTES` | 响应体或 markdown 超限 |\n| `save_failed` | 文件写入失败 | 目录不存在或权限不足 |\n| `save_forbidden` | 路径超出允许的写入根目录 | MCP 模式下路径未通过沙箱校验 |\n\n资料来源：[README.md:74-82]()\n\n## 与其他方案的对比\n\n| 方案 | 真实浏览器指纹 | Reader-View 提取 | 结构化错误 | 零配置 |\n|---|---|---|---|---|\n| 内置 Agent fetch 工具 | ✗ | ✗ | ✗ | ✓ |\n| 通用 Playwright/Puppeteer | ✓ | ✗ | ✗ | ✗ |\n| mcp-server-fetch (Python) | ✗ | 基础 | ✗ | ✗ |\n| CloudFlare /markdown | ✗ | ✓ | ✗ | ✗ |\n| **markfetch** | **✓** | **✓** | **✓** | **✓** |\n\nmarkfetch 的独特优势在于同时实现了真实浏览器指纹和 Reader-View 风格的内容提取，而无需运行无头浏览器。\n\n资料来源：[README.md:55-62]()\n\n## 设计原则\n\n### 单进程架构\n\nmarkfetch 采用纯 Node.js 实现，不依赖 Playwright、headless Chromium 或 Python 子进程。这使得：\n\n- 启动开销极低\n- 内存占用可控\n- 适合在 MCP stdio 服务器等受限环境中运行\n\n### Stdio 清洁原则\n\n- **stdout** 保留给 MCP 协议帧\n- **stderr** 仅用于致命错误\n- 无日志输出、无 ANSI 转义码\n\n### MCP 与 CLI 的行为一致性\n\n两种调用方式使用相同的核心模块，唯一的差异在于：\n\n- MCP 模式启用写入沙箱 (`save_forbidden` 错误码)\n- CLI 模式完全不受限（人类用户是安全边界）\n\n资料来源：[README.md:49-54]()\n\n## 版本历史\n\n| 版本 | 变更内容 |\n|---|---|\n| 0.4.0 | 引入 HTTP/2 传输和 Chrome 请求头集，`MARKFETCH_USER_AGENT` 环境变量 |\n| 0.5.0 | 新增 CLI 模式，保持 MCP 和 CLI 行为一致 |\n| 0.6.0 | 完善错误码体系和环境变量验证 |\n\n资料来源：[CHANGELOG.md:1-15]()\n\n---\n\n<a id='content-extraction'></a>\n\n## 内容提取管道\n\n### 相关页面\n\n相关主题：[HTTP 指纹与请求模拟](#http-fingerprint), [错误处理机制](#error-handling)\n\n<details>\n<summary>相关源码文件</summary>\n\n以下源码文件用于生成本页说明：\n\n- [src/core.ts](https://github.com/vasylenko/markfetch/blob/main/src/core.ts)\n- [src/mcp.ts](https://github.com/vasylenko/markfetch/blob/main/src/mcp.ts)\n- [src/cli.ts](https://github.com/vasylenko/markfetch/blob/main/src/cli.ts)\n- [src/sandbox.ts](https://github.com/vasylenko/markfetch/blob/main/src/sandbox.ts)\n- [package.json](https://github.com/vasylenko/markfetch/blob/main/package.json)\n- [README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)\n- [CHANGELOG.md](https://github.com/vasylenko/markfetch/blob/main/CHANGELOG.md)\n</details>\n\n# 内容提取管道\n\n## 概述\n\n内容提取管道（Content Extraction Pipeline）是 markfetch 项目的核心模块，负责将任意 HTTP/HTTPS URL 的 HTML 内容转换为干净的 Markdown 格式。该管道是纯 Node.js 实现，不依赖 Playwright 或无头浏览器，通过 HTTP/2 传输和完整的 Chrome 请求头集实现真实浏览器指纹模拟。\n\n资料来源：[README.md:1-15]()\n\n## 架构总览\n\nmarkfetch 的内容提取管道遵循经典的\"获取-解析-转换\"三阶段架构。整个管道从 URL 输入开始，依次经过网络请求层、内容解析层和 Markdown 转换层，最终输出结构化的 Markdown 文档。\n\n```mermaid\ngraph TD\n    A[URL 输入] --> B[HTTP/2 网络请求]\n    B --> C{响应状态码}\n    C -->|2xx| D[HTML 内容]\n    C -->|非2xx| E[http_error]\n    D --> F[内容类型检测]\n    F -->|text/html| G[解码编码标签]\n    F -->|非HTML| I[unsupported_content_type]\n    G --> H[注入 Base Href]\n    H --> J[Readability 解析]\n    J --> K{提取结果}\n    K -->|有内容| L[转换为 Markdown]\n    K -->|无内容| M[extraction_failed]\n    L --> N[大小检查]\n    N -->|未超限| O[返回结果]\n    N -->|超出限制| P[too_large]\n    O --> Q{savePath}\n    Q -->|指定路径| R[沙盒写入]\n    Q -->|标准输出| S[stdout 输出]\n```\n\n资料来源：[src/core.ts:1-100]()\n\n## 核心组件\n\n### 核心模块结构\n\nmarkfetch 的核心提取逻辑集中在 `src/core.ts` 中，包含以下关键函数：\n\n| 函数名 | 职责 | 返回值 |\n|--------|------|--------|\n| `decodeEncodedCodeTags()` | 解码 HTML 实体编码的代码标签 | `string` |\n| `ensureBaseHref()` | 注入 `<base>` 标签确保相对路径正确解析 | `string` |\n| `rewriteForReadability()` | 重写 DOM 结构以优化提取效果 | `void` |\n| `extractArticle()` | 使用 Readability 提取文章内容 | `{title, content} \\| null` |\n| `convertToMarkdown()` | 将 HTML 转换为 Markdown | `string` |\n\n资料来源：[src/core.ts:1-150]()\n\n### 依赖库\n\n| 库名 | 版本 | 用途 |\n|------|------|------|\n| `@mozilla/readability` | 最新稳定版 | 从 HTML 中提取主要文章内容 |\n| `turndown` | 最新稳定版 | 将 HTML DOM 转换为 Markdown |\n| `linkedom` | 最新稳定版 | 轻量级 DOM 解析器 |\n| `undici` | 最新稳定版 | HTTP/2 客户端 |\n\n资料来源：[package.json:1-30]()\n\n## 管道各阶段详解\n\n### 阶段一：HTML 实体解码\n\n```typescript\nfunction decodeEncodedCodeTags(html: string): string {\n  return html.replaceAll(\n    /&lt;(\\/?(?:code|pre)(?:\\s[^&]*?)?\\/?)&gt;/g,\n    (_, tag) => `<${tag}>`,\n  );\n}\n```\n\n此函数专门处理 HTML 实体编码的 `<code>` 和 `<pre>` 标签。由于代码块在转换过程中需要保留原始格式，markfetch 需要将这些被实体编码的标签转换回标准 HTML 标签形式。\n\n正则表达式说明：\n- `&lt;` - 匹配左尖括号的实体编码形式\n- `\\/?` - 可选的闭合斜杠\n- `(?:code|pre)` - 匹配 `code` 或 `pre` 标签名\n- `(?:\\s[^&]*?)?` - 可选的属性部分\n- `&gt;` - 匹配右尖括号的实体编码形式\n\n资料来源：[src/core.ts:10-18]()\n\n### 阶段二：Base Href 注入\n\n```typescript\nfunction ensureBaseHref(html: string, url: string): string {\n  const safeUrl = url.replaceAll(\"&\", \"&amp;\").replaceAll('\"', \"&quot;\");\n  const stripped = html.replaceAll(/<base\\s[^>]*>/gi, \"\");\n  if (/<head\\b[^>]*>/i.test(stripped)) {\n    return stripped.replace(\n      /<head\\b([^>]*)>/i,\n      `<head$1><base href=\"${safeUrl}\">`,\n    );\n  }\n  if (/<html\\b[^>]*>/i.test(stripped)) {\n    return stripped.replace(\n      /<html\\b([^>]*)>/i,\n      `<html$1><head><base href=\"${safeUrl}\"></head>`,\n    );\n  }\n  return stripped;\n}\n```\n\n此函数的目的是确保相对链接和图片路径能够正确解析。由于 linkedom 解析器不会自动填充 `baseURI`，导致相对路径（如 `/wiki/...`）无法正确转换为绝对 URL。管道通过以下步骤解决此问题：\n\n1. 移除页面原有的 `<base>` 标签（避免冲突）\n2. 在 `<head>` 标签内注入新的 `<base href>` 指向最终重定向后的 URL\n3. 如果没有 `<head>` 标签，则在 `<html>` 标签后创建新的 `<head>`\n\n资料来源：[src/core.ts:25-50]()\n\n### 阶段三：DOM 重写优化\n\n```typescript\nfunction rewriteForReadability(document: Document): void {\n  // 处理脚注aside元素\n  const footnoteAsides = document.querySelectorAll(\n    'aside.footnote-brackets, ' +\n    'aside[role=\"doc-endnotes\"], aside[role=\"doc-footnote\"], aside[role=\"doc-footnotes\"]',\n  );\n  \n  // 处理details/summary折叠元素\n  for (const el of Array.from(document.querySelectorAll(\"details\"))) {\n    const parent = el.parentNode;\n    if (!parent) continue;\n    while (el.firstChild) parent.insertBefore(el.firstChild, el);\n    el.remove();\n  }\n  \n  // 处理 MediaWiki 样式标题容器\n  for (const el of Array.from(document.querySelectorAll(\"div.mw-heading\"))) {\n    const heading = el.querySelector(\"h1, h2, h3, h4, h5, h6\");\n    if (!heading) continue;\n    el.parentNode?.replaceChild(heading, el);\n  }\n}\n```\n\n此函数对 DOM 结构进行预处理，使 Readability 能够更准确地提取主要内容：\n\n| 处理类型 | 选择器 | 处理方式 |\n|----------|--------|----------|\n| 脚注区域 | `aside.footnote-*` | 替换为 `<section>` 以保留内容 |\n| 折叠元素 | `details` | 展开内容并移除容器 |\n| 标题容器 | `div.mw-heading` | 提换为纯标题元素 |\n\n资料来源：[src/core.ts:75-115]()\n\n### 阶段四：Readability 内容提取\n\n```typescript\nfunction extractArticle(\n  html: string,\n  url: string,\n): { title: string; content: string } | null {\n  const decoded = decodeEncodedCodeTags(html);\n  const withBase = ensureBaseHref(decoded, url);\n  const { document } = parseHTML(withBase);\n  rewriteForReadability(document);\n  \n  const article = new Readability(document, {\n    keepClasses: true,\n  }).parse();\n  \n  if (!article?.content?.trim()) return null;\n  return { title: (article.title ?? \"\").trim(), content: article.content };\n}\n```\n\nReadability 是 Mozilla 开发的专门用于从网页中提取主要文章内容的库。markfetch 的配置使用 `keepClasses: true` 选项，目的是保留 `<code>` 元素的 `class=\"language-X\"` 属性，使 turndown 能够在代码块中输出语言提示标记。\n\n资料来源：[src/core.ts:118-145]()\n\n### 阶段五：Markdown 转换\n\n```typescript\nfunction convertToMarkdown(article: {\n  title: string;\n  content: string;\n}): string {\n  const body = TURNDOWN.turndown(article.content);\n  \n  // 如果 Readability 保留了页面的 <h1>，不重复添加标题\n  const contentLeadsWithH1 = /^\\s*<h1[\\s>]/i.test(article.content);\n  let result = article.title && !contentLeadsWithH1\n    ? `# ${article.title}\\n\\n${body}`\n    : body;\n  \n  // 移除空标题\n  result = pruneEmptyHeadings(result);\n  \n  // 处理代码块语言提示\n  result = patchCodeFenceLanguageHints(result);\n  \n  return result;\n}\n```\n\nTurndown 配置了自定义的转义规则，以解决以下问题：\n\n1. **行内下划线保护**：Markdown 中行内代码外的下划线通常需要转义，但 markfetch 保留了未转义的形式以提高可读性\n2. **标题下划线处理**：CommonMark 的 setext 标题使用 `=` 或 `-` 字符，但后面紧跟字母数字字符的情况不是标题，应保留原样\n\n资料来源：[src/core.ts:50-75]()\n\n## 错误处理机制\n\n### 错误代码表\n\n| 错误代码 | 含义 | 触发条件 |\n|----------|------|----------|\n| `network_error` | 网络层故障 | DNS 解析、TCP 连接、TLS 握手失败 |\n| `http_error` | HTTP 协议错误 | 服务器返回非 2xx 状态码 |\n| `timeout` | 请求超时 | 超过 `MARKFETCH_TIMEOUT_MS` 配置的时间 |\n| `unsupported_content_type` | 不支持的类型 | 响应不是 `text/html` 或 `application/xhtml+xml` |\n| `extraction_failed` | 提取失败 | Readability 返回空内容（典型于纯客户端渲染 SPA） |\n| `too_large` | 内容过大 | 响应体或提取后的 Markdown 超过 `MARKFETCH_MAX_BYTES` |\n| `save_failed` | 保存失败 | 指定了 `savePath` 但写入文件失败 |\n| `save_forbidden` | 写入被禁止 | `savePath` 超出允许的写入根目录 |\n\n资料来源：[README.md:60-80]()\n\n### 错误传播流程\n\n```mermaid\ngraph LR\n    A[核心模块抛出] --> B[MarkfetchError]\n    B --> C[MCP 适配器]\n    B --> D[CLI 适配器]\n    C --> E[errorResult 格式化]\n    D --> F[classifyError 处理]\n    E --> G[返回给 LLM]\n    F --> H[stderr 输出]\n    H --> I[非零退出码]\n```\n\n资料来源：[src/mcp.ts:1-50]() 和 [src/cli.ts:1-50]()\n\n## 配置参数\n\n### 环境变量配置\n\n| 变量名 | 默认值 | 用途 |\n|--------|--------|------|\n| `MARKFETCH_TIMEOUT_MS` | `30000` | 单次请求超时时间（毫秒） |\n| `MARKFETCH_MAX_BYTES` | `5000000` | 响应体和提取 Markdown 的最大字节数 |\n| `MARKFETCH_USER_AGENT` | Chrome 130 UA 字符串 | HTTP User-Agent 头 |\n| `MARKFETCH_ALLOWED_WRITE_ROOTS` | `os.tmpdir()` + `process.cwd()` | MCP 模式下允许写入的根目录列表 |\n\n所有配置变量在启动时进行验证，无效值会立即失败并输出到 stderr，避免产生难以调试的运行时错误。\n\n资料来源：[README.md:55-65]()\n\n## 适配器模式\n\nmarkfetch 采用适配器模式同时支持 CLI 和 MCP 两种调用方式。入口文件 `src/index.ts` 根据 `process.argv.length` 延迟加载对应的适配器：\n\n```typescript\n// 伪代码示例\nif (process.argv.length > 1) {\n  // CLI 模式\n  import('./cli.js');\n} else {\n  // MCP 模式\n  import('./mcp.js');\n}\n```\n\n这种设计确保了\"stdout 保留给 MCP 帧\"这一不变量是结构性的——CLI 代码在 MCP 模式下永远不会加载，因此不存在通过 `console.log` 污染输出的可能性。\n\n资料来源：[CHANGELOG.md:40-60]()\n\n## 安全沙盒\n\nMCP 模式下的文件写入操作受到沙盒限制，防止恶意提示词诱导 LLM 将文件写入任意位置：\n\n```mermaid\ngraph TD\n    A[savePath 参数] --> B{绝对路径检查}\n    B -->|否| C[返回 save_forbidden]\n    B -->|是| D[符号链接解析]\n    D --> E{路径限制检查}\n    E -->|在允许范围内| F[写入文件]\n    E -->|超出允许范围| G[返回 save_forbidden]\n```\n\n沙盒默认允许写入 `os.tmpdir()` 和 `process.cwd()` 目录。可以通过 `MARKFETCH_ALLOWED_WRITE_ROOTS` 环境变量覆盖（注意：覆盖是替换而非合并）。\n\n资料来源：[src/sandbox.ts:1-80]()\n\n## 性能特性\n\n### 内存效率\n\nmarkfetch 采用流式处理策略：\n- 响应体直接流入解析器，不经过完整的内存缓冲\n- DOM 操作在 linkedom 轻量级解析器中完成，相比原生 DOM 节省内存\n- Markdown 转换采用增量处理，避免一次性加载整个文档树\n\n### 大小限制\n\n`MARKFETCH_MAX_BYTES` 限制应用于两个阶段：\n1. 原始响应体大小\n2. 转换后的 Markdown 大小\n\n如果任一阶段超出限制，管道返回 `too_large` 错误而非返回不完整的内容。\n\n资料来源：[README.md:50-55]()\n\n## 版本历史\n\n| 版本 | 变更内容 |\n|------|----------|\n| 0.4.0 | 初始 MCP 工具实现，引入 Readability + Turndown 管道 |\n| 0.4.1 | 修复 `bin` 入口路径问题，改进文档 |\n| 0.5.0 | 新增 CLI 模式，引入适配器架构 |\n| 0.6.0 | 新增沙盒写入限制，8 种确定性错误码体系完成 |\n\n资料来源：[CHANGELOG.md:1-100]()\n\n---\n\n<a id='write-sandbox'></a>\n\n## 写操作沙箱\n\n### 相关页面\n\n相关主题：[MCP 服务器](#mcp-server), [配置与环境变量](#configuration)\n\n<details>\n<summary>相关源码文件</summary>\n\n以下源码文件用于生成本页说明：\n\n- [src/sandbox.ts](https://github.com/vasylenko/markfetch/blob/main/src/sandbox.ts)\n- [src/mcp.ts](https://github.com/vasylenko/markfetch/blob/main/src/mcp.ts)\n- [README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)\n</details>\n\n# 写操作沙箱\n\n## 概述\n\n写操作沙箱（Write Sandbox）是 markfetch 项目中为 MCP（Model Context Protocol）模式设计的文件系统写入安全机制。其核心功能是限制 MCP 工具的 `savePath` 参数只能写入预定义的允许目录范围内，防止语言模型通过路径遍历（如 `../../etc/passwd`）将文件写入敏感位置。\n\n该沙箱**仅在 MCP 模式下生效**，CLI 模式不执行任何沙箱检查，因为在命令行环境中人类用户本身就是安全边界。\n\n资料来源：[README.md:沙箱设计说明]()\n\n## 架构设计\n\n### 组件关系\n\n```mermaid\ngraph TD\n    A[MCP 客户端调用 fetch_markdown] --> B{是否提供 savePath?}\n    B -->|否| Z[直接返回 markdown]\n    B -->|是| C[沙箱校验模块]\n    C --> D[路径解析]\n    C --> E[符号链接展开]\n    C --> F[边界检查]\n    D --> G{是否在允许范围内?}\n    G -->|是| H[写入文件]\n    G -->|否| I[返回 save_forbidden 错误]\n    \n    J[环境变量配置] --> C\n    K[默认根目录] --> C\n```\n\n### 核心模块\n\n| 模块 | 文件位置 | 职责 |\n|------|----------|------|\n| 沙箱校验核心 | `src/sandbox.ts` | 实现路径合法性检查逻辑 |\n| MCP 适配器 | `src/mcp.ts` | 在工具调用前调用沙箱校验 |\n| 环境配置解析 | `src/mcp.ts` | 读取并验证 `MARKFETCH_ALLOWED_WRITE_ROOTS` |\n\n资料来源：[src/sandbox.ts:1-50](), [src/mcp.ts:构建允许根目录]()\n\n## 工作流程\n\n### 写入校验流程\n\n```mermaid\nsequenceDiagram\n    participant MCP as MCP 客户端\n    participant Server as markfetch Server\n    participant Sandbox as 沙箱模块\n    participant FS as 文件系统\n    \n    MCP->>Server: fetch_markdown(url, savePath=\"/tmp/out.md\")\n    Server->>Sandbox: validatePath(\"/tmp/out.md\")\n    Sandbox->>Sandbox: realpath(\"/tmp/out.md\")\n    Sandbox->>Sandbox: 获取 allowedRoots\n    Sandbox->>Sandbox: 检查相对路径\n    alt 路径在允许范围内\n        Sandbox-->>Server: { ok: true, resolved: \"/private/tmp/out.md\" }\n        Server->>FS: writeFile(resolved)\n        FS-->>Server: 写入成功\n        Server-->>MCP: { content: [...], isError: false }\n    else 路径超出范围\n        Sandbox-->>Server: { ok: false, reason: \"...\" }\n        Server-->>MCP: { content: \"[save_forbidden] ...\", isError: true }\n    end\n```\n\n### 默认允许根目录\n\n启动时，markfetch 会自动解析并记录两个默认写根目录：\n\n| 默认根目录 | 获取方式 | 说明 |\n|------------|----------|------|\n| 系统临时目录 | `os.tmpdir()` | 通常为 `/tmp`（POSIX）或 `C:\\Users\\xxx\\AppData\\Local\\Temp`（Windows） |\n| 当前工作目录 | `process.cwd()` | markfetch 进程启动时的目录 |\n\n两个路径都会通过 `fs.realpath` 展开符号链接，确保路径规范唯一。\n\n资料来源：[src/mcp.ts:构建 allowedRoots](), [README.md:默认值说明]()\n\n## 路径校验算法\n\n### 核心检查逻辑\n\n沙箱的路径校验算法位于 `src/sandbox.ts`，主要步骤如下：\n\n```mermaid\ngraph TD\n    A[接收 savePath] --> B[realpath 解析符号链接]\n    B --> C{Win32 平台?}\n    C -->|是| D[转换为小写比较]\n    C -->|否| E[保持原大小写]\n    D --> F[遍历 allowedRoots]\n    E --> F\n    F --> G{计算相对路径}\n    G --> H{rel === ''?}\n    H -->|是| J[允许 - 目标即为根目录]\n    G --> K{rel 不以 '..' 开头且非绝对路径?}\n    K -->|是| J\n    K -->|否| L[拒绝 - 路径超出边界]\n    L --> M[返回 ok: false]\n    J --> N[返回 ok: true]\n```\n\n### Windows 大小写处理\n\nWindows 文件系统大小写不敏感，但 `fs.realpath` 不会自动规范化大小写。为防止 `/Users/Me/../me/` 这类绕过检查，沙箱在 Windows 平台会将路径和小写的根目录都转换为小写进行比较。\n\n```typescript\nconst fold = process.platform === \"win32\"\n  ? (s: string) => s.toLowerCase()\n  : (s: string) => s;\n```\n\n资料来源：[src/sandbox.ts:Win32 case-fold 处理]()\n\n## 配置选项\n\n### 环境变量\n\n| 环境变量 | 默认值 | 说明 |\n|----------|--------|------|\n| `MARKFETCH_ALLOWED_WRITE_ROOTS` | `os.tmpdir() + ':' + process.cwd()` | 冒号（POSIX）或分号（Windows）分隔的绝对路径列表 |\n\n### 配置规则\n\n1. **替换而非合并**：设置该变量会**完全替换**默认值，不会追加\n2. **必须为绝对路径**：每个路径必须是绝对路径，相对路径会导致启动失败\n3. **目录必须存在**：路径指向的目录必须在启动时存在，否则失败\n4. **平台分隔符**：\n   - POSIX（Linux/macOS）：使用冒号 `:` 分隔\n   - Windows：使用分号 `;` 分隔\n\n### 配置示例\n\n**Linux/macOS 配置：**\n```json\n{\n  \"mcpServers\": {\n    \"markfetch\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"markfetch\"],\n      \"env\": {\n        \"MARKFETCH_ALLOWED_WRITE_ROOTS\": \"/Users/me/markfetch-out:/tmp\"\n      }\n    }\n  }\n}\n```\n\n**Windows 配置：**\n```json\n{\n  \"mcpServers\": {\n    \"markfetch\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"markfetch\"],\n      \"env\": {\n        \"MARKFETCH_ALLOWED_WRITE_ROOTS\": \"C:\\\\Users\\\\me\\\\markfetch-out;C:\\\\Users\\\\me\\\\AppData\\\\Local\\\\Temp\"\n      }\n    }\n  }\n}\n```\n\n资料来源：[README.md:环境变量配置说明]()\n\n## 符号链接处理\n\n### 安全修复历史\n\n早期版本存在符号链接逃逸漏洞：检查时对 `<sandbox>/link/../out.md` 进行路径规范化后看似合法，但写入时路径从左到右解析符号链接，导致实际写入位置超出沙箱。\n\n**修复方案**：将 `realpath` 解析后的规范路径直接传递给 `writeFile`，确保检查路径和写入路径完全一致。\n\n| 版本 | 问题 | 影响 |\n|------|------|------|\n| 修复前 | 检查规范化路径，但写入原始路径 | `link/..` 可逃逸到沙箱外 |\n| 修复后 | 检查路径即为写入路径 | 无逃逸可能 |\n\n资料来源：[CHANGELOG.md:符号链接逃逸修复说明]()\n\n## 错误处理\n\n### 错误码\n\n| 错误码 | 触发条件 | 返回内容示例 |\n|--------|----------|--------------|\n| `save_forbidden` | `savePath` 解析后不在允许的写根目录内 | `[save_forbidden] '/etc/passwd' is outside the allowed write roots: ['/tmp', '/project']` |\n\n### 返回数据结构\n\n```typescript\n// 允许的路径\n{\n  ok: true,\n  resolved: \"/private/tmp/out.md\"  // realpath 展开后的规范路径\n}\n\n// 拒绝的路径\n{\n  ok: false,\n  reason: \"'/etc/passwd' is outside the allowed write roots: ['/tmp', '/project']\"\n}\n```\n\n注意：确认消息中仍会回显原始 `savePath`，以确保在 tmpdir 本身是符号链接的主机（如 macOS `/var` → `/private/var`）上消息的稳定性。\n\n资料来源：[src/sandbox.ts:返回值结构](), [src/mcp.ts:错误转换]()\n\n## MCP 工具 schema 约束\n\nMCP 适配器在 `src/mcp.ts` 中使用 Zod 对 `savePath` 参数进行预校验：\n\n```typescript\nsavePath: z\n  .string()\n  .refine(isAbsolute, \"savePath must be an absolute filesystem path\")\n  .optional()\n```\n\n这确保了：\n1. `savePath` 必须是字符串\n2. 字符串必须是绝对路径（`isAbsolute` 校验）\n3. 参数可选（未提供时直接返回 markdown）\n\n如果路径不是绝对路径，校验会直接失败，不会进入沙箱检查阶段。\n\n资料来源：[src/mcp.ts:savePath schema 定义]()\n\n## CLI 模式差异\n\n| 特性 | MCP 模式 | CLI 模式 |\n|------|----------|----------|\n| 沙箱检查 | ✅ 启用 | ❌ 禁用 |\n| 路径要求 | 必须为绝对路径 | 可为相对路径（相对 cwd） |\n| 符号链接检查 | ✅ 启用 | ❌ 禁用 |\n| 错误码 | `save_forbidden` | 直接拒绝写入 |\n\nCLI 模式的无限制设计基于以下假设：命令行环境中的人类用户是天然的安全边界，可以自行判断路径的适当性。\n\n资料来源：[README.md:CLI 模式说明](), [README.md:沙箱设计说明]()\n\n## 安全考量\n\n### 设计原则\n\n1. **最小权限原则**：默认只允许写入临时目录和当前工作目录\n2. **显式优于隐式**：环境变量会替换而非合并默认配置\n3. **fail-fast**：配置错误在启动时立即失败，而非运行时\n4. **路径规范化**：所有路径经过 `realpath` 展开，防止符号链接攻击\n\n### 潜在风险\n\n- **并发写入冲突**：多个请求写入相同文件名时，后写覆盖前写（无锁保护）\n- **临时目录清理**：系统清理 `/tmp` 可能导致未保存的文件丢失\n- **符号链接竞态**：在路径检查和写入之间存在微小时间窗口，攻击难度极高但理论上可能\n\n资料来源：[README.md:安全说明](), [src/sandbox.ts:安全设计]()\n\n---\n\n<a id='configuration'></a>\n\n## 配置与环境变量\n\n### 相关页面\n\n相关主题：[命令行界面](#cli-usage), [MCP 服务器](#mcp-server), [写操作沙箱](#write-sandbox)\n\n<details>\n<summary>相关源码文件</summary>\n\n以下源码文件用于生成本页说明：\n\n- [src/core.ts](https://github.com/vasylenko/markfetch/blob/main/src/core.ts)\n- [src/mcp.ts](https://github.com/vasylenko/markfetch/blob/main/src/mcp.ts)\n- [src/cli.ts](https://github.com/vasylenko/markfetch/blob/main/src/cli.ts)\n- [src/sandbox.ts](https://github.com/vasylenko/markfetch/blob/main/src/sandbox.ts)\n- [package.json](https://github.com/vasylenko/markfetch/blob/main/package.json)\n</details>\n\n# 配置与环境变量\n\nmarkfetch 通过环境变量提供运行时配置能力，支持自定义超时、响应大小限制、用户代理以及写入沙箱策略。这些配置项在服务启动时进行验证，确保在处理请求前就能捕获无效配置。\n\n## 环境变量总览\n\nmarkfetch 提供以下四个环境变量用于运行时配置：\n\n| 变量名 | 默认值 | 说明 |\n|--------|--------|------|\n| `MARKFETCH_TIMEOUT_MS` | `30000` | 单次请求超时时间（毫秒） |\n| `MARKFETCH_MAX_BYTES` | `5000000` | 响应体和提取后 Markdown 的最大字节数 |\n| `MARKFETCH_USER_AGENT` | Chrome 130 固定字符串 | HTTP 请求的用户代理 |\n| `MARKFETCH_ALLOWED_WRITE_ROOTS` | `os.tmpdir()` + `process.cwd()` | MCP 模式下允许写入的根目录列表 |\n\n所有环境变量在服务启动时进行验证，无效值会快速失败并将错误输出到 stderr，避免在请求处理时才产生混淆的错误信息。资料来源：[README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)\n\n## 超时配置\n\n`MARKFETCH_TIMEOUT_MS` 控制单个 HTTP 请求的最大等待时间。当请求超过设定时间未完成时，返回 `timeout` 错误码。资料来源：[README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)\n\n```json\n{\n  \"mcpServers\": {\n    \"markfetch\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"markfetch\"],\n      \"env\": {\n        \"MARKFETCH_TIMEOUT_MS\": \"60000\"\n      }\n    }\n  }\n}\n```\n\n增加超时值适用于网络环境较差或目标服务器响应缓慢的场景。\n\n## 响应大小限制\n\n`MARKFETCH_MAX_BYTES` 设置响应体和最终提取的 Markdown 内容的大小上限。当任一阶段超过此限制时，返回 `too_large` 错误码。资料来源：[README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)\n\n```\nMARKFETCH_MAX_BYTES=5000000\n```\n\n默认值约 5MB，适用于绝大多数网页场景。超大型文档建议使用 `savePath` 参数将结果直接写入磁盘。\n\n## 用户代理配置\n\n`MARKFETCH_USER_AGENT` 用于自定义 HTTP 请求头中的 User-Agent 字段。默认值是固定的 Chrome 130 字符串，用于模拟真实浏览器指纹。资料来源：[README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)\n\n```json\n{\n  \"env\": {\n    \"MARKFETCH_ALLOWED_WRITE_ROOTS\": \"/Users/me/markfetch-out:/tmp\"\n  }\n}\n```\n\nmarkfetch 会从 `MARKFETCH_USER_AGENT` 推导出 `Sec-CH-UA-*` 客户端提示头。非 Chrome 浏览器的 UA 字符串会启动时快速失败。\n\n## 写入沙箱（Write Sandbox）\n\n写入沙箱是 MCP 模式下特有的安全机制，用于限制 `savePath` 参数可以写入的目录范围。CLI 模式下不实施沙箱限制，因为命令行用户本身就是安全边界。资料来源：[README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)\n\n### 默认允许的写入根目录\n\n默认情况下，允许写入的根目录为：\n\n- 系统临时目录（`os.tmpdir()`）\n- 当前工作目录（`process.cwd()`）\n\n两个路径在服务启动时通过 `fs.realpath` 解析一次，后续路径验证使用解析后的绝对路径。\n\n### 自定义允许根目录\n\n通过 `MARKFETCH_ALLOWED_WRITE_ROOTS` 可以覆盖默认的允许根目录列表：\n\n| 平台 | 路径分隔符 | 示例 |\n|------|-----------|------|\n| POSIX | `:` | `/Users/me/markfetch-out:/tmp` |\n| Windows | `;` | `C:\\Users\\me\\markfetch-out;C:\\Users\\me\\AppData\\Local\\Temp` |\n\n配置此变量会**完全替换**默认值，不会合并。因此如果需要保留临时目录访问权限，必须显式列出。资料来源：[README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)\n\n```json\n{\n  \"mcpServers\": {\n    \"markfetch\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"markfetch\"],\n      \"env\": {\n        \"MARKFETCH_ALLOWED_WRITE_ROOTS\": \"/Users/me/markfetch-out:/tmp\"\n      }\n    }\n  }\n}\n```\n\n### 沙箱验证逻辑\n\n沙箱验证在 `src/sandbox.ts` 中实现，核心逻辑如下：资料来源：[src/sandbox.ts](https://github.com/vasylenko/markfetch/blob/main/src/sandbox.ts)\n\n```typescript\n// 将 savePath 重新附加到可能的根目录\nconst reattached = isAbsolute(savePath)\n  ? savePath\n  : join(resolvedAncestor, ...trailing);\n\n// Windows 平台大小写不敏感，使用小写比较\nconst fold = process.platform === \"win32\"\n  ? (s: string) => s.toLowerCase()\n  : (s: string) => s;\n\n// 检查重附加后的路径是否在允许的根目录范围内\nfor (const root of roots) {\n  const rel = relative(fold(root), foldedTarget);\n  if (rel === \"\") return { ok: true, resolved: reattached };\n  if (!rel.startsWith(\"..\") && !isAbsolute(rel)) {\n    return { ok: true, resolved: reattached };\n  }\n}\n```\n\n### 验证失败处理\n\n当 `savePath` 解析后不在允许的根目录范围内时：\n\n- MCP 模式返回 `save_forbidden` 错误码\n- CLI 模式不受沙箱限制，无此检查\n\n```typescript\nreturn {\n  ok: false,\n  reason: `'${reattached}' is outside the allowed write roots: [${roots.map((r) => `'${r}'`).join(\", \")}]`\n};\n```\n\n### 符号链接处理\n\n沙箱机制会阻止指向允许根目录外的符号链接。每个路径都通过 `fs.realpath` 解析后再验证。资料来源：[README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)\n\n### 配置验证\n\n沙箱配置在服务启动时进行验证：\n\n- 路径必须是绝对路径\n- 目录必须存在\n- 格式错误会在启动时快速失败并输出到 stderr\n\n## 配置传递方式\n\n### MCP 模式\n\nMCP 客户端通过配置文件的 `env` 块传递环境变量：资料来源：[src/mcp.ts](https://github.com/vasylenko/markfetch/blob/main/src/mcp.ts)\n\n```json\n{\n  \"mcpServers\": {\n    \"markfetch\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"markfetch\"],\n      \"env\": {\n        \"MARKFETCH_TIMEOUT_MS\": \"60000\",\n        \"MARKFETCH_MAX_BYTES\": \"10000000\",\n        \"MARKFETCH_USER_AGENT\": \"Mozilla/5.0 ...\",\n        \"MARKFETCH_ALLOWED_WRITE_ROOTS\": \"/custom/path\"\n      }\n    }\n  }\n}\n```\n\n### CLI 模式\n\nCLI 模式下可直接通过 shell 环境变量设置：\n\n```bash\nexport MARKFETCH_TIMEOUT_MS=60000\nmarkfetch https://example.com\n```\n\n或在一行内设置：\n\n```bash\nMARKFETCH_TIMEOUT_MS=60000 markfetch https://example.com\n```\n\n## 架构流程图\n\n```mermaid\ngraph TD\n    A[服务启动] --> B{环境变量验证}\n    B -->|有效| C[启动 MCP/CLI 服务]\n    B -->|无效| D[输出错误到 stderr<br/>进程退出]\n    \n    C --> E[接收请求]\n    E --> F{MCP 模式?}\n    F -->|是| G[检查 savePath 沙箱]\n    F -->|否| H[直接处理请求]\n    \n    G -->|在允许范围内| H\n    G -->|超出范围| I[返回 save_forbidden]\n    \n    H --> J[执行 fetch_markdown]\n    J --> K{超时?}\n    J --> L{响应大小?}\n    J --> M{内容类型?}\n    \n    K -->|是| N[返回 timeout]\n    L -->|是| O[返回 too_large]\n    M -->|否| P[返回 unsupported_content_type]\n    \n    J --> Q[提取文章内容]\n    Q --> R{提取成功?}\n    R -->|否| S[返回 extraction_failed]\n    R -->|是| T[转换为 Markdown]\n    \n    T --> U{保存到文件?}\n    U -->|是| V[写入 savePath]\n    U -->|否| W[返回 content[0].text]\n    \n    V --> X[返回确认信息]\n```\n\n## 错误码与配置关系\n\n| 错误码 | 相关配置 | 说明 |\n|--------|----------|------|\n| `timeout` | `MARKFETCH_TIMEOUT_MS` | 请求超时 |\n| `too_large` | `MARKFETCH_MAX_BYTES` | 响应或内容超出限制 |\n| `save_failed` | 路径写入权限 | 写入文件失败 |\n| `save_forbidden` | `MARKFETCH_ALLOWED_WRITE_ROOTS` | 路径超出沙箱范围 |\n\n资料来源：[README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)\n\n## 最佳实践建议\n\n1. **生产环境**：建议设置合理的 `MARKFETCH_TIMEOUT_MS`（如 30000-60000ms）和 `MARKFETCH_MAX_BYTES`（如 5-10MB）\n\n2. **MCP 部署**：显式设置 `MARKFETCH_ALLOWED_WRITE_ROOTS`，明确允许的输出目录\n\n3. **安全考虑**：不要在 MCP 模式下完全移除沙箱限制，保持最小权限原则\n\n4. **Windows 平台**：使用 `;` 作为分隔符，并确保路径格式正确（反斜杠或正斜杠均可）\n\n5. **调试场景**：可以临时增大超时和大小限制来排查问题\n\n---\n\n<a id='error-handling'></a>\n\n## 错误处理机制\n\n### 相关页面\n\n相关主题：[命令行界面](#cli-usage), [MCP 服务器](#mcp-server), [内容提取管道](#content-extraction)\n\n<details>\n<summary>相关源码文件</summary>\n\n以下源码文件用于生成本页说明：\n\n- [src/core.ts](https://github.com/vasylenko/markfetch/blob/main/src/core.ts)\n- [src/cli.ts](https://github.com/vasylenko/markfetch/blob/main/src/cli.ts)\n- [src/mcp.ts](https://github.com/vasylenko/markfetch/blob/main/src/mcp.ts)\n- [src/sandbox.ts](https://github.com/vasylenko/markfetch/blob/main/src/sandbox.ts)\n- [README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)\n- [CHANGELOG.md](https://github.com/vasylenko/markfetch/blob/main/CHANGELOG.md)\n</details>\n\n# 错误处理机制\n\n## 概述\n\nmarkfetch 采用统一的错误处理架构，确保在 CLI 和 MCP 两种运行模式下都能提供一致的、机器可读的诊断信息。所有错误均通过预定义的错误代码标识，支持确定性错误处理，便于调用方进行逻辑分支和日志记录。\n\n核心设计原则：\n\n- **确定性错误代码**：8 种标准化错误代码覆盖所有故障场景\n- **统一错误类型**：`MarkfetchError` 类在 core 层统一抛出\n- **适配器隔离**：CLI 和 MCP 适配器各自负责错误格式转换\n- **启动时验证**：环境变量在进程启动时即进行校验，失败快速报错\n\n资料来源：[README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)\n\n---\n\n## 错误代码体系\n\n### 错误代码一览表\n\n| 错误代码 | 含义 | 触发条件 |\n|---------|------|----------|\n| `network_error` | 网络故障 | DNS 解析失败、TCP 连接失败、TLS 握手错误，或 fetcher 内部未预期错误 |\n| `http_error` | HTTP 错误 | 上游服务器返回非 2xx 状态码 |\n| `timeout` | 请求超时 | 超过 `MARKFETCH_TIMEOUT_MS` 配置的超时时间 |\n| `unsupported_content_type` | 不支持的 Content-Type | 响应不是 `text/html` 或 `application/xhtml+xml` |\n| `extraction_failed` | 内容提取失败 | Readability 算法无法提取文章内容（典型于纯客户端渲染 SPA） |\n| `too_large` | 响应过大 | 响应体或提取的 markdown 超过 `MARKFETCH_MAX_BYTES` |\n| `save_failed` | 文件保存失败 | 指定了 `savePath` 但写入失败（目录不存在、权限不足等） |\n| `save_forbidden` | 写入路径禁止 | `savePath` 超出允许的写入根目录范围 |\n\n资料来源：[README.md:错误代码表](https://github.com/vasylenko/markfetch/blob/main/README.md)\n\n### 错误格式\n\n所有错误均遵循统一格式：`[<错误代码>] <错误消息>`\n\n```\n[network_error] getaddrinfo ENOTFOUND example.invalid\n[http_error] HTTP 403 Forbidden\n[timeout] Per-request timeout exceeded (30000ms)\n[unsupported_content_type] application/json\n[extraction_failed] No article content found\n[too_large] Response body (52428800 bytes) exceeds MARKFETCH_MAX_BYTES (5242880)\n[save_failed] write EPERM /root/restricted.txt\n[save_forbidden] '/etc/passwd' is outside the allowed write roots\n```\n\n资料来源：[src/cli.ts:错误输出格式](https://github.com/vasylenko/markfetch/blob/main/src/cli.ts)\n\n---\n\n## 架构设计\n\n### 错误处理流程图\n\n```mermaid\ngraph TD\n    A[请求入口] --> B{运行模式}\n    B -->|MCP| C[mcp.ts 适配器]\n    B -->|CLI| D[cli.ts 适配器]\n    \n    C --> E[调用 core.fetchMarkdown]\n    D --> E\n    \n    E --> F{处理结果}\n    F -->|成功| G[返回 Markdown]\n    F -->|失败| H[抛出 MarkfetchError]\n    \n    H --> I{MCP 适配器}\n    H --> J{CLI 适配器}\n    \n    I --> K[构建 errorResult]\n    J --> L[classifyError 分类]\n    \n    K --> M[返回 MCP 错误响应<br/>isError: true]\n    L --> N[console.error 输出<br/>process.exitCode = 1]\n    \n    style H fill:#ff6b6b\n    style K fill:#feca57\n    style L fill:#feca57\n    style M fill:#48dbfb\n    style N fill:#48dbfb\n```\n\n### 核心组件职责\n\n| 组件 | 文件 | 职责 |\n|------|------|------|\n| MarkfetchError | src/core.ts | 统一的错误类型，携带错误代码和消息 |\n| errorResult | src/mcp.ts | 构建 MCP 格式的错误响应 |\n| classifyError | src/core.ts | 对异常进行分类，提取代码和消息 |\n| 写入沙箱验证 | src/sandbox.ts | 验证 savePath 是否在允许范围内 |\n\n资料来源：[src/core.ts](https://github.com/vasylenko/markfetch/blob/main/src/core.ts) 和 [src/mcp.ts](https://github.com/vasylenko/markfetch/blob/main/src/mcp.ts)\n\n---\n\n## 统一错误类型\n\n### MarkfetchError 类\n\n```typescript\n// src/core.ts 中的核心实现\nclass MarkfetchError extends Error {\n  constructor(\n    public readonly code: ErrorCode,\n    message: string\n  ) {\n    super(message);\n    this.name = \"MarkfetchError\";\n  }\n}\n```\n\n`MarkfetchError` 是项目的核心异常类型，继承自 `Error`，包含：\n\n- **code**：标准化的错误代码（ErrorCode 枚举）\n- **message**：人类可读的错误描述\n\n资料来源：[src/core.ts](https://github.com/vasylenko/markfetch/blob/main/src/core.ts)\n\n### 错误代码枚举\n\n```typescript\ntype ErrorCode =\n  | \"network_error\"\n  | \"http_error\"\n  | \"timeout\"\n  | \"unsupported_content_type\"\n  | \"extraction_failed\"\n  | \"too_large\"\n  | \"save_failed\"\n  | \"save_forbidden\";\n```\n\n资料来源：[src/core.ts](https://github.com/vasylenko/markfetch/blob/main/src/core.ts)\n\n### 错误分类器\n\n```typescript\n// src/core.ts\nfunction classifyError(err: unknown): { code: string; message: string } {\n  if (err instanceof MarkfetchError) {\n    return { code: err.code, message: err.message };\n  }\n  // 处理未知异常\n  return { code: \"network_error\", message: String(err) };\n}\n```\n\n资料来源：[src/core.ts:classifyError](https://github.com/vasylenko/markfetch/blob/main/src/core.ts)\n\n---\n\n## MCP 适配器错误处理\n\n### MCP 错误响应格式\n\nMCP 适配器通过 `errorResult` 函数构建符合 MCP 协议的错误响应：\n\n```typescript\n// src/mcp.ts\nfunction errorResult(code: ErrorCode, message: string) {\n  return {\n    content: [{ type: \"text\" as const, text: `[${code}] ${message}` }],\n    isError: true,\n  };\n}\n```\n\n响应结构：\n\n| 字段 | 类型 | 说明 |\n|------|------|------|\n| content | Array | 包含单个文本消息的数组 |\n| content[0].type | `\"text\"` | 固定值 |\n| content[0].text | string | 格式化的错误信息 `[code] message` |\n| isError | `true` | 标识为错误响应 |\n\n资料来源：[src/mcp.ts:errorResult](https://github.com/vasylenko/markfetch/blob/main/src/mcp.ts)\n\n### MCP 工具注册\n\n```typescript\nserver.registerTool(\n  \"fetch_markdown\",\n  {\n    description: \"...\",\n    inputSchema: {\n      url: z.string().url(),\n      savePath: z.string().refine(isAbsolute).optional(),\n    },\n  },\n  async ({ url, savePath }) => {\n    try {\n      const { markdown, bytes, savedTo } = await fetchMarkdown({ url, savePath });\n      return {\n        content: [{ type: \"text\", text: markdown }],\n        meta?: { bytes, savedTo },\n      };\n    } catch (err) {\n      const { code, message } = classifyError(err);\n      return errorResult(code, message);\n    }\n  }\n);\n```\n\n资料来源：[src/mcp.ts:工具注册](https://github.com/vasylenko/markfetch/blob/main/src/mcp.ts)\n\n---\n\n## CLI 适配器错误处理\n\n### CLI 错误输出\n\nCLI 适配器使用 `classifyError` 函数对异常进行分类，然后将结果输出到 stderr：\n\n```typescript\n// src/cli.ts\ntry {\n  const { markdown, bytes, savedTo } = await fetchMarkdown({ url, savePath });\n  if (savedTo !== undefined) {\n    console.log(`Saved ${bytes} bytes to ${savedTo}`);\n  } else {\n    process.stdout.write(markdown);\n  }\n} catch (err) {\n  const { code, message } = classifyError(err);\n  console.error(`[${code}] ${message}`);\n  process.exitCode = 1;\n}\n```\n\nCLI 错误处理特点：\n\n- **stderr 专用**：错误信息写入 stderr，不干扰 stdout 的 markdown 输出\n- **进程退出码**：设置 `process.exitCode = 1` 确保管道脚本能检测到失败\n- **优雅关闭**：使用 `exitCode` 而非 `exit()`，确保输出缓冲区排空\n\n资料来源：[src/cli.ts:错误处理](https://github.com/vasylenko/markfetch/blob/main/src/cli.ts)\n\n---\n\n## 写入沙箱错误\n\n### save_forbidden 错误机制\n\n当 MCP 调用指定 `savePath` 时，系统会验证目标路径是否在允许的写入根目录范围内：\n\n```typescript\n// src/sandbox.ts\nexport function validateSavePath(\n  savePath: string,\n  roots: string[]\n): { ok: true; resolved: string } | { ok: false; reason: string } {\n  // 解析绝对路径\n  const resolved = resolve(savePath);\n  \n  // 检查是否在允许的根目录下\n  for (const root of roots) {\n    const rel = relative(fold(root), foldedTarget);\n    if (rel === \"\" || (!rel.startsWith(\"..\") && !isAbsolute(rel))) {\n      return { ok: true, resolved };\n    }\n  }\n  \n  return {\n    ok: false,\n    reason: `'${resolved}' is outside the allowed write roots: [${roots.join(\", \")}]`,\n  };\n}\n```\n\n验证规则：\n\n| 检查项 | 说明 |\n|--------|------|\n| 绝对路径 | savePath 必须为绝对路径（由 schema 的 `isAbsolute` 约束） |\n| 根目录匹配 | 解析后的路径必须位于允许的根目录下 |\n| 符号链接 | 指向外部的符号链接会被阻止 |\n\n资料来源：[src/sandbox.ts](https://github.com/vasylenko/markfetch/blob/main/src/sandbox.ts)\n\n### 允许的写入根目录\n\n| 环境变量 | 默认值 | 说明 |\n|----------|--------|------|\n| `MARKFETCH_ALLOWED_WRITE_ROOTS` | `os.tmpdir()` + `process.cwd()` | MCP 专用，可覆盖默认值 |\n\n平台差异：\n\n- **POSIX**：路径分隔符为 `:`\n- **Windows**：路径分隔符为 `;`\n\n资料来源：[README.md:Write sandbox](https://github.com/vasylenko/markfetch/blob/main/README.md)\n\n---\n\n## 环境变量验证\n\n### 启动时验证机制\n\nmarkfetch 在进程启动时对所有环境变量进行校验，无效配置会快速失败：\n\n| 环境变量 | 默认值 | 校验规则 |\n|----------|--------|----------|\n| `MARKFETCH_TIMEOUT_MS` | `30000` | 正整数，最大 300000 |\n| `MARKFETCH_MAX_BYTES` | `5000000` | 正整数，最大 104857600 |\n| `MARKFETCH_USER_AGENT` | Chrome 130 UA | 必须是 Chrome User-Agent 字符串 |\n| `MARKFETCH_ALLOWED_WRITE_ROOTS` | 参见上表 | 所有路径必须为绝对路径且存在 |\n\n验证失败时：\n\n- 错误信息输出到 stderr\n- 进程立即退出（非零退出码）\n- 不执行任何请求处理\n\n资料来源：[README.md:Configuration](https://github.com/vasylenko/markfetch/blob/main/README.md) 和 [CHANGELOG.md:0.6.0](https://github.com/vasylenko/markfetch/blob/main/CHANGELOG.md)\n\n---\n\n## 错误恢复策略\n\n### 调用方建议\n\n```mermaid\ngraph LR\n    A[调用 fetch_markdown] --> B{isError?}\n    B -->|是| C[解析错误代码]\n    B -->|否| D[处理 markdown]\n    \n    C --> E{错误代码分支}\n    E -->|network_error| F[重试 / 记录日志]\n    E -->|http_error| G[检查 URL 有效性]\n    E -->|timeout| H[增加 MARKFETCH_TIMEOUT_MS]\n    E -->|unsupported_content_type| I[确认 URL 是否为 HTML]\n    E -->|extraction_failed| J[尝试直接访问 API]\n    E -->|too_large| K[增加 MARKFETCH_MAX_BYTES]\n    E -->|save_failed| L[检查目录权限]\n    E -->|save_forbidden| M[使用允许的路径]\n```\n\n### 错误处理建议\n\n| 错误代码 | 建议操作 |\n|----------|----------|\n| `network_error` | 检查网络连接，等待后重试 |\n| `http_error` | 验证 URL 是否有效，检查网站是否允许访问 |\n| `timeout` | 增大 `MARKFETCH_TIMEOUT_MS` 环境变量 |\n| `unsupported_content_type` | 确认目标 URL 返回 HTML 内容 |\n| `extraction_failed` | 该页面可能是纯 JS 渲染，暂无解决方案 |\n| `too_large` | 增大 `MARKFETCH_MAX_BYTES` 或使用 `savePath` 写入文件 |\n| `save_failed` | 检查目录存在性和写入权限 |\n| `save_forbidden` | 使用允许的根目录路径，或设置 `MARKFETCH_ALLOWED_WRITE_ROOTS` |\n\n---\n\n## 版本演进\n\n### 0.6.0（当前版本）\n\n统一错误处理架构完成：\n\n- 3 处内联 `return errorResult(...)` 站点改为抛出 `MarkfetchError`\n- CLI 和 MCP 适配器统一捕获并转换错误\n- 错误消息格式保持一致\n\n资料来源：[CHANGELOG.md:0.6.0](https://github.com/vasylenko/markfetch/blob/main/CHANGELOG.md)\n\n### 0.4.0\n\n引入 `save_forbidden` 错误代码和写入沙箱机制：\n\n- MCP `savePath` schema 改用 `z.string().refine(path.isAbsolute)`\n- 不符合规范的路径直接返回 `save_forbidden`\n\n资料来源：[CHANGELOG.md:0.4.0](https://github.com/vasylenko/markfetch/blob/main/CHANGELOG.md)\n\n### 0.3.0\n\n确立 7 种基础错误代码：\n\n- 完整的错误代码体系首次定义\n- 环境变量验证机制引入\n\n资料来源：[CHANGELOG.md:0.3.0](https://github.com/vasylenko/markfetch/blob/main/CHANGELOG.md)\n\n---\n\n## 总结\n\nmarkfetch 的错误处理机制具有以下核心特征：\n\n1. **8 种确定性错误代码**：覆盖所有可能的故障场景\n2. **统一格式**：`[<代码>] <消息>` 便于解析和日志处理\n3. **双通道输出**：MCP 使用 `isError: true` 响应，CLI 使用 stderr\n4. **启动时验证**：环境变量错误提前暴露\n5. **写入安全**：MCP 模式下的路径沙箱保护\n\n这套机制确保了工具在各种调用场景下都能提供一致、可预测的错误反馈。\n\n---\n\n---\n\n## Doramagic 踩坑日志\n\n项目：vasylenko/markfetch\n\n摘要：发现 7 个潜在踩坑项，其中 0 个为 high/blocking；最高优先级：安装坑 - 来源证据：v0.4.1。\n\n## 1. 安装坑 · 来源证据：v0.4.1\n\n- 严重度：medium\n- 证据强度：source_linked\n- 发现：GitHub 社区证据显示该项目存在一个安装相关的待验证问题：v0.4.1\n- 对用户的影响：可能增加新用户试用和生产接入成本。\n- 建议检查：来源显示可能已有修复、规避或版本变化，说明书中必须标注适用版本。\n- 防护动作：不得脱离来源链接放大为确定性结论；需要标注适用版本和复核状态。\n- 证据：community_evidence:github | cevd_749b65614f7b40e0b524f4e932cd4aca | https://github.com/vasylenko/markfetch/releases/tag/v0.4.1 | 来源讨论提到 node 相关条件，需在安装/试用前复核。\n\n## 2. 能力坑 · 能力判断依赖假设\n\n- 严重度：medium\n- 证据强度：source_linked\n- 发现：README/documentation is current enough for a first validation pass.\n- 对用户的影响：假设不成立时，用户拿不到承诺的能力。\n- 建议检查：将假设转成下游验证清单。\n- 防护动作：假设必须转成验证项；没有验证结果前不能写成事实。\n- 证据：capability.assumptions | github_repo:1234238440 | https://github.com/vasylenko/markfetch | README/documentation is current enough for a first validation pass.\n\n## 3. 维护坑 · 维护活跃度未知\n\n- 严重度：medium\n- 证据强度：source_linked\n- 发现：未记录 last_activity_observed。\n- 对用户的影响：新项目、停更项目和活跃项目会被混在一起，推荐信任度下降。\n- 建议检查：补 GitHub 最近 commit、release、issue/PR 响应信号。\n- 防护动作：维护活跃度未知时，推荐强度不能标为高信任。\n- 证据：evidence.maintainer_signals | github_repo:1234238440 | https://github.com/vasylenko/markfetch | last_activity_observed missing\n\n## 4. 安全/权限坑 · 下游验证发现风险项\n\n- 严重度：medium\n- 证据强度：source_linked\n- 发现：no_demo\n- 对用户的影响：下游已经要求复核，不能在页面中弱化。\n- 建议检查：进入安全/权限治理复核队列。\n- 防护动作：下游风险存在时必须保持 review/recommendation 降级。\n- 证据：downstream_validation.risk_items | github_repo:1234238440 | https://github.com/vasylenko/markfetch | no_demo; severity=medium\n\n## 5. 安全/权限坑 · 存在评分风险\n\n- 严重度：medium\n- 证据强度：source_linked\n- 发现：no_demo\n- 对用户的影响：风险会影响是否适合普通用户安装。\n- 建议检查：把风险写入边界卡，并确认是否需要人工复核。\n- 防护动作：评分风险必须进入边界卡，不能只作为内部分数。\n- 证据：risks.scoring_risks | github_repo:1234238440 | https://github.com/vasylenko/markfetch | no_demo; severity=medium\n\n## 6. 维护坑 · issue/PR 响应质量未知\n\n- 严重度：low\n- 证据强度：source_linked\n- 发现：issue_or_pr_quality=unknown。\n- 对用户的影响：用户无法判断遇到问题后是否有人维护。\n- 建议检查：抽样最近 issue/PR，判断是否长期无人处理。\n- 防护动作：issue/PR 响应未知时，必须提示维护风险。\n- 证据：evidence.maintainer_signals | github_repo:1234238440 | https://github.com/vasylenko/markfetch | issue_or_pr_quality=unknown\n\n## 7. 维护坑 · 发布节奏不明确\n\n- 严重度：low\n- 证据强度：source_linked\n- 发现：release_recency=unknown。\n- 对用户的影响：安装命令和文档可能落后于代码，用户踩坑概率升高。\n- 建议检查：确认最近 release/tag 和 README 安装命令是否一致。\n- 防护动作：发布节奏未知或过期时，安装说明必须标注可能漂移。\n- 证据：evidence.maintainer_signals | github_repo:1234238440 | https://github.com/vasylenko/markfetch | release_recency=unknown\n\n<!-- canonical_name: vasylenko/markfetch; human_manual_source: deepwiki_human_wiki -->\n",
      "summary": "DeepWiki/Human Wiki 完整输出，末尾追加 Discovery Agent 踩坑日志。",
      "title": "Human Manual / 人类版说明书"
    },
    "pitfall_log": {
      "asset_id": "pitfall_log",
      "filename": "PITFALL_LOG.md",
      "markdown": "# Pitfall Log / 踩坑日志\n\n项目：vasylenko/markfetch\n\n摘要：发现 7 个潜在踩坑项，其中 0 个为 high/blocking；最高优先级：安装坑 - 来源证据：v0.4.1。\n\n## 1. 安装坑 · 来源证据：v0.4.1\n\n- 严重度：medium\n- 证据强度：source_linked\n- 发现：GitHub 社区证据显示该项目存在一个安装相关的待验证问题：v0.4.1\n- 对用户的影响：可能增加新用户试用和生产接入成本。\n- 建议检查：来源显示可能已有修复、规避或版本变化，说明书中必须标注适用版本。\n- 防护动作：不得脱离来源链接放大为确定性结论；需要标注适用版本和复核状态。\n- 证据：community_evidence:github | cevd_749b65614f7b40e0b524f4e932cd4aca | https://github.com/vasylenko/markfetch/releases/tag/v0.4.1 | 来源讨论提到 node 相关条件，需在安装/试用前复核。\n\n## 2. 能力坑 · 能力判断依赖假设\n\n- 严重度：medium\n- 证据强度：source_linked\n- 发现：README/documentation is current enough for a first validation pass.\n- 对用户的影响：假设不成立时，用户拿不到承诺的能力。\n- 建议检查：将假设转成下游验证清单。\n- 防护动作：假设必须转成验证项；没有验证结果前不能写成事实。\n- 证据：capability.assumptions | github_repo:1234238440 | https://github.com/vasylenko/markfetch | README/documentation is current enough for a first validation pass.\n\n## 3. 维护坑 · 维护活跃度未知\n\n- 严重度：medium\n- 证据强度：source_linked\n- 发现：未记录 last_activity_observed。\n- 对用户的影响：新项目、停更项目和活跃项目会被混在一起，推荐信任度下降。\n- 建议检查：补 GitHub 最近 commit、release、issue/PR 响应信号。\n- 防护动作：维护活跃度未知时，推荐强度不能标为高信任。\n- 证据：evidence.maintainer_signals | github_repo:1234238440 | https://github.com/vasylenko/markfetch | last_activity_observed missing\n\n## 4. 安全/权限坑 · 下游验证发现风险项\n\n- 严重度：medium\n- 证据强度：source_linked\n- 发现：no_demo\n- 对用户的影响：下游已经要求复核，不能在页面中弱化。\n- 建议检查：进入安全/权限治理复核队列。\n- 防护动作：下游风险存在时必须保持 review/recommendation 降级。\n- 证据：downstream_validation.risk_items | github_repo:1234238440 | https://github.com/vasylenko/markfetch | no_demo; severity=medium\n\n## 5. 安全/权限坑 · 存在评分风险\n\n- 严重度：medium\n- 证据强度：source_linked\n- 发现：no_demo\n- 对用户的影响：风险会影响是否适合普通用户安装。\n- 建议检查：把风险写入边界卡，并确认是否需要人工复核。\n- 防护动作：评分风险必须进入边界卡，不能只作为内部分数。\n- 证据：risks.scoring_risks | github_repo:1234238440 | https://github.com/vasylenko/markfetch | no_demo; severity=medium\n\n## 6. 维护坑 · issue/PR 响应质量未知\n\n- 严重度：low\n- 证据强度：source_linked\n- 发现：issue_or_pr_quality=unknown。\n- 对用户的影响：用户无法判断遇到问题后是否有人维护。\n- 建议检查：抽样最近 issue/PR，判断是否长期无人处理。\n- 防护动作：issue/PR 响应未知时，必须提示维护风险。\n- 证据：evidence.maintainer_signals | github_repo:1234238440 | https://github.com/vasylenko/markfetch | issue_or_pr_quality=unknown\n\n## 7. 维护坑 · 发布节奏不明确\n\n- 严重度：low\n- 证据强度：source_linked\n- 发现：release_recency=unknown。\n- 对用户的影响：安装命令和文档可能落后于代码，用户踩坑概率升高。\n- 建议检查：确认最近 release/tag 和 README 安装命令是否一致。\n- 防护动作：发布节奏未知或过期时，安装说明必须标注可能漂移。\n- 证据：evidence.maintainer_signals | github_repo:1234238440 | https://github.com/vasylenko/markfetch | release_recency=unknown\n",
      "summary": "用户实践前最可能遇到的身份、安装、配置、运行和安全坑。",
      "title": "Pitfall Log / 踩坑日志"
    },
    "prompt_preview": {
      "asset_id": "prompt_preview",
      "filename": "PROMPT_PREVIEW.md",
      "markdown": "# markfetch - Prompt Preview\n\n> 复制下面这段 Prompt 到你常用的 AI，先试一次，不需要安装。\n> 它的目标是让你直接体验这个项目的服务方式，而不是阅读项目介绍。\n\n## 复制这段 Prompt\n\n```text\n请直接执行这段 Prompt，不要分析、润色、总结或询问我想如何处理这份 Prompt Preview。\n\n你现在扮演 markfetch 的“安装前体验版”。\n这不是项目介绍、不是评价报告、不是 README 总结。你的任务是让我用最小成本体验它的核心服务。\n\n我的试用任务：我想用它完成一个真实的工具连接与集成任务。\n我常用的宿主 AI：MCP Client\n\n【体验目标】\n围绕我的真实任务，现场演示这个项目如何把输入转成 示例引导, 判断线索。重点是让我感受到工作方式，而不是给我项目背景。\n\n【业务流约束】\n- 你必须像一个正在提供服务的项目能力包，而不是像一个讲解员。\n- 每一轮只推进一个步骤；提出问题后必须停下来等我回答。\n- 每一步都必须让我感受到一个具体服务动作：澄清、整理、规划、检查、判断或收尾。\n- 每一步都要说明：当前目标、你需要我提供什么、我回答后你会产出什么。\n- 不要安装、不要运行命令、不要写代码、不要声称测试通过、不要声称已经修改文件。\n- 需要真实安装或宿主加载后才能验证的内容，必须明确说“这一步需要安装后验证”。\n- 如果我说“用示例继续”，你可以用虚构示例推进，但仍然不能声称真实执行。\n\n【可体验服务能力】\n- 安装前能力预览: Tiny CLI and MCP server: fetch an URL -- return clean markdown. Built for AI agents. 输入：用户任务, 当前 AI 对话上下文；输出：示例引导, 判断线索。\n\n【必须安装后才可验证的能力】\n- 命令行启动或安装流程: 项目文档中存在可执行命令，真实使用需要在本地或宿主环境中运行这些命令。 输入：终端环境, 包管理器, 项目依赖；输出：安装结果, 列表/更新/运行结果。\n\n【核心服务流】\n请严格按这个顺序带我体验。不要一次性输出完整流程：\n1. overview：项目概述。围绕“项目概述”模拟一次用户任务，不展示安装或运行结果。\n2. system-architecture：系统架构。围绕“系统架构”模拟一次用户任务，不展示安装或运行结果。\n3. cli-usage：命令行界面。围绕“命令行界面”模拟一次用户任务，不展示安装或运行结果。\n4. mcp-server：MCP 服务器。围绕“MCP 服务器”模拟一次用户任务，不展示安装或运行结果。\n5. http-fingerprint：HTTP 指纹与请求模拟。围绕“HTTP 指纹与请求模拟”模拟一次用户任务，不展示安装或运行结果。\n\n【核心能力体验剧本】\n每一步都必须按“输入 -> 服务动作 -> 中间产物”执行。不要只说流程名：\n1. overview\n输入：用户提供的“项目概述”相关信息。\n服务动作：模拟项目在这一步的核心判断和整理方式。\n中间产物：一个可检查的小结果。\n\n2. system-architecture\n输入：用户提供的“系统架构”相关信息。\n服务动作：模拟项目在这一步的核心判断和整理方式。\n中间产物：一个可检查的小结果。\n\n3. cli-usage\n输入：用户提供的“命令行界面”相关信息。\n服务动作：模拟项目在这一步的核心判断和整理方式。\n中间产物：一个可检查的小结果。\n\n4. mcp-server\n输入：用户提供的“MCP 服务器”相关信息。\n服务动作：模拟项目在这一步的核心判断和整理方式。\n中间产物：一个可检查的小结果。\n\n5. http-fingerprint\n输入：用户提供的“HTTP 指纹与请求模拟”相关信息。\n服务动作：模拟项目在这一步的核心判断和整理方式。\n中间产物：一个可检查的小结果。\n\n【项目服务规则】\n这些规则决定你如何服务用户。不要解释规则本身，而要在每一步执行时遵守：\n- 先确认用户任务、输入材料和成功标准，再模拟项目能力。\n- 每一步都必须形成可检查的小产物，并等待用户确认后再继续。\n- 凡是需要安装、调用工具或访问外部服务的能力，都必须标记为安装后验证。\n\n【每一步的服务约束】\n- Step 1 / overview：Step 1 必须围绕“项目概述”形成一个小中间产物，并等待用户确认。\n- Step 2 / system-architecture：Step 2 必须围绕“系统架构”形成一个小中间产物，并等待用户确认。\n- Step 3 / cli-usage：Step 3 必须围绕“命令行界面”形成一个小中间产物，并等待用户确认。\n- Step 4 / mcp-server：Step 4 必须围绕“MCP 服务器”形成一个小中间产物，并等待用户确认。\n- Step 5 / http-fingerprint：Step 5 必须围绕“HTTP 指纹与请求模拟”形成一个小中间产物，并等待用户确认。\n\n【边界与风险】\n- 不要声称已经安装、运行、调用 API、读写本地文件或完成真实任务。\n- 安装前预览只能展示工作方式，不能证明兼容性、性能或输出质量。\n- 涉及安装、插件加载、工具调用或外部服务的能力必须安装后验证。\n\n【可追溯依据】\n这些路径只用于你内部校验或在我追问“依据是什么”时简要引用。不要在首次回复主动展开：\n- https://github.com/vasylenko/markfetch\n- https://github.com/vasylenko/markfetch#readme\n- README.md\n- package.json\n- src/index.ts\n- src/cli.ts\n- src/core.ts\n- src/mcp.ts\n- src/sandbox.ts\n- .mcp.json\n\n【首次问题规则】\n- 首次三问必须先确认用户目标、成功标准和边界，不要提前进入工具、安装或实现细节。\n- 如果后续需要技术条件、文件路径或运行环境，必须等用户确认目标后再追问。\n\n首次回复必须只输出下面 4 个部分：\n1. 体验开始：用 1 句话说明你将带我体验 markfetch 的核心服务。\n2. 当前步骤：明确进入 Step 1，并说明这一步要解决什么。\n3. 你会如何服务我：说明你会先改变我完成任务的哪个动作。\n4. 只问我 3 个问题，然后停下等待回答。\n\n首次回复禁止输出：后续完整流程、证据清单、安装命令、项目评价、营销文案、已经安装或运行的说法。\n\nStep 1 / brainstorming 的二轮协议：\n- 我回答首次三问后，你仍然停留在 Step 1 / brainstorming，不要进入 Step 2。\n- 第二次回复必须产出 6 个部分：澄清后的任务定义、成功标准、边界条件、\n  2-3 个可选方案、每个方案的权衡、推荐方案。\n- 第二次回复最后必须问我是否确认推荐方案；只有我明确确认后，才能进入下一步。\n- 第二次回复禁止输出 git worktree、代码计划、测试文件、命令或真实执行结果。\n\n后续对话规则：\n- 我回答后，你先完成当前步骤的中间产物并等待确认；只有我确认后，才能进入下一步。\n- 每一步都要生成一个小的中间产物，例如澄清后的目标、计划草案、测试意图、验证清单或继续/停止判断。\n- 所有演示都写成“我会建议/我会引导/这一步会形成”，不要写成已经真实执行。\n- 不要声称已经测试通过、文件已修改、命令已运行或结果已产生。\n- 如果某个能力必须安装后验证，请直接说“这一步需要安装后验证”。\n- 如果证据不足，请明确说“证据不足”，不要补事实。\n```\n",
      "summary": "不安装项目也能感受能力节奏的安全试用 Prompt。",
      "title": "Prompt Preview / 安装前试用 Prompt"
    },
    "quick_start": {
      "asset_id": "quick_start",
      "filename": "QUICK_START.md",
      "markdown": "# Quick Start / 官方入口\n\n项目：vasylenko/markfetch\n\n## 官方安装入口\n\n### Node.js / npm · 官方安装入口\n\n```bash\nnpm i -g markfetch\n```\n\n来源：https://github.com/vasylenko/markfetch#readme\n\n## 来源\n\n- repo: https://github.com/vasylenko/markfetch\n- docs: https://github.com/vasylenko/markfetch#readme\n",
      "summary": "从项目官方 README 或安装文档提取的开工入口。",
      "title": "Quick Start / 官方入口"
    }
  },
  "validation_id": "dval_340184719b7f4ddea815de0bc4647491"
}
