构建可离线阅读的文档镜像:页面、附件与内部链接处理
把明确范围的文档页转换为 Markdown,保存图片和附件,重写页间链接并保留章节锚点,输出来源映射与失败清单。
把网页正文存成 Markdown 后,图片仍指向线上、章节跳转失效、两个不同 URL 覆盖同一文件,都会让“离线文档”只剩下几份孤立文本。镜像需要同时处理页面身份、资源和链接关系。
本文镜像一份明确 URL 清单中的静态文档页,输出 Markdown、同源图片与常见附件,并保留已纳入页面之间的跳转。没有纳入清单的文档仍是外部链接,会单独记录;本文不自动遍历整个互联网,也不包含站点登录。
准备输入地址
先在浏览器找到下列页面并确认内容,再从地址栏复制完整地址。服务或仓库地址从相应账户设置、官方项目的接入说明或克隆菜单取得。不要从示例输出中反推真实地址。
DOC_URL_1:Python 教程 introduction.html 页地址。DOC_URL_2:同目录 controlflow.html 页地址。
在执行后续命令的同一个 Bash/WSL 终端中设置:
export DOC_URL_1='<paste-the-actual-doc-url-1>'
export DOC_URL_2='<paste-the-actual-doc-url-2>'下文 JSON 或任务参数中的 <变量名> 是待替换标记,提交前填写刚复制的实际值;JSON 不会自动展开环境变量。结果示例中的 example.16yun.cn 只表示来源字段的形状,不是本文部署的演示站。
安装与输入范围
使用 Python 3.12:
python3.12 -m venv .venv
source .venv/bin/activate
python -m pip install httpx beautifulsoup4 markdownifyHTTPX 下载材料,Beautiful Soup 修改 DOM,markdownify 转换内容;接口见 markdownify 官方项目。
示例清单包含两个 Python 教程页面。它们彼此关联,但不代表整份教程只有两页。对自己的文档站,先完成 URL 发现并确定版本、语言和路径,再把清单交给镜像阶段。
文件名不能只取 URL 最后一段
多个目录都可能有 index.html;带查询参数的页面也可能内容不同。本例按完整、去除片段后的 URL 哈希命名,文件名不再冲突于相同 basename。
页面锚点不参与下载身份,但要在链接重写后保留。Markdown 的自动标题锚点不一定与网页 id 相同,所以代码显式保存原 id。阅读器需要支持内嵌 HTML 锚点,否则应采用其支持的另一套锚点方案。
完整的转换与重写程序
创建 mirror.py:
import hashlib
import html
import json
import os
import sys
from pathlib import Path
from urllib.parse import urldefrag, urljoin, urlsplit
import httpx
from bs4 import BeautifulSoup, NavigableString
from markdownify import markdownify
urls = list(dict.fromkeys(urldefrag(url)[0] for url in sys.argv[1:]))
if not urls:
raise SystemExit("请提供明确的文档 URL")
root = Path("mirror")
root.mkdir()
(root / "pages").mkdir()
(root / "assets").mkdir()
(root / "raw").mkdir()
names = {url: Path("pages") / (hashlib.sha256(url.encode()).hexdigest() + ".md")
for url in urls}
documents, assets, errors, external = {}, {}, [], []
with httpx.Client(timeout=30, follow_redirects=False) as client:
for url in urls:
try:
response = client.get(url)
response.raise_for_status()
if "text/html" not in response.headers.get("content-type", ""):
raise ValueError("文档响应不是 HTML")
soup = BeautifulSoup(response.text, "html.parser")
body = soup.select_one("main, article, div.body")
if body is None:
raise ValueError("没有识别到正文")
for item in body.select("nav, script, style"):
item.decompose()
documents[url] = body
(root / "raw" / (names[url].stem + ".html")).write_text(
response.text, encoding="utf-8"
)
except Exception as error:
errors.append({"url": url, "stage": "page", "error_type": type(error).__name__})
for url, body in documents.items():
for element in body.select("a[href], img[src]"):
attribute = "src" if element.name == "img" else "href"
absolute = urljoin(url, element[attribute])
target, fragment = urldefrag(absolute)
parsed = urlsplit(target)
if parsed.scheme not in {"http", "https"}:
continue
local = None
if target in documents:
local = names[target]
else:
suffix = Path(parsed.path).suffix.lower()
attachment = element.name == "img" or suffix in {".pdf", ".zip", ".txt"}
same_origin = (parsed.scheme, parsed.netloc) == (
urlsplit(url).scheme, urlsplit(url).netloc
)
if attachment and same_origin:
try:
if target not in assets:
resource = client.get(target)
resource.raise_for_status()
kind = resource.headers.get("content-type", "")
if element.name == "img" and not kind.startswith("image/"):
raise ValueError("图片地址返回了非图片内容")
if suffix == ".pdf" and not resource.content.startswith(b"%PDF-"):
raise ValueError("PDF 签名不符")
name = hashlib.sha256(target.encode()).hexdigest() + (suffix or ".bin")
path = Path("assets") / name
(root / path).write_bytes(resource.content)
assets[target] = path
local = assets[target]
except Exception as error:
errors.append({"url": target, "stage": "asset",
"error_type": type(error).__name__})
if local is not None:
link = os.path.relpath(root / local, (root / names[url]).parent)
element[attribute] = link.replace(os.sep, "/") + ("#" + fragment if fragment else "")
else:
element[attribute] = absolute
external.append({"page": url, "target": absolute})
anchors = {}
anchor_nodes = ([body] if body.has_attr("id") else []) + list(body.select("[id]"))
for number, element in enumerate(anchor_nodes):
token = f"MIRRORANCHOR{number:06d}TOKEN"
anchors[token] = '<a id="' + html.escape(element["id"], quote=True) + '"></a>'
if element is body:
body.insert(0, NavigableString(token))
else:
element.insert_before(NavigableString(token))
markdown = markdownify(str(body), heading_style="ATX")
for token, anchor in anchors.items():
markdown = markdown.replace(token, anchor)
(root / names[url]).write_text(markdown, encoding="utf-8")
manifest = {
"pages": {url: str(names[url]) for url in documents},
"assets": {url: str(path) for url, path in assets.items()},
"errors": errors,
"external_links": external,
}
(root / "manifest.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8"
)
print({"pages": len(documents), "assets": len(assets), "errors": len(errors)})
raise SystemExit(1 if errors else 0)运行:
python mirror.py "$DOC_URL_1" "$DOC_URL_2"mirror 必须是新目录。下一轮先换输出位置或移动旧目录,不自动覆盖上一份材料。示例不跟随重定向,遇到 3xx 先确认最终规范地址,再更新输入清单;这样避免把两种版本误认为同一页。
为什么分两轮处理
第一轮先知道哪些页面成功取得,第二轮再重写链接。否则提前把失败页面改写成本地地址,会留下一个确定打不开的本地文件。
成功镜像的页面链接使用相对路径,图片与附件放到 assets。没有纳入范围或未成功下载的目标保留在线地址并写入 external_links,不能将其宣称为可离线资源。
raw 目录保留收到的 HTML 文本,便于重做解析;它不保存压缩传输字节和全部响应头。长期证据归档需要另加原始响应保留策略。
怎样确认不是只有正文能看
打开 manifest,找到某个 Markdown 文件,用支持相对图片与内嵌锚点的阅读器查看。依次检查标题、图片、跳到另一份已镜像文档的链接,以及同页章节跳转。
断网后重做这些检查,能够区分资源真的落盘还是阅读器偷偷从线上加载。外部链接本来就需要网络,它们应有明确清单,不在本轮离线承诺内。
图片只覆盖 src 声明,未覆盖 srcset、CSS 背景和运行时懒加载。若页面实际使用这些机制,需要补充对应发现逻辑,不把一次转换结果描述成完整视觉镜像。
附件下载也不代表附件内容已提取到 Markdown。PDF、压缩包和表格文件仍按原文件保留,打开或检索它们需要相应软件。
更新镜像怎样避免破坏旧版
为每次批次保留独立目录与来源清单,先检查新版本,再切换阅读入口。不要先删除旧镜像,再期待下载过程中没有失败。
比较文档时用 URL 身份与内容差异,不只用文件修改时间。需要多个语言或版本时,把这些维度放在镜像范围与目录层级里,不能只靠页面标题区分。
常见问题与修复
两个页面文件名相同。 检查是否仍用 index.html 或标题作文件名。本例按完整 URL 建立映射,页面片段单独处理。
图片文件存在但看不到。 检查相对路径是从 Markdown 所在目录计算,还是错误地从镜像根计算;再检查阅读器支持的图片格式。
章节链接失效。 查看原 id 是否保留,阅读器是否支持 HTML 锚点,以及目标章节是否真的在所选正文中。标题文字相同不保证锚点一致。
附件变成登录 HTML。 检查 Content-Type 与签名,并处理来源访问条件。不能因为文件扩展名是 pdf 就接受任意响应。
本地链接跳回线上。 查看目标是否在成功 pages 映射中,是否存在规范 URL 或版本差异。先补齐范围与下载,不简单替换域名字符串。
镜像少于输入页数。 查 errors 中的页面失败,不用剩余页面自动覆盖旧批次。失败是需要解释的缺口,不是“站点内容减少”。
一个可用的离线镜像是有范围的文档与资源关系集合。来源映射和未镜像清单,与正文文件本身同样重要。
需要企业代理方案?
我们可根据目标站点、并发规模与稳定性目标提供定制方案。