新闻采集字段坑:source/time 是解析文本,不是结构化字段
新闻端点有个和其他端点不一样的坑:source(来源媒体)和 time(发布时间)不是 Google 直接给的结构化字段,而是解析器从一段紧凑的元数据文本里"抠"出来的。文档原话:“Source and time are parsed from compact Google metadata text, so clients should treat both as optional.”
翻译成工程语言:你脚本里 item["source"] 和 item["time"] 直取,某天就会 KeyError;更隐蔽的是你用 .get() 兜底拿到了空字符串,然后在"来源媒体数量"“发布时效分布"这类统计里,把"解析失败"算成了"没有来源”。
这篇把这个坑讲透,并给一个带填充率监控的采集脚本。字段口径以 SerpBase 官方文档的 news 端点字段表 为准;每次成功请求 1 credit。
先说路径:是 /google/news/search
端点路径是 POST /google/news/search,不是 /google/news。和 image/search、video/search 一个路子——单数资源名 + /search。如果你从旧教程里抄的路径,先改过来。
入参四个:q 必填,hl/gl/page 可选(默认 en/us/1)。没有 device 参数,那是搜索端点独有的。返回信封里 search_type 会解析成 news,可以拿来断言路由对不对。
字段口径:三个必填,其余全靠解析
NewsResult 里必填的只有 rank、title、link 三个。其余全是可选,而且可选的理由分两类:
| position | rank 的别名,“when available” | 能用 rank 就别用 |
| url | 归一化后的规范链接 | 一般有,没有就用 link |
| source_url | 原始 Google 跳转 URL,调试/溯源用 | 别依赖 |
| display_url | 从展示文本或域名推导 | 别依赖 |
| source | “when publisher text is parsed” | 不能 |
| time | “when Google exposes time text” | 不能 |
| published_at | 解析到 time 时的归一化别名 | 跟随 time |
| snippet | “when a snippet is parsed” | 新闻卡常常没有 |
| thumbnail_url/thumbnail | Google 暴露缩略图时 | 别依赖 |
顶层 news 数组本身也是可选的——"解析到新闻结果时"才有。零结果时可能整个键缺失,所以必须 data.get("news", [])。
source 和 time 为什么不可靠
新闻卡片在 Google 里的呈现是一行紧凑文本,大概长这样:“媒体名 · 3 小时前 · 来源域名”。解析器要从这一行里切出媒体名和时间文本。这里每一环都可能断:
- Google 对某些结果不渲染这行元数据(比如聚合页、视频新闻)
- 元数据行的格式随布局变化,解析规则没覆盖到就整条丢
- 时间文本是给人看的相对时间(“3小时前”“昨天”),不是机器时间;published_at 只是它的归一化别名,解析不出 time 就没有 published_at
所以这两个字段的正确用法是:有就用,没有就记空,统计时单独看填充率。把它们当"一定有"来写代码,等于把解析器的上游依赖藏进了你的数据管道——出问题那天,你看到的现象是"媒体数量突然少了",而真实原因是"解析失败率突然高了"。
带填充率监控的采集脚本
import csv, json, pathlib, requests
from datetime import date
API = "https://api.serpbase.dev/google/news/search" # 注意路径
KEY = "你的 API Key"
HEADERS = {"X-API-Key": KEY, "Content-Type": "application/json"}
TERMS = ["人工智能", "新能源汽车", "半导体"]
def news(q: str, hl: str = "zh-CN", gl: str = "cn", page: int = 1) –> list:
resp = requests.post(API, headers=HEADERS,
json={"q": q, "hl": hl, "gl": gl, "page": page}, timeout=30)
data = resp.json()
if data.get("status") != 0:
print(f"{q} p{page}: status={data.get('status')} rid={data.get('request_id')}")
return []
return data.get("news", []) # 数组本身可选,必须兜底
def collect(terms: list) –> list:
rows = []
for q in terms:
for page in (1, 2):
items = news(q, page=page)
if not items:
break # 空页即停,别继续翻
for it in items:
rows.append({
"词组": q,
"页": page,
"位次": it["rank"], # 必填,直取
"标题": it["title"], # 必填,直取
"链接": it["link"], # 必填,直取
"来源": it.get("source", ""), # 解析字段,兜底
"时间文本": it.get("time", ""), # 解析字段,兜底
"发布时间": it.get("published_at", ""), # 别名,跟随 time
"摘要": it.get("snippet", ""),
})
return rows
def fill_report(rows: list) –> dict:
"""来源/时间的填充率,和数据一起落盘。"""
n = len(rows) or 1
return {
"总条数": len(rows),
"来源填充率": round(sum(1 for r in rows if r["来源"]) / n, 3),
"时间填充率": round(sum(1 for r in rows if r["时间文本"]) / n, 3),
"去重来源数": len({r["来源"] for r in rows if r["来源"]}),
}
if __name__ == "__main__":
rows = collect(TERMS)
day = date.today().isoformat()
with open(f"news_{day}.csv", "w", newline="", encoding="utf-8-sig") as f:
w = csv.DictWriter(f, fieldnames=list(rows[0].keys()) if rows else
["词组", "页", "位次", "标题", "链接", "来源", "时间文本", "发布时间", "摘要"])
w.writeheader()
w.writerows(rows)
report = fill_report(rows)
pathlib.Path(f"news_fill_{day}.json").write_text(
json.dumps(report, ensure_ascii=False, indent=1), encoding="utf-8")
print(f"{len(rows)} 条 → news_{day}.csv")
print(f"填充率: {report['来源填充率']:.0%} 来源 / {report['时间填充率']:.0%} 时间")
关键点是 fill_report:来源/时间填充率必须和数据分开落一份。下次看到"去重来源数从 30 掉到 12",先翻填充率文件——如果是 95% 掉到 60%,那是解析退化,不是行业变天。
三条实战经验
FAQ
路径到底是 /google/news 还是 /google/news/search? 文档写的是 /google/news/search。如果旧代码用的 /google/news 还能跑,也别依赖——以文档为准改过来。
snippet 为空正常吗? 正常。它是可选字段,"解析到摘要时"才有;新闻卡的标题+来源行往往就是全部内容,没有摘要。
thumbnail 能直接当封面用吗? 和图片端点一样有防盗链和可达性问题,thumbnail_url 只是 Google 暴露的地址。落库记着,展示层先校验再决定是否代理。
为什么搜索端点有 device 参数新闻没有? 文档里 news 的入参就是 q/hl/gl/page 四个,device 属于搜索端点。别把搜索端点的参数习惯带到新闻端点。
把 fill_report 加进你现有的新闻采集流程——它不改变你采到什么,只让你知道采到的东西里有多少是真的。
网硕互联帮助中心


评论前必须登录!
注册