跳到正文
阿里雲開發者社區 AI·· 2 小時前AI 評分48

用 Python + Ollama 本地大模型從零搭建「AI 驅動的合規資料採集」流水線

从零搭建「AI 驱动的合规采集」流水线:Python + Ollama 本地大模型实战

AI 導讀

Python + Ollama 本地大模型可搭建全程離線、零 API 費用的合規資料採集流水線,由 AI 負責選題與解析,爬蟲負責採集。流程以資料源白名單限制選題,並透過 robots 預檢、限速、403/429 止損,以及 URL 和 MD5 雙重去重落實合規約束。文章提供環境配置、模型接入及核心程式碼,並包含 AI 小結、JSONL 匯出與降級解析。

正文

2026-10-29 7

版权

版权声明:

本文内容由阿里云实名注册用户自发贡献,版权归原作者所有,阿里云开发者社区不拥有其著作权,亦不承担相应法律责任。具体规则请查看《 阿里云开发者社区用户服务协议》和 《阿里云开发者社区知识产权保护指引》。如果您发现本社区中有涉嫌抄袭的内容,填写 侵权投诉表单进行举报,一经查实,本社区将立刻删除涉嫌侵权内容。

简介: 本文教你用Python+Ollama本地大模型,从零搭建「AI驱动的合规采集」流水线:全程离线、零API费用。AI负责智能选题与结构化解析,爬虫严格遵守robots、限速、去重等合规约束。80%为可直接运行代码,含环境配置、AI适配、合规请求封装、白名单选题、统一采集器、双重去重及降级解析等核心模块。

从零搭建「AI 驱动的合规采集」流水线:Python + Ollama 本地大模型实战

全程本地运行、断网可用、零 API 费用。AI 负责选题与解析,爬虫守规矩干活。

本文 80% 是可直接复制的代码和命令,跟着敲完你就有一条完整流水线。

一、为什么是「AI + 合规爬虫」

传统爬虫的三大痛点:选题靠拍脑袋(不知道采什么)、解析靠硬编码(每个站写一套正则)、维护靠缘分(页面一改版全崩)。

把本地大模型塞进流水线后:

环节 传统做法 AI 驱动做法
选题 人工想关键词 模型按方向生成选题 + 检索词
解析 每站写正则/CSS 模型按统一 schema 抽字段
小结 无 模型对批次数据自动摘要

而「合规」不是道德口号,是工程约束:robots 预检、限速、止损、去重,全部代码化、默认开启。AI 负责脑力,不负责对抗——逆向、破解、轰炸,本文一概没有。

技术栈一句话:requests(请求)+ feedparser(RSS)+ Ollama(本地 LLM,OpenAI 兼容接口)+ 标准库。

话不多说,先上资源,边看边做链接:https://pan.quark.cn/s/e284d92f8ed5

二、环境准备(5 条命令)

# 1. 安装 Python 依赖(3.8+)
pip install requests beautifulsoup4 lxml trafilatura feedparser
# 2. 安装 Ollama(Windows:官网 ollama.com 下载 OllamaSetup.exe,约 1.5GB)
#    Linux 一条命令:
curl -fsSL https://ollama.com/install.sh | sh
# 3. 拉一个小模型先跑通(~1GB,7B/8B 模型约 5GB,建议内存 16GB+)
ollama pull qwen2.5:1.5b
# 4. 验证服务活着(11434 是 Ollama 默认端口)
curl http://localhost:11434/api/tags
# 5. 验证 OpenAI 兼容接口
curl http://localhost:11434/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"model":"qwen2.5:1.5b","messages":[{"role":"user","content":"说一句你好"}]}'

两个必设的环境变量(Windows:系统环境变量 → 新建,然后重启):

OLLAMA_MODELS = D:\ollamaModels     # 模型别塞 C 盘,动辄几个 GB

跑大任务的机器建议把间隔和重试也固化成环境变量,代码里读:

import os
MIN_INTERVAL = float(os.getenv("CRAWL_INTERVAL", "2.0"))

三、架构总览

┌────────────────────────────────────────────────┐
│ config.ai.json   ← 你唯一的配置文件(模型/限速) │
└───────────────────────┬────────────────────────┘
                        ▼
┌──────────────┐   ┌─────────────────────────┐
│ ① AI 选题    │ → │ topics.json             │
└──────────────┘   └───────────┬─────────────┘
                               ▼
┌──────────────────────────────────────────────┐
│ ② 总控流水线                                  │
│   robots 预检 → 采集 → URL/MD5 双重去重       │
│   → AI 小结 → 导出 JSONL                      │
└───────────────────────┬──────────────────────┘
                        ▼
┌──────────────┐   ┌─────────────────────────┐
│ ③ AI 解析    │   │ report.jsonl(结构化)  │
└──────────────┘   └─────────────────────────┘

分工原则一句话:AI 出脑,爬虫出手,配置出权限。

四、核心代码 ①:合规请求封装(先立规矩)

写爬虫第一行代码前,先把"礼貌"写进基础设施。这个封装默认开启:2 秒限速、超时、重试上限、403/429 止损。

# polite.py —— 合规请求基础设施
import time
import requests
MIN_INTERVAL = 2.0        # 全局强制间隔(秒)
MAX_RETRIES = 2           # 重试上限,防无限轰炸
STOP_STATUS = (403, 429)  # 止损码:收到即放弃
HEADERS = {"User-Agent": "Mozilla/5.0 (compatible; learning-bot/1.0)"}
_last = [0.0]
def _throttle():
    elapsed = time.time() - _last[0]
    if elapsed < MIN_INTERVAL:
        time.sleep(MIN_INTERVAL - elapsed)
    _last[0] = time.time()
def polite_get(url, params=None, retries=MAX_RETRIES, timeout=12):
    for attempt in range(1, retries + 2):
        _throttle()
        try:
            resp = requests.get(url, params=params, headers=HEADERS, timeout=timeout)
            if resp.status_code == 200:
                return resp
            if resp.status_code in STOP_STATUS:
                print(f"[止损] {url} 返回 {resp.status_code},立即停止")
                return None                      # 绝不重试轰炸
            print(f"[重试 {attempt}/{retries}] {url} → {resp.status_code}")
        except requests.RequestException as e:
            print(f"[重试 {attempt}/{retries}] {type(e).__name__}")
    return None

用起来和无脑 requests.get 一样:

from polite import polite_get
resp = polite_get("https://hn.algolia.com/api/v1/search",
                  params={"tags": "front_page", "hits_per_page": 5})

限速不是可选项。 收到 429 时读响应头的 Retry-After 等待,是服务器在告诉你"多久后可以再来"——听它的话:

if resp.status_code == 429:
    wait = int(resp.headers.get("Retry-After", 30))
    print(f"超速,等 {wait}s")
    time.sleep(wait)

五、核心代码 ②:本地模型适配层(20 行接入任意模型)

Ollama 提供 OpenAI 兼容接口,所以一套代码通吃 Ollama / DeepSeek / 通义 / Kimi / 智谱——只换 api_base 和 model。

# ai_adapter.py —— 可插拔 LLM 适配层
import json, re, time, requests
class AIAdapter:
    def __init__(self, api_base="http://localhost:11434/v1",
                 api_key="ollama", model="qwen2.5:1.5b", timeout=60):
        self.api_base, self.api_key = api_base.rstrip("/"), api_key
        self.model, self.timeout = model, timeout
        self._last = 0.0
    def chat(self, prompt, system=None, json_mode=False):
        messages = []
        if system:
            messages.append({"role": "system", "content": system})
        messages.append({"role": "user", "content": prompt})
        payload = {"model": self.model, "messages": messages,
                   "temperature": 0.3, "max_tokens": 1000}
        if json_mode:
            payload["response_format"] = {"type": "json_object"}
        try:
            resp = requests.post(
                f"{self.api_base}/chat/completions",
                headers={"Authorization": f"Bearer {self.api_key}"},
                json=payload, timeout=self.timeout)
            content = resp.json()["choices"][0]["message"]["content"]
            return self._extract_json(content) if json_mode else content
        except Exception as e:
            print(f"[AI降级] {type(e).__name__}")   # 失败永不抛异常
            return None
    @staticmethod
    def _extract_json(text):
        """模型输出的 JSON 容错提取:裸JSON → ```围栏 → 大括号扫描"""
        try:
            return json.loads(text)
        except Exception:
            pass
        m = re.search(r"\{[\s\S]*\}|\[[\s\S]*\]", text)
        try:
            return json.loads(m.group()) if m else None
        except Exception:
            return None

验证一下:

ai = AIAdapter()
print(ai.chat("用一句话解释什么是robots.txt"))
print(ai.chat('输出JSON {"ok":1}', json_mode=True))   # {'ok': 1}

经验:json_mode 依赖模型支持 response_format;不支持的老模型就靠 _extract_json 兜底——永远做两层防御,因为模型总会在 JSON 前后说废话。

六、核心代码 ③:AI 选题(约束在合规源白名单内)

关键技巧:把数据源白名单写进提示词,模型只能在清单里选,从源头杜绝它"自由发挥"出不可控的目标。

# topic_agent.py
from ai_adapter import AIAdapter
WHITELIST = [
    {"name": "Open-Meteo 天气", "url": "https://api.open-meteo.com", "适合": "天气/气候"},
    {"name": "Frankfurter 汇率", "url": "https://api.frankfurter.dev", "适合": "金融/经济"},
    {"name": "Hacker News", "url": "https://hn.algolia.com/api/v1/search", "适合": "技术热点"},
    {"name": "NASA 图片库", "url": "https://images-api.nasa.gov/search", "适合": "科普/图片"},
    {"name": "Gutendex 公版书", "url": "https://gutendex.com", "适合": "文学/文本分析"},
]
def generate_topics(ai: AIAdapter, direction: str, count=5):
    src_desc = "\n".join(f"- {s['name']}({s['url']},适合:{s['适合']})" for s in WHITELIST)
    prompt = (
        f"你是数据选题策划。方向:{direction}\n"
        f"从下面的合规数据源中挑选,生成{count}个采集选题。\n"
        f'严格输出JSON数组,字段:topic, source, keywords(数组), reason\n'
        f"可用数据源:\n{src_desc}\n"
        f"注意:只能用列表中的数据源。"
    )
    topics = ai.chat(prompt, system="只输出JSON。", json_mode=True)
    if isinstance(topics, list):
        src_map = {s["name"]: s["url"] for s in WHITELIST}
        for t in topics:
            t["source_url"] = src_map.get(t["source"], "")
        return topics[:count]
    return []   # AI失败时上层降级为人工清单
if __name__ == "__main__":
    ai = AIAdapter()
    for t in generate_topics(ai, "数据分析练手", 3):
        print(t["topic"], "→", t["source"], t["keywords"])

本地 1.5B 小模型的输出质量有限,两个加固手段:

# 加固1:关键词清洗——中文选题词对英文接口无意义,过滤掉
kws = [k for k in t.get("keywords", []) if k and any(c.isascii() and c.isalpha() for c in k)]
# 加固2:schema 校验——字段缺失直接丢弃该选题
valid = [t for t in topics if {"topic", "source"} <= set(t.keys())]

七、核心代码 ④:采集器(每个源一个函数,统一三字段)

所有采集器返回统一格式 {"title": ..., "text": ..., "url": ...},下游就完全不用关心数据来自谁。

# crawlers.py
import requests
from polite import polite_get, HEADERS
def crawl_hn(keywords, per_page=10):
    """Hacker News 公开检索接口"""
    q = " ".join(keywords) or "python"
    resp = polite_get("https://hn.algolia.com/api/v1/search",
                      params={"query": q, "tags": "story", "hits_per_page": per_page})
    if not resp:
        return []
    return [{"title": h.get("title", ""),
             "text": f"{h.get('title','')} {h.get('story_text') or ''} 分值{h.get('points',0)}",
             "url": h.get("url") or f"https://news.ycombinator.com/item?id={h.get('objectID')}"}
            for h in resp.json().get("hits", [])]
def crawl_weather(_keywords):
    """Open-Meteo 免费天气接口(无需密钥)"""
    resp = polite_get("https://api.open-meteo.com/v1/forecast",
                      params={"latitude": 39.9, "longitude": 116.4,
                              "current_weather": "true", "timezone": "auto"})
    if not resp:
        return []
    cw = resp.json().get("current_weather", {})
    return [{"title": f"北京实时天气 {cw.get('temperature')}°C",
             "text": str(cw), "url": "https://api.open-meteo.com"}]
def crawl_wiki(keywords, limit=10):
    """Wikipedia MediaWiki API:搜索 + 摘要"""
    kw = keywords[0] if keywords else "人工智能"
    resp = polite_get("https://zh.wikipedia.org/w/api.php", params={
        "action": "query", "list": "search", "srsearch": kw,
        "srlimit": limit, "format": "json"})
    if not resp:
        return []
    import re
    out = []
    for h in resp.json().get("query", {}).get("search", []):
        snippet = re.sub(r"<[^>]+>", "", h.get("snippet", ""))   # 去高亮标签
        out.append({"title": h.get("title", ""), "text": snippet,
                    "url": "https://zh.wikipedia.org/wiki/" + h.get("title", "")})
    return out
ROUTES = {"Hacker News": crawl_hn, "Open-Meteo 天气": crawl_weather,
          "Wikipedia": crawl_wiki}

路由就一行字典查询,找不到就跳过——路由不存在时宁可放弃,不要现场发明采集方式。

八、核心代码 ⑤:robots 预检 + 双重去重(合规的牙齿)

# compliance.py
import hashlib
import urllib.parse
import requests
from polite import HEADERS
def robots_allows(url):
    """robots.txt 预检:只看 User-agent: * 组是否全站 Disallow"""
    try:
        p = urllib.parse.urlparse(url)
        resp = requests.get(f"{p.scheme}://{p.netloc}/robots.txt",
                            headers=HEADERS, timeout=8)
        if resp.status_code != 200:
            return True                      # 无robots → 允许(仍保持限速)
        in_star = False
        for line in resp.text.splitlines():
            line = line.strip().lower()
            if line.startswith("user-agent:"):
                in_star = line.split(":", 1)[1].strip() == "*"
            elif in_star and line.startswith("disallow:"):
                if line.split(":", 1)[1].strip() == "/":
                    return False             # 全站禁爬
        return True
    except Exception:
        return True
def md5_of(text):
    return hashlib.md5(text.encode("utf-8")).hexdigest()
def dedup(items, seen_urls, seen_md5):
    """URL + 内容MD5 双保险去重"""
    fresh = []
    for it in items:
        if not it.get("title"):
            continue
        u, h = it.get("url", ""), md5_of(it["title"] + it.get("text", "")[:100])
        if (u and u in seen_urls) or h in seen_md5:
            continue
        seen_urls.add(u); seen_md5.add(h)
        fresh.append(it)
    return fresh

实测里有个反直觉的发现:arXiv 的 export.arxiv.org/robots.txt 写的是全站 Disallow: /,而 Open-Meteo、Gutendex 这些纯 API 站点也这么写——因为 robots 是写给"爬网页"的,官方 API 提供方在文档里明确邀请程序化调用。所以预检要区分:登记在册的开放 API 按 API 条款调用;网页类目标严格过 robots 门。一刀切要么漏掉合法 API,要么放进不该爬的页面。

九、核心代码 ⑥:AI 解析器(一套 schema 吃遍所有源)

# parser_agent.py
from ai_adapter import AIAdapter
SCHEMA = {
    "主题分类": "technology|business|science|other 四选一",
    "一句话摘要": "不超过40字",
    "涉及技术或公司": "数组,没有则空数组",
    "热度评价": "low|medium|high 三选一",
}
def ai_extract(ai: AIAdapter, text):
    prompt = (f"按schema抽取信息:{json.dumps(SCHEMA, ensure_ascii=False)}\n"
              f"只输出JSON。\n\n内容:{text[:3000]}")
    return ai.chat(prompt, system="你是信息抽取助手,只输出JSON。", json_mode=True)
def rule_extract(text, points=0):
    """AI不可用时的规则降级:关键词匹配"""
    tech = [k for k in ("python", "ai", "llm", "rust", "api") if k in text.lower()]
    heat = "high" if points >= 300 else "medium" if points >= 100 else "low"
    return {"主题分类": "technology" if tech else "other",
            "一句话摘要": text[:40], "涉及技术或公司": tech, "热度评价": heat}

为什么降级模式这么重要:本地模型会崩(显存不够)、会输出怪 JSON、会超时。规则模式保证流水线永远出得来数据,AI 只是锦上添花——这是"AI 参与关键路径"和"AI 挂在关键路径上"的区别。

十、核心代码 ⑦:总控流水线(串起来)

# main.py —— 完整流水线
import json, time
from ai_adapter import AIAdapter
from topic_agent import generate_topics
from crawlers import ROUTES
from compliance import robots_allows, dedup
from parser_agent import ai_extract, rule_extract
def run(direction="数据分析练手", count=3):
    ai = AIAdapter()
    topics = generate_topics(ai, direction, count)
    if not topics:                     # AI 降级:手工兜底选题
        topics = [{"topic": "HN技术热点", "source": "Hacker News",
                   "keywords": [], "source_url": "https://hn.algolia.com/api/v1/search"}]
    seen_urls, seen_md5, report = set(), set(), []
    for i, t in enumerate(topics, 1):
        print(f"\n── 选题 {i}/{len(topics)}:{t['topic']} → {t['source']}")
        # 1) robots 预检(合规第一道门)
        if t.get("source_url") and not robots_allows(t["source_url"]):
            print("  [跳过] robots 全站禁爬")
            continue
        # 2) 路由采集
        crawler = ROUTES.get(t["source"])
        if not crawler:
            print(f"  [跳过] 无 {t['source']} 的适配器")
            continue
        items = crawler(t.get("keywords", []))
        time.sleep(2)
        # 3) 去重
        fresh = dedup(items, seen_urls, seen_md5)
        print(f"  [采集] {len(items)} 条 → 去重后 {len(fresh)} 条")
        # 4) AI 解析
        for it in fresh:
            parsed = ai_extract(ai, it["title"] + " " + it["text"][:200])
            it["ai"] = parsed if isinstance(parsed, dict) else rule_extract(it["title"])
            report.append(it)
    # 5) 导出 JSONL(一行一条,方便流式读取和增量)
    with open("report.jsonl", "w", encoding="utf-8") as f:
        for it in report:
            f.write(json.dumps(it, ensure_ascii=False) + "\n")
    print(f"\n[完成] {len(report)} 条 → report.jsonl")
if __name__ == "__main__":
    run()

实际运行输出长这样:

── 选题 1/3:USD兑CNY汇率季度波动观察 → Frankfurter 汇率
  [采集] 1 条 → 去重后 1 条
── 选题 2/3:技术圈「web scraping」热点趋势观察 → Hacker News
  [采集] 10 条 → 去重后 10 条
── 选题 3/3:Wikipedia「网络爬虫」词条知识梳理 → Wikipedia
  [采集] 5 条 → 去重后 5 条
[完成] 16 条 → report.jsonl

十一、接入第三方 API(不改代码)

新源不想写采集器?做成注册表,把请求和响应映射写成配置:

{
  "name": "我的第三方API",
  "enabled": true,
  "type": "custom_api",
  "request": {
    "method": "GET",
    "url": "https://api.example.com/v1/search",
    "params": {"q": "{keyword}", "page_size": 10},
    "headers": {"Authorization": "Bearer 你的token"}
  },
  "response": {
    "items_path": "data.items",
    "title_path": "item.name",
    "text_path": "item.description",
    "url_path": "item.link"
  }
}

通用采集器只需一个点路径解析函数(约 20 行):

import re
def resolve_path(data, path):
    """支持 data.items / results[] / items[0].name / @常量"""
    if path.startswith("@"):
        return path[1:]
    cur = data
    for seg in path.split("."):
        m = re.match(r"^(\w+)?(\[\])?(?:\[(\d+)\])?$", seg)
        if not m or cur is None:
            return None
        name, wild, idx = m.groups()
        if name:
            cur = ([x.get(name) for x in cur if isinstance(x, dict)]
                   if isinstance(cur, list) else cur.get(name))
        if wild:
            cur = cur if isinstance(cur, list) else ([] if cur is None else [cur])
        if idx is not None:
            cur = cur[int(idx)] if isinstance(cur, list) and int(idx) < len(cur) else None
    return cur

{keyword} 占位符在请求前替换,选题关键词自动注入第三方接口——选题、采集、解析整条链就通了。

十二、常见坑速查

现象 原因 解决
connection refused localhost:11434 Ollama 没启动 启动 Ollama / ollama serve
AI 输出全是废话 JSON 解析失败 小模型能力弱 加 response_format;_extract_json 兜底;换 7B+ 模型
中文关键词查英文接口 0 结果 检索词没清洗 过滤非 ASCII 词,置空让接口走默认值
Windows 模型爆 C 盘 默认路径 环境变量 OLLAMA_MODELS
429 越重试越多 重试无退避 读 Retry-After,最多重试 2 次
resp.json() 报错 接口返回 HTML 先看 Content-Type;嗅探真实接口
磁盘里模型文件找不到 默认藏用户目录 ollama list / OLLAMA_MODELS

调试请求的第一站永远是 httpbin.org——它把你发的所有头、参数原样弹回来:

curl "https://httpbin.org/headers" -H "User-Agent: learning-bot/1.0"

十三、合规清单(发布前对照打勾)

  • 目标全是官方开放 API / 公版内容 / 明确允许的练习站
  • robots.txt 预检在代码里,默认开启
  • 全局限速 ≥2 秒,Session 复用连接
  • 403/429 止损,重试有上限
  • URL + 内容双重去重
  • AI 提示词约束在数据源白名单内
  • 数据只用于学习研究,不商用不转售
  • API Key 不进代码仓库(用环境变量)

十四、写在最后

这条流水线的精髓不是某个函数,而是分层降级的设计:AI 挂了走规则模式、接口挂了跳过、内容重复被去重、全站禁爬被预检拦下——每一层失败都有兜底,整条线永远出得来数据。

把 1.5B 小模型换成 7B,把三个采集器换成十个,把 JSONL 换成 DuckDB——这条线的上限取决于你,而不取决于某个网站改没改版。

完整工程化版本(含第三方 API 注册表、9 种内置源路由、断点续爬、词频统计)已整理成资源包,本文所有代码都在其中可以直接运行。

來源:阿里雲開發者社區 AI · developer.aliyun.com