☰
一站式微博数据聚合采集:从脚本到长期稳定运行的工程实践
2026/10/29 23:24:16 网站建设 项目流程

上周有个做舆情分析的朋友把 weibo_one_spider 这个项目丢给我,说想拿去跑一批公开内容做话题趋势,问我能不能直接上手。我看了一眼标题——一站式微博数据聚合采集软件——第一反应不是“这玩意儿怎么写”,而是“一站”这两个字到底想解决什么问题。做过采集的人都知道,写一个能跑通的脚本只需要一个下午,但写一个能连续跑三个月、字段不漂移、中断能续、数据能直接进分析流程的东西,完全是另一个量级的事情。weibo_one_spider 这类项目真正的价值不在“能抓”,而在“抓得稳、存得规整、用得起来”。这篇东西就是围绕这三个字展开的:聚合采集的架构怎么搭、分页与增量怎么设计、清洗和落库怎么做、出问题怎么查。文章偏工程实践,适合已经写过一点采集脚本、准备把玩具升级成长期运行服务的读者,也适合做数据分析但不想天天手动导表的朋友。代码都能直接抄,参数都给推导过程,踩过的坑我尽量都摆出来。

1. 需求与边界先行:一站式聚合到底“一站”在哪

1.1 拆标题:聚合、一站式、采集三个词各指什么

很多人看到这类标题会默认理解成“一个脚本把内容抓下来”,实际上“一站式”在工程语境里指的是链路完整:请求调度、页面或接口解析、字段清洗、去重、增量、存储、导出、失败重试、日志观测,这一整套都在同一个项目里闭环,而不是抓完之后再手忙脚乱地写第二个脚本做清洗、第三个脚本做入库。我在实际项目里见过太多这样的烂摊子:采集脚本输出一堆半结构化文本,清洗脚本用正则二次解析,入库脚本再猜字段类型,三处逻辑各写各的,字段一改全链路崩。weibo_one_spider 的价值就在于把这条链路压到一处,字段定义只有一份,改一处全链路生效。

“聚合”这个词容易被误解成“抓得多”。我更愿意把它理解成多来源、多维度归并到统一模型。比如同一条内容可能从列表页来、从详情页来、从搜索结果来,三处的字段完整度不一样,聚合层的职责是把它合并成一条主记录,而不是存三份。这就要求我们在设计阶段就定好主键和字段优先级,而不是抓完再想办法。这部分后面第 3 章会展开讲,这里先建立概念:聚合是“收敛”,不是“堆量”。

“采集”则是整个项目里技术含量最低但最容易出事的部分。真正难的不是把请求发出去,而是在有限频率下把该拿的都拿到、不该拿的一条不碰。这个“不该拿”既包括隐私类字段,也包括平台明确不开放的区间。所以我把边界问题放在最前面讲,因为架构再漂亮,边界错了就是白干。

1.2 采集边界:开跑之前必须先划的三条线

第一条线是数据范围。只处理公开可见的内容,不碰需要额外授权才能看到的区域,不采集任何可以定位到具体个人的敏感信息。这一点不是道德说教,而是实打实的工程约束:一旦你的数据里混进了不该有的字段,后面做分析、做展示、做交付时,每一环都要额外做脱敏处理,成本翻倍还容易漏。我在项目里做的第一件事就是把字段清单拉出来,逐条标注“公开可见/需要脱敏/不采集”,标完之后字段数量直接砍掉三分之一,反而让 schema 更清爽了。

第二条线是请求频率。这条线决定了你能不能长期跑。我的经验值是单实例对单一站点的请求间隔不低于 2 秒,并发控制在 2 到 3,遇到响应变慢自动降速。这个数字不是拍脑袋来的,后面 4.2 节会给出具体的算术推导。这里想强调的是:频率限制不是“怕被封”,而是“保证你自己能跑下去”。你把对方打挂了,自己也拿不到数据;你把频率压到很低但稳定跑一个月,拿到的数据量反而更大。这是长期采集和一次性抓取最大的思维差异。

第三条线是数据用途。我一般会在项目里留一个 usage 说明文件,写清楚这批数据打算干什么、保存多久、谁可以访问。听起来很形式主义,但真出事的时候,这份文件能帮你快速判断哪些字段可以留、哪些必须删。对于做学术分析或者内部趋势观察的场景,通常只需要聚合后的统计结果,原始文本甚至可以定期清理,只保留结构化指标。这个取舍在架构设计阶段就要想清楚,因为它直接影响到存储方案的选择——只存指标的话,SQLite 一个文件就够了;要存原文做语义分析,就得考虑分表和压缩。

注意:边界不是写一次就完事的文档,它应该体现在代码里。我习惯把字段白名单写进配置,解析层只允许输出白名单内的字段,多出来的直接丢弃。这样即使目标站点的响应结构变了、多返回了字段,也不会悄悄流进你的数据库。

2. 架构选型:为什么把单体脚本拆成四层

2.1 从一坨脚本到四层骨架

我接手过的采集项目里,八成以上最初都是这个形态:一个 main 函数,里面 for 循环翻页,requests 拿到 JSON,解析完直接写 CSV。前两周跑得很爽,第三周开始出问题——某个字段突然变成 null,排查半天发现是目标站点把数字改成了字符串;某次跑了一半断网,重跑之后数据重复了一倍;想加个新维度,发现要在三层嵌套的循环里改代码。

所以 weibo_one_spider 这类项目我建议一开始就按四层切:调度层、抓取层、解析层、存储层。调度层负责决定“下一个请求打哪”,管队列、管并发、管重试;抓取层只负责“把请求发出去、把响应拿回来”,不关心内容长什么样;解析层负责“把响应变成结构化字典”,是唯一知道字段含义的地方;存储层负责“把字典变成记录”,管去重、管 upsert、管导出。四层之间用 plain dict 传递,不做过度抽象,Python 里用 dataclass 或者 TypedDict 就够,别上来就搞一堆抽象基类。

这么拆的直接好处是排查路径变短。数据不对的时候,你只需要问三个问题:响应回来了吗(看抓取层日志)、解析出字段了吗(看解析层单测)、写进去了吗(看存储层计数)。三个问题定位到具体层,比在一坨脚本里打断点快十倍。另一个好处是可替换性:目标站点如果从接口返回改成页面渲染,你只需要换抓取层和解析层,调度和存储完全不用动。我在一个项目里经历过目标站点半年内改了三次返回结构,因为分层清楚,每次改动都在两小时内完成。

分层也会带来一点额外成本:数据要在层之间传递,字段名要统一。我的做法是定一份 fields 清单文件,四层都从这里导入,谁都不许自己写字符串字面量。这个习惯看着啰嗦,但能省掉后面大量的拼写错误排查。

2.2 框架选型的实测对比

选型这块我踩过不少坑,直接给结论:中小规模的聚合采集,不要上重型框架。下面这张表是我自己几个项目里的实测感受,场景是单机、日增几万条、需要长期运行。

方案上手成本长期稳定性调试难度适合场景
requests + 自写调度低中,需要自己实现重试和限速低,断点直接打目标单一、字段不多、需要精细控制频率
asyncio + aiohttp中高,但异常处理要写仔细中,堆栈较深需要并发但又不想开多进程
Scrapy高高,中间件生态成熟高,回调链不好跟站点多、规则多、需要统一管道
浏览器自动化中低,资源占用大、易碎中页面强依赖脚本渲染且无接口可用

我最后在 weibo_one_spider 里选的是requests + 自写轻量调度,理由是目标场景相对单一,字段数量在三十个以内,最需要的是对请求间隔的精确控制。Scrapy 的下载中间件当然也能做限速,但它的调度模型是围绕“大量 URL”设计的,我这里的关键是“按时间窗口顺序推进 + 断点续跑”,用自己的队列反而更直观。asyncio 方案我也试过,QPS 提升明显,但异常栈变深之后,线上排查一个超时问题要多花半小时,权衡下来不值。

这里要强调一点:选型不是选最强的,是选你能在凌晨两点修好的。采集服务的故障大多发生在你不想干活的时候,方案越简单,恢复越快。如果你的场景确实需要高并发多站点,那 Scrapy 是对的;如果是单站点长期跟踪,轻量方案更省心。

2.3 数据模型:一条内容从原始响应到结构化记录

数据模型这块,我的原则是三层落地:原始响应、解析结果、业务视图,分开存。原始响应存成 JSONL 按天切文件,压缩后占地很小,它的作用是“后悔药”——解析逻辑改了可以重跑,不用重新请求。解析结果是结构化记录,进数据库,带 created_at 和 updated_at。业务视图是给分析用的宽表或者视图,字段经过清洗和归一化。

为什么一定要存原始响应?我吃过亏。有次一个计数字段从整数变成了带单位的字符串,比如“1.2万”,解析层直接抛异常,那批数据全丢了,只能重新抓。后来加上原始响应归档,同类问题再出现,改完解析逻辑重跑本地文件就行,十分钟搞定。这个设计在存储上多花 20% 空间,换来的是可重放能力,非常值。

字段设计上有几个细节值得说。主键不要用自增 ID,用平台侧的稳定标识;时间字段统一存 UTC 时间戳,展示层再转本地时区,否则跨时区分析会乱;数值字段如果可能缺失,用 NULL 而不是 0,因为在分析时 0 和“没有数据”是完全不同的含义,混在一起算平均值会得出荒谬结论。这些细节单看都很小,但累积起来决定了你的数据能不能直接用。

3. 核心细节拆解:分页、增量、去重、清洗

3.1 分页与游标:三种翻页模型的实际差别

翻页看着简单,其实是采集里最容易翻车的地方。常见的三种模型:页码翻页、偏移量翻页、游标翻页。页码翻页(page=1,2,3)最直观,但有个致命问题——数据在翻页过程中会变。你翻到第 5 页的时候,前面新插入了三条内容,第 5 页的内容就整体后移了,结果就是重复抓三条、漏抓三条。这个问题在活跃度高的站点上非常明显,越翻越乱。

偏移量翻页(offset=0,20,40)本质一样,而且偏移量越大查询越慢,翻到几十页之后响应时间会明显上升。我一般只在“总量小、变化慢”的场景下用它。

游标翻页是最稳的,用上一页最后一条的标识或时间戳作为下一页的起点。它的好处是不受插入影响,缺点是必须有稳定的排序字段。如果目标站点支持按时间倒序 + 游标,优先用这个。

def iter_pages(fetch, cursor=None, stop_at=None, max_pages=200): """通用游标翻页:fetch(cursor) 返回 (items, next_cursor)""" page = 0 seen = set() while page < max_pages: items, next_cursor = fetch(cursor) if not items: break for item in items: if item["id"] in seen: continue # 同一次任务内防重 seen.add(item["id"]) yield item if stop_at and items[-1]["created_ts"] <= stop_at: break # 到达水位线,提前收工 if not next_cursor or next_cursor == cursor: break # 游标不再前进,防止死循环 cursor = next_cursor page += 1

这段代码里有三个保护点值得注意。seen集合防同任务重复,防止游标回退导致死循环;next_cursor == cursor判断防止接口在不前进时无限循环,这个坑我踩过,跑了一晚上发现同一个游标请求了两万次;stop_at水位线判断让增量任务能提前结束,而不是每次都把所有页翻完。最后这个优化在长期运行里效果显著,日常增量任务通常只需要翻两三页就结束。

3.2 增量采集的水位线设计

全量重跑在数据量小的时候无所谓,但一旦积累到几十万条,每次重跑就是几小时的浪费。增量采集的核心是水位线,也就是“上次跑到哪了”。水位线有两种存法:存时间戳、存主键集合。

存时间戳最省事,记录上次成功采集的最大时间,下次从这个时间往前回溯一小段(我一般回溯 10 分钟,用来兜住发布延迟和时钟误差)。回溯窗口不能省,因为内容发布到进入列表是有延迟的,你严格从上次结束点开始,中间就会有缝隙。

存主键集合更精确,但集合会无限膨胀。实际做法是只存最近 N 条的主键,配合时间戳一起用:先用时间戳圈定范围,再用主键集合做精去重。这套组合拳我在多个项目里用下来比较稳。

import json, os, time STATE_FILE = "state.json" def load_state(): if not os.path.exists(STATE_FILE): return {"last_ts": 0, "recent_ids": []} with open(STATE_FILE, encoding="utf-8") as f: return json.load(f) def save_state(last_ts, recent_ids): state = {"last_ts": last_ts, "recent_ids": recent_ids[-2000:], "saved_at": int(time.time())} tmp = STATE_FILE + ".tmp" with open(tmp, "w", encoding="utf-8") as f: json.dump(state, f, ensure_ascii=False) os.replace(tmp, STATE_FILE) # 原子替换,防止写一半断电

这里用os.replace做原子替换是个小细节但很重要。我有一次在写入状态文件时机器重启,文件变成半截 JSON,下次启动直接解析失败,只能手动修。改成先写临时文件再原子替换之后,这类问题再没出现过。同理,数据库写入也要用事务包起来,别一条一条 autocommit。

水位线还有个隐含要求:任务失败时不能推进水位线。只在整批数据处理并落库成功之后再更新状态,否则中途失败会导致数据缺口。我见过有人为了省事在请求成功后就更新状态,结果落库那一步崩了,缺口再也补不回来。

3.3 去重与主键策略:内容哈希为什么不能当唯一键

去重这件事,新手最容易犯的错是拿内容的哈希值当主键。听起来很合理——内容一样就是同一条。但实际数据里,同一条内容在转发、引用、重新发布时文本可能一模一样,哈希相同但它们是不同记录;反过来,同一条记录因为编辑、补充话题标签,文本变了但标识没变,哈希不同却被当成两条。两种情况一起出现,你的数据就会既漏又重。

正确的做法是用平台侧的内容标识做主键,这个是稳定的。如果某些来源拿不到标识(比如搜索结果里的摘要),就用“作者标识 + 发布时间 + 内容前 N 字符的哈希”做一个复合键,命中率也够用。我在项目里定了一个规则:优先用原标识,拿不到才降级到复合键,并且降级记录要打标记,方便后面抽查。

数据库层面用唯一索引兜底,写入用 upsert 而不是先查后插。先查后插在并发场景下有竞态,两个线程同时查不到然后同时插入,唯一索引会报错;用 upsert 则是一条语句搞定,冲突就更新。SQLite 从 3.24 开始支持ON CONFLICT DO UPDATE,语法和 PostgreSQL 基本一致,迁移方便。

INSERT INTO posts (id, author_id, created_ts, text, like_cnt, repost_cnt, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET text = excluded.text, like_cnt = excluded.like_cnt, repost_cnt = excluded.repost_cnt, updated_at = excluded.updated_at;

注意这里更新的是互动计数和文本,作者和发布时间不更新——这两个字段如果变了,大概率是数据源出错或者你抓错了东西,覆盖反而会污染数据。这个“哪些字段可以覆盖”的规则应该在设计阶段就写清楚,而不是等出了问题再改。

3.4 字段清洗与归一化:三个高频踩坑点

清洗这活儿看着琐碎,但它决定了你的数据能不能直接用来算指标。我总结下来有三个高频坑:时间格式、计数单位、文本编码。

时间格式最麻烦,同一批数据里可能混着“3分钟前”“今天 14:32”“2024-05-11 14:32:07”三种格式。统一策略是先尝试绝对时间解析,失败再走相对时间解析,相对时间的基准用采集时刻而不是当前时刻,否则补跑历史数据时会算错。这个细节很多人忽略,导致补跑出来的时间全部偏移。

import re, datetime as dt def parse_time(raw, fetch_ts): raw = (raw or "").strip() for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d"): try: return dt.datetime.strptime(raw, fmt).replace(tzinfo=dt.timezone.utc).timestamp() except ValueError: pass m = re.match(r"(\d+)\s*(秒|分钟|小时|天)前", raw) if m: n, unit = int(m.group(1)), m.group(2) delta = {"秒": 1, "分钟": 60, "小时": 3600, "天": 86400}[unit] * n return fetch_ts - delta if raw.startswith("今天"): hm = re.match(r"今天\s*(\d{1,2}):(\d{2})", raw) if hm: base = dt.datetime.fromtimestamp(fetch_ts, tz=dt.timezone.utc) return base.replace(hour=int(hm.group(1)), minute=int(hm.group(2)), second=0, microsecond=0).timestamp() return None

计数单位是第二个坑。“1.2万”“3.5k”“10万+”,这些都必须归一成整数才能算平均值。归一的时候要保留精度信息,我一般会额外存一个is_exact标记,因为“10万+”这类是下界,直接当 100000 用会让统计偏低。做趋势分析时影响不大,但做精确汇总时要小心。

文本编码的坑在于混入的特殊字符和零宽字符。有些内容里带不可见字符,会导致去重失效、关键词匹配不上。统一在清洗阶段做一次规范化:转成 Unicode NFC、去掉零宽字符、把全角标点统一成半角(除非你知道你要保留原文)。这一步做完,后面所有文本处理都会省心。

4. 从零跑通:环境、参数、运行、落库

4.1 环境准备与目录结构

环境不复杂,Python 3.9 以上,主要依赖就几个:requests 做请求、lxml 或 selectolax 做解析(如果有页面)、pydantic 做字段校验、SQLite 内置就够。我建议用虚拟环境,别装到全局,采集项目依赖变动频率不低。

weibo_one_spider/ ├── config.yaml # 频率、并发、字段白名单 ├── state.json # 水位线 ├── data/ │ ├── raw/ # 原始响应 JSONL,按天切 │ └── db.sqlite3 ├── logs/ └── src/ ├── scheduler.py ├── fetcher.py ├── parser.py └── storage.py

目录结构本身就是一种文档。raw 和 db 分开,是为了让“重放”这个动作变得理所当然;config 独立,是为了不改代码就能调频率;logs 单独放,是因为采集服务的日志会长得很快,需要单独做轮转。

配置我建议用 YAML 而不是全塞环境变量,因为参数之间有依赖关系(比如并发和间隔要一起看),放在一个文件里更直观。

fetch: min_interval: 2.0 # 秒,两次请求最小间隔 max_concurrency: 2 timeout: 15 retry: 3 backoff: [5, 20, 60] # 三次重试的等待秒数 fields: whitelist: [id, author_id, created_ts, text, like_cnt, repost_cnt, comment_cnt] storage: db_path: data/db.sqlite3 raw_dir: data/raw

4.2 参数怎么算出来的:限速的算术推导

参数不能拍脑袋,算一遍心里才有底。假设目标日增内容量是 5 万条,每页返回 20 条,那么一天需要 2500 次请求。分散到 24 小时,平均每小时 104 次,也就是每 35 秒一次。这个频率非常轻松,2 秒间隔完全够用,甚至可以用更保守的 3 秒。

再看峰值场景:假设你要做一次历史回溯,补最近 30 天的数据,总量 150 万条,每页 20 条,就是 7.5 万次请求。按 2 秒间隔单线程算,需要 41.7 小时;开 2 并发,间隔不变的情况下理论 21 小时,但实际会因为响应耗时和重试拉长到 26 小时左右。这个数字决定了你要不要安排夜间跑、要不要做分批断点。

反过来推:如果你只有 8 小时窗口,要补完 30 天数据,需要 75000 次请求 / 28800 秒 ≈ 2.6 QPS,也就是平均每 0.38 秒一次。这个频率对单站点来说偏高,风险不小。我的处理方式是拉长窗口或者缩小回溯范围,而不是硬提频率。比如只补最近 7 天,请求量降到 1.75 万次,8 小时窗口下每 1.6 秒一次,完全在舒适区。

并发数的选择也有讲究。并发不是越高越好,因为限速是全局的,2 并发配 2 秒间隔,实际总请求间隔是 1 秒,相当于把频率翻倍了。所以我一般在配置里把“最小间隔”定义为全局最小间隔,用令牌桶实现,而不是每个 worker 各自 sleep。这一点如果不注意,很容易出现“配置写的 2 秒,实际跑了 0.5 秒”的情况。

import time, threading class RateLimiter: def __init__(self, min_interval): self.min_interval = min_interval self.lock = threading.Lock() self.next_ok = 0.0 def acquire(self): with self.lock: now = time.monotonic() wait = max(0.0, self.next_ok - now) self.next_ok = max(now, self.next_ok) + self.min_interval if wait > 0: time.sleep(wait)

这个实现保证任意两次请求之间的启动间隔不小于设定值,多线程共享同一个实例即可。比每个线程各自 sleep 靠谱得多,也不会因为某个请求耗时特别长而导致整体忽快忽慢。

4.3 运行、断点续跑与日志观察

启动方式我习惯做成子命令,全量和增量分开,避免误操作把全量跑成增量或者反过来。

# 首次全量回溯最近 7 天 python -m src.scheduler --mode backfill --days 7 # 日常增量,从水位线往后推 python -m src.scheduler --mode incremental # 只跑解析,用本地原始响应重放,不打网络 python -m src.scheduler --mode replay --date 2024-05-11

replay模式是我用得最多的。解析逻辑一改,先 replay 一批历史原始数据验证字段对不对,确认没问题再上线上跑。这个习惯能让 90% 的解析 bug 在本地暴露,而不是在凌晨三点的日志里暴露。

断点续跑依赖两件事:状态文件 + 数据库唯一索引。中断之后直接重跑增量模式,它会从上次水位线继续,重复的部分被 upsert 吃掉。这里有个细节:状态文件更新必须在落库事务提交之后,顺序反了就会出现“状态说跑完了但数据没进去”的缺口。

日志方面,我建议至少记录四类信息:每次请求的耗时和状态码、每页解析出的条数、每批落库的新增/更新条数、以及异常堆栈。其中“解析条数”这个指标最有用——正常情况下每页条数应该是稳定的,一旦某几页突然变成 0 条,说明字段结构变了,这时候日志会第一时间告诉你。

import logging logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s", handlers=[ logging.FileHandler("logs/run.log", encoding="utf-8"), logging.StreamHandler(), ], )

日志文件一定要设轮转,采集服务跑一周就能写出几百兆。用RotatingFileHandler按大小切,保留 7 个文件足够。

4.4 存储与导出:SQLite 够不够用

存储选型我一般先问三个问题:数据量多大、几个人用、要不要并发写。日增五万条、单人分析、单进程写,SQLite 完全够,一个文件拷走就能在别的机器上打开,这是它最大的优势。等到要多进程写或者数据上千万,再考虑换库,不要一开始就上重型数据库给自己找运维负担。

写 SQLite 有几个必须调的参数,否则性能会很难看:PRAGMA journal_mode=WAL让读写不互相阻塞,PRAGMA synchronous=NORMAL在可接受的可靠性下大幅提升写入速度,批量写入用事务包住,一千条一提交。这三条加上去,写入速度能有数量级的差别,我实测过单条 autocommit 和批量事务,差距在二十倍以上。

导出方面,分析场景常用三种格式:CSV 给表格工具、JSONL 给脚本处理、Parquet 给大数据工具。我一般提供一个统一的导出命令,支持按时间范围和字段筛选,默认导出 CSV,需要别的格式加参数。

python -m src.export --start 2024-05-01 --end 2024-05-31 \ --fields id,created_ts,text,like_cnt --format csv --out data/may.csv

导出时有个细节值得注意:CSV 里的大整数和时间戳容易被表格工具自动转成科学计数法或日期,导致数据失真。我的做法是导出时把 ID 强制加引号,或者干脆导出一份带类型说明的 schema 文件放在旁边。这个坑我踩过,客户打开 CSV 之后 ID 全变成 1.23E+18,对不上号,来回折腾了半天。

5. 常见问题与排查实录

5.1 异常速查表

下面这张表是我在长期运行中整理出来的,按出现频率排序。遇到问题时先对照这张表,能覆盖八成情况。

现象可能原因排查动作处理方式
某几页解析出 0 条字段结构变化或返回了错误页看原始响应里是不是提示信息更新解析规则,用 replay 验证
数据量突然翻倍水位线没推进或游标回退检查 state.json 的 last_ts修正状态文件,重跑去重
请求大量超时网络抖动或频率过高被限流看日志里的响应耗时分布降并发、加退避、缩短单次任务
写入报唯一约束冲突并发写入同一主键看冲突的主键是否重复改 upsert,检查去重逻辑
文本出现乱码编码判断错误检查响应头的字符集显式指定编码,加兜底解码
时间字段偏移几小时时区处理不一致对比原始字符串和入库值统一存 UTC,展示层转换
任务跑一半卡住死循环或游标不前进看最后几条日志的游标值加最大页数保护
磁盘快速增长原始响应没压缩、日志没轮转看 data/raw 和 logs 目录大小开压缩、配日志轮转

5.2 四个容易被忽略的坑

坑一:解析层的隐式类型假设。我最开始写解析的时候默认计数字段是整数,直接int(value),结果遇到带单位的字符串就崩。后来改成统一走一个to_int函数,内部处理单位换算和异常兜底,返回(value, is_exact)二元组。这个改动让解析层的健壮性提升了一个档次,也让“数据缺失”和“数据为 0”能明确区分开。

坑二:重试策略用错了地方。早期我所有异常都重试三次,结果遇到一个 40x 的错误码,重试了三次还是一样,白白浪费了三次请求配额。正确做法是区分错误类型:网络超时、连接重置这类可以重试;明确的拒绝类响应不该重试,应该直接记录并跳过;解析异常更不该重试,因为它重试一百次结果都一样。我把异常分成RetryableError和FatalError两类,前者走退避重试,后者直接进死信队列人工处理。

坑三:字符串里混入不可见字符导致去重失效。有一次发现数据库里有明显重复的记录,主键却不一样。查了半天发现内容里带了零宽字符,看起来一样的两条记录字节不同,平台侧标识也不同(可能是重复发布)。处理方式是清洗阶段统一做 Unicode 规范化并剥离零宽字符,同时在分析层再加一层“文本相似度”判断,用于识别近似重复。这两层配合,重复率从 3% 降到了 0.2% 以下。

坑四:任务日志里没有请求样本。出问题的时候最想要的是“当时到底返回了什么”,但日志里只有状态码和条数,啥也看不出来。后来我加了一个采样机制:每批任务随机保留 3 条原始响应到 debug 目录,出问题时直接翻样本,比复现快得多。采样比例可以配置,正常运行设成 0,排查时临时打开。

提示:排查顺序建议固定成“先看响应、再看解析、最后看存储”。很多人一上来就怀疑数据库,实际上九成问题出在前两步。养成按层排查的习惯,平均定位时间能缩短一半以上。

6. 采完之后:数据可用性校验与分析口径

6.1 常用分析口径与指标定义

采集只是手段,数据能用才是目的。我一般会在项目里内置一套基础统计,跑完采集立刻生成一份概览,用来判断这批数据是否可分析。核心指标就几个:时间分布(按小时/天聚合的条数)、作者集中度(Top N 作者占比)、互动量分布(均值、中位数、分位数)、文本长度分布。这几个指标能快速暴露数据质量问题,比如时间分布出现大片空白说明采集有缺口,作者集中度异常高说明可能抓到了某个聚合页而不是真实内容流。

指标定义上要特别注意口径一致性。比如“平均互动量”,分母是全部记录还是只有互动量非空的记录?两种算法结果可能差一倍。我习惯在代码里把口径写死并在字段名上体现,比如avg_like_cnt_nonnull,虽然名字长,但谁看都不会误解。这比事后写文档解释靠谱得多,因为文档会过期,字段名不会。

还有一个容易忽略的点是去重后的统计。原始数据和去重数据的指标要分开算,否则你会高估热度。我在概览里同时输出raw_count和unique_count,两者差距大就说明重复采集严重,需要回头检查增量逻辑。

6.2 数据质量校验清单

每次采集任务结束后,我会跑一遍自动校验,发现问题就告警。校验项不多,但都是踩过坑总结出来的。

第一项,时间连续性。检查最近 N 天的小时分布,如果有连续几个小时条数为 0,标记为可疑。正常的内容流不会突然断几小时,除非采集失败或者水位线跳过了。

第二项,字段完整率。统计每个必填字段的非空比例,低于阈值就告警。字段完整率突然下降通常意味着解析规则和目标结构脱节了,越早发现越好。

第三项,主键唯一性。虽然数据库有唯一索引兜底,但还是要定期跑一次全表检查,确认没有因为数据迁移或者手工操作引入重复。

第四项,数值合理性。计数字段出现负数、超大值(比如超过一亿)或者分布突然偏移,都要人工确认。我遇到过单位换算错误导致所有数值放大一万倍的情况,就是因为没做这项校验,白白分析了一周的错误数据。

-- 时间连续性检查:找出条数为 0 的小时 WITH hours AS ( SELECT strftime('%Y-%m-%d %H', created_ts, 'unixepoch') AS h FROM posts WHERE created_ts >= :start_ts ), all_hours AS ( SELECT strftime('%Y-%m-%d %H', v, 'unixepoch') AS h FROM (SELECT :start_ts + (n * 3600) AS v FROM (WITH RECURSIVE seq(n) AS ( SELECT 0 UNION ALL SELECT n + 1 FROM seq WHERE n < :span_hours ) SELECT n FROM seq)) ) SELECT a.h FROM all_hours a LEFT JOIN hours b ON a.h = b.h WHERE b.h IS NULL ORDER BY a.h;

这段 SQL 用递归 CTE 生成完整的小时序列,再左连接实际数据,空出来的就是缺口。跑一次就能看出采集有没有断档,比肉眼看图表准。

我个人在实际操作中的体会是,采集项目里真正花时间的从来不是“抓取”本身,而是边界确认、字段清洗和异常排查这三件事,它们占了整个项目八成以上的工作量。所以我在新项目启动时,会把一半的精力放在设计阶段:字段清单先定死、参数先算清楚、校验先写好。看起来慢,但后面改动的次数会少很多。另外有个小技巧分享给你——把每次跑任务的耗时、条数、异常数记在一张表里,跑上一个月你就能看出规律,什么时候该降速、什么时候该扩容,全都有据可依,比凭感觉调参靠谱得多。

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

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

立即咨询