- 后端
【免费下载链接】defuddle
Get the main content of any page as Markdown.
<time>元素在 HTML 中扮演双重角色:既可以是文章开头的发布时间标签(元数据),也可以嵌入散文正文表示事件发生的具体时刻(叙述内容)。本文以 defuddle 仓库中的 fixture 测试 tests/fixtures/issues--136-time-element.html 及其期望输出 tests/expected/issues--136-time-element.md 为核心案例,深入剖析 defuddle 如何区分这两类<time>元素:边界处的独立日期被提取为 published 元数据并从正文清除,而正文内联的时间被完整保留——同时给出底层源码实现与完整的 fixture 测试工作流。
问题背景:<time>元素的双重身份
在 Web 内容抽取(content extraction)场景中,<time>标签(以及 GitHub 风格的<relative-time>自定义元素)存在两种截然不同的用途:
- 元数据角色:独立出现在文章开头或结尾的
<time>,通常承载发布日期(如January 15, 2025)。这类信息与文章元数据(published 字段)重复,若保留在正文中会造成输出冗余。 - 叙述内容角色:嵌入段落文字中的
<time>,表示事件发生的具体时刻(如10:00 AM、2:00 PM),是文章叙事的一部分,删除会破坏内容完整性。
抽取工具必须在两者之间做出精确区分,才能既输出干净的 Markdown,又不丢失原文的有效信息——这正是 issue #136 要解决的核心问题。从 src/constants.ts 中可以看到这段演化痕迹:'time'和'relative-time'两行选择器被注释掉,并标注// see issue #136,说明它们未被列入无条件移除的默认选择器,而是交由更精细的模式检测逻辑决定去留。
fixture 全景:一个兼顾两类场景的测试页面
用于验证该逻辑的 fixture 页面结构如下(tests/fixtures/issues--136-time-element.html):
<article> <time class="date-label" datetime="2025-01-15">January 15, 2025</time> <!-- ① h1 之前的独立日期 --> <h1>Article with Time Elements</h1> <p><i><time datetime="2025-01-15">15 Jan, 2025</time></i></p> <!-- ② h1 下方、仅包裹 time 的段落 --> <p>This article explores how time elements should be handled ...</p> <!-- 正文 --> <p>However, time elements that appear inline within prose paragraphs should be preserved. For example, the event happened at <time datetime="2025-01-15T10:00:00Z">10:00 AM</time> and ended at <time datetime="2025-01-15T14:00:00Z">2:00 PM</time>. ...</p> <!-- ③ 正文内联时间 --> <p>The <relative-time datetime="2025-01-15T10:00:00Z">3 months ago</relative-time> update was significant ...</p> <!-- ④ 正文内联相对时间 --> ... </article>对应的期望输出 tests/expected/issues--136-time-element.md 完整记录了处理结果:
{ "title": "Article with Time Elements", "author": "", "site": "136-time-element", "published": "2025-01-15" }正文中January 15, 2025与15 Jan, 2025两处独立日期均已消失,而10:00 AM、2:00 PM与3 months ago则完整保留在段落中。published元数据被成功提取为2025-01-15。这个 fixture 同时覆盖了四种time形态,是理解 defuddle 时间元素策略的最佳入口。
第一类:边界独立日期 → 提取为元数据并移出正文
从<time>提取 published 元数据
defuddle 的元数据抽取由MetadataExtractor完成,核心入口在 src/metadata.ts 的getPublished方法。它按优先级依次尝试多个日期来源:
private static getPublished(doc: Document, schemaOrgData: any, metaTags: MetaTagItem[], url: string): string { const result = this.firstValid([ () => this.getSchemaProperty(schemaOrgData, 'datePublished'), () => this.getMetaContent(metaTags, "name", "publishDate"), () => this.getMetaContent(metaTags, "property", "article:published_time"), () => (doc.querySelector('abbr[itemprop="datePublished"]') as HTMLElement)?.title?.trim() || '', () => this.getTimeElement(doc, url), () => this.getMetaContent(metaTags, "name", "sailthru.date"), ]); ...其中getTimeElement(src/metadata.ts)正是针对<time>元素:优先读取datetime属性,缺失时退回文本内容:
private static getTimeElement(doc: Document, url: string): string { // Skip <time> elements that belong to a link pointing at a different page for (const element of Array.from(doc.querySelectorAll('time'))) { if (this.isLinkedToOtherPage(element, url)) continue; const content = element.getAttribute("datetime")?.trim() || element.textContent?.trim() || ""; if (content) return content; } return ""; }值得注意的细节是isLinkedToOtherPage守卫:当<time>被包裹在指向其他页面的同站链接中时(典型场景是"相关文章推荐卡片"自带的日期),该日期不属于当前文章,会被跳过——相关逻辑的说明见 src/metadata.ts,这一守卫与 issue #295(openai.com 博客场景)有关。此外,若 h1 附近没有可用的<time>或 meta 标签,getPublished还会扫描 h1 前后三个兄弟节点,在<p>/<time>子元素中通过parseDateText解析日期文本(src/metadata.ts)。在本文的 fixture 中,<time class="date-label" datetime="2025-01-15">的datetime属性直接被采用,产出published: "2025-01-15"。
从正文移除边界独立日期:removeByContentPattern
元数据提取解决"信息去哪里",而"从正文清除"由removeByContentPattern负责。该函数在 defuddle 主流水线中被调用(src/defuddle.ts),其"边界日期元素"逻辑位于 src/removals/content-patterns.ts:
// Remove standalone time/date elements near the start or end of content. // A <time> in its own paragraph at the boundary is metadata (publish date), // but <time> inline within prose should be preserved (see issue #136). const timeElements = Array.from(mainContent.querySelectorAll('time')); for (const time of timeElements) { if (!time.parentNode) continue; // Walk up through inline/formatting wrappers only (i, em, span, b, strong) let target: Element = time; let targetText = target.textContent?.trim() || ''; while (target.parentElement && target.parentElement !== mainContent) { const parentTag = target.parentElement.tagName.toLowerCase(); const parentText = target.parentElement.textContent?.trim() || ''; // If parent is a <p> that only wraps this time, include it if (parentTag === 'p' && parentText === targetText) { target = target.parentElement; break; } // Only walk through inline formatting wrappers if (['i', 'em', 'span', 'b', 'strong', 'small'].includes(parentTag) && parentText === targetText) { target = target.parentElement; targetText = parentText; continue; } break; } const text = target.textContent?.trim() || ''; const words = countWords(text); if (words > 10) continue; // Check if this element is near the start or end of mainContent const pos = contentText.indexOf(text); const distFromEnd = contentText.length - (pos + text.length); if (pos > 200 && distFromEnd > 200) continue; ... target.remove(); }这段代码体现了四个关键设计决策:
- 沿内联包装器向上冒泡:从
<time>出发,只允许穿越i、em、span、b、strong、small等内联格式化元素,且要求父元素文本与当前目标完全一致(确保包装器没有夹带其他内容)。一旦遇到块级元素或包含其他文本的容器立即停止,避免误删有内容的容器。 - 整段吞噬仅含 time 的
<p>:若父元素是<p>且其全部文本就是该 time 的文本,则把整个段落作为移除目标——fixture 中第 ② 处<p><i><time>15 Jan, 2025</time></i></p>正是通过这条路径被整体清除。 - 字数防线:目标文本超过 10 个词则跳过(
if (words > 10) continue;),防止把长文本段落误判为日期标签。 - 位置约束:通过
contentText.indexOf(text)计算目标在正文中的偏移,只有出现在开头 200 字符内或结尾 200 字符内的独立日期才被移除(if (pos > 200 && distFromEnd > 200) continue;)。这是区分"边界元数据"与"正文叙述"的核心阈值。
第二类:正文内联时间 → 完整保留
fixture 中第 ③ 处的10:00 AM、2:00 PM和第 ④ 处的3 months ago均位于段落中后部。按照上述逻辑,它们的位置远超开头 200 字符,pos > 200 && distFromEnd > 200成立,直接continue跳过,从而保留在最终 Markdown 中。
此外还有一层结构性保护:removeEyebrowLabel(处理正文前置"眉题"标签时)明确将包含time或[datetime]属性的元素排除在可移除范围之外(src/removals/content-patterns.ts),避免将带有时间戳的标签误当作装饰性眉题删除。
在保留策略层面,src/constants.ts 将time与relative-time列入INLINE_ELEMENTS(内联元素集合,不会被 unwrap 拆散),同时在无条件移除选择器列表中保持注释状态(// 'time', // see issue #136)。这意味着:
- 独立
<time>不被"一刀切"删除,而是交给位置与字数启发式判断; <relative-time>这类 GitHub 风格自定义元素同样走此路径——fixture 第 ④ 处"3 months ago"因此得以保留。
值得注意的是,RELATIVE_TIME_PATTERN(/\b\d+\s+(?:second|minute|hour|day|week|month|year)s?\s+ago\b/i,src/removals/content-patterns.ts)会在正文起始处把"3 months ago"这类相对时间识别为前置元数据(src/removals/content-patterns.ts),用于清除"21 hours ago"这类新闻站点眉题。其适用前提同样是isPreContent位置判断——一旦时间戳深嵌正文,它就不再是元数据而是叙述内容。
测试工作流:fixture 如何驱动这一行为
issues--136-time-element属于 defuddle 的 fixture 驱动测试体系,运行机制定义在 tests/fixtures.test.ts:
- 自动发现
tests/fixtures/下所有.html文件; - 用
Defuddle(Node 入口 src/node.ts)处理每个 fixture,解析 URL 取自文件名(136-time-element映射为https://136-time-element); - 将结果与
tests/expected/下同名.md文件比对——期望文件以 JSON 代码块前置元数据(仅含 title/author/site/published),随后是 Markdown 正文; - 若期望文件不存在,则首次运行生成基线;若结果有差异则测试失败。
开发者复现该场景的方式:
# 安装依赖后运行 fixture 测试套件 npm test当行为变更导致期望输出变化时,删除tests/expected/中对应.md文件后重新运行测试即可生成新基线(该流程在 tests/fixtures.test.ts 的注释中有明确说明)。命名规则issues--NNN直接关联 GitHub issue 编号(此处为 #136),便于回溯历史决策。
总结:一条"位置 + 结构 + 字数"三重判据的取舍规则
通过本案例可以归纳出 defuddle 对<time>元素的完整处理链路:
| 场景 | 处理方式 | 判定依据 |
|---|---|---|
文章开头/结尾的独立<time>(含仅包裹它的<p>/内联包装器) | 移出正文,并作为 published 元数据来源 | 字数 ≤ 10、距正文开头/结尾 ≤ 200 字符 |
正文中段的内联<time>(如 10:00 AM) | 完整保留 | 位置越界(pos > 200且距结尾 > 200) |
正文中的<relative-time>(如 3 months ago) | 完整保留 | 不在无条件移除选择器内,位置不满足前置元数据条件 |
指向其他页面的链接内<time>(相关文章卡片) | 不作为当前文章日期 | isLinkedToOtherPage守卫 |
整个逻辑贯穿了"宁可多保留、不愿误删"的设计哲学(README 中即声明 "More forgiving, removes fewer uncertain elements"):删除只发生在边界元数据这种高置信场景,凡是嵌在叙述流中的时间信息一律保全。对于需要定制类似抽取规则的读者,本案例的 fixture(HTML 输入 + Markdown 期望输出)与 src/removals/content-patterns.ts 的注释代码是最直接的学习与验证素材。
- 后端
【免费下载链接】defuddle
Get the main content of any page as Markdown.
相关推荐
AI Deadlines:科研人员的时间管理神器,告别错过会议投稿的烦恼
AI Deadlines:科研人员的时间管理神器,告别错过会议投稿的烦恼 你是否曾经因为忘记某个重要AI会议的投稿截止日期而错失发表机会?作为AI领域的研究人员
前端人工智能Defuddle 实战:实时博客(Live Blog)页面的元数据提取与内容清理
Defuddle 实战:实时博客(Live Blog)页面的元数据提取与内容清理 实时博客(Live Blog / 滚动新闻页)是新闻网站典型的高难度提取场景:
后端Defuddle 元数据回退机制:识别并跳过 CMS 占位符值,提取真实标题、作者与发布时间
Defuddle 元数据回退机制:识别并跳过 CMS 占位符值,提取真实标题、作者与发布时间 本篇文章以 Defuddle 仓库中的 tests/expecte
后端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考