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 秒实验
- 把演示第二段的
//a改成//h3/a,看命中数从 3 变成几。 - 把演示第四段的网址加一个
&tag=a&tag=b,再看parse_qs的输出, 看 tag 对应的是不是两个元素的列表。 - 把演示第五段的
urlencode换成手写拼接('?page=%d&kw=螺栓' % p), 看打印出来的地址里中文变成什么样,再想想这个地址发出去会怎样。
常见误区
- 「
//div[@class='item']能匹配所有含 item 的 class。」它是完全相等。class="item hot"匹配不上。要包含得写contains(@class, 'item')。 - 「XPath 里加个括号无所谓。」括号位置决定「先筛谁」。
//div[2]//span和(//div//span)[2]完全不是一回事。 - 「
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 说起。
- ☐ (本篇完,下一篇 7.1 用 dict 保存结构化数据)