内容聚合器最小实现:抓取、两级去重与SQLite排序

发布时间:2026/10/31 22:55:38
内容聚合器最小实现:抓取、两级去重与SQLite排序

有个做独立开发的朋友前阵子问我,他写了个小工具,把十几个资讯站的更新抓到一个页面上,按时间倒序排好,这算不算“聚合器”。我说叫不叫不重要,重要的是你得先想清楚自己聚的是内容,还是选择权——这两个东西落到代码上完全是两套架构,落到指标上更是两回事。内容聚合器拼的是抓取覆盖率和去重准确率,选择权聚合器拼的是接入速度和降级能力,混在一起做,通常两头都不讨好。

“聚合器”这个词现在被用得太宽了。订阅阅读器叫聚合器,多个模型服务统一成一个接口叫聚合器,比价网站叫聚合器,日志采集管道也叫聚合器。名字一样,内核差得很远。大多数人第一次接触这个词,脑子里浮现的画面是“把东西堆到一个页面上”,这个理解不算错,但它会让你在动手第三天就想放弃——因为堆起来容易,堆完之后没人看,才是真问题。

这篇东西想干三件事:把“聚”和“合”这两个动作拆开讲清楚,让你判断自己要做的是哪一类;给一份能直接跑起来的最小实现,包含选型理由、参数计算和几个必踩的坑;最后聊聊聚合这件事在个人和团队层面还有哪些值得做的空间。写给两类人看:想自己动手搭一个小工具的技术人,和正在评估“我们该不该在系统里加一层聚合”的负责人。不需要多深的背景,会写点脚本就能跟上。

1. 拆词:聚合器真正在“聚”什么,“合”什么

1.1 三种聚合动作:搬内容、接接口、并供给

把市面上叫聚合器的东西摆在一起看,会发现它们的动作其实只有三种,而且难度是递增的。

第一种是搬内容。把分散在各处的文章、播客、视频更新、代码提交记录、公告抓回来,放在一个地方。这类聚合器的核心工作在采集和清洗,技术要求集中在解析、去重、排序三件事上。它的特点是上游是“只读”的,你搬不搬,源站都在那里更新,你和源站之间没有强耦合。绝大多数个人做的小聚合工具都属于这一类。

第二种是接接口。把多个能力相似但协议不同的服务,统一成一个对外接口。比如三个不同供应商的短信通道、两家地图服务、若干个模型服务,业务侧只调用一个方法,内部按规则路由。这类聚合器的工作量不在采集,在标准化和容错:鉴权怎么统一、失败怎么重试、超时怎么定、某一家挂了怎么切。它比搬内容难,因为你必须替业务侧承担不确定性。

第三种是并供给。把大量分散的供给方(商品、房源、服务、运力)合并成一个可检索、可比价的池子。这类聚合器看起来最像“商业产品”,实际上技术难度最高,因为供给方的状态是实时变化的,价格、库存、可售状态随时在动,你聚合出来的那一刻就可能已经过期。它拼的不是抓取,是状态同步和一致性。

这三类经常被混着叫,但你只要问一句“上游状态会不会在你聚合之后继续变化”,就能分清楚:几乎不变的是第一类,变化但可以缓存的是第二类,必须实时对齐的是第三类。这个判断直接决定你要不要做实时链路,也决定你的成本结构。

1.2 聚合器和平台、中间商的界线在哪里

很多人做着做着就把聚合器做成了平台,然后发现自己既没有平台的资源,也没有平台的议价能力。这条界线值得提前划清楚。

平台拥有双边关系:它同时掌握供给方和需求方,能制定规则、能分配流量、能从两边收费。聚合器只拥有其中一边,通常是需求侧的注意力和信任,对供给侧只有调用权,没有控制权。中间商赚的是差价,低买高卖,承担库存和履约风险;聚合器通常不碰库存,赚的是效率差、流量分成或者订阅费。

这个区别的现实意义在于:聚合器天生是“薄”的,它的护城河不在资产,在切换成本和标准化能力。一旦你开始囤库存、签独家、承诺履约,你就不再是聚合器了,你在做平台,而平台需要完全不同的组织能力和资金结构。

我见过不少项目卡在这个位置上:一开始只想做个入口,做着做着发现用户要“一站式”,于是开始自己接履约,接了履约发现要垫资、要担责、要处理售后,最后变成一个又重又慢的平台,把原来的轻优势全丢了。判断标准很简单——你新增的这块能力,是让上游更愿意接进来,还是让你自己越背越重?前者是聚合,后者是转型,得认。

2. 聚合器能成立的前提:碎、散、贵

2.1 用户侧的成本结构:真正的成本是切换和筛选

聚合器存在的第一性理由,不是“东西多”,而是用户的获取成本太高。这个成本可以拆成四块:切换成本(打开第五个App)、筛选成本(从一百条里挑三条有用的)、信任成本(这个源靠不靠谱)、决策成本(看完之后选哪个)。

一个聚合器只要能在其中任意两块上做出明显改善,就有存在价值。订阅阅读器省的是切换和筛选,比价工具省的是决策,统一接口层省的是开发和维护。反过来,如果一个聚合器在这四块上都没改善,只是把内容从A页面搬到B页面,那它就是个搬运工,用户没有任何理由留下。

这里有个反直觉的点:聚合器不需要覆盖全部供给,只需要覆盖用户决策路径上的关键少数。做比价,覆盖头部二十家往往就够用户下判断了,剩下那两百家的边际价值极低,但抓取成本极高。很多聚合项目死在“我要全”,而不是死在“我不准”。先做覆盖率曲线的前 80%,把准确率做扎实,比盲目扩源划算得多。

2.2 供给侧的动机:长尾为什么愿意被聚合

站在被聚合的一方想问题,会更清楚这门生意能不能长久。单个小供给方最大的痛点是获客成本:自己建站、做内容、买流量,转化率低得可怜。被一个有一定流量的聚合器收录,等于用极低成本获得曝光,这笔账很好算。

但这条路上有三个隐患,做聚合器的人必须提前想明白。第一是品牌被稀释:用户在聚合器里只看内容不看来源,久而久之供给方变成了“内容供应商”,失去了自己的用户资产。第二是议价权转移:当聚合器成为主要流量入口,供给方就失去了定价能力,这在很多行业都上演过。第三是数据不可见:供给方拿不到用户行为数据,无法优化自己的产品。

所以一个健康的聚合关系,通常要给供给方留三样东西:可见的来源标识、可回传的基础数据、可退出的通道。这三样听着像情怀,其实是防御性设计——你不给,供给方迟早会通过技术手段(限流、改协议、加验证)自己拿回去,那时候你的采集链路就开始大面积失败了。我在自己的小工具里一直保留“点击跳转原站”和“显示来源名”,不是为了好看,是为了让上游知道这个入口对它是有增量的。

2.3 三种价值来源:选择权、标准化、数据回流

聚合器的价值可以归到三个来源,而且它们的建立难度是递增的。

选择权最容易理解,也最容易被复制:你接了二十家,别人接三十家就超过你了。单纯靠选择权活着的聚合器,最后都会陷入规模竞赛,谁也赚不到钱。

标准化是最硬的壁垒。把异构的东西变成同构,需要定义一套中间模型,并且能被上游的变化不断冲击而不垮。比如把不同来源的商品信息统一成“标题、价格、货币、库存状态、更新时间”这五个字段,看起来简单,实际要处理单位、时区、货币精度、缺货语义(是售罄还是下架)等一堆细节。这套东西做扎实了,换一个上游只需要写一个适配器,边际成本极低。

数据回流是最难也最值钱的。用户在聚合结果上的行为——点了哪条、停留多久、跳转到哪里就再也没回来——这些信号反过来能优化排序和覆盖策略。注意这里说的是“排序优化”,不是“猜你喜欢”,前者是效率问题,后者是另一个赛道。

这三种价值对应三种完全不同的投入方向,做之前得选一个主攻。我的建议是:如果你资源有限,先把标准化做扎实,因为它是另外两个的地基;选择权靠堆人力就能补,数据回流需要足够的用户量才有意义。

3. 五种常见形态的拆解与各自的关键指标

3.1 内容聚合:抓取、归一化、去重、排序四段式

内容聚合器的流水线基本是固定的:抓取 → 解析归一化 → 去重 → 排序呈现。四段里最容易做的是抓取,最容易出问题的是去重,最容易被忽略的是排序。

抓取层要处理的是网络不确定性:超时、重试、限速、编码问题、源站返回 200 但内容是个错误页。解析层要处理结构不确定性:字段名可能变、日期格式可能变、有的源给全文有的源只给摘要。去重层要处理语义不确定性:同一件事被五家转载,标题还各不相同。排序层要处理的是价值判断:什么叫“重要”。

关键指标我给四个:覆盖率(目标源里成功解析的比例,低于 90% 就该看日志了)、新鲜度延迟(源站发布到出现在你这里的中位时间差)、去重准确率(人工抽检一百条,重复的比例控制在 2% 以内算及格)、点击率(聚合结果里被点开的比例,这个指标低说明排序有问题,不是内容不够)。

3.2 接口聚合:对外一个口,对内多路路由

接口聚合的典型形态是网关或者路由层:业务侧只认一个地址、一套鉴权、一种错误码,内部按权重、成本、健康度路由到不同供应商。它和内容聚合最大的区别是它在关键路径上——它挂了,业务就挂了。

所以这类聚合器的设计重点全在容错上:超时预算要逐层递减(总预算 800ms,那么每一跳最多 300ms),失败策略要区分可重试和不可重试(网络超时重试,参数错误直接返回),熔断要有明确的恢复条件(连续失败 N 次打开,半开状态下放 5% 流量试探),降级要有兜底(返回缓存或者默认值,而不是把错误抛给前端)。

关键指标:P99 延迟(不是平均值,平均值会骗人)、错误率(按上游拆开看,才能定位是谁拖后腿)、单位调用成本、切换耗时(从发现某家不行到流量切走,需要多久)。

这里有个经验:接口聚合层一定要做得薄。所谓薄,就是它只做协议转换和路由决策,不承载业务逻辑。一旦有人开始往里加业务规则,这层就会迅速膨胀成第二个单体,最后没人敢改。我见过一个内部网关,本来只做鉴权和转发,两年后里面塞了限流、计费、参数校验、A/B 分流、数据脱敏,改一行配置要走三轮评审。

3.3 服务与商品聚合:难点在状态同步而不是抓取

比价、房源、票务这类聚合器,外人看着是“抓数据”,内行知道真正的难点是状态同步。商品的价格和库存随时在变,你抓下来的那一刻是 99 元有货,用户点进去可能是 109 元无货,这个体验落差会直接毁掉信任。

处理这类问题有几条常见做法:标注更新时间(让用户知道数据有多新,这一条几乎零成本但极其有效)、跳转前二次校验(点击时实时查一次,代价是延迟)、分级缓存(高频变动的字段短缓存,低频的长缓存)、过期降权(超过一定时间未更新的条目降低排序甚至隐藏)。

关键指标就三个:价格准确率(抽样对比,低于 95% 就要缩短缓存时间)、状态新鲜度(中位数更新间隔)、跳转后的转化率。最后一个指标最能说明问题——如果用户点进去大量发现信息不符,转化率会明显掉,这是最诚实的反馈。

3.4 数据与日志聚合:可观测性里的汇聚层

日志和指标的聚合是另一个方向:把几十上百个实例产生的数据汇聚到一个地方,统一查询。它的技术难点和前面几类完全不同,核心是写入吞吐和查询成本。

这类聚合层最容易被忽视的是基数问题。指标系统里,一个标签如果有一万个取值,再乘上其他标签的组合,内存和时间序列数量会爆炸。所以设计时要严格控制高基数标签,必要时做聚合或者丢弃。日志同理,全量采集的成本可能远超它带来的价值,采样策略(错误全采、正常按比例采)几乎是必选项。

关键指标:写入吞吐(每秒多少条)、查询 P95 延迟、单位存储成本、采样后的有效信息保留率。这套东西看起来离普通开发者很远,但只要你的服务实例超过二十台,早晚要面对。

3.5 五种形态放在一张表里

形态聚合对象核心难点关键指标典型失败模式
内容聚合文章、音视频更新去重与排序覆盖率、新鲜度、去重准确率条目越来越多,用户越来越少
接口聚合同类服务能力容错与路由P99 延迟、错误率、切换耗时聚合层膨胀成新单体
商品服务聚合价格、库存、供给状态同步价格准确率、新鲜度、转化率信息过期导致信任崩塌
数据日志聚合指标、日志、追踪吞吐与基数控制写入吞吐、查询延迟、存储成本成本失控被迫降采样
模型能力聚合多个推理服务质量评估与降级一次通过率、单次成本、切换损耗路由策略黑箱,出问题查不清

这张表最实际的用法是:先确认自己要做的是哪一行,然后只盯那一行的关键指标。跨行套指标是最常见的自欺——拿内容聚合的“覆盖率”去衡量接口聚合,毫无意义。

4. 动手搭一个最小可用内容聚合器

4.1 需求边界与选型:为什么先用 SQLite 单文件

先把范围收窄:订阅 40 个公开的更新源,每 30 分钟跑一次,抓回来做归一化和去重,输出一个按时间倒序的单页,重复事件折叠显示。就这么点需求。

存储选 SQLite,理由得说清楚,不然容易被当成偷懒。条目量级:40 个源,每次每源平均新增 3 到 5 条,半小时一轮就是 120 到 200 条,一天 3000 到 5000 条,一年在 150 万上下。这个量级对单表索引查询来说毫无压力。并发模型:只有一个写入者(定时任务),没有多实例写入,不存在分布式锁的问题。运维成本:单文件,备份就是拷贝,迁移就是 scp。查询能力:需要 group by、需要按时间范围过滤、需要 like 检索,SQL 全都能给。

什么时候该换掉它?三个信号:需要多实例同时写、需要复杂全文检索和相关性排序、需要按用户做权限隔离。这三个信号出现之前,上 MySQL 或者搜索引擎都是在给自己找活干。顺便说一句,我见过不少个人项目一上来就搭一套服务端数据库加缓存,最后数据库里只有几百行数据,全在折腾运维。

先建表:

PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000; CREATE TABLE IF NOT EXISTS feed_item ( link_fp TEXT PRIMARY KEY, title_fp TEXT NOT NULL, source TEXT NOT NULL, title TEXT NOT NULL, url TEXT NOT NULL, published TEXT, fetched TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_title_fp ON feed_item(title_fp); CREATE INDEX IF NOT EXISTS idx_published ON feed_item(published DESC);

journal_mode=WAL让读写不互相阻塞,busy_timeout让偶发的写锁等待自动重试而不是直接抛错。这两个 pragma 加起来三行,能省掉后面 80% 的“database is locked”报错。

4.2 指纹设计:两级去重比一把梭更管用

去重是内容聚合里最容易做错的一环。常见做法是拿链接做唯一键,简单有效,但漏掉了“同一件事被多家报道”的情况;另一种是拿标题做键,能抓到跨源重复,但会把“标题相同内容不同”的条目误杀。

我的做法是两级指纹,各管各的事:

import hashlib import re from urllib.parse import urlsplit, urlunsplit, parse_qsl, urlencode TRACKING_KEYS = {"utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", "spm", "from", "ref"} def normalize_url(url: str) -> str: parts = urlsplit(url.strip()) query = [(k, v) for k, v in parse_qsl(parts.query, keep_blank_values=True) if k.lower() not in TRACKING_KEYS] path = parts.path.rstrip("/") or "/" return urlunsplit((parts.scheme.lower(), parts.netloc.lower(), path, urlencode(query), "")) def normalize_title(title: str) -> str: s = title.strip().lower() s = re.sub(r"[\s\u3000]+", "", s) s = re.sub(r"[“”\"'‘’·,,。.!!??::;;\-—_()()\[\]【】《》]", "", s) return s def fp(text: str) -> str: return hashlib.sha1(text.encode("utf-8")).hexdigest()[:16]

link_fp用归一化后的 URL 做主键,它代表“这条内容的身份”,负责严格去重;title_fp用去掉空白和标点的标题做普通索引,它代表“疑似同一件事”,负责展示时折叠。

为什么要剥掉 utm 这类参数?因为同一个页面在传播中会带不同的追踪参数,不剥掉,一条内容能变成五条。为什么要保留路径尾斜杠的处理?因为不少站点/post/1和/post/1/返回同一内容。这些细节单看都微不足道,但它们是重复条目的主要来源。至于 sha1 取前 16 位,在百万级条目下碰撞概率可以忽略,真要做到极致就换 blake2b 全长度,代价是索引变大。

4.3 抓取层:超时、退避重试与礼貌并发

抓取层的目标是稳,不是快。先写单个源的抓取函数:

import time import random import requests HEADERS = {"User-Agent": "agg-demo/0.1 (+https://example.invalid/bot)"} TIMEOUT = 10 MAX_RETRY = 3 MAX_ITEMS_PER_FEED = 40 def fetch_bytes(url: str) -> bytes: last_err = None for attempt in range(MAX_RETRY): try: resp = requests.get(url, headers=HEADERS, timeout=TIMEOUT) resp.raise_for_status() if not resp.content: raise ValueError("empty body") return resp.content except Exception as exc: last_err = exc time.sleep((2 ** attempt) + random.random()) raise last_err

几个参数的理由:超时给 10 秒,是因为大多数更新源在 3 秒内响应,超过 10 秒基本可以判定异常,继续等只会拖累整轮任务。重试 3 次,是因为偶发网络抖动占比很高,一次失败就放弃会让覆盖率长期卡在 85% 左右。退避用 1 秒、2 秒、4 秒加随机抖动,是为了避免多个源同时重试形成小高峰。User-Agent里带上标识和联系方式占位,是让源站运维能识别你是谁——这既是礼貌,也是万一被封了能沟通的余地。

并发不能开太大。4 个 worker 是我的默认值,理由有三:一是对源站的请求压力可控,40 个源在 4 并发下每轮每个源最多 1 次请求,不会触发限流;二是本机文件描述符和连接池足够;三是再往上加,整体收益会被最慢的那几个源吃掉,属于无效并发。真要提高速度,应该做的是同域名串行、跨域名并行,而不是无脑加线程数。

4.4 一个容易踩死的坑:SQLite 跨线程写入

最直观的写法是把连接传到线程池里,让每个线程自己入库。这样写必然报错:

sqlite3.ProgrammingError: SQLite objects created in a thread can only be used in that same thread.

原因是sqlite3默认check_same_thread=True,连接不允许跨线程使用。有两种解法,但只有一种是对的。

第一种是设check_same_thread=False然后加锁。能跑,但你只是把线程安全问题从库转移到了自己身上,且 SQLite 同一时刻只允许一个写入者,多线程写同一个库会频繁撞锁,日志里开始出现database is locked。

第二种是把抓取和写入拆开:线程池只负责网络请求和解析,返回结构化的行数据;主线程统一收口写库。这样只有一个写入者,check_same_thread的问题不存在,锁竞争也不存在,还能顺带做全局的进度统计。

from concurrent.futures import ThreadPoolExecutor, as_completed INSERT_SQL = ( "INSERT OR IGNORE INTO feed_item" "(link_fp, title_fp, source, title, url, published, fetched) " "VALUES (?, ?, ?, ?, ?, ?, ?)" ) def fetch_rows(source: str, feed_url: str, now_iso: str): raw = fetch_bytes(feed_url) parsed = feedparser.parse(raw) rows = [] for entry in parsed.entries[:MAX_ITEMS_PER_FEED]: link = (entry.get("link") or "").strip() title = (entry.get("title") or "").strip() if not link or not title: continue rows.append(( fp(normalize_url(link)), fp(normalize_title(title)), source, title, link, entry.get("published", "") or entry.get("updated", ""), now_iso, )) return rows def run_once(conn, feeds, workers: int = 4): now_iso = datetime.now(timezone.utc).isoformat(timespec="seconds") total_new = 0 with ThreadPoolExecutor(max_workers=workers) as pool: futures = {pool.submit(fetch_rows, name, url, now_iso): name for name, url in feeds} for fut in as_completed(futures): name = futures[fut] try: rows = fut.result() except Exception as exc: print(f"[fail] {name}: {exc!r}") continue cur = conn.executemany(INSERT_SQL, rows) conn.commit() total_new += cur.rowcount print(f"[ok] {name} fetched={len(rows)} new={cur.rowcount}") return total_new

INSERT OR IGNORE配合link_fp主键,天然实现幂等——同一轮跑两次、任务重跑、甚至中途崩溃重来,都不会产生重复数据。这个性质在做定时任务时价值极高,你不需要维护“上次跑到哪了”的状态。

4.5 输出与排序:把聚合结果做成能读的东西

数据进库之后,最关键的一步是怎么拿出来。直接把所有条目按时间倒序排,用户看到的是四十条同一件事的转载。所以输出要分两层:先折叠,再排序。

-- 折叠:同一 title_fp 只保留最早入库的一条,并统计相关条数 WITH ranked AS ( SELECT *, ROW_NUMBER() OVER (PARTITION BY title_fp ORDER BY fetched ASC) AS rn, COUNT(*) OVER (PARTITION BY title_fp) AS dup_cnt FROM feed_item WHERE fetched >= datetime('now', '-7 days') ) SELECT title, url, source, published, dup_cnt FROM ranked WHERE rn = 1 ORDER BY COALESCE(NULLIF(published, ''), fetched) DESC LIMIT 200;

ROW_NUMBER()取每组第一条,COUNT()给出“另有 N 条相关”,渲染时显示成一个角标。这个做法有个副作用:不同源报道同一件事但标题措辞不同时,折叠不会生效,用户会看到两条相似内容。要不要再做一层模糊相似度,取决于你的重复率有多高——我的实测是,加了标题归一化之后,人工抽检一百条里明显重复的能压到个位数,剩下的是真不同,没必要为它引入一套文本相似度计算,那会让整个链路的复杂度和维护成本上一个台阶。

渲染这一步唯一需要注意的是转义。标题和来源都来自外部,直接拼进 HTML 会出问题:

import html def render(rows) -> str: parts = ["<!doctype html><meta charset='utf-8'>", "<style>body{font:16px/1.7 system-ui;max-width:760px;" "margin:40px auto;padding:0 16px}" ".src{color:#666;font-size:13px}</style>", "<h1>今日聚合</h1><ul>"] for title, url, source, published, dup_cnt in rows: badge = f"<sup>+{dup_cnt - 1}</sup>" if dup_cnt > 1 else "" parts.append( f"<li><a href='{html.escape(url)}' target='_blank' rel='noopener'>" f"{html.escape(title)}</a>{badge}" f"<span class='src'> — {html.escape(source)} " f"{html.escape(published or '')}</span></li>" ) parts.append("</ul>") return "".join(parts)

定时运行用 cron 就够,两行搞定:

*/30 * * * * cd /opt/agg && /usr/bin/python3 run.py >> agg.log 2>&1

4.6 实测量级与调优参数

按 40 个源、4 并发跑,我这边一轮的耗时在 20 到 25 秒之间,主要波动来自个别响应慢的源。串行跑同样这批源要 60 秒以上。两者的差距不是线性的四倍,因为最慢的那几个源拖住了整体——这也是为什么再往上加并发收益很小:瓶颈不在并发度,在长尾。

存储按我上面的计算,一年 150 万到 350 万条(取决于运行频率),每条平均 250 到 400 字节,数据库文件在 500MB 到 1.2GB 之间。这个体量下,WHERE fetched >= ...配合索引的查询仍在百毫秒内。

几个值得调的参数,以及调整时会牵动什么:

参数默认值调大的后果调小的后果
抓取并发4慢源带来的长尾被放大,可能触发源站限流单轮耗时长,密集定时任务会重叠
单源最大条目40首次运行入库量大,历史内容挤占首页更新密集的源会漏条目
重试次数3一轮耗时被失败源拖长覆盖率下降
保留天数7(查询层)查询变慢,需要更多内存排序回顾历史时查不到

还有一个实践中的小技巧:把每次抓回来的原始响应按源名存一份最近一次的快照,出问题时可以直接回放解析过程,不用重新请求源站。磁盘成本几乎为零,排障效率提升非常明显。

5. 聚合器翻车的五个高发位置

5.1 上游一变,全线崩:结构漂移的排查链路

这是所有内容聚合器遇到的第一个大坑,而且它不是一次性的,是持续发生的。具体表现是:昨天还好好的,今天某个源的入库量突然从 15 条变成 0,但任务日志里全是[ok]。

排查顺序我固定走四步。第一步看原始响应,直接打开存下来的快照,如果是 HTML 错误页或者登录页,说明被拦了;如果是正常内容但结构变了,进入第二步。第二步看解析结果,打印len(parsed.entries),如果是 0 而响应里有内容,说明格式不匹配(比如从 RSS 换成了 Atom,或者字段名从link换成了id)。第三步看字段完整性,统计这一批里title或link为空的比例,超过 30% 基本可以确认结构变了。第四步查源站公告,很多站点在改版前会发通知,虽然大多数人不会去看。

防御手段比排查手段更重要。我加了两条不起眼的检查:每次入库前统计“解析成功但被过滤掉的条目比例”,超过阈值就在日志里打 warn;每天统计一次各源的入库量,环比下降超过 80% 的源单独列出来。这两条加起来不到二十行代码,但能把“静默失败”变成“有声音的失败”。

5.2 聚合噪音:条目变多,信息反而变少

第二个坑是体验层面的,也更隐蔽。你把源从 10 个扩到 50 个,条目数翻了五倍,但用户每天的点击次数没变,停留时间还下降了。这就是聚合噪音——你提供的是更多的信息,但用户需要的是更少的决策。

处理这事有几种做法,效果从弱到强排一下。按源配额:每个源在首页最多出现 N 条,防止某个高产源霸屏,简单有效,五行代码。跨源折叠:就是前面说的 title_fp 折叠,能消掉大部分转载。更新去重:有些源会反复更新同一篇文章(修个错别字就重发),这种情况 URL 会变但标题不变,靠 title_fp 拦。按来源权重排序:给长期质量高的源更高权重,但这需要数据支撑,早期只能拍脑袋。

最后一条是控制总量。首页固定给 30 到 50 条,宁可让用户翻页,也不要把 500 条全铺上去。人的阅读带宽是有限的,超出部分不是信息,是噪音。

5.3 单点依赖与“假冗余”

很多聚合器以为自己接了很多源,实际上接的是同一个源的不同马甲。表现是:某个源挂了,你的内容量掉了 40%,因为另外五个源都在转载它。

识别方法不难,做一次跨源标题指纹统计:

SELECT source, COUNT(*) AS items, COUNT(DISTINCT title_fp) AS uniq_titles, ROUND(1.0 * COUNT(DISTINCT title_fp) / COUNT(*), 3) AS diversity FROM feed_item WHERE fetched >= datetime('now', '-30 days') GROUP BY source ORDER BY diversity ASC;

diversity越低,说明这个源的内容越可能和别人重合。再进一步,把同一个title_fp出现在哪些源里列出来,就能画出转载关系图——如果发现 A、B、C 三个源的内容有 80% 高度重合,那你在源列表里其实只需要保留一个。

这个分析还有个副产品:它能帮你识别哪些源是“原创源”,哪些是“二传手”。做聚合器的人应该优先保证原创源的覆盖率,二传手可以适当砍掉,既省请求也降噪。

5.4 成本与延迟:聚合层是一笔隐性的税

聚合层天然在关键路径上,它会把所有上游的尾延迟叠加起来。如果每个上游的 P99 是 200ms,你串行调五个,整体 P99 就不是 200ms 而是接近 1000ms,因为尾延迟很少同时发生但也几乎必然发生。

这条规律在接口聚合上尤其致命。做法只有一条:并行 + 独立超时 + 部分成功。并行调五个上游,总耗时等于最慢的那个;给每个上游单独设超时,超时的直接放弃;返回结果里明确标出哪些来源成功了,前端据此展示部分数据,而不是整页报错。

“部分成功”这个设计需要产品侧配合,技术上要提前和前端约定好数据结构,别等到出事了才临时加字段。我见过一个项目因为聚合层坚持“要么全成功要么全失败”,一个非核心上游抖动就导致整个页面白屏,最后被迫重构。

成本上也一样:聚合层的每一次调用都是真金白银,尤其是调用计费的外部服务。这时候要区分核心路径和非核心路径,非核心的走缓存、走异步、走抽样,别一视同仁。

5.5 边界感:哪些东西不适合被聚合

技术上能做,不代表应该做。有几条线我自己是划死的:不绕过源站明确声明的访问限制,包括 robots 协议和接口的鉴权要求;不做全量镜像式转载,只存标题、摘要和跳转链接,正文留在原站;控制请求频率,宁可慢一轮也不要给源站造成压力;不聚合需要授权才能获取的私有内容,这是常识但总有人踩。

还有一条更容易被忽略:不要把不确定的信息包装成确定的信息展示。聚合器天然会丢失上下文,一条原本带了很多限定条件的消息,被你摘出标题放在列表里,含义可能完全变了。所以在展示层保留来源和时间戳不是可选项,是必须项。

6. 可能性:把“聚合”当成一种结构思维

6.1 个人层:把碎片信息变成可检索的资产

一个人日常产生的信息散落在书签、笔记、稍后读、聊天记录、代码片段、订阅列表里,分散在七八个地方,用到的时候一个都想不起来在哪。这件事的技术解法其实很朴素:统一入口 + 统一字段 + 全文检索。

具体做法是建一张宽表,字段就那几个:来源、标题、正文摘要、原始链接、创建时间、标签。所有来源都写适配器往里灌,重复的用指纹去重,检索先用 SQLite 的 FTS5 顶着。这套东西的工程量在一个周末的量级,但收益是长期的:你终于可以“搜一下我三年前存的那篇东西”。

这里的关键不是技术,是字段设计要一次定死。我一开始贪心,给不同类型的条目加了不同的扩展字段,结果查询时要写一堆 union,维护成本飞起。后来统一成“通用字段 + 一个 JSON 扩展列”,代码量直接砍半。

6.2 团队层:内部能力聚合层比造轮子更划算

团队里最常见的浪费是:三个业务线各自接了一遍同样的外部服务,各自处理了一遍鉴权、重试、限流、监控。这种重复不是技术能力问题,是组织结构问题,但技术上可以用一层聚合来缓解。

这层的定位要非常清楚:它只做统一接入和统一治理,不做业务逻辑。对外提供稳定的接口契约,对内屏蔽供应商差异,供应商换了只改这一层。收益是把“改 N 个业务”变成“改一层”,风险是这一层自己变成新的瓶颈和事故点。

所以这层必须配三样东西才敢上线:完整的上游健康度监控、可灰度可回滚的路由配置、明确的 SLA 承诺。缺一样都会在第一次故障时变成背锅现场。

6.3 判断一个垂直领域值不值得做聚合器的四个问题

不是所有“分散”的行业都适合做聚合。我自己的判断框架是四个问题,全过才动手:

供给是不是真的碎且不稳定?如果头部三家占了 80% 份额,你的聚合价值很有限,因为用户直接去那三家就行。需求是不是高频且决策成本高?一周用一次、决策只花十秒的场景,做聚合器的天花板很低。标准化能不能做到 80%?如果每个供给方的核心字段都不一样,你永远在写适配器,规模越大越亏。被聚合方愿不愿意长期配合?如果对方的商业模式就是靠信息不透明赚钱,你的存在对它是威胁,它会用各种方式让你采不到。

这四个问题里,我觉得第三和第四最容易被低估。前者决定你的边际成本能不能降下来,后者决定你的供给能不能稳定。两个都不行的话,技术再好也做不成。

6.4 我自己的取舍:先做窄的、能自己天天用的

回看这几年折腾过的几个聚合项目,活下来的都有一个共同点:我自己每天都在用。死掉的也有共同点:功能很全,但我自己不用。

这个规律听起来很朴素,背后的逻辑是:只有当你是自己的用户,你才能持续发现质量问题——哪条重复了、哪条过期了、哪个源三天没更新了。这些细节靠测试用例发现不了,只能靠日复一日的使用。而一旦你自己不用,这些问题会在你完全不知情的情况下堆积三个月,然后某天发现“怎么全是旧内容”。

所以如果让我给一个起步建议,就是:选一个你自己有强需求的窄场景,源控制在 20 个以内,先把去重和新鲜度做到能天天看,再考虑扩。扩源是最容易的一步,也是最不值得提前做的一步。

最后分享一个小技巧:在输出页面顶部加一行状态栏,显示“本轮抓取 N 个源,成功 M 个,新增 K 条,最旧一条距今多久”。这四个数字往那一放,链路是不是健康一眼就知道,比翻日志快得多。我加这行字花了十分钟,之后每次出问题都能在三十秒内定位到是哪一环。