坑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_timedate类型。查询用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点跑,再没出过问题。