☰
Defuddle 中 `<time>` 元素的精准取舍:发布时间元数据提取与正文内联时间的保留策略
2026/10/15 2:31:57 网站建设 项目流程
  • 后端

【免费下载链接】defuddle

Get the main content of any page as Markdown.

项目地址:https://gitcode.com/gh_mirrors/de/defuddle
点击查看免费下载

<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(); }

这段代码体现了四个关键设计决策:

  1. 沿内联包装器向上冒泡:从<time>出发,只允许穿越i、em、span、b、strong、small等内联格式化元素,且要求父元素文本与当前目标完全一致(确保包装器没有夹带其他内容)。一旦遇到块级元素或包含其他文本的容器立即停止,避免误删有内容的容器。
  2. 整段吞噬仅含 time 的<p>:若父元素是<p>且其全部文本就是该 time 的文本,则把整个段落作为移除目标——fixture 中第 ② 处<p><i><time>15 Jan, 2025</time></i></p>正是通过这条路径被整体清除。
  3. 字数防线:目标文本超过 10 个词则跳过(if (words > 10) continue;),防止把长文本段落误判为日期标签。
  4. 位置约束:通过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:

  1. 自动发现tests/fixtures/下所有.html文件;
  2. 用Defuddle(Node 入口 src/node.ts)处理每个 fixture,解析 URL 取自文件名(136-time-element映射为https://136-time-element);
  3. 将结果与tests/expected/下同名.md文件比对——期望文件以 JSON 代码块前置元数据(仅含 title/author/site/published),随后是 Markdown 正文;
  4. 若期望文件不存在,则首次运行生成基线;若结果有差异则测试失败。

开发者复现该场景的方式:

# 安装依赖后运行 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.

项目地址:https://gitcode.com/gh_mirrors/de/defuddle
点击查看免费下载

相关推荐

上一篇:HashLips Art Engine批量重命名工具:图层文件管理最佳实践
下一篇:qqwweee/keras-yolo3与AWS Lambda函数集成部署教程

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询