1. 本课定位:是什么、为何重要
从 33 到 38,你分别学会了 HTTP、稳健客户端、并发选型、协程、HTML 解析、接口优先。但它们若一直是散落的 demo,仍然不像「能交付的东西」。工作里老板要的是:输入源列表,输出库里可查的数据,并且跑第二遍不能乱套。
这一课做迷你采集流水线:配置源→拉 JSON→抽字段→SQLite 去重更新→汇总打印。明确不做 Selenium 和分布式。学完你应能讲清模块怎么拆、如何验收、如何和前几课能力一一对应。
把33~38收成一条可演示流水线:
配置源 → 拉取 JSON → 抽取字段 → 写入 SQLite(去重/更新)→ 汇总打印
| 已学能力 | 用在本项目 |
|---|---|
| Day33/34 HTTP + requests | fetch |
| Day35/36 并发(可选扩展) | 多源可改 gather/线程池 |
| Day37 解析/清洗直觉 | 从 dict 取字段、校验 |
| Day38 接口优先 | 不爬整页 HTML |
| Day24~32 数据库习惯 | 参数绑定、upsert、commit |
为何重要:真实工作里「能跑通的端到端」比零散知识点更有说服力;也练习模块边界与验收标准。
本课不做:Selenium、分布式爬虫、复杂调度系统。
2. 流水线总览图
先给一张总图:SOURCES 进来,经 fetch、parse、store 进 SQLite,第二遍 upsert 不增行,最后 COUNT 验收。
有了图,后面每一节都是在填某个方框的细节,不会迷路。
SOURCES 列表 | v +-------+ +-------+ +-------+ | fetch | --> | parse | --> | store | --> SQLite items +-------+ +-------+ +-------+ ^ | | 第二遍同源 | +--------- upsert 不增行 -----+ | v COUNT / 打印 rows 验收| 阶段 | 输入 | 输出 |
|---|---|---|
| fetch | url | dict(JSON) |
| parse | dict | name, stars |
| store | source, name, stars | 表中 1 行(插入或更新) |
| report | 连接 | COUNT + 明细 |
3. 本质:各段职责与失败
流水线本质是分段:每段输入输出是什么、失败时怎么办。错误设计每次 INSERT 新行,跑两遍变四行;正确设计 UNIQUE+UPSERT,仍两行只刷新。
把「修改前/后」记牢,验收时才知道 COUNT 该看什么。
| 步骤 | 职责 | 失败时 |
|---|---|---|
| fetch | HTTP GET,timeout、UA | 网络错 / 4xx5xx → 抛错或重试 |
| parse | 取full_name、stargazers_count | 缺字段 → 降级空串/0 或跳过 |
| store | 写入,同源更新 | SQL 错 → 事务回滚(本示例简单 commit) |
| run | 编排多源;第二遍验证去重 | 打印 COUNT 对照预期 |
第一遍抓取后:N 个源 → N 行。
第二遍同源再抓:仍是 N 行(ON CONFLICT DO UPDATE),不是 2N 行。
修改前(错误设计:每次 INSERT 新行):跑两遍变成 4 行重复源。
修改后(UNIQUE + UPSERT):仍 2 行,字段刷新。
4. 约束、坑与合规
公开 API 有限流,密钥不能进仓,唯一键要设计对,不要把 HTML 爬虫硬塞进本课目标。
合规清单与安全课精神一致:timeout、重试、UA、不采隐私。技术正确包含合规正确。
| 约束/坑 | 说明 |
|---|---|
| 公开 API 限流 | 控制频率;设合理 timeout |
| Token 不进仓库 | 需要鉴权时用环境变量 |
| 唯一键设计 | sourceUNIQUE 才能稳定去重 |
| 把 HTML 爬虫硬塞进本项目 | 偏离「接口优先」教学目标 |
无raise_for_status | 错误页被当数据 |
| star 写死断言 | 真实数据会变,断言会误伤 |
合规清单(项目级):
- 优先官方/公开 API
- timeout + 有限重试
- 不把密钥写进 Git
- 不采未授权隐私
- 遵守对方 rate limit
- User-Agent 标明自己的教学客户端
5. 表设计详解
items 表字段为何这样设:source 逻辑名唯一,name/stars 业务字段,fetched_at 记录新鲜度。
为什么不用 URL 当唯一键?URL 易变、带 query。逻辑源名更稳、更好读。
CREATETABLEitems(idINTEGERPRIMARYKEYAUTOINCREMENT,sourceTEXTNOTNULLUNIQUE,nameTEXTNOTNULL,starsINTEGERNOTNULL,fetched_atREALNOTNULL);| 列 | 含义 | 备注 |
|---|---|---|
| id | 自增主键 | 内部用 |
| source | 逻辑源 id | 如github-cpython,业务唯一 |
| name | 仓库全名 | 如python/cpython |
| stars | star 数 | 会变,upsert 更新 |
| fetched_at | 抓取时间戳 | time.time() |
为什么 source 用逻辑名而不是 URL?
URL 可能带 query、换域名;逻辑名更稳定,也好读。
6. 能力分组(代码怎么切)
init_db、fetch、store、main 各干什么,对应测试与阅读友好。UPSERT SQL 在说什么,用表解释插入与更新两种情况。
函数短、SQL 参数化,承接 Day28/32 习惯,避免一个 200 行脚本揉成一团。
| 函数 | 输入 | 输出 | 注意 |
|---|---|---|---|
init_db | 连接 | 建表 | IF NOT EXISTS |
fetch | url | dict | timeout + UA + raise_for_status |
store | source, name, stars | 写入/更新 | 参数绑定 + UPSERT |
main | 源列表 | 打印过程与 COUNT | 可第二遍 |
保持函数短——一个函数只做一件事,便于测fetch/store。
6.1 UPSERT 在说什么
INSERTINTOitems(source,name,stars,fetched_at)VALUES(?,?,?,?)ONCONFLICT(source)DOUPDATESETname=excluded.name,stars=excluded.stars,fetched_at=excluded.fetched_at| 情况 | 行为 |
|---|---|
| source 不存在 | 插入新行 |
| source 已存在 | 更新 name/stars/fetched_at |
7. 数据源设计
SOURCES 用(逻辑名, URL)元组列表,扩展第三源只加一行。
验收 COUNT 与源数量绑定,改源列表时预期也要改——这是可验收的关键。
SOURCES=[("github-cpython","https://api.github.com/repos/python/cpython"),("github-requests","https://api.github.com/repos/psf/requests"),]| 字段 | 含义 |
|---|---|
| 元组第一项 | 逻辑 source |
| 元组第二项 | 真实请求 URL |
扩展第三个源:再加一行元组即可,验收 COUNT 变 3。
8. 综合实践
一键脚本跑 GitHub 两仓库,第二遍 upsert,看 COUNT=2。star 数会变,正常。
请真实运行。这是阶段收官的主证据:你能端到端交付,而不只是会单课语法。
mkdir-p~/python-lab/src/day39cd~/python-lab/src/day39 pipinstallrequestsWindows 推荐 Cygwin 或 WSL。
cat>pipeline_demo.py<<'EOF' # Day39: mini pipeline API + sqlite import sqlite3 import time from pathlib import Path import requests DIR = Path(__file__).resolve().parent DB = DIR / "pipeline.db" SOURCES = [ ("github-cpython", "https://api.github.com/repos/python/cpython"), ("github-requests", "https://api.github.com/repos/psf/requests"), ] def init_db(conn): conn.execute( """ CREATE TABLE IF NOT EXISTS items ( id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL, name TEXT NOT NULL, stars INTEGER NOT NULL, fetched_at REAL NOT NULL, UNIQUE(source) ) """ ) conn.commit() def fetch(url: str) -> dict: r = requests.get( url, timeout=20, headers={"User-Agent": "python-lab-day39/1.0", "Accept": "application/json"}, ) r.raise_for_status() return r.json() def store(conn, source: str, name: str, stars: int): conn.execute( """ INSERT INTO items (source, name, stars, fetched_at) VALUES (?, ?, ?, ?) ON CONFLICT(source) DO UPDATE SET name=excluded.name, stars=excluded.stars, fetched_at=excluded.fetched_at """, (source, name, stars, time.time()), ) conn.commit() def main(): if DB.exists(): DB.unlink() with sqlite3.connect(DB) as conn: init_db(conn) for source, url in SOURCES: data = fetch(url) name = data.get("full_name") or data.get("name") or "" stars = int(data.get("stargazers_count") or 0) print(f"fetched {source}: {name} stars={stars}") store(conn, source, name, stars) print("--- second pass (upsert same sources) ---") for source, url in SOURCES: data = fetch(url) name = data.get("full_name") or data.get("name") or "" stars = int(data.get("stargazers_count") or 0) store(conn, source, name, stars) n = conn.execute("SELECT COUNT(*) FROM items").fetchone()[0] print("--- count ---") print(n) print("--- rows ---") for row in conn.execute( "SELECT id, source, name, stars FROM items ORDER BY id" ): print(row) if __name__ == "__main__": main() EOFpython3 pipeline_demo.py实测输出(star 数会变):
fetched github-cpython: python/cpython stars=73867 fetched github-requests: psf/requests stars=54147 --- second pass (upsert same sources) --- --- count --- 2 --- rows --- (1, 'github-cpython', 'python/cpython', 73867) (2, 'github-requests', 'psf/requests', 54147)第二遍前:已有 2 行。
第二遍后:仍是 2 行,字段被刷新——不是插入重复源。
9. 验收清单(务必过一遍)
用清单当「作业评分表」:跑通、COUNT、去重、参数化与 UA、合规、错误可见。
全部勾上,本课才算完成,而不是脚本能 print 就行。
- 一行命令跑通
COUNT(*) == 源数量(本例 2)- 第二遍不产生重复
source - 全程参数化 SQL、有 timeout 与 UA
- 合规:公开 API + 控制频率
- 失败时(可手动改错 URL)能看到异常而不是静默脏数据
10. 可选扩展(作业方向)
异步、线程池、重试、Redis、配置文件、日志——指向 35/36/30 等课。
强调:先串行正确,再并发。顺序反了会放大错误,排障更痛苦。
| 扩展 | 做法 | 对应课 |
|---|---|---|
| 异步拉取 | httpx+asyncio.gather | Day36 |
| 线程池拉取 | ThreadPoolExecutor | Day35 |
| 失败重试 | for + sleep + timeout | Day34 |
| Redis 去重/缓存 | 记 source 或缓存 JSON | Day30 |
| 配置文件 | sources.yaml | 工程化 |
| 日志 | logging 打到文件 | 运维习惯 |
扩展时先保证串行正确,再加并发——并发会放大错误。
11. 错误处理怎么加(示例思路)
给 fetch 加重试循环的示例思路,让短暂网络抖动不至于整条挂掉。
修改前一次失败就崩,修改后可恢复仍失败再抛——工程上更常见。
deffetch(url:str)->dict:last_err=Noneforattemptinrange(1,4):try:r=requests.get(url,timeout=20,headers={...})r.raise_for_status()returnr.json()exceptrequests.RequestExceptionase:last_err=e time.sleep(0.5*attempt)raiselast_err修改前:一次网络抖就整条流水线挂。
修改后:短暂故障可恢复;仍失败再抛出。
12. 常见问答
为何第二遍还请求、COUNT 不是 2 怎么办、403、能否改爬 HTML——集中答疑。
帮助你独立排障,而不是一出错就怀疑整课概念。
Q:为什么第二遍还要请求网络?
A:演示 upsert 与「刷新 stars」;生产可按 TTL 决定是否重抓。
Q:COUNT 不是 2?
A:检查是否删库失败、是否改过 SOURCES、是否旧 DB 未删。
Q:403/rate limit?
A:降频、加 Token、换时段;不要死循环猛打。
Q:能否改成爬 HTML?
A:能,但本课教学目标是接口流水线;HTML 请回到 Day37 思路。
13. 和「脚本随便写写」的差别
对照随便写与本课结构:分离模块、upsert、验收、密钥。
这是从「练手」到「像项目」的差别列表,写简历项目时也能用这套说法。
| 随便写 | 本课结构 |
|---|---|
| 请求和 SQL 揉在一起 | fetch/store 分离 |
| 每次插入新行 | 唯一键 + upsert |
| 无验收 | COUNT + 第二遍 |
| 密钥写死 | 环境变量(扩展) |
14. 阶段收官对照
一张表回顾 33~39 你带走的能力,形成阶段闭环。
网络与异步不是终点,而是能取数、能叠等待、能入库的底座。
| 课 | 你带走的能力 |
|---|---|
| 33 | HTTP 五件套 |
| 34 | 稳健 requests 客户端 |
| 35 | 并发选型 |
| 36 | asyncio + httpx |
| 37 | HTML 解析入库 |
| 38 | 接口优先 |
| 39 | 端到端流水线 |
15. 模块边界:什么样叫「好拆」
好拆与坏拆对照,强调测试友好:fetch 可 mock,不必事事连外网。
为以后写更大项目留接口意识。
| 好 | 不好 |
|---|---|
fetch只负责 HTTP | fetch里又写 SQL 又 print 排版 |
store只负责写入 | 全局到处sqlite3.connect |
main只编排 | 一个 200 行函数从头写到尾 |
测试友好:可以对fetch做 mock,不必真连外网(进阶)。
16. 验收脚本片段(人工也可)
用 assert 锁住行数与源集合,但不要断言 star 具体数字。
把「顺眼」升级成「可自动检查」的一小步。
n=conn.execute("SELECT COUNT(*) FROM items").fetchone()[0]assertn==len(SOURCES)sources={row[0]forrowinconn.execute("SELECT source FROM items")}assertsources=={sfors,_inSOURCES}修改前:只看 print 是否「顺眼」。
修改后:用断言锁住「行数与源集合」。
注意:不要断言 star 的具体数字。
17. 并发改造草图(可选,接 Day35/36)
线程池与 async 改造直觉,并警告 SQLite 多线程写连接不安全。
再次强调先串行正确再并发——和 35/36 课的选型、限流呼应。
线程池版直觉:
# 伪代码withThreadPoolExecutor(max_workers=4)asex:futs={ex.submit(fetch,url):sourceforsource,urlinSOURCES}forfutinas_completed(futs):source=futs[fut]data=fut.result()store(conn,source,...)注意:SQLite 多线程写同一连接不安全;应「主线程统一 store」,或每任务短连接并小心锁。
async 版直觉:gather多个 fetch,回到主协程再 store。
先串行正确,再并发——顺序不要反。
18. 故障演练建议
错 URL、断网、第二遍、加源——主动演练,建立预期。
故障演练是工程习惯,不是可选闲聊。
| 演练 | 操作 | 期望 |
|---|---|---|
| 错 URL | 改成不存在的仓库 | 非 200,进程报错退出 |
| 断网 | 拔网/防火墙 | 超时或连接错误 |
| 第二遍 | 连续跑两轮 | COUNT 不变 |
| 加源 | SOURCES 加一项 | COUNT+1 |
19. 自我检查清单
打勾:图、UPSERT、COUNT、timeout/UA/绑定、扩展与陷阱。
全过则本阶段实践目标达成。
- 能画出 fetch → parse → store
- 会 UPSERT 去重
- 会用 COUNT 验收
- timeout/UA/参数绑定齐全
- 知道可选扩展与并发陷阱
20. 字段字典(本项目)
逻辑名、JSON 来源、SQLite 列对照,外加解析两行示例。
换数据源时先填这种字典,再写代码,少返工。
| 逻辑名 | JSON 来源 | SQLite 列 |
|---|---|---|
| 源标识 | 自定 SOURCES[0] | source |
| 仓库名 | full_name / name | name |
| Star | stargazers_count | stars |
| 抓取时间 | time.time() | fetched_at |
解析示例:
name=data.get("full_name")ordata.get("name")or""stars=int(data.get("stargazers_count")or0)21. 运行结果怎么读(对照实测)
逐段解读 fetched 行、second pass、count、rows;COUNT 为 4 时如何排查。
会读输出,才会判断实验成功还是环境/代码问题。
fetched github-cpython: python/cpython stars=73867| 片段 | 含义 |
|---|---|
| fetched … | fetch+parse 成功 |
| stars=数字 | 当前公开 star,会变 |
| second pass | 再次 upsert |
| count 2 | 去重成功 |
| rows 两行 | 与 SOURCES 一一对应 |
若 count 为 4:说明唯一约束没生效或跑了两次建库逻辑异常——检查UNIQUE(source)与是否每次unlinkDB。
22. 提交作业前检查(给学员)
交作业应贴完整输出、说明 SOURCES 与第二遍 COUNT、扩展另说、禁止贴 Token。
这是课堂规范,也是职业沟通的缩影。
- 贴运行完整终端输出
- 说明 SOURCES 列表
- 说明第二遍 COUNT
- 若做了扩展(重试/异步),单独说明
- 不要贴 Token
总结
流水线四段:拉、解析、存、汇总;接口优先;阶段收官。可重复可验收,比「写出过一次请求」更重要。
你可以继续走向 Web 服务(对外提供 API)或数据分析(消费已入库数据)。
- 流水线 = 拉 → 解析 → 存 → 汇总。
- 接口优先降低解析成本;SQLite 承接持久化能力。
- 「网络与异步」阶段收官:HTTP → 客户端 → 并发 → 解析/接口 → 项目。
- 可重复、可验收,比「写出过一次请求」更重要。
小练笔
自测含 upsert、验收、安全与可选加源实践。先做后看答案。
可选实践请真加第三源并验证 COUNT。
题 1
为什么第二遍 count 仍是 2?
题 2
源改成 3 个仓库且均成功时,验收 count 应是?
题 3(可选)
为fetch增加失败重试 2 次(思路即可)。
题 4
判断:本流水线必须以 Selenium 打开 GitHub 页面才能取 star。
题 5
store里用?占位的主要安全收益是什么?
题 6
source列为什么建议 UNIQUE?
题 7
raise_for_status放在fetch里而不是忽略状态码,好处是?
题 8
判断:应用assert stars == 73867作为长期自动化测试。
题 9(可选实践)
增加第三个公开仓库源,确认 COUNT 为 3,且第二遍仍为 3。
题 10
列出流水线四个阶段名称(中文或英文)。
小练笔参考答案
先独立完成。意思对即可。
与 UPSERT、合规、参数绑定冲突的理解需修正。
题 1
UNIQUE(source)+ON CONFLICT DO UPDATE,同源更新不新增行。
题 2
3
题 3
for attempt in range(3): try/except包裹requests.get,失败 sleep 再试。
题 4
错(公开 REST API 即可)。
题 5
降低 SQL 注入风险,参数与语句分离。
题 6
保证同一逻辑源只有一行,支撑 upsert 去重。
题 7
尽早发现 4xx/5xx,避免把错误响应当业务 JSON。
题 8
错(star 会变;应断言类型/存在性或允许范围)。
题 9
以你运行为准。
题 10
拉取(fetch)、解析(parse)、存储(store)、汇总/验收(report)。(合理即可)