网页不是图片,是一段带尖括号的文本。尖括号一层层套,天然就是一棵树。 所谓「抓网页」,就是先把它变成树,再按规则在树上找节点。 CSS 选择器那套语法(点、井号、空格)不只是给样式用的,后面找元素也用同一套。
前五章动的是本机的文件。从这一章开始,程序要伸到网上去。
教材第 6 章开头两节是【认识网页与 HTML】和【认识 CSS 样式】。
这两节看着像前端知识,其实是爬虫的地基:不知道网页长什么样,就没法从里面取东西。
本篇用一个 16 行的小页面把「树」这件事讲透。
演示里用 Python 自带的 html.parser 建树,不装任何第三方库。
一、网页不是图片,是一段文本
浏览器里看到的那一页,本质是一个文本文件。你在页面上右键「查看源代码」,看到的就是它。
这个文本有固定的写法,叫 HTML。它由一对对尖括号组成,<div> 开头、</div> 结尾,中间夹着内容。
<div> 这种叫标签。标签可以带属性:<div id="main" class="list">,id 和 class 就是两个属性。
标签还分两种:一种包着东西(<div>内容</div>),一种自己就完了(<img src="…">)。
这个区别在解析时很重要,因为前者要等闭合标签。
浏览器做的事就是:读这串文本 → 建一棵树 → 按树画出画面。
我们要做的第一步跟它一模一样,只是不画。
二、解析出来是一棵树
演示第二段把那个页面解析成了树,打印出来是这样的层级:html 下面有 head 和 body,
body 下面有一个 div,div 下面有 h1 和三个 div.item。
这个形状不是巧合,是 HTML 的规矩决定的。 尖括号一层层套,套出来的必然是树。
树上的每个点有个正式名字叫节点。
节点之间的关系也有名字:外面的叫父节点,里面的叫子节点,同一层的叫兄弟节点。
为什么要建树?因为在树上找东西有固定套路,在一段乱糟糟的文本里找没有。
「第三个 item 里的那个链接」,这句话只有在树上才说得清。
Python 自带的 html.parser 就能干这事,不用装库。
它是个「事件式」解析器:遇到开始标签调一个方法,遇到文本调另一个方法,你自己把这些拼成树。
演示里就是这么干的,大概三十行代码。真做项目会用 BeautifulSoup 或 lxml,它们把这段封装好了。
但手写一遍才知道「树」是怎么来的。
三、标签、属性、文本,三样东西
一个商品条目在 HTML 里长这样:外层 div 带 class=item 和 data-sku=A-01,
里面一个 h3 包着 a(带链接和文字),再一个 span 带价格。
| sku | 链接 | 品名 | 单价 |
|---|---|---|---|
| A-01 | /p/1 | 六角螺栓 M8 | 1.50 |
| B-02 | /p/2 | 膨胀螺栓 10mm | 2.30 |
| C-03 | /p/3 | 不锈钢垫片 | 0.40 |
抓数据就是把这四样取出来拼成一行。 每一列都对应树上某个节点的某个位置:sku 在 div 的属性里,
链接在 a 的 href 属性里,品名和价格在节点的文字里。
注意 data-sku 这个属性:data- 开头的叫自定义属性,HTML 标准里没有它,是作者自己加的。
这类属性常常是最好用的定位依据,因为它是为程序准备的,不会为了排版而改。
还有一个细节:文字可能被空白包着。
<span class="price"> 1.50 </span> 取出来的文字带前后空格,得 strip() 一下。
这是抓数据时最常见的脏数据来源。
四、CSS 在管什么
HTML 管结构,CSS 管样子。同一份 HTML 换个 CSS,可以是白底黑字,也可以是黑底白字。
CSS 的写法是「选中谁 → 给它什么样式」:.price { color: red; } 意思是「class 里有 price 的元素,
文字变红」。
| 选择器 | 选中谁 | 本例中 |
|---|---|---|
.price |
class 里有 price 的元素 | 三个 span |
#main |
id 等于 main 的元素 | 那一个 div |
div.item |
既是 div 又有 class=item | 三个条目 |
div a |
div 里面的所有 a | 三个链接 |
规矩就三条:
点表示 class,井号表示 id,直接写标签名就是选中所有这个标签。中间用空格表示「里面的」。
class 可以很多元素共用(本例三个条目都是 class=item),id 一个页面只能有一个。
所以 id 定位最稳,class 定位最常用。
记住这套写法不只是为了看懂样式。
下一节用 Selenium 找元素,用的就是同一套选择器语法。CSS 选择器是前端和爬虫共用的语言。
五、先把「捞出所有同类」跑通
演示最后一段做了两件事:数页面上有几张图、收集所有链接。
结果是 1 张图、3 个链接(/p/1、/p/2、/p/3)。
「把所有图片下下来」「把所有链接收集起来」,起点都是这一步。
第 11 章会真的去下载这些图片。到那时候你会发现,难的不是下载,是「怎么把地址找准」。
这一章三篇讲的全是这件事。
一个提醒:链接可能是相对路径(/p/1),也可能是绝对路径(https://…)。
相对路径要拼上主机名才能访问。 这一步第 11 章会做。
六、动手 Demo:网页是一棵树
先看一小段真 HTML,然后用标准库把它解析成一棵树打印出来。 再按标签、属性、文字三样东西把商品数据抓出来。 最后数一数页面里有几张图、几个链接。
from html.parser import HTMLParser
HTML = '''<!DOCTYPE html>
<html><head><title>材料报价</title>
<style>.price { color: red; } #main { width: 800px; }</style>
</head>
<body>
<div id="main" class="list">
<h1>今日材料报价</h1>
<div class="item" data-sku="A-01">
<h3><a href="/p/1">六角螺栓 M8</a></h3>
<span class="price">1.50</span>
</div>
<div class="item" data-sku="B-02">
<h3><a href="/p/2">膨胀螺栓 10mm</a></h3>
<span class="price">2.30</span>
</div>
<div class="item" data-sku="C-03">
<h3><a href="/p/3">不锈钢垫片</a></h3>
<span class="price">0.40</span>
</div>
<img src="/img/logo.png" alt="logo">
</div>
</body></html>'''
class Node:
def __init__(self, tag, attrs, parent=None):
self.tag, self.attrs, self.parent = tag, dict(attrs), parent
self.kids, self.text = [], ''
class Builder(HTMLParser):
def __init__(self):
super().__init__()
self.root = Node('#root', [])
self.stack = [self.root]
def handle_starttag(self, tag, attrs):
n = Node(tag, attrs, self.stack[-1])
self.stack[-1].kids.append(n)
if tag not in ('img', 'br', 'meta', 'link', 'input', 'hr'):
self.stack.append(n)
def handle_endtag(self, tag):
if len(self.stack) > 1 and self.stack[-1].tag == tag:
self.stack.pop()
def handle_data(self, data):
t = data.strip()
if t:
self.stack[-1].text += t
b = Builder()
b.feed(HTML)
print('① 先看这页 HTML')
for ln in HTML.splitlines()[:8]:
print(' ' + ln)
print(' …(后面还有三个条目和一个 img)')
print('② 解析出来是棵树')
def show(n, depth=0):
if n.tag == '#root':
for k in n.kids:
show(k, depth)
return
attrs = ''.join(' %s=%s' % (k, v) for k, v in n.attrs.items())
print(' ' + ' ' * depth + '<%s%s>' % (n.tag, attrs))
for k in n.kids:
show(k, depth + 1)
show(b.root)
print('③ 标签、属性、文本三样东西')
def walk(n):
yield n
for k in n.kids:
yield from walk(k)
items = [n for n in walk(b.root) if n.attrs.get('class') == 'item']
print(' class=item 的 div 一共 %d 个' % len(items))
print(' %-6s %-8s %-14s %s' % ('sku', '链接', '品名', '单价'))
for it in items:
a = next(n for n in walk(it) if n.tag == 'a')
sp = next(n for n in walk(it) if n.tag == 'span')
print(' %-6s %-8s %-14s %s' % (it.attrs['data-sku'], a.attrs['href'], a.text, sp.text))
print('④ CSS 在管什么')
print(' 页面里的样式表:.price { color: red; } #main { width: 800px; }')
print('| 选择器写法 | 选中谁 | 本例中 |')
print('|---|---|---|')
print('| .price | class 里有 price 的元素 | 三个 span |')
print('| #main | id 等于 main 的元素 | 那一个 div |')
print('| div.item | 既是 div 又有 class=item | 三个条目 |')
print('| div a | div 里面的所有 a(不限层级) | 三个链接 |')
print('⑤ 找个东西出来')
imgs = [n for n in walk(b.root) if n.tag == 'img']
links = [n for n in walk(b.root) if n.tag == 'a']
print(' 页面里的图片:%d 张' % len(imgs))
for n in imgs:
print(' src=%s alt=%s' % (n.attrs.get('src'), n.attrs.get('alt')))
print(' 页面里的链接:%d 个,分别是 %s' % (len(links), '、'.join(n.attrs['href'] for n in links)))
实跑输出:
--------------------------------------------------------------------
网页是一棵树
--------------------------------------------------------------------
① 先看这页 HTML
--------------------------------------------------------------------
<!DOCTYPE html>
<html><head><title>材料报价</title>
<style>.price { color: red; } #main { width: 800px; }</style>
</head>
<body>
<div id="main" class="list">
<h1>今日材料报价</h1>
<div class="item" data-sku="A-01">
<h3><a href="/p/1">六角螺栓 M8</a></h3>
<span class="price">1.50</span>
</div>
<div class="item" data-sku="B-02">
<h3><a href="/p/2">膨胀螺栓 10mm</a></h3>
<span class="price">2.30</span>
…(后面还有三个条目和一个 img)
② 解析出来是棵树
--------------------------------------------------------------------
<html>
<head>
<title>
<style>
<body>
<div id=main class=list>
<h1>
<div class=item data-sku=A-01>
<h3>
<a href=/p/1>
<span class=price>
<div class=item data-sku=B-02>
<h3>
<a href=/p/2>
<span class=price>
<div class=item data-sku=C-03>
HTML 的尖括号一层层套,天然就是一棵树:html 里面有 head 和 body,body 里面有 div,
div 里面有 h1 和好几个 item。
所谓「抓网页」,就是先把它变成树,再按规则在树上找节点。
Python 自带的 html.parser 就能干这事,不用装库。
③ 标签、属性、文本三样东西
--------------------------------------------------------------------
class=item 的 div 一共 3 个
sku 链接 品名 单价
A-01 /p/1 六角螺栓 M8 1.50
B-02 /p/2 膨胀螺栓 10mm 2.30
C-03 /p/3 不锈钢垫片 0.40
一个条目由三部分组成:外层的 div 带着 data-sku 这个自定义属性,里层的 a 带着链接和文字,
span 带着价格。抓数据就是把这几个取出来拼成一行。
④ CSS 在管什么
--------------------------------------------------------------------
页面里的样式表:.price { color: red; } #main { width: 800px; }
| 选择器写法 | 选中谁 | 本例中 |
|---|---|---|
| .price | class 里有 price 的元素 | 三个 span |
| #main | id 等于 main 的元素 | 那一个 div |
| div.item | 既是 div 又有 class=item | 三个条目 |
| div a | div 里面的所有 a(不限层级) | 三个链接 |
class 可以很多个元素共用,id 一个页面只能有一个。
CSS 用点表示 class,用井号表示 id,直接写标签名就是选中所有这个标签。
记住这套写法不只是为了看懂样式——下一节用 Selenium 找元素,用的就是同一套选择器语法。
⑤ 找个东西出来
--------------------------------------------------------------------
页面里的图片:1 张
src=/img/logo.png alt=logo
页面里的链接:3 个,分别是 /p/1、/p/2、/p/3
「把所有图片下下来」「把所有链接收集起来」——这两件事的起点都是这一步:
在树上把所有同名标签捞出来。第 11 章会真的去下载它们。
第一段那十几行就是这个页面的全部。 浏览器看到的是同样的字符,它多做的两件事是「按树画」和「按 CSS 上色」。 第二段树的样子要看清层级:html 下面是 head 和 body,body 下面是那一个 div, div 下面是 h1 和三个条目。这个形状是被尖括号的嵌套关系定死的。 第三段抓数据靠的是「先找到 div.item,再在它里面找 a 和 span」。 注意它是「在它里面找」,不是「在整页找」——这个范围限定很重要,不然会抓到别的条目的价格。 建树那段代码里有个 self.stack,它是干这个用的:遇到开始标签就压进去,遇到结束标签就弹出来。 栈顶永远是「我现在在哪个节点里面」。 第五段数出来 1 张图、3 个链接,这跟肉眼看到的一致。 第 11 章会真的去下载那张图,到时候要处理的是相对路径拼主机名的问题。
30 秒实验
- 把演示里的
HTML加一条<div class="item" data-sku="D-04">和它的内容,看表格变成几行。 - 把
if n.attrs.get('class') == 'item'改成if 'item' in n.attrs.get('class', ''), 想想为什么要这么改(提示:一个元素可以有好几个 class)。 - 把
handle_data里的.strip()去掉,再看抓出来的品名和价格,看多了什么。
常见误区
- 「抓网页就是拿文本正则匹配。」能跑,但很脆。先建树再按结构找,页面改了几个空格也不会崩。
- 「class 和 id 是一回事。」class 可以很多元素共用,id 一页只能有一个。 id 定位最稳,class 定位最常用。
- 「HTML 里的文字取出来就能用。」可能带着前后空白和换行。
strip()是抓数据时的必备动作,不然后面比较字符串永远对不上。
自测三问(点开对答案)
1. 为什么说 HTML 解析出来「必然是」一棵树? —— 因为 HTML 的写法就是一对对尖括号嵌套:开始标签和结束标签之间夹着内容,内容里又可以套标签。 这种「里面还有」的关系画出来就是树,没有第二种可能。 2. 要抓「三个条目里各自的价格」,为什么要先找到 `div.item` 再在它里面找 `span`, 而不是直接在整页找 `span`? —— 因为整页找 `span` 会把所有 span 都捞出来,数量对得上还好,一旦页面上还有别的 span, 数据就错位了。**先限定范围再找,才能保证「这条数据属于这个条目」。** 3. `class=item` 和 `id=main` 这两个属性有什么区别?用哪个定位更稳? —— class 可以很多元素共用,一个页面上可能有三个 `class=item`; id 一个页面只能有一个,理论上唯一。所以按 id 定位最稳。 但很多页面根本没有 id,那时候只能靠 class 加层级来定位。下一篇换个问题:如果页面上的内容是 JavaScript 跑完才填进去的,光拿 HTML 就抓不到。
- ☐ (本篇完,下一篇 6.2 Selenium 与元素定位)