1. 本课定位:是什么、为何重要
> 前几课我们一直在和「程序可读的接口」打交道:HTTP 五件套、requests、并发与协程。可是世界上仍有大量信息以网页 HTML 的形式公开——文档站、公告列表、课程目录。若数据就在页面源码里,你还不会「把标签变成表格」,就只能手工复制。 > > 这一课定位静态页抓取:下载 HTML、按结构抽出标题和链接、清洗后写入 SQLite。它和第 34 课调 JSON 是两条腿:字段在 dict 里用键取,字段在标签里用选择器取。学完你能为「页面上有、接口没有」的简单站点做入库,并为下一课「页面上看得到但源码是空壳」埋下对比。 前面会调 API、会并发。很多资料仍以 **网页 HTML** 形式公开。 **静态页抓取** = 下载 HTML 文本 → 按标签抽出字段 → 清洗 → 入库(接 SQLite)。 | 概念 | 一句话 | |------|--------| | **静态页** | 服务器直接返回较完整的 HTML,主要信息不依赖浏览器再跑复杂 JS | | **解析** | 从 HTML 字符串里定位节点,取出标题、链接等 | | **BeautifulSoup** | 常用解析库,支持 CSS 选择器 | | **选择器** | 描述「树里走哪条路径」的短语法 | **为何重要:** 文档站、列表页、公告页仍常见;也是理解「页面结构」的基础。动态站点优先走接口(第 38 课)。 **对比已学:** | Day33/34 调 JSON API | 本课解析 HTML | |----------------------|---------------| | 字段在 dict 键里 | 字段在标签文本/属性里 | | 结构相对稳定 | 改版选择器易碎 | | 直接 `r.json()` | `BeautifulSoup` + `select` | | 参数绑定防注入 | 入库时同样要参数绑定 | ```bash pip install beautifulsoup4 lxml ``` --- ### 2. HTML 像一棵树 > 要解析页面,先换一种看法:别把 HTML 当成乱七八糟的长字符串,而当成一棵嵌套的树——大盒子套小盒子。浏览器渲染靠这棵树,解析库找数据也靠这棵树。 > > 这一节用一小段列表 HTML,对应标签、属性、文本、父子关系,并给出常用 CSS 选择器直觉。只有树的画面立住了,后面的 select 才不是魔法咒语。 浏览器和解析库都把 HTML 看成**嵌套的盒子(节点)**: ```html <body> <h1>Course Notes</h1> <ul class="posts"> <li> <a class="title" href="/p/1">HTTP 入门</a> </li> </ul> </body> ``` | 说法 | 对应 | |------|------| | 标签 | `a`、`ul`、`li` | | 属性 | `class="title"`、`href="/p/1"` | | 文本 | `HTTP 入门` | | 父子 | `ul` 包含 `li`,`li` 包含 `a` | **选择器直觉:** | 选择器 | 含义 | |--------|------| | `ul.posts` | class 为 posts 的 ul | | `a.title` | class 为 title 的 a | | `ul.posts a.title` | posts 列表里的标题链接 | | `#main` | id 为 main 的节点(示例) | --- ### 3. 本质、约束与坑 > 解析的本质,就是在树上按路径找到节点,把文本和属性变成 Python 里的字符串和元组。它默认只读内存中的 HTML,不会偷偷改你磁盘文件。 > > 但抓取不是纯技术题:还有合规、频率、编码、相对链接、以及「页面其实是空壳」等坑。这一节把约束和坑表摊开,避免你一上来就对公网站点狂轰滥炸,或在 JS 渲染站上死磕选择器。 **本质:** 解析 = 在树上按路径找节点,把文本/属性变成 Python 数据。 **解析前:** 一整段字符串。 **解析后:** 列表/元组/字典;**默认不改磁盘上的 HTML**(除非你自己写回)。 **约束与合规(必读):** | 要点 | 说明 | |------|------| | robots.txt / 服务条款 | 是否允许抓 | | 频率 | 不要轰炸;加 timeout 与间隔 | | 数据用途 | 隐私与版权 | | User-Agent | 部分站点会拒绝空白 UA | | 教学 | 优先 **本地 HTML** 或明确允许的源 | **常见坑:** | 坑 | 后果 | 做法 | |----|------|------| | 页面靠 JS 填充 | HTML 是空壳,select 为空 | 第 38 课:找 XHR/API | | 选择器写死易变 class | 改版全挂 | 选更稳定结构;写测试 | | 相对链接未补全 | 存了 `/p/1` 拼不出绝对 URL | `urllib.parse.urljoin` | | 编码不对 | 中文乱码 | 看 charset;`r.encoding` | | 把整页当正则硬拆 | 极脆、难维护 | 用解析器 | | 入库拼接 SQL | 注入风险 | `?` 占位 | --- ### 4. 解析能力分组 > 和学 requests 时一样,我们把 BeautifulSoup 的用法按能力分组:如何加载、如何选多个/一个节点、如何取文本和属性、如何做点清洗。 > > 下面用最小例子带预期输出,让你从「一个链接」练到「多个链接」,并知道选不中时返回 None。先会这些,综合脚本里的循环才读得懂。 | 类别 | 常用 | 作用 | |------|------|------| | 加载 | `BeautifulSoup(html, "lxml")` | 建树 | | 查找多节点 | `select("...")` | 返回列表 | | 查找单节点 | `select_one("...")` | 返回一个或 None | | 旧式查找 | `find` / `find_all` | 也能用 | | 取值 | `get_text`、`get("href")` | 文本与属性 | | 清洗 | `strip`、过滤空 | 落地前整理 | #### 4.1 最小解析 ```python from bs4 import BeautifulSoup html = '<ul class="posts"><li><a class="title" href="/p/1">HTTP 入门</a></li></ul>' soup = BeautifulSoup(html, "lxml") a = soup.select_one("ul.posts a.title") print(a.get_text(strip=True)) print(a.get("href")) ``` **预期输出:** ```text HTTP 入门 /p/1 ``` #### 4.2 多个节点 ```python from bs4 import BeautifulSoup html = """ <ul class="posts"> <li><a class="title" href="/p/1">HTTP 入门</a></li> <li><a class="title" href="/p/2">requests 实战</a></li> </ul> """ soup = BeautifulSoup(html, "lxml") for a in soup.select("ul.posts a.title"): print(a.get_text(strip=True), a.get("href")) ``` **预期输出:** ```text HTTP 入门 /p/1 requests 实战 /p/2 ``` #### 4.3 找不到时 ```python a = soup.select_one("a.not-exist") print(a) # None ``` 生产代码要判断 `if a is None`,避免空指针。 #### 4.4 相对链接补全(了解) ```python from urllib.parse import urljoin base = "https://example.com/docs/" print(urljoin(base, "/p/1")) print(urljoin(base, "p/1")) ``` **预期形态:** 得到可访问的绝对 URL 字符串。 --- ### 5. 从「字符串」到「表」:清洗清单 > 选中节点只是第一步。真实页面常有多余空白、空标题、相对路径,直接入库会很难看也很难用。 > > 这一节给出清洗清单:去空白、过滤空项、补全链接、去重、类型处理。清洗前是脏列表,清洗后才适合 INSERT——这也是和第 39 课流水线「parse」阶段的同一思维。 | 步骤 | 做什么 | 例子 | |------|--------|------| | 去空白 | `strip()` | `" 标题 "` → `"标题"` | | 去空项 | 过滤空标题 | 广告空节点 | | 统一链接 | urljoin | 相对 → 绝对 | | 去重 | set / SQL UNIQUE | 同一链接抓两次 | | 类型 | 日期字符串再解析 | 进阶 | **清洗前:** 标题可能含换行、链接可能是相对路径。 **清洗后:** 字段干净,适合 `INSERT`。 --- ### 6. 落地场景 > 技术要落到场景才记得住:课程笔记列表、公告栏、文档目录、简易导航站,分别抽什么、存哪。 > > 本课实践用本地生成的 sample.html,是为了稳定可复现;真实站点则用 requests 取 r.text 再交给 Soup,并遵守合规。场景表帮你想象「毕业后可能用在哪」。 | 场景 | 抽取什么 | 存哪 | |------|----------|------| | 课程笔记列表 | 标题 + 链接 | `articles` | | 公告栏 | 标题 + 日期 | 业务表 | | 文档目录 | 章节名 + anchor | 本地索引 | | 简易导航站 | 站名 + href | 书签库 | 本课用**本地生成 sample.html**,不依赖外站改版。 真实站点时: ```python import requests from bs4 import BeautifulSoup r = requests.get(url, timeout=20, headers={"User-Agent": "python-lab-day37/1.0"}) r.raise_for_status() soup = BeautifulSoup(r.text, "lxml") ``` 并遵守合规与频率。 --- ### 7. 与数据库衔接(回顾) > 解析结果若只 print,关掉程序就没了。数据持久化阶段你已学过参数化入库——这里把 executemany 接回来。 > > 对照错误与正确写法:f-string 拼 SQL 危险,标题里的引号都可能炸语句;参数绑定更安全。解析失败时也不要强行 insert 空数据。 ```python conn.executemany( "INSERT INTO articles (title, url) VALUES (?, ?)", items, # [(title, url), ...] ) ``` | 错误 | 正确 | |------|------| | f-string 拼 SQL | `?` 占位 | | 标题含 `'` 导致语句炸 | 参数绑定自动处理 | | 解析失败仍强行 insert | 先校验 items 非空 | --- ### 8. 综合实践 > 一键生成 sample、解析、建表、写入、查询,走通「字符串 → 列表 → SQLite」全链路。 > > 请真实运行并看 COUNT 是否为 3。这是本课的验收证据,也是后面流水线项目里「解析 + 存储」的迷你版。 ```bash mkdir -p ~/python-lab/src/day37 cd ~/python-lab/src/day37 pip install beautifulsoup4 lxml ``` Windows 推荐 Cygwin 或 WSL。 ```bash cat > parse_demo.py << 'EOF' # Day37: static HTML parse + sqlite import sqlite3 from pathlib import Path from bs4 import BeautifulSoup DIR = Path(__file__).resolve().parent HTML = DIR / "sample.html" DB = DIR / "articles.db" SAMPLE = """<!DOCTYPE html> <html> <head><meta charset="utf-8"><title>Demo Board</title></head> <body> <h1>Course Notes</h1> <ul class="posts"> <li><a class="title" href="/p/1">HTTP 入门</a></li> <li><a class="title" href="/p/2">requests 实战</a></li> <li><a class="title" href="/p/3">asyncio 笔记</a></li> </ul> </body> </html> """ def main(): HTML.write_text(SAMPLE, encoding="utf-8") html = HTML.read_text(encoding="utf-8") soup = BeautifulSoup(html, "lxml") items = [] for a in soup.select("ul.posts a.title"): title = a.get_text(strip=True) href = a.get("href", "") items.append((title, href)) print("--- parsed ---") for row in items: print(row) if DB.exists(): DB.unlink() with sqlite3.connect(DB) as conn: conn.execute( """ CREATE TABLE articles ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, url TEXT NOT NULL ) """ ) conn.executemany( "INSERT INTO articles (title, url) VALUES (?, ?)", items, ) conn.commit() print("--- db count ---") print(conn.execute("SELECT COUNT(*) FROM articles").fetchone()[0]) print("--- db rows ---") for row in conn.execute("SELECT id, title, url FROM articles ORDER BY id"): print(row) if __name__ == "__main__": main() EOF python3 parse_demo.py ``` **实测输出:** ```text --- parsed --- ('HTTP 入门', '/p/1') ('requests 实战', '/p/2') ('asyncio 笔记', '/p/3') --- db count --- 3 --- db rows --- (1, 'HTTP 入门', '/p/1') (2, 'requests 实战', '/p/2') (3, 'asyncio 笔记', '/p/3') ``` **流水线直觉:** ```text HTML 字符串 → select 列表 → [(title,url)...] → SQLite articles ``` --- ### 9. 解析器选择(了解) > BeautifulSoup 后面可以接不同解析器:lxml 快、html.parser 免安装、html5lib 更宽容但慢。 > > 本课默认 lxml;若环境装不上,知道能改 html.parser 即可,不必纠结细节。 | parser | 说明 | |--------|------| | `lxml` | 快、常用(需安装 lxml) | | `html.parser` | 标准库自带,免额外依赖 | | `html5lib` | 更宽容,更慢 | 本课用 `lxml`。若环境装不上,可改 `"html.parser"`。 --- ### 10. 常见问答 > 为什么不用正则硬扒 HTML?select 和 find 怎么选?数据能商用吗?Vue/React 页面怎么办? > > 集中回答这些问题,避免你走「万能正则」弯路,也把「空壳页去第 38 课」的路径再次强调。 **Q:为什么不用正则扒 HTML?** A:嵌套、属性顺序、换行都会让正则脆弱;解析器更合适。 **Q:select 和 find_all 哪个好?** A:都会用即可;CSS 选择器对「前端熟悉的同学」更直观。 **Q:抓到的数据能直接商用吗?** A:不一定。看版权、协议与法律;本课只教学。 **Q:页面是 Vue/React 的怎么办?** A:先看 Network 有没有 JSON;不要只死磕首屏 HTML(第 38 课)。 --- ### 11. 调试技巧 > 选择器写不对时最崩溃。这一节给可操作的调试步骤:存文件打开看结构、控制选中数量、先打印前几条。 > > 调试能力比背更多 API 更重要——真实页面一改版,你要靠这些步骤快速定位。 1. 先把 HTML 存成文件,用浏览器打开看结构。 2. 在开发者工具里对节点「Copy selector」作参考(再手工简化)。 3. 打印 `len(soup.select(...))`,先确认选中了几个。 4. 只对前 3 条 print,避免刷屏。 ```python nodes = soup.select("ul.posts a.title") print("count=", len(nodes)) for a in nodes[:3]: print(a.get_text(strip=True)) ``` --- ### 12. 错误示例 vs 正确示例 > 用脆弱正则和解析器两种写法对照,让你看见「为什么课堂坚持用 Soup」。 > > 属性顺序一变正则就挂,而 select 走的是结构关系。把正确气味记下来。 **脆弱正则(不推荐):** ```python import re # 属性顺序一变就挂 re.findall(r'<a class="title" href="([^"]+)">([^<]+)</a>', html) ``` **解析器(推荐):** ```python for a in soup.select("a.title"): href, title = a.get("href"), a.get_text(strip=True) ``` --- ### 13. 选择器再练三组 > 再给一小段 HTML 和几组选择器,训练「改变 class 后选中集合如何变」。 > > 自己改一改再 print len(select(...)),比只看表格印象深。 ```html <div id="box"> <p class="item">A</p> <p class="item highlight">B</p> <span class="item">C</span> </div> ``` | 选择器 | 大致选中 | |--------|----------| | `#box .item` | A、B、C | | `p.item` | A、B | | `p.highlight` | B | | `span.item` | C | 自己在本地 HTML 里改 class,观察 `select` 数量变化——这是调试基本功。 --- ### 14. 入库后查询(衔接 SQL) > 数据进库后,就可以用 SQL 过滤,例如标题模糊匹配。这展示了「为何不满足于 Python 列表」。 > > 和数据持久化课程衔接:解析是取数,SQL 是用数。 ```python for row in conn.execute( "SELECT title FROM articles WHERE title LIKE ?", ("%HTTP%",), ): print(row[0]) ``` **修改前:** 数据只在 Python 列表。 **修改后:** 可用 SQL 过滤、排序、分页——这也是「解析完要入库」的理由之一。 --- ### 15. 端到端伪流程(真实站点时) > 把合规、请求、解析、入库、自检排成 1~7 步清单,作为真实站点时的作战流程。 > > 任一步失败都不要假装成功写脏数据——这和 Day34 的 raise_for_status 精神一致。 ```text 1. 确认合规 2. requests.get(list_url, timeout=..., headers=UA) 3. raise_for_status 4. BeautifulSoup(r.text, "lxml") 5. select → 清洗 → 列表 6. executemany 入库 7. SELECT COUNT 自检 ``` 任一步失败都不要假装成功写脏数据。 --- ### 16. 自我检查清单 > 打勾确认:树结构、select/取值、入库、空壳页意识、合规与参数绑定。 > > 过关后再进第 38 课,对比「有接口时为什么不该死磕 HTML」。 - [ ] 能画 HTML 父子关系 - [ ] 会 `select` / `get_text` / `get("href")` - [ ] 会把结果写入 SQLite - [ ] 知道空壳页该去找 API - [ ] 知道合规与参数绑定 --- ## 总结 > 静态抓取四步:下载、解析、清洗、入库;选择器定位;本地样例优先;空壳页改找接口;SQL 要参数绑定。 > > 下一课专门处理「页面看得见、源码没有」的动态内容,主线是接口优先。 - 静态抓取 = 下载 + 解析 + 清洗 + 入库。 - CSS 选择器定位;教学优先本地样例。 - 空壳页 / JS 渲染 → 宜优先找 JSON 接口。 - 入库坚持参数绑定;合规与频率不能省。 --- ## 小练笔 > 自测题含概念与可选改 SAMPLE 的实践。先做后对答案。 > > 可选实践请真改 HTML 重跑,确认 COUNT 变化。 ### 题 1 `a.get_text(strip=True)` 取的是什么? ### 题 2 判断:任何网站都可以无限制高频爬取。 ### 题 3 本课数据进了哪张表? ### 题 4 HTML 是空壳、数据在 XHR 里时,本课方法常见失败点是什么? ### 题 5 判断:BeautifulSoup 解析一定会修改磁盘上的 HTML 文件。 ### 题 6 选择器 `ul.posts a.title` 在选什么? ### 题 7 相对链接 `/p/1` 入库前通常要做什么? ### 题 8 判断:用 f-string 把标题拼进 SQL 是推荐做法。 ### 题 9(可选实践) 在 SAMPLE 里再加一条链接,重跑后确认 `COUNT(*)==4`。 ### 题 10 `select` 与 `select_one` 的差别? --- ## 小练笔参考答案 > 先独立完成。简答意思对即可。 > > 与「空壳页/合规/参数绑定」冲突的理解需要修正。 ### 题 1 链接可见文字(标题)。 ### 题 2 **错** ### 题 3 `articles` ### 题 4 下载到的 HTML 没有目标节点,选择器结果为空。 ### 题 5 **错** ### 题 6 `ul.posts` 下的 `a.title` 节点。 ### 题 7 用 `urljoin` 等补成绝对 URL(若需要可访问链接)。 ### 题 8 **错**(应用参数绑定) ### 题 9 以你改完后的输出为准。 ### 题 10 `select` 返回列表;`select_one` 返回单个节点或 None。