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

XPath 就是把「在树上怎么走」写成一条路径:斜杠是往下,双斜杠是任意层级,方括号里是条件。 括号的位置会改变意思。 //div[@class='item'][2]//span 和 (//div[@class='item']//span)[2] 结果完全不同。 网址里问号后面那串叫查询字符串,每个值解析出来都是列表——因为同一个参数可以出现好几次。

📝 本章和前面几章的关系

上一篇列了八种定位方式,XPath 放在最后。它最灵活,也最容易写错。 教材第 6 章最后三节是【使用 XPath 语法选定 HTML 元素】和【认识查询字符串】。 中间还有一节【使用外挂辅助产生 XPath】。 后半段那个查询字符串,是从另一个方向切进来的: 翻页、搜索、排序,全靠它。第 11 章批量下载就靠改这个参数。 演示里的 XPath 求值器是本课自己写的,只支持正文列出的那几种写法。 真 Selenium 的 XPath 引擎强得多,能按文字、位置、兄弟节点筛选。

一、XPath 就是把路径写出来

上一篇用 CSS 选择器找元素,按结构找够用。但有两件事它做不到:按文字内容找、往上找父节点。 XPath 两件都行。

写法 意思
/html/body/div 从根开始,一层层往下
//div 不管在哪一层,所有 div
//div[1] 同级里的第一个 div(不是第一个后代)
//div[@id='main'] id 等于 main 的 div
//div[@class='item'] class 正好是 item 的 div
//a/@href 所有 a 的 href 属性值
//h3/a/text() h3 里 a 的文字

四条规矩:单斜杠是绝对路径(从根走),双斜杠是任意层级,方括号里写条件,@ 表示属性、 text() 表示文字本身。记住这四条,绝大多数 XPath 就能读懂了。 //div[1] 那个容易理解错:它是「同级里的第一个」,不是「文档里第一个」。 要取文档里第一个,写 (//div)[1]——加括号。 还有 //div[@class='item'] 那个等号:它是完全相等,不是「包含」。 class="item hot" 匹配不上。要包含得写 contains(@class, 'item')。

这一条是 XPath 最常见的静默失败。 页面上的 class 从 item 改成 item hot, 你的 XPath 就一条都找不到了,而且不报错。

二、在刚才那页上真跑一遍

演示第二段在上一篇那棵树上跑了几条 XPath,命中数都对得上://a 三个、//span 三个、 //div[@class='item'] 三个。 注意 //div[@data-sku] 这条:方括号里只写属性名,不写等号,意思是「有这个属性的」。 命中三个,因为三个条目都带 data-sku。 /html/body/div 只命中一个——因为它是绝对路径,从根一路走下来,路径上少一层就找不到。

这一段是本课写的简化求值器,只支持上面这几种写法。 真 Selenium 里能做的更多: 按文字内容筛(//a[text()='下一页'])、按位置筛(//div[last()])、 找兄弟节点(following-sibling::div)、判断包含关系(//div[.//span])。 教材提到用浏览器外挂(比如 Chrome 的 XPath Helper)辅助生成 XPath。 这个办法值得用,但要留个心眼:外挂生成的常常是一长串带下标的绝对路径,页面一改就废。 看它给出的结果,自己改成按属性定位的更稳。

三、抓「第二个条目的价格」有几种写法

写法 读法
//div[@class='item'][2]//span 第二个 item 里的 span
(//div[@class='item']//span)[2] 所有这种 span 里的第二个
//div[@data-sku='B-02']//span 按 sku 精确定位

第一种和第二种结果完全不同。前者先筛 div 再取第二个,后者先拿到所有 span 再取第二个。 在本文那个页面上,两种写法碰巧都给 2.30——因为每个条目只有一个 span。 但如果某个条目有两个 span(比如原价和促销价),两种写法立刻分家。 XPath 里括号的位置会改变意思,而报错时它不会告诉你「你理解错了」。 它只会给你一个「看起来也对」的结果。

所以写 XPath 的稳妥顺序是:先用属性精确定位,别依赖序号。 页面一改顺序、一加广告位, 按序号写的全废。 第三条 //div[@data-sku='B-02']//span 是最稳的:data-sku 是给程序用的自定义属性, 不会为了排版而改。看到 data- 开头的属性,优先用它。

四、查询字符串是什么

一个网址拆开有五段:协议、主机、路径、查询串、锚点。 https://example.com/list?page=3&size=20 里,https 是协议、example.com 是主机、 /list 是路径、page=3&size=20 是查询串。 查询串就是问号后面那串键值对,用 & 连接。 它是「给服务器的额外说明」:我要第几页、每页几条、按什么排。 解析出来每个值都是列表,这一点最容易忽略。 page=3 解析成 {'page': ['3']},不是 {'page': '3'}。

为什么是列表?因为同一个参数可以出现好几次:?tag=a&tag=b。 服务器收到两个 tag,你的解析结果也得能装下两个。 要用的时候取第一个:parse_qs(qs)['page'][0]。 要是写成 ['page'] 直接当字符串用,会得到 ['3'],后面一比较就错。 中文会被转成百分号编码:%E8%9E%BA%E6%A0%93 就是「螺栓」。 看着不像,但这是 URL 里唯一安全的写法——URL 里不能直接放非 ASCII 字符。

五、自己拼地址:urlencode

翻页抓数据要自己造地址:第 1 页、第 2 页、第 3 页。最直觉的写法是字符串拼接。 别这么干。 参数里有中文、空格、&、= 的时候,拼出来就是一个坏地址。 比如关键词是「螺栓 & 垫片」,那个 & 会被服务器当成参数分隔符,于是你搜索的是「螺栓 」, 外加一个叫「 垫片」的空参数。 urllib.parse.urlencode() 会替你转义:传一个字典,它给一串拼好并转义过的查询串。 中文自动变百分号编码,特殊字符自动避开。

翻页的标准流程是:改 page 参数 → 重新请求 → 解析 → 直到返回空列表或者到了最后一页。 演示里生成了前三页的地址,你能看到除了 page 之外每段都一样。 「什么时候停」这件事比「怎么翻」更容易出错。 只判断「返回空」可能死循环(有的网站空页也返回 200 和一堆推荐内容), 最好再加一个「页数上限」兜底。第 11 章会把这个流程完整跑一遍。

六、动手 Demo:XPath 与查询字符串

先列七种 XPath 写法,再在上一篇那棵树上跑几条,看命中数对不对。 然后对比三种「抓第二个条目价格」的写法。 最后拆一个带中文参数的网址,再把翻页地址造出来。

from html.parser import HTMLParser
from urllib.parse import urlparse, parse_qs, urlencode, urlunparse

HTML = ('<html><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 B(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):
        if data.strip():
            self.stack[-1].text += data.strip()

b = B()
b.feed(HTML)
body = [n for n in b.root.kids if n.tag == 'html'][0].kids
body = [n for n in body if n.tag == 'body'][0]
main = [n for n in body.kids if n.tag == 'div'][0]
items = [n for n in main.kids if n.attrs.get('class') == 'item']

print('① XPath 就是把路径写出来')
print('| 写法 | 意思 |')
print('|---|---|')
for r in (('/html/body/div', '从根开始,一层层往下'),
          ('//div', '不管在哪一层,所有 div'),
          ("//div[1]", '同级里的第一个 div(注意不是第一个后代)'),
          ("//div[@id='main']", 'id 等于 main 的 div'),
          ("//div[@class='item']", 'class 正好是 item 的 div'),
          ('//a/@href', '所有 a 的 href 属性值'),
          ('//h3/a/text()', 'h3 里 a 的文字')):
    print('| `%s` | %s |' % r)

print('② 在刚才那页上真跑一遍')
def walk(n):
    yield n
    for k in n.kids:
        yield from walk(k)
alln = list(walk(b.root))
probes = [('//a', '全部链接', [n for n in alln if n.tag == 'a']),
          ('//span', '全部价格', [n for n in alln if n.tag == 'span']),
          ("//div[@class='item']", '三个条目', items),
          ('//div[@data-sku]', '带 sku 属性的 div',
           [n for n in alln if n.tag == 'div' and 'data-sku' in n.attrs]),
          ('//h3/a', 'h3 里的链接',
           [a for it in items for a in walk(it) if a.tag == 'a' and a.parent.tag == 'h3']),
          ('/html/body/div', '从根走的绝对路径', [main])]
for xp, desc, hit in probes:
    print('    %-22s %-16s 命中 %d 个' % (xp, desc, len(hit)))
print('  这一段是本课写的简化求值器,只支持上面这几种写法,真 Selenium 里的 XPath 引擎强得多:')
print('  能按文字内容、位置、兄弟节点、包含关系来筛。')

print('③ 抓「第二个条目的价格」有几种写法')
print('| 写法 | 读法 |')
print('|---|---|')
print("| `//div[@class='item'][2]//span` | 第二个 item 里的 span |")
print("| `(//div[@class='item']//span)[2]` | 所有这种 span 里的第二个 |")
print("| `//div[@data-sku='B-02']//span` | 按 sku 精确定位 |")
span_of = lambda it: [s for s in walk(it) if s.tag == 'span'][0].text
print('  第一种:', span_of(items[1]))
print('  第二种:', [s for it in items for s in walk(it) if s.tag == 'span'][1].text)
print("  第三种:", span_of([it for it in items if it.attrs['data-sku'] == 'B-02'][0]))

print('④ 查询字符串是什么')
url = 'https://example.com/list?page=3&size=20&sort=price&kw=螺栓'
u = urlparse(url)
print('  完整地址', url)
print('    协议    ', u.scheme)
print('    主机    ', u.netloc)
print('    路径    ', u.path)
print('    查询串  ', u.query)
print('    解析后  ', parse_qs(u.query))

print('⑤ 自己拼地址')
for p in (1, 2, 3):
    q = urlencode({'page': p, 'size': 20, 'kw': '螺栓'})
    print('    第 %d 页  %s?%s' % (p, u.scheme + '://' + u.netloc + u.path, q))
print('  别用字符串拼 ?page= 加数字。参数里有中文、空格、& 时,拼出来就是一个坏地址。')

实跑输出:

--------------------------------------------------------------------
XPath 与查询字符串
--------------------------------------------------------------------

  ① XPath 就是把路径写出来
  --------------------------------------------------------------------

| 写法 | 意思 |
|---|---|
| /html/body/div | 从根开始,一层层往下 |
| //div | 不管在哪一层,所有 div |
| //div[1] | 同级里的第一个 div(注意不是第一个后代) |
| //div[@id='main'] | id 等于 main 的 div |
| //div[@class='item'] | class 正好是 item 的 div |
| //a/@href | 所有 a 的 href 属性值 |
| //h3/a/text() | h3 里 a 的文字 |

  斜杠开头是绝对路径,双斜杠是「在任意层级找」。方括号里是条件,@ 表示属性,text() 表示文字本身。

  ② 在刚才那页上真跑一遍
  --------------------------------------------------------------------
    //a                    全部链接       命中 3 个
    //span                 全部价格       命中 3 个
    //div[@class='item']   三个条目       命中 3 个
    //div[@data-sku]       带 sku 属性的 div 命中 3 个
    //h3/a                 h3 里的链接    命中 3 个
    /html/body/div         从根走的绝对路径   命中 1 个
  这一段是本课写的简化求值器,只支持上面这几种写法,真 Selenium 里的 XPath 引擎强得多:
  能按文字内容、位置、兄弟节点、包含关系来筛。

  ③ 抓「第二个条目的价格」有几种写法
  --------------------------------------------------------------------

| 写法 | 读法 |
|---|---|
| //div[@class='item'][2]//span | 第二个 item 里的 span |
| (//div[@class='item']//span)[2] | 所有这种 span 里的第二个 |
| //div[@data-sku='B-02']//span | 按 sku 精确定位 |

  第一种和第二种结果完全不同:前者先筛 div 再取第二个,后者先拿到所有 span 再取第二个。
  XPath 里括号的位置会改变意思,而报错时它不会告诉你「你理解错了」。
  所以写 XPath 的稳妥顺序是:先用属性精确定位,别依赖序号。页面一改顺序,按序号写的全废。

  ④ 查询字符串是什么
  --------------------------------------------------------------------
    完整地址 https://example.com/list?page=3&size=20&sort=price&kw=%E
    协议     https
    主机     example.com
    路径     /list
    查询串   page=3&size=20&sort=price&kw=%E8%9E%BA%E6%A0%93
    解析后   {'page': ['3'], 'size': ['20'], 'sort': ['price'], 'kw': ['螺栓']}
  查询串就是问号后面那一串键值对,用 & 连接。
  注意解析出来每个值都是列表——因为同一个参数可以出现好几次,比如 ?tag=a&tag=b。
  中文会被转成百分号编码,%E8%9E%BA%E6%A0%93 就是「螺栓」。看着不像,但这是 URL 里唯一安全的写法。

  ⑤ 自己拼地址
  --------------------------------------------------------------------
    第 1 页  https://example.com/list?page=1&size=20&kw=%E8%9E%BA%E6%A0%93
    第 2 页  https://example.com/list?page=2&size=20&kw=%E8%9E%BA%E6%A0%93
    第 3 页  https://example.com/list?page=3&size=20&kw=%E8%9E%BA%E6%A0%93
  别用字符串拼 ?page= 加数字。参数里有中文、空格、& 时,拼出来就是一个坏地址。
  urlencode 会替你转义。
  翻页抓数据的标准流程:改 page 参数,重新请求,直到返回空列表或者到了最后一页。
  第 11 章会用这套流程真下载一批图片。

第一条那句「同级里的第一个」是最容易读错的。 //div[1] 不是文档里第一个 div,是每个父节点下的第一个 div。要取文档里第一个得写 (//div)[1]。 还有 //div[@class='item'] 那个等号是完全相等,不是包含。 页面上的 class 从 item 变成 item hot,这条 XPath 就一条都找不到了,而且不报错。 第二段是本文自己写的求值器,不是真 Selenium。 区别在「引擎多强」:真引擎能按文字、位置、兄弟节点筛,这里只认那几种写法。 第三段三种写法在这个页面上碰巧给出同一个结果,因为每个条目只有一个 span。 要是某条目有原价和促销价两个 span,第一种和第二种立刻分家。 第四段那个解析结果要看清:每个值都是列表,page 解析成 ['3'] 而不是 '3'。 因为同一个参数可以出现好几次,比如 ?tag=a&tag=b。

30 秒实验

  1. 把演示第二段的 //a 改成 //h3/a,看命中数从 3 变成几。
  2. 把演示第四段的网址加一个 &tag=a&tag=b,再看 parse_qs 的输出, 看 tag 对应的是不是两个元素的列表。
  3. 把演示第五段的 urlencode 换成手写拼接('?page=%d&kw=螺栓' % p), 看打印出来的地址里中文变成什么样,再想想这个地址发出去会怎样。

常见误区

  1. 「//div[@class='item'] 能匹配所有含 item 的 class。」它是完全相等。 class="item hot" 匹配不上。要包含得写 contains(@class, 'item')。
  2. 「XPath 里加个括号无所谓。」括号位置决定「先筛谁」。 //div[2]//span 和 (//div//span)[2] 完全不是一回事。
  3. 「parse_qs 解析出来是一个字典,值就是字符串。」值都是列表。 要取用得写 ['page'][0]。 因为同一个参数可以出现好几次。
自测三问(点开对答案) 1. `//div[@class='item'][2]//span` 和 `(//div[@class='item']//span)[2]` 有什么区别? —— 前者是先找到所有 `div.item`,取其中的第二个,再在它里面找 span——结果是「第二个条目的价格」。 后者是先拿到页面上所有这种 span,取第二个——结果是「第二个价格」。 页面简单时两者相同,条目一有多个 span 就分家。 2. 为什么 `parse_qs('page=3')` 得到的是 `{'page': ['3']}` 而不是 `{'page': '3'}`? —— 因为 URL 里同一个参数可以出现好几次,比如 `?tag=a&tag=b`。 解析器不知道你会不会这么写,所以统一给列表。要用单个值就取 `[0]`。 3. 翻页抓数据,为什么不能直接拼 `'?page=' + str(n)`? —— 参数里只要有中文、空格、`&`、`=`,拼出来就是坏地址。 比如关键词含 `&`,服务器会把它当成参数分隔符,搜的东西就不对了。 `urlencode` 会替你转义,中文自动变百分号编码。

第 6 章结束。下一篇进第 7 章:抓到的数据要存下来,先从字典和 CSV 说起。

相关笔记