- 后端
【免费下载链接】defuddle
Get the main content of any page as Markdown.
本文以 Defuddle 仓库中的footnotes--numeric-anchor-id测试夹具为切入点,讲解 Defuddle 如何把一类"正文内嵌数字锚点链接 + 文末参考文献列表"的典型脚注模式,标准化为统一的脚注结构与规范 Markdown 语法。读完本文,你将理解 Defuddle 脚注处理链路的完整工作原理、tryGenericIdDetection泛化检测算法的判定逻辑,以及如何通过 fixtures 测试机制验证这类页面结构的转换结果。
场景:什么是 Numeric Anchor-ID 脚注模式
在许多学术期刊、出版社与静态博客生成的页面中,脚注(或参考文献)并不使用<sup>上标元素,而是以纯数字锚点 ID组织:正文中的引用是一个普通链接<a href="#ref1">[1]</a>,文末则是一个承载引文内容的容器,容器内的每条引文对应一个id="refN"的锚点。这类结构没有任何footnote类名或role属性,无法被常规的脚注列表选择器直接命中。
仓库中对应的 HTML 夹具 tests/fixtures/footnotes--numeric-anchor-id.html 完整复现了这一模式:
<article class="article-body"> <p>Dietary guidelines were examined in the first study <a href="#ref1">[1]</a> with follow-up findings in subsequent research <a href="#ref2">[2]</a> and a meta-analysis <a href="#ref3">[3]</a>.</p> <div class="references"> <h3>References</h3> <div class="reference"> <a class="reference-number" id="ref1">1.</a> <div class="reference-content">A. Author, B. Researcher. <em>Dietary Guidelines and Health Outcomes</em>. <em>Journal of Nutrition</em>. 2020;150(3):500-510. doi:10.1000/jn.2020.001</div> </div> <!-- ref2、ref3 结构相同 --> </div> </article>特征可以归纳为三点:
- 正文引用锚点的
href指向页内锚点(#ref1),链接文本是带方括号的数字[1]; - 文末容器(如
div.references)内每条引文以带id的锚点元素开头,数字后跟句点(1.); - 整个容器没有任何脚注语义标记,
h3标题 "References" 是唯一的自然语言提示。
Defuddle 的目标是把上述 HTML 转换为标准 Markdown 脚注语法,而不是简单地保留一堆裸链接。
期望输出:标准 Markdown 脚注
关联文档 tests/expected/footnotes--numeric-anchor-id.md 是该夹具的期望输出。它遵循 fixtures 测试的统一格式:文件头部是 JSON 元数据代码块(仅含title、author、site、published四项,不含正文),之后是转换后的 Markdown:
```json { "title": "Numeric Anchor-ID Footnote Pattern", "author": "", "site": "numeric-anchor-id", "published": "" } ``` Dietary guidelines were examined in the first study [^1] with follow-up findings in subsequent research [^2] and a meta-analysis [^3]. [^1]: A. Author, B. Researcher. *Dietary Guidelines and Health Outcomes*. *Journal of Nutrition*. 2020;150(3):500-510. doi:10.1000/jn.2020.001 [^2]: C. Scholar, D. Investigator. *Follow-up Analysis of Nutritional Patterns*. *American Journal of Medicine*. 2021;134(5):720-731. doi:10.1000/ajm.2021.042 [^3]: E. Analyst, F. Reviewer. *Meta-analysis of Dietary Intervention Studies*. *Clinical Nutrition*. 2022;41(8):1200-1215. doi:10.1000/cn.2022.088输出质量的关键点:正文三处引用被替换为[^1]/[^2]/[^3]上标引用;文末生成三条[^N]:脚注定义,且完全复用原文的引文编号(1、2、3),而非重新排序;<em>斜体、DOI 号等细节被完整保留;"References" 标题与整个容器被移除,避免与脚注正文混排。元数据中的title来自页面<title>标签,site由测试 URL(https://numeric-anchor-id)经域名归一化得到,author与published因页面无相关标记而保持为空。
处理链路总览:从 HTML 到 Markdown 的三段式管线
这一输出由 Defuddle 主流程中的三段协作完成,对应三个源码文件:
- 标准化阶段:src/defuddle.ts 在
standardizeFootnotesCallouts步骤调用standardizeFootnotes(mainContent!)(见 src/defuddle.ts),此时页面主内容已被选中,脚注标准化发生在清理阶段之前; - 脚注收集与重建:src/elements/footnotes.ts 中的
FootnoteHandler负责识别正文引用、收集脚注定义、删除原容器,并在主内容末尾重建统一的脚注列表; - Markdown 序列化:src/markdown.ts 通过 Turndown 自定义规则
citations与footnotesList,把重建后的<sup id="fnref:N">引用和<div id="footnotes">列表转换为[^N]与[^N]:语法。
值得强调的是,这类 numeric anchor-id 页面不在常规脚注列表选择器的覆盖范围内:FOOTNOTE_LIST_SELECTORS(定义于 src/constants.ts)命中的是ol.references、div[role="doc-endnotes"]、div.footnote-definitions等带语义的结构,而本夹具的div.references+div.reference组合只能走通用兜底路径。
深入源码:tryGenericIdDetection 的泛化检测算法
collectFootnotes在列表选择器一无所获后,会依次尝试多个兜底策略(见 src/elements/footnotes.ts),其中第二个策略tryGenericIdDetection正是为本类模式设计的(实现位于 src/elements/footnotes.ts)。它的判定过程分四步:
第一步:收集候选引用。遍历所有a[href*="#"]链接,取其 href 的 fragment(getHrefFragment,见 src/elements/footnotes.ts),并要求链接文本命中数字标记正则:
const FOOTNOTE_MARKER_RE = /^\[?\(?(\d{1,4})\)?\]?$/;该正则宽容地匹配1、[1]、(23)、([1])等形态(src/elements/footnotes.ts)。本夹具的[1]、[2]、[3]全部命中,得到 3 个候选 fragment(ref1/ref2/ref3)。若候选引用少于 2 个则直接放弃——这是防止正文中孤立的交叉引用被误判为脚注的第一道门槛。
第二步:为候选 fragment 寻找最佳承载容器。遍历主内容下的div, section, aside, footer, ol, ul,通过findMatchingFootnoteElements(src/elements/footnotes.ts)统计每个容器内"自身 id 命中 fragment 集合,或内部子锚点 id 命中"的元素数量。div.references下三个div.reference各自包含a#ref1等子锚点,命中数达到 3,成为唯一候选。
第三步:75% 交叉校验。这是整个算法最关键的防误判机制(src/elements/footnotes.ts):统计位于容器外部的候选引用中,有多少指向该容器内的脚注元素,要求externalMatch >= max(2, ceil(externalTotal * 0.75))。本夹具正文的三个引用全部指向容器内的refN锚点,通过校验。这一比例约束专门用来拦截"正文中仅有少量链接指向某个容器的数学公式/定理交叉引用"——例如issues--141-arxiv-equation-tables等测试所覆盖的误判场景。
第四步:提取脚注内容并登记。对容器内每个命中元素:克隆后移除纯数字的 ID 锚点(idAnchor.textContent命中/^\d+[.)]*\s*$/即移除),移除a[name]命名锚点,剥离文本节点开头的数字前缀(/^\d+\.\s*/),再通过addFootnote以ref1等原始 ID 登记脚注(src/elements/footnotes.ts)。由于数字 ID 在FOOTNOTE_MARKER_RE语义下天然有序,state.count从 1 递增,脚注编号恰好等于原文编号。最后把整个div.references容器压入pendingRemovals,等待统一删除。
内联引用的匹配:选择器优先、href fragment 兜底
脚注定义收集完成后,standardizeFootnotes开始处理正文引用(src/elements/footnotes.ts)。它先按FOOTNOTE_INLINE_REFERENCES(src/constants.ts)选择所有候选内联引用元素——本夹具的<a href="#ref1">恰好命中其中'a[href^="#r"]'(注释标明 "Common in academic papers",学术论文常见模式)。
随后按INLINE_REF_EXTRACTORS表(src/elements/footnotes.ts)逐条尝试提取脚注 ID,该表覆盖sup.footnoteref、Nature 的a[id^="ref-link"]、Science.org 的a[role="doc-biblioref"]、Wikipedia 的sup.reference、Substack 的a.footnote-anchor等十余种站点特化结构。本夹具的普通a[href^="#r"]不在表中,于是走最后兜底:
// Fallback: use the href fragment when no selector matched. const href = el.getAttribute('href'); if (href) footnoteId = href.replace(/^[#]/, '').toLowerCase();即直接用 href 的 fragmentref1作为脚注 ID,与收集阶段登记的originalId完成配对。配对成功后,createFootnoteReference(src/elements/footnotes.ts)生成标准引用元素:
<sup id="fnref:1"><a href="#fn:1">1</a></sup>原链接容器被整体替换(replaceContainerPreservingText会小心保留容器内的其他文本),链接文本[1]由新上标的数字1取代。
标准化中间形态:统一的脚注列表
所有引用配对完成后,standardizeFootnotes的收尾逻辑(src/elements/footnotes.ts)在主内容末尾重建统一列表:新建<div id="footnotes">,内含<ol>,每个脚注由createFootnoteItem(src/elements/footnotes.ts)生成<li id="fn:N">。内容处理上:纯内联内容会被包进<p>,含块级子元素时避免非法的<p><div>嵌套;refs数组中的每个引用 ID 都会在末段追加返回链接。
期望的标准化 HTML 见 tests/expected/footnotes--numeric-anchor-id.html,其结构为:
<article> <p>Dietary guidelines were examined in the first study <sup id="fnref:1"><a href="#fn:1">1</a></sup> ... </p> <div id="footnotes"><ol> <li id="fn:1"> <p>A. Author, B. Researcher. <em>Dietary Guidelines and Health Outcomes</em>. ...</p> <a class="footnote-backref" title="return to article" href="#fnref:1">↩</a></li> <!-- fn:2、fn:3 结构相同 --> </ol></div></article>fnref:N(正文引用锚点)与fn:N(脚注定义锚点)的 ID 约定构成双向跳转闭环,返回链接↩由removeBackrefs逻辑中的BACKREF_SYMBOLS_RE(匹配^、↩等符号)在收集阶段与输出阶段同步识别、去重。
Markdown 序列化:citations 与 footnotesList 规则
最终 Markdown 由 src/markdown.ts 的两条 Turndown 规则产出:
citations规则(src/markdown.ts):匹配id以fnref:开头的<sup>,取其内部链接指向的#fn:fragment(或回退到引用 ID 本身),经formatFootnoteId后输出[^N]。formatFootnoteId(src/markdown.ts)会把 ID 小写化,并将非[a-z0-9_-]字符转为%XXXX十六进制转义——纯数字 ID "1" 原样保留,这也是[^1]等输出不加修饰的原因;footnotesList规则(src/markdown.ts):匹配父节点id="footnotes"的<ol>,逐个li输出[^N]: 内容;期间会剔除与脚注 ID 同文本的前导<sup>元素,并移除内容末尾的返回链接↩︎(/\s*↩︎$/),保证定义行干净可读。
两条规则协同,最终把重建的<div id="footnotes">完整映射为关联文档所示的规范脚注语法。
测试与验证:fixtures 机制的闭环
src/elements/footnotes.ts 中standardizeFootnotes的导出函数(src/elements/footnotes.ts)是流水线的入口,而验证则由 fixtures 测试闭环保证。测试框架 tests/fixtures.test.ts 自动扫描tests/fixtures/下所有.html文件,用parseDocument解析后调用Defuddle提取,通过createComparableResult把元数据与contentMarkdown拼装成"JSON 前置块 + Markdown"的格式,与tests/expected/下的期望文件逐字符比对(normalizeMarkdownHtml归一化后)。若结果不一致测试失败;若期望文件缺失则生成基线。本主题的 HTML 夹具与期望输出成对存在,正是这套机制的产物,也是理解"Defuddle 眼中脚注长什么样"最直接的资料。
边界与适用限制
从源码结构可以推断,numeric anchor-id 模式的处理有以下约束,实际应用时需要注意:
- 最少两个引用:
tryGenericIdDetection要求候选引用size >= 2(src/elements/footnotes.ts),单条引用的孤立页面不会走此路径,需要依赖带footnote类名或其他语义标记的兜底策略; - 75% 命中率校验:正文引用中必须有足够比例指向同一容器内的锚点,否则容器会被判为公式/交叉引用区而跳过(防止正文内嵌链接被误移);
- 链接文本必须是纯数字标记:正文链接需命中
FOOTNOTE_MARKER_RE,若链接文本是描述性文字(如 "see note")则不会被识别为脚注引用; - 需要
standardize选项开启:脚注标准化位于options.standardize分支内(src/defuddle.ts),关闭该选项时原页面结构将原样保留。
理解这些边界后,无论你是在为自建站点编写导出工具,还是在分析 Defuddle 源码以适配新的学术出版页面结构,都可以把footnotes--numeric-anchor-id这一对夹具作为最小复现样本:先构造类似 HTML,再跑一遍 fixtures 测试即可快速验证自己的改动。
- 后端
【免费下载链接】defuddle
Get the main content of any page as Markdown.
相关推荐
Defuddle 脚注标准化实战:named-anchor 风格脚注如何变成标准 Markdown 脚注
Defuddle 脚注标准化实战:named anchor 风格脚注如何变成标准 Markdown 脚注 本文以 Defuddle 仓库中的回归测试用例 foo
后端Defuddle 脚注规范化机制:把一个「<br> 分隔的命名锚点脚注块」转换成标准 Markdown 脚注
Defuddle 脚注规范化机制:把一个「<br 分隔的命名锚点脚注块」转换成标准 Markdown 脚注 本文以 Defuddle 仓库中 tests/exp
后端defuddle 脚注标准化实战:从 maggieappleton.com 的 Tufte 侧注 HTML 到干净 Markdown
defuddle 脚注标准化实战:从 maggieappleton.com 的 Tufte 侧注 HTML 到干净 Markdown 导读 本文以 defudd
后端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考