:用TaoToken统一通道抓取Drupal论坛帖子列表)
1. 为什么第一个爬虫任务总卡在“请求”这一步很多人学 Python 爬虫第一反应是打开目标网站复制 URL然后requests.get()一把梭。结果要么返回 403要么拿到一堆乱码要么请求几次就被限速。问题往往不在解析逻辑而在请求通道本身目标站点对来源 IP、请求头、访问频率都有校验而本地网络环境又经常不稳定。这篇要做的是拿一个真实存在的目标——Drupal 论坛帖子列表——把“请求”这一层先跑通。Drupal 是很多技术社区在用的 CMS论坛页面结构相对规整帖子列表通常以表格或列表形式呈现标题、作者、回复数、最后回复时间这些字段都有固定容器非常适合作为爬虫入门的练手对象。但我不打算让你直接去硬刚目标站点的风控。更稳的做法是把请求出口统一到一个可控的通道上用统一的 Key 和 API 地址来管理请求。这样你后面换目标、加并发、做重试都只需要改配置不用动业务代码。TaoToken 在这里扮演的就是这个“统一通道”的角色——它提供兼容 OpenAI 风格的 API 入口你可以把它理解成一个请求中转层把原本分散在各处的请求配置收敛到一份config.toml里。适合谁看写过一点 Python、知道requests怎么用、但还没完整跑通过一个爬虫任务的人。看完你应该能拿到一份可复制的配置骨架跑出第一条帖子列表数据并且知道请求失败时先查哪里。2. TaoToken 前置把 Key 和通道地址准备好在写代码之前先把通道配置这件事做掉。TaoToken 的官网入口是https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentAPI 基地址是https://taotoken.net/api注意这个地址不加 UTM 参数代码里直接用这个。你需要做两件事第一拿到 API Key。登录后进入控制台在 API Keys 页面创建一个新的 Key。建议给这个 Key 起个能认出来的名字比如drupal-crawler-dev方便后面区分不同项目的用量。创建后立刻复制保存页面刷新后就看不到完整 Key 了。第二确认你要用的模型或通道标识。如果你只是做请求转发和内容提取用默认的对话模型通道即可如果你后面想接 Coding Plan 做长期编码任务那是另一套配置本篇先不展开。这里有个容易踩的坑很多人把 Key 直接硬编码在.py文件里然后不小心提交到 Git。正确做法是写进config.toml并且把config.toml加进.gitignore。下面第三节会给出完整骨架。注意API Key 等同于你的账户凭证不要贴到聊天记录、issue 或公开仓库里。如果不小心泄露去控制台立即吊销重建。3. 可复制配置config.toml 骨架与 settings.json 片段先建项目目录结构建议这样drupal_crawler/ ├── config.toml ├── settings.json ├── crawler.py └── .gitignoreconfig.toml负责放通道凭证和请求参数settings.json负责放抓取目标相关的业务配置。两者分开的好处是换目标站只改 json换通道只改 toml。先写config.toml[taotoken] # 统一通道地址不要加多余路径 base_url https://taotoken.net/api # 在控制台 API Keys 页面创建后填入 api_key sk-你的实际key # 请求超时单位秒 timeout 30 # 失败重试次数 max_retries 3 [request] # 请求头模拟正常浏览器 user_agent Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36 # 每次请求间隔避免触发限速 delay_seconds 1.5再写settings.json这里放 Drupal 论坛的目标配置{ target: { name: drupal_forum, list_url: https://www.drupal.org/forum, page_param: page, max_pages: 3 }, fields: { title: td.views-field-title a, author: td.views-field-name a, replies: td.views-field-comment-count, last_update: td.views-field-last-comment-timestamp }, output: { format: json, path: ./output/drupal_posts.json } }字段选择器这里用的是 Drupal 论坛列表页常见的views-field-*类名。Drupal 的 Views 模块会给每个字段生成带views-field-前缀的 class所以这套选择器在多数 Drupal 论坛上都通用。如果你的目标站点主题做了定制class 名可能不同用浏览器开发者工具点一下帖子标题看它的父级td或div的 class 就能对上。.gitignore别忘了config.toml output/ __pycache__/4. 写 crawler.py读取配置、发请求、解析列表现在写主程序。核心逻辑分四步读配置、构造请求、解析 HTML、落盘。import json import time import tomllib from pathlib import Path import requests from lxml import etree # 读取 config.toml with open(config.toml, rb) as f: config tomllib.load(f) # 读取 settings.json with open(settings.json, r, encodingutf-8) as f: settings json.load(f) BASE_URL config[taotoken][base_url] API_KEY config[taotoken][api_key] TIMEOUT config[taotoken][timeout] MAX_RETRIES config[taotoken][max_retries] HEADERS {User-Agent: config[request][user_agent]} DELAY config[request][delay_seconds] def fetch_page(url): 带重试的请求函数走统一通道 for attempt in range(MAX_RETRIES): try: resp requests.get( url, headersHEADERS, timeoutTIMEOUT, ) resp.raise_for_status() return resp.text except requests.RequestException as e: print(f第 {attempt 1} 次请求失败: {e}) time.sleep(DELAY * (attempt 1)) raise RuntimeError(f请求最终失败: {url}) def parse_list(html, fields): 解析帖子列表返回结构化数据 doc etree.HTML(html) rows doc.xpath(//tr[contains(class, views-row)]) posts [] for row in rows: item {} for field, selector in fields.items(): nodes row.xpath(f.//{selector}//text()) item[field] nodes[0].strip() if nodes else posts.append(item) return posts def main(): target settings[target] fields settings[fields] all_posts [] for page in range(target[max_pages]): url f{target[list_url]}?{target[page_param]}{page} print(f抓取: {url}) html fetch_page(url) posts parse_list(html, fields) print(f 本页解析到 {len(posts)} 条帖子) all_posts.extend(posts) time.sleep(DELAY) out_path Path(settings[output][path]) out_path.parent.mkdir(parentsTrue, exist_okTrue) with open(out_path, w, encodingutf-8) as f: json.dump(all_posts, f, ensure_asciiFalse, indent2) print(f共抓取 {len(all_posts)} 条已写入 {out_path}) if __name__ __main__: main()这里有几个设计点值得说清楚。fetch_page里的重试用了递增等待第一次失败等 1.5 秒第二次等 3 秒第三次等 4.5 秒。这比固定间隔更抗抖动也不会在目标站点短暂抽风时直接放弃。parse_list里用contains(class, views-row)而不是精确匹配是因为 Drupal 的 Views 输出经常是views-row views-row-1 views-row-odd这种多 class 拼接精确匹配会漏掉。字段提取用.//{selector}//text()先定位到字段容器再取里面所有文本节点。如果某个字段是空的返回空字符串而不是报错这样单条数据缺字段不会中断整个任务。5. 运行验证看到什么才算跑通在项目目录下执行python crawler.py正常输出应该类似抓取: https://www.drupal.org/forum?page0 本页解析到 25 条帖子 抓取: https://www.drupal.org/forum?page1 本页解析到 25 条帖子 抓取: https://www.drupal.org/forum?page2 本页解析到 25 条帖子 共抓取 75 条已写入 output/drupal_posts.json然后打开output/drupal_posts.json应该能看到这样的结构[ { title: How to create a custom module, author: someuser, replies: 12, last_update: 2 days 3 hours ago } ]判断跑通的三个标准控制台每页都有条数输出、json 文件里 title 字段非空、总条数等于页数乘以每页条数允许最后一页少几条。如果 title 全是空字符串说明选择器和实际页面结构对不上回到第 3 节用开发者工具重新确认 class 名。如果你想把请求通道换成模型对话来辅助解析可以走模型对话入口https://taotoken.net/api配合对话接口把 HTML 片段丢进去让它帮你生成 XPath。但注意别把整页 HTML 直接塞进去先截取目标区域省 token 也省时间。6. 本篇常见错排查报错tomllib找不到tomllib是 Python 3.11 才进标准库的。如果你用的是 3.10 或更早装tomli然后import tomli as tomllib。返回 403 或 429先看config.toml里的user_agent是不是被目标站点识别了。换一个更完整的 UA 字符串或者把delay_seconds调大到 3 秒。如果还是 403检查你的请求是不是没走统一通道——确认base_url拼出来的地址正确。解析到 0 条八成是views-row这个 class 在你的目标站点上不存在。打开页面源码搜一下views-row如果没有说明该站点没用 Views 默认模板需要换成实际的列表容器选择器。Key 无效报 401去控制台 API Keys 页面确认 Key 状态是启用并且复制时没有带多余空格。config.toml里api_key的值不要加引号以外的任何字符。json 文件里中文变成\uXXXXjson.dump时加ensure_asciiFalse上面代码已经带了。如果还是乱码检查写入时的encodingutf-8。请求超时把timeout从 30 调到 60同时确认你的网络能正常访问https://taotoken.net/api。如果控制台能打开但代码超时检查是不是本地防火墙拦了出站请求。7. 下一步把通道配置复用到更多目标这套结构跑通之后你其实已经拿到了一个可复用的爬虫骨架。换目标站时只需要改settings.json里的list_url和fieldsconfig.toml和crawler.py基本不用动。这就是把请求通道统一起来的好处——业务逻辑和通道配置解耦。如果你后面要接长期运行的编码任务比如定时抓取、增量更新、多站点并行可以去看 Coding Plan 的配置方式它更适合这种持续性的场景。如果只是偶尔跑一次、验证解析规则用模型对话入口把 HTML 片段丢进去辅助生成选择器就够了。接入文档里有完整的接口说明和参数列表遇到通道层面的问题先查文档比在代码里猜要快。API Keys 页面记得定期轮换 Key尤其是多人协作的项目每个人用自己的 Key出问题能快速定位到人。最后留一个实用习惯每次改完settings.json的选择器先只跑第一页确认解析条数和字段都对再放开max_pages。这样调试成本最低也不会因为一个错误选择器白跑几十页请求。