新闻发布时间抽取分析
前言
gne是一款对新闻网页(主要针对中文网页)的信息抽取Python库github仓库。本文基于gne进行新闻发布时间抽取实验,并对gne抽取规则进行完善,同时补充实现了基于JSON-LD
数据的时间抽取,提高了抽取的召回率和准确率。
1. 统计结果
主要抽取方法:
① gne原版meta规则://meta/@name
前缀匹配
② gne修改meta规则://meta/@name
后缀匹配
③ gne原版文本抽取:主要支持中文日期和纯数字日期
④ gne添加抽取规则:添加英文规则
⑤ JSON-LD
抽取规则
2. gne源码分析
gne项目采用了模块化设计,源码较为简单。主要组件包括:
① MetaExtractor
元数据抽取,对应meta信息提取
② TitleExtractor
标题抽取
③ AuthorExtractor
作者抽取
④ TimeExtractor
发布时间抽取
⑤ ContentExtractor
正文抽取
⑥ ListExtractor
列表内容抽取
其中TimeExtractor
时间抽取逻辑为:用户xpath
匹配、meta
标签xpath
匹配、文本正则匹配。核心代码如下:
def extractor(self, element: HtmlElement, publish_time_xpath: str = '') -> str:
publish_time_xpath = publish_time_xpath or config.get('publish_time', {}).get('xpath')
publish_time = (self.extract_from_user_xpath(publish_time_xpath, element) # 用户指定的 Xpath 是第一优先级
or self.extract_from_meta(element) # 第二优先级从 Meta 中提取
or self.extract_from_text(element)) # 最坏的情况从正文中提取
return publish_time
其抽取规则统一配置在defaults.py
文件中。
3.抽取方法
3.1.基于标签的时间抽取
原版gne基于<meta>
标签的名字属性进行前缀匹配,xpath
规则如下:
'//meta[starts-with(@property, "rnews:datePublished")]/@content',
'//meta[starts-with(@property, "article:published_time")]/@content',
'//meta[starts-with(@property, "og:published_time")]/@content',
'//meta[starts-with(@property, "og:release_date")]/@content',
'//meta[starts-with(@itemprop, "datePublished")]/@content',
'//meta[starts-with(@itemprop, "dateUpdate")]/@content',
'//meta[starts-with(@name, "OriginalPublicationDate")]/@content',
'//meta[starts-with(@name, "article_date_original")]/@content',
'//meta[starts-with(@name, "og:time")]/@content',
'//meta[starts-with(@name, "apub:time")]/@content',
'//meta[starts-with(@name, "publication_date")]/@content',
'//meta[starts-with(@name, "sailthru.date")]/@content',
'//meta[starts-with(@name, "PublishDate")]/@content',
'//meta[starts-with(@name, "publishdate")]/@content',
'//meta[starts-with(@name, "PubDate")]/@content',
'//meta[starts-with(@name, "pubtime")]/@content',
'//meta[starts-with(@name, "_pubtime")]/@content',
'//meta[starts-with(@name, "weibo: article:create_at")]/@content',
'//meta[starts-with(@pubdate, "pubdate")]/@content'
可以看出大多数围绕publish-date
/time
的同义词或变体词在进行。根本问题在于<meta>
标签没有统一规范造成的问题。
经过实验发现,采用后缀匹配方式能够匹配更多,且规则配置上更加简单。
设计如下规则:
["property","datePublished"],
["property","published_time"],
["property","release_date"],
["itemprop","datePublished"],
["itemprop","dateUpdate"],
["name","OriginalPublicationDate"],
["name","article_date_original"],
["name","publication_date"],
["name","PublishDate"],
["name","publishdate"],
["name","PubDate"],
["name","pubtime"],
["name","pubdate"],
["name","create_at"],
["name","date"],
["name","time"]
每条规则为一个数组,第一项表示meta
标签的属性名称,第二项表示该属性值应匹配的后缀。可以看出来,规则顺序是从精确到宽泛。
由于gne库使用的lxml
库不支持XPATH 2.0
,不支持ends-with
语法,所以通过代码实现:
def extract_from_meta_end(self, element: HtmlElement) -> str:
for prop, ends in META_NAME_ENDS.items():
tags = element.xpath(f"//meta[@{prop}]")
if tags:
for tag in tags:
prop_val = tag.get(prop)
for end in ends:
if prop_val.endswith(end):
return tag.get("content")
return ''
通过修改后的meta
匹配规则,召回率约为57.9%。
3.2.基于ld+json元数据的时间抽取
原版gne组件不支持JSON-LD
(即script
的type
属性为"application/ld+json")元数据的抽取,增加相关逻辑进行处理。
首先,需要查找全部类型“application/ld+json”的script
标签,可能有多个。对于每个script
标签需要先去掉一些不规范的JSON
字符;获取的对象可能是数组,也可能是dict
,最后都转成dict
的数组。
def get_ld_data(self):
all_ld = []
scripts = self.soup.find_all('script', type='application/ld+json')
for script in scripts:
# 提取script标签中的内容
json_content = script.get_text().strip()
# 移除json中不合法的字符
json_content = re.sub(r'[\n\r\t]+', '', json_content).strip()
if not json_content:
continue
try:
data = json.loads(json_content)
if isinstance(data, list):
all_ld.extend([v for v in data if isinstance(v, dict)])
elif isinstance(data, dict):
all_ld.append(data)
except:
traceback.print_exc()
print("invalid application/ld+json", json_content)
return all_ld
进行属性查找时,需要兼容多种情况:顶层对象的字段;是某个下层对象字段的字段,逻辑如下所示:
def find_value_from_ld(self, key: str):
if not self.ld_data:
return None
for row in self.ld_data:
if row.get('@type') in ['NewsArticle', 'Article', 'WebPage']:
if key in row:
return row[key]
else:
r = self.deep_search(row, key)
if r:
return r
return None
注意:通过@type
属性指定元数据类型,通常日期在NewsArticle
或Article
对象中。
深度查找逻辑如下:
def deep_search(self, row: dict, key: str):
if key in row:
return row[key]
for val in row.values():
if isinstance(val, dict):
r = self.deep_search(val, key)
if r:
return r
elif isinstance(val, list):
for v in val:
if isinstance(v, dict):
r = self.deep_search(v, key)
if r:
return r
return None
基于上述方法大约**71.9%的网页存在ld+json
标签,大约63.7%**的网页成功获取了时间。可能还有一定的优化空间,例如可能有的字段名不叫“datePublished”或者还有其他类型的@type
值。
进一步优化策略:ld+json
无效json
字符串处理;完善抽取规则。
下一步考虑将这部分抽取逻辑融合到gne组件中。
3.3.基于文本内容正则匹配的时间抽取
从统计结果可以看出,如果仅使用元数据,召回率为**76.3%**左右,因此可以看出仍然有大量网页需要考虑其他方法抽取时间。最直观的一个位置就是网页的文本内容,大部分网页在标题下面或正文结束后会有专门的块展示文章发布时间。
gne组件通过一组正则表达式对网页文本内容(正则表达式:.//text()
)进行依次匹配,一旦匹配即返回结果。规则如下:
DATETIME_PATTERN = [
"(\d{4}[-|/|.]\d{1,2}[-|/|.]\d{1,2}\s*?[0-1]?[0-9]:[0-5]?[0-9]:[0-5]?[0-9])",
"(\d{4}[-|/|.]\d{1,2}[-|/|.]\d{1,2}\s*?[2][0-3]:[0-5]?[0-9]:[0-5]?[0-9])",
"(\d{4}[-|/|.]\d{1,2}[-|/|.]\d{1,2}\s*?[0-1]?[0-9]:[0-5]?[0-9])",
"(\d{4}[-|/|.]\d{1,2}[-|/|.]\d{1,2}\s*?[2][0-3]:[0-5]?[0-9])",
"(\d{4}[-|/|.]\d{1,2}[-|/|.]\d{1,2}\s*?[1-24]\d时[0-60]\d分)([1-24]\d时)",
"(\d{2}[-|/|.]\d{1,2}[-|/|.]\d{1,2}\s*?[0-1]?[0-9]:[0-5]?[0-9]:[0-5]?[0-9])",
"(\d{2}[-|/|.]\d{1,2}[-|/|.]\d{1,2}\s*?[2][0-3]:[0-5]?[0-9]:[0-5]?[0-9])",
"(\d{2}[-|/|.]\d{1,2}[-|/|.]\d{1,2}\s*?[0-1]?[0-9]:[0-5]?[0-9])",
"(\d{2}[-|/|.]\d{1,2}[-|/|.]\d{1,2}\s*?[2][0-3]:[0-5]?[0-9])",
"(\d{2}[-|/|.]\d{1,2}[-|/|.]\d{1,2}\s*?[1-24]\d时[0-60]\d分)([1-24]\d时)",
"(\d{4}年\d{1,2}月\d{1,2}日\s*?[0-1]?[0-9]:[0-5]?[0-9]:[0-5]?[0-9])",
"(\d{4}年\d{1,2}月\d{1,2}日\s*?[2][0-3]:[0-5]?[0-9]:[0-5]?[0-9])",
"(\d{4}年\d{1,2}月\d{1,2}日\s*?[0-1]?[0-9]:[0-5]?[0-9])",
"(\d{4}年\d{1,2}月\d{1,2}日\s*?[2][0-3]:[0-5]?[0-9])",
"(\d{4}年\d{1,2}月\d{1,2}日\s*?[1-24]\d时[0-60]\d分)([1-24]\d时)",
"(\d{2}年\d{1,2}月\d{1,2}日\s*?[0-1]?[0-9]:[0-5]?[0-9]:[0-5]?[0-9])",
"(\d{2}年\d{1,2}月\d{1,2}日\s*?[2][0-3]:[0-5]?[0-9]:[0-5]?[0-9])",
"(\d{2}年\d{1,2}月\d{1,2}日\s*?[0-1]?[0-9]:[0-5]?[0-9])",
"(\d{2}年\d{1,2}月\d{1,2}日\s*?[2][0-3]:[0-5]?[0-9])",
"(\d{2}年\d{1,2}月\d{1,2}日\s*?[1-24]\d时[0-60]\d分)([1-24]\d时)",
"(\d{1,2}月\d{1,2}日\s*?[0-1]?[0-9]:[0-5]?[0-9]:[0-5]?[0-9])",
"(\d{1,2}月\d{1,2}日\s*?[2][0-3]:[0-5]?[0-9]:[0-5]?[0-9])",
"(\d{1,2}月\d{1,2}日\s*?[0-1]?[0-9]:[0-5]?[0-9])",
"(\d{1,2}月\d{1,2}日\s*?[2][0-3]:[0-5]?[0-9])",
"(\d{1,2}月\d{1,2}日\s*?[1-24]\d时[0-60]\d分)([1-24]\d时)",
"(\d{4}[-|/|.]\d{1,2}[-|/|.]\d{1,2})",
"(\d{2}[-|/|.]\d{1,2}[-|/|.]\d{1,2})",
"(\d{4}年\d{1,2}月\d{1,2}日)",
"(\d{2}年\d{1,2}月\d{1,2}日)",
"(\d{1,2}月\d{1,2}日)"
]
可以看出,上述规则覆盖了中文和纯数字的日期+时间与日期。对于英文网页中常见的格式如March 14, 2025
或14 Mar 2025
则不支持。本次测试选择的1000篇新闻大多数是英文,根据统计结果,基于上述规则的召回率为86.1%。通过增加常见的英文日期格式,召回率提高到95%。增加规则如下所示:
DATETIME_PATTERN = [
r"((?:January|February|March|April|May|June|July|August|September|October|November|December|Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2},?\s+\d{4})",
r"(\d{1,2}\s+(?:January|February|March|April|May|June|July|August|September|October|November|December|Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{4})",
]
上述规则同时处理了英文月份的全称和缩写以及月份与日的两种前后顺序。
存在问题:(1)基于.//text()
方法,传入整个html
可能导致匹配到style
、script
等非正文节点中的字符串;(2)规则不完整或过于宽泛。
3.4.三种方法总结
通过组合三种抽取方法,时间抽取召回率达到95.3%,而单独使用三种方法的召回率分别为57.9%、63.7%和95%。虽然基于文本内容规则匹配的方法的召回率非常接近最优,但是由于新闻文本为非结构化数据,且可能存在日期,其相比网页元数据有更大概率为文章发布时间。例如,文本中抽取的日期可能是事件日期或其他故事的日期。考虑到抽取结果的准确率,推荐方案是按照meta
标签、ld+json
、文本内容正则匹配的顺序进行抽取。
4.结论
本文通过对gne组件源码进行分析,了解其时间抽取的规则,通过分析总结网页,对其基于meta
元数据的时间抽取和基于文本内容正则匹配的时间抽取计算所及和配置规则进行完善和补充,提高了时间抽取召回率。