🐍 第6章 自动收集网络信息
📄 一句话概括

网页不是图片,是一段带尖括号的文本。尖括号一层层套,天然就是一棵树。 所谓「抓网页」,就是先把它变成树,再按规则在树上找节点。 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 秒实验

  1. 把演示里的 HTML 加一条 <div class="item" data-sku="D-04"> 和它的内容,看表格变成几行。
  2. 把 if n.attrs.get('class') == 'item' 改成 if 'item' in n.attrs.get('class', ''), 想想为什么要这么改(提示:一个元素可以有好几个 class)。
  3. 把 handle_data 里的 .strip() 去掉,再看抓出来的品名和价格,看多了什么。

常见误区

  1. 「抓网页就是拿文本正则匹配。」能跑,但很脆。先建树再按结构找,页面改了几个空格也不会崩。
  2. 「class 和 id 是一回事。」class 可以很多元素共用,id 一页只能有一个。 id 定位最稳,class 定位最常用。
  3. 「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 就抓不到。

相关笔记