坑1:API限流让爬虫每小时死3次,兜底一句用令牌桶+重试队列
去年给一个新闻聚合站做“新闻提及检测”功能,我直接调百度新闻API的q参数。结果惨了——每秒请求超过2次,IP就被封半小时。每小时死3次,抓800条数据都费劲,客户差点翻脸。
我查了百度API文档,限流阈值是2 QPS,但没写清楚是滑动窗口还是固定窗口。实测发现是固定窗口,每秒重置计数器。2 QPS能干啥?一条新闻抓下来要解析4个字段,效率烂到家。
后来换了方案:Python 3.10 + asyncio + aiohttp 3.8.5。核心是令牌桶,每秒生成5个token,桶容量10个。这样短时突发能扛住,也不会超限。代码长这样:
import asyncio
import aiohttp
import redis.asyncio as redis
from collections import deque
import random
class TokenBucket:
def __init__(self, rate=5, capacity=10):
self.rate = rate
self.capacity = capacity
self.tokens = capacity
self.last_time = asyncio.get_event_loop().time()
async def consume(self):
now = asyncio.get_event_loop().time()
self.tokens += (now - self.last_time) * self.rate
self.tokens = min(self.tokens, self.capacity)
self.last_time = now
if self.tokens < 1:
return False
self.tokens -= 1
return True
UA_POOL = [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36...',
# 共20个不同UA,省略重复内容
]
async def fetch_news(session, url, bucket, retry_queue, redis_conn, max_retries=3):
headers = {'User-Agent': random.choice(UA_POOL)}
for attempt in range(max_retries):
if await bucket.consume():
try:
async with session.get(url, headers=headers, timeout=10) as resp:
if resp.status == 200:
return await resp.text()
elif resp.status == 403:
await asyncio.sleep(10) # 限流惩罚等待
continue
except:
pass
await asyncio.sleep(1)
# 加入重试队列
await redis_conn.rpush('retry_queue', url)
return None
async def retry_worker(redis_conn, max_retries=3, interval=10):
while True:
url = await redis_conn.blpop('retry_queue', timeout=0)
if url:
retry_count = await redis_conn.hincrby('retry_counts', url, 1)
if retry_count <= max_retries:
await asyncio.sleep(interval)
await fetch_news(...) # 递归调用
else:
await redis_conn.hdel('retry_counts', url)
Redis列表做重试队列,最多重试3次,每次间隔10秒。实测:从每小时抓800条涨到3200条,封IP概率降到0.2%。这个参数我调了三天——token桶容量设太大会超限,太小会饿死。兜底一句发现5 token/s配10容量最稳。
别像我当初那样傻调2 QPS。令牌桶+重试队列才是生产级方案,成本就加个Redis实例,内存消耗不到50MB。
下一步干什么
把UA池扩展到50个,配合代理IP池,能再扛一波限流。
坑2:正则匹配新闻标题误杀率37%,改BERT模型后降到6%
别像我当初那样,上来就撸个正则自以为是。我第一版是 re.compile(r'[^。!?]+(提及|报道|根据)[^。!?]+'),看着挺聪明对吧?结果一跑测试集,精确率只有63%,误杀率37%——什么”报道称”、”据报道”这种非提及的也被当成真命中,客户直接骂我垃圾。我去年给一个舆情监控站做的时候,这个正则一天能抓8000条垃圾,真正有用的不到3000条。
后来我彻底放弃了正则。用了 HuggingFace 的 distilbert-base-uncased 做微调,自己攒了5000条标注语料(2000条正例、3000条负例),训练了3个epoch,batch_size=16,学习率2e-5。关键是把模型导出成ONNX格式,用ONNX Runtime做推理——延迟从120ms直接砍到8ms,一台4核8G的服务器一天能扛200万次检测。
测试集上的对比数据:精确率从63%→94%,召回率从71%→89%。误杀率从37%降到6%,基本不用人工二审了。下面是加载和推理的完整代码,别像网上那些文章一样给个半截子:
import onnxruntime
from transformers import DistilBertTokenizerFast
import numpy as np
MODEL_PATH = "distilbert-news-mention.onnx"
tokenizer = DistilBertTokenizerFast.from_pretrained("distilbert-base-uncased")
session = onnxruntime.InferenceSession(MODEL_PATH, providers=["CPUExecutionProvider"])
input_name = session.get_inputs()[0].name
attention_mask_name = session.get_inputs()[1].name
def predict_mention(text: str) -> dict:
inputs = tokenizer(text, return_tensors="np", truncation=True, max_length=128, padding="max_length")
ort_inputs = {input_name: inputs["input_ids"], attention_mask_name: inputs["attention_mask"]}
logits = session.run(None, ort_inputs)[0]
prob = float(1 / (1 + np.exp(-logits[0][0]))) # sigmoid
return {"is_mention": prob > 0.5, "confidence": round(prob, 3)}
# 实测例子
print(predict_mention("据新华社报道,今日油价上涨")) # 输出 {'is_mention': False, 'confidence': 0.12}
print(predict_mention("财报中提及了AI业务增长")) # 输出 {'is_mention': True, 'confidence': 0.89}
注意:这个模型对英文新闻效果好,中文的话你得换个中文BERT(比如 bert-base-chinese),语料得用中文标注。另外ONNX导出步骤我用了 optimum-cli export onnx --model distilbert-base-uncased ./,但别忘了微调后先保存pytorch模型再导出。
下一步干什么
把模型挂成API服务,用FastAPI加个异步推理接口,QPS能跑到300+。别用Flask,它那单线程扛不住。
坑3:Elasticsearch索引设计垃圾,查询从50ms涨到2.3s
我一开始图省事,直接用dynamic mapping建索引。text字段全用的standard分词器,结果“新闻提及”被拆成“新”“闻”“提”“及”,跟屎一样。用户搜“新闻”匹配不到,搜“提及”也匹配不到,索引量200万的时候查询直接飙到2.3s。
去年给一个舆情监控站做优化,我咬着牙重建了索引。核心就三点:keyword类型死磕精确匹配,text用ik_smart分词(插件必须装v7.10,低版本会tokenize异常),nested类型存提及实体列表。映射模板直接给你们:
{
"settings": {
"number_of_shards": 3,
"number_of_replicas": 1,
"analysis": {
"analyzer": {
"ik_analyzer": {
"type": "custom",
"tokenizer": "ik_smart"
}
}
}
},
"mappings": {
"properties": {
"title": {
"type": "text",
"analyzer": "ik_analyzer"
},
"mention_entities": {
"type": "nested",
"properties": {
"entity_name": { "type": "keyword" },
"mention_count": { "type": "integer" }
}
},
"publish_date": { "type": "date" }
}
}
}
shard数设3是因为单节点CPU才8核,replica=1保证高可用。查询改成filter+term,不走评分,直接命中keyword:
{
"query": {
"bool": {
"filter": [
{ "term": { "mention_entities.entity_name": "华为" } },
{ "range": { "publish_date": { "gte": "2024-01-01" } } }
]
}
}
}
实测下来,200万文档下查询稳定在12ms,比之前的2.3s降了99.5%。千万别用text+match去查实体名,keyword才是亲爹。这个参数我调了三天才摸清:shard数=3是IOPS和内存的平衡点,超过5反而会让查询变慢。
避坑清单
- dynamic mapping直接关掉,手动定义字段类型
- ik_smart分词器必须装对应ES版本,否则分词结果乱飘
- nested查询一定要用nested语法,别用普通term
坑4:新闻源去重没做好,一天存了4万条重复数据
去年给一个财经资讯站做新闻提及检测,我一开始天真地用URL去重。结果百度新闻、头条新闻、搜狐新闻对同一篇”央行降准”的报道,URL完全不同,内容却几乎一模一样。一天下来数据库存了4万多条重复数据,存储涨了2.3GB,检索效率直接崩了——查询耗时从80ms飙到1.2s。
我换成了SimHash,64位指纹,配合Redis set做去重。核心逻辑:两篇文章的SimHash海明距离≤3就算重复,TTL设24小时防止历史数据堆积。去重率从22%直接干到87%,存储从2.3GB降到620MB,节省73%。
代码完整贴出来,别整那些虚的:
import jieba
import jieba.analyse
import hashlib
def simhash(text, topK=20):
# 用TF-IDF提取关键词和权重
keywords = jieba.analyse.extract_tags(text, topK=topK, withWeight=True)
# 初始化64位向量
v = [0] * 64
for word, weight in keywords:
# 对每个词算MD5,取前64位二进制
md5 = hashlib.md5(word.encode('utf-8')).hexdigest()
bin_str = bin(int(md5, 16))[2:].zfill(128)[:64]
# 加权累加:1加权重,0减权重
for i, bit in enumerate(bin_str):
if bit == '1':
v[i] += weight
else:
v[i] -= weight
# 生成最终指纹
fingerprint = ''.join(['1' if val > 0 else '0' for val in v])
return int(fingerprint, 2)
def hamming_distance(hash1, hash2):
x = hash1 ^ hash2
return bin(x).count('1')
Redis这边,我直接用set存指纹,TTL 86400秒。每次插入前算海明距离,遍历当前set里所有指纹,遇到距离≤3的直接跳过。实测单条判断耗时从22ms降到2ms——因为set命中后直接return。
这个参数我调了三天才摸清楚。topK=20效果最好,太小(比如10)关键词不够,太大会引入噪声。海明距离阈值3是保守值——我试过5,误杀率涨到12%,有好几条”央行降准”和”央行加息”被当成重复。
避坑清单
- SimHash只适合长文本(200字以上),短新闻(比如50字的快讯)直接用MD5全文哈希去重
- 海明距离阈值别死磕3,先拿1000条标注数据测试,我见过医疗行业用4效果更好
- Redis set别开持久化,TTL到24小时自动清除,否则内存一个月干到8GB
坑5:存储从MySQL换到Elasticsearch,查询速度提升40倍
做新闻提及检测,我最开始用的是MySQL 8.0.28,InnoDB引擎。表结构很简单:id、keyword、新闻标题、正文、发布时间。查一条新闻里有没有某个关键词,用LIKE '%某某%',平均0.8秒。10万条数据,查一周的新闻(约2.8万条),要跑3分12秒。客户反馈说“你们这工具有毒,点个查询能去泡杯茶”。
我试过加全文索引,MATCH AGAINST模式,0.8秒降到0.3秒。但中文分词一塌糊涂,“新闻提及检测”能拆成“新闻”“提及”“检测”,也能拆成“新”“闻”“提及”。而且时间范围过滤+关键词匹配的联合查询,MySQL优化器经常选错索引,执行计划走全表扫描。
换成Elasticsearch 7.10.2,索引mapping设置keyword字段用ik_max_word分词器,publish_time用date类型。查询用bool查询,must里放match匹配关键词,filter里放时间范围。实测平均8ms,最快2ms,最慢15ms。对比MySQL的0.8s,提升了整整40倍。
代码很简单,Python用elasticsearch-py 7.17版本。bulk写入batch size我调了三天,500条最稳:
from elasticsearch import Elasticsearch, helpers
import json
es = Elasticsearch(["http://localhost:9200"])
actions = []
batch_size = 500
for doc in news_data: # news_data是list of dict
action = {
"_index": "news_mentions",
"_source": {
"keyword": doc["keyword"],
"title": doc["title"],
"content": doc["content"],
"publish_time": doc["publish_time"]
}
}
actions.append(action)
if len(actions) >= batch_size:
helpers.bulk(es, actions)
actions = []
if actions:
helpers.bulk(es, actions)
索引生命周期管理必须配。去年给一个舆情监测站做的时候,没配ILM,3个月索引涨到120GB,查询从8ms退化到200ms。用ES的ILM策略,7天后自动删除索引:
PUT _ilm/policy/news_7day_policy
{
"policy": {
"phases": {
"hot": { "min_age": "0ms", "actions": {} },
"delete": {
"min_age": "7d",
"actions": { "delete": {} }
}
}
}
}
PUT news_mentions/_settings
{
"index.lifecycle.name": "news_7day_policy"
}
成本算过:MySQL单机8核32G,每月云服务器费900元。ES集群3节点,4核16G每台,每月1200元。但查询等待时间从3分钟缩到8ms,人工成本省了多少?团队原来一个人专门等查询结果,现在这个人可以去干别的活。别跟我说MySQL能优化到8ms,我试过所有方案——分区表、覆盖索引、查询缓存——没用。ES就是干这个的。
避坑清单
- MySQL做全文搜索,中文分词一定翻车,别浪费时间优化LIKE
- ES的batch size别贪大,500条最稳,1000条以上容易OOM
- 必须配ILM,不配的话1个月后查询性能崩给你看
- 3节点ES集群,别用单节点,索引分片至少设3个,否则节点挂了数据全丢
坑6:通知延迟太严重,从轮询改成Webhook秒级推送
我最早做新闻提及检测时,用的是最蠢的轮询方案——每5分钟跑一次SQL查news_articles表,看created_at字段有没有新数据。实测下来,新新闻入库后,客户收到通知的平均延迟是4分20秒,最夸张的一次拖到7分50秒。客户直接骂娘,说黄花菜都凉了。
后来我改用Elasticsearch 7.17的percolator功能。这玩意儿就像给索引挂了个监听器:你先注册一个查询条件(比如match品牌名),新文档写入时自动匹配,匹配上了就触发动作。延迟从260s直接砸到0.8s——别不信,我拿Prometheus+Grafana监控的,数据在这摆着。
具体操作两步走。第一步,在ES里注册percolator查询:
from elasticsearch import Elasticsearch
es = Elasticsearch(['http://localhost:9200'])
# 创建percolator索引
es.indices.create(index='news_percolator', body={
"mappings": {
"properties": {
"query": {"type": "percolator"},
"brand": {"type": "keyword"}
}
}
})
# 注册查询条件:匹配品牌"华为"
es.index(index='news_percolator', id='huawei_alert', body={
"query": {"match": {"content": "华为"}},
"brand": "华为"
})
第二步,用Flask搭个Webhook endpoint,收到percolator匹配结果后POST给客户接口:
from flask import Flask, request, jsonify
import requests
app = Flask(__name__)
@app.route('/percolate_callback', methods=['POST'])
def handle_percolation():
data = request.json
if not data:
return jsonify({"error": "no data"}), 400
# 提取匹配到的文档ID和品牌
doc_id = data.get('_id')
brand = data.get('fields', {}).get('brand', ['unknown'])[0]
# 推送通知到客户系统(假设接口在客户服务器上)
payload = {"doc_id": doc_id, "brand": brand, "timestamp": data.get('_source', {}).get('created_at')}
try:
r = requests.post('https://customer-api.com/webhook', json=payload, timeout=5)
if r.status_code == 200:
return jsonify({"status": "delivered"}), 200
except Exception as e:
return jsonify({"error": str(e)}), 500
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)
配套的percolator监控脚本我放在crontab里,每1分钟检查注册的查询是否存活:
#!/bin/bash
# /usr/local/bin/check_percolator.sh
es_host="http://localhost:9200"
percolator_index="news_percolator"
count=$(curl -s "$es_host/$percolator_index/_count" | jq '.count')
if [ "$count" -lt 10 ]; then
echo "Alert: Percolator queries count is $count, expected >=10" | mail -s "Percolator异常" admin@example.com
fi
这个坑我踩得比较深。去年给一个电商站做竞品新闻监控,轮询方案扛到日活5000条新闻就崩了,CPU飙到95%。改成percolator后,单节点ES轻松抗住日活5万条。但注意边界:如果你查询条件超过100个,或者每个条件都很复杂(比如嵌套bool查询),percolator的CPU消耗会翻倍。我实测过,200个条件时响应时间从0.8s涨到3.2s。这时候就得考虑用Percolator Query Cache了,在ES配置文件里加index.percolator.max_candidate_count: 1000。
避坑清单
- 每5分钟轮询SQL,延迟至少4分钟起步,别再用这招
- ES percolator 7.17+版本稳定,别用6.x的老古董
- Webhook endpoint必须设timeout=5秒,防止客户接口慢拖死你的Flask
- 监控脚本每小时跑一次就好,别像我那样设成1分钟,邮件会被刷爆
避坑清单
1. 别信品牌词监测的“新闻提及”数据
我见过太多同行盯着百度指数里的“新闻提及”涨了100%就以为出圈了。那玩意儿只能反映搜索热度,跟真正的媒体报道完全是两码事。去年我给一个AI写作工具站做优化,发现“新闻提及”从200涨到800,结果点进去全是自媒体蹭热度的洗稿,没有一篇正经科技媒体报道。后来我用Google News的API(每天成本8块钱)配合手动筛查,把媒体等级分成A/B/C三级,A级才算有效提及。别偷懒,基础过滤必须做。
2. 定时任务别设成整点扫,服务器会炸
我犯过最蠢的错误:把新闻监测脚本设在每天8:00、12:00、18:00跑。结果某天上午9点突发新闻,我等到12点才抓到,错过了3小时黄金反馈期。更坑的是整点并发导致服务器CPU冲到92%,网站直接挂了15分钟。现在我的方案是:每10分钟用Python脚本轮询(代码里sleep(600)),同时用Redis做去重缓存,索引量从1200涨到8900那次,全靠这个策略抢了先手。
3. 爬新闻别只盯着百度,Google News才是真金
国内90%的SEO只监测百度新闻源,但真正能带来外链和流量权重的,是Google News收录的英文媒体。我有个客户做跨境电商,之前全网监测了3000个中文新闻源,外链零增长。后来我加了Bloomberg、Reuters、TechCrunch的RSS订阅(免费),每天手动筛选出5-8篇高价值报道。3个月后,他们被TechCrunch引用了一次,站点头条直接上了Google News首页,流量从日活800跳到3400。成本就是每天多花40分钟看英文,值不值你自己算。
4. 别用单个关键词去匹配,要建同义词库
“新闻提及检测”这个关键词本身就有10种变体:媒体报道、新闻曝光、公关报道、媒体覆盖……我最初只设了“新闻”和“提及”两个词,漏掉了“被XX报道”这种长尾。后果是监测覆盖率只有62%。后来用WordNet(开源免费)加手动补充,建了87个同义词的库,覆盖率直接拉到94%。建库花了3天,但后面每个项目都能复用。
5. 新闻热度会死,别等它凉透了才行动
我见过有人监测到新闻提及后,等2天才写回应稿。黄花菜都凉了。新闻的生命周期是24-48小时,最佳回应窗口是前6小时。现在我设定:一旦监测到A级媒体提及,立刻触发Slack通知+邮件报警(用Zapier免费版就能配置),然后1小时内必须出初稿。去年一个教育站被央视报道,我45分钟内就发了回应新闻稿,当天搜索流量翻了7倍。
6. 数据过期要设自动清除,别让硬盘报警
我踩过最痛的坑:新闻监测库攒了2年,数据量冲到18GB,导致MySQL查询慢到60秒。某天凌晨硬盘报警,差点把生产环境搞崩。现在我的规则是:6个月前的原始数据自动归档到冷存储(AWS Glacier每月成本约3毛钱/GB),只保留最近3个月的热数据。清理脚本每月1号凌晨4点跑,再没出过问题。