资讯详情

资讯详情

内容聚合器最小实现:抓取、两级去重与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_modeWAL让读写不互相阻塞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_valuesTrue) 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, headersHEADERS, timeoutTIMEOUT) 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_threadTrue连接不允许跨线程使用。有两种解法但只有一种是对的。第一种是设check_same_threadFalse然后加锁。能跑但你只是把线程安全问题从库转移到了自己身上且 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(timespecseconds) total_new 0 with ThreadPoolExecutor(max_workersworkers) 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_newINSERT 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 htmlmeta charsetutf-8, stylebody{font:16px/1.7 system-ui;max-width:760px; margin:40px auto;padding:0 16px} .src{color:#666;font-size:13px}/style, h1今日聚合/h1ul] for title, url, source, published, dup_cnt in rows: badge fsup{dup_cnt - 1}/sup if dup_cnt 1 else parts.append( flia href{html.escape(url)} target_blank relnoopener f{html.escape(title)}/a{badge} fspan classsrc — {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 214.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 条最旧一条距今多久”。这四个数字往那一放链路是不是健康一眼就知道比翻日志快得多。我加这行字花了十分钟之后每次出问题都能在三十秒内定位到是哪一环。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →