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

requests 拿回来的是服务器发的那段文本。 如果内容要靠 JavaScript 跑完才填进去,你拿到的只是一个空壳。 Selenium 的思路反过来:真开一个浏览器,让它把页面跑完,再问它「现在长什么样」。 慢,但什么都能看。 八种定位方式里,CSS 选择器和 XPath 覆盖九成场景,其余六种只是让常见情况好读一点。

📝 本章和前面几章的关系

上一篇学会了在树上找节点,这一篇换一种「树」:浏览器里那份活的。 教材第 6 章中间两节是【认识浏览器操控工具】和【通过 Chrome 浏览器和 Selenium 选定网页元素】。 本沙箱没有 Chrome,也没有浏览器驱动,所以 Selenium 那部分标了「平台原样 · 本沙箱不执行」。 但「按选择器找元素」这件事本身能真跑——本篇用一个自己写的查找函数,在上一篇那棵树上跑, 语义和 Selenium 一致。等待那一节的时间也是算出来的,不是实测,演示里会明说。

一、为什么光有 requests 不够

早期的网页是「服务器把整页 HTML 发给你,浏览器只负责画」。 这种页面,requests.get() 拿回来的文本里就有全部内容。 现在的网页不是这样。服务器发过来的 HTML 只有骨架,内容靠 JavaScript 跑完再填进去。 购物车、无限滚动、点「加载更多」才出来的列表,都属于这一类。 用 requests 抓这种页面,抓到的是骨架,看不到数据。 而且它不报错,你只会发现「明明页面上有, 怎么抓出来是空的」。

Selenium 的思路反过来:真的开一个浏览器,让它把页面跑完,再问浏览器「现在长什么样」。 慢,但什么都能看。 代价也明显:要装浏览器、要装驱动、跑起来占几百兆内存、比发个请求慢几十倍。 所以能不用就不用——先试 requests,抓不到再换。

二、八种找元素的方式

方式 写法(Selenium 4) 什么时候用
id By.ID, 'main' 最稳,页面唯一
name By.NAME, 'q' 表单字段常用
class By.CLASS_NAME, 'item' 一类元素批量取
tag By.TAG_NAME, 'a' 捞全部链接
链接文字 By.LINK_TEXT, '下一页' 翻页按钮
部分文字 By.PARTIAL_LINK_TEXT, '下一' 文字有变动时
CSS 选择器 By.CSS_SELECTOR, 'div.item a' 够用且快
XPath By.XPATH, '//div[@class="item"]' 最灵活,能按文字找

前六种是后两种的特例。 By.ID, 'main' 等价于 By.CSS_SELECTOR, '#main', By.CLASS_NAME, 'item' 等价于 By.CSS_SELECTOR, '.item'。 那为什么还留着前六种?因为它们更短、更好读,而且写错了不容易错得莫名其妙。 真写起来,CSS 选择器和 XPath 覆盖了九成场景。 区别是:CSS 选择器快、语法简单,但不能按文字内容找;XPath 能按文字找,也能往上找父节点。

所以规律是:按结构找用 CSS,按文字找用 XPath。下一篇专门讲 XPath。

三、这几种定位在本机 HTML 上是什么效果

演示第三段用一个自己写的查找函数在上一篇那棵树上跑,语义跟 Selenium 一致:id 找唯一, class 找一批,标签名找全部。

定位方式 命中 前几个的内容
id=main 1 个 六角螺栓 M8
class=item 3 个 六角螺栓 M8、膨胀螺栓 10mm、不锈钢垫片
class=price 3 个 1.50、2.30、0.40
tag=a 3 个 六角螺栓 M8、膨胀螺栓 10mm、不锈钢垫片
tag=img 1 个 <img>
div.item 3 个 六角螺栓 M8、膨胀螺栓 10mm、不锈钢垫片

这一段是本课自己写的,不是真 Selenium。 区别只在「由谁来找」:这里是在 Python 建的那棵树上找, 真机上是浏览器在它自己那份 DOM 上找。 真机上换成 driver.find_elements(By.ID, 'main') 就行。 元素多的时候用 find_element(单数,只取第一个),要一批用 find_elements(复数)。 单数那个找不到会抛异常,复数那个找不到返回空列表。 这一个小差别, 决定了你是要写 try 还是要写 if。

四、等,是这件事最大的坑

页面加载要时间。你 driver.get() 完立刻找元素,很可能那个元素还没渲染出来。 最笨的办法是 time.sleep(5)。问题是:网快的时候白等五秒,网慢的时候五秒不够。 写死的时间永远在「太短」和「白等」之间二选一。 正确的做法叫显式等待:条件成立就往下走,最多等 N 秒。

WebDriverWait(driver, 10).until(EC.presence_of_element_located((By.CSS_SELECTOR, 'div.item')))—— 意思是「等到 div.item 出现,最多等 10 秒」。出现了立刻返回,不白等。 还有一个隐式等待:driver.implicitly_wait(10)。 它是全局的,设一次影响之后所有查找,每次找不到就先等一会儿,最多等 10 秒。 两种别混着用。 混起来等待时间会叠:隐式等 10 秒、显式又等 10 秒,实际可能等到 20 秒, 而你以为设的是 10 秒。

演示第四段跑的是一个模拟轮询:间隔 20、50、100、200 毫秒重试,第 4 次拿到元素,累计 0.37 秒。 这个 0.37 是把间隔加起来算的,不是实测——本沙箱没有浏览器,测不出真实页面的加载时间。 要说的是这个结构:重试次数不确定,但最坏情况有上限。这就是等待该有的样子。

五、真机上长这样

完整的流程是五步:开浏览器 → 打开网址 → 等到关键元素出现 → 取数据 → 关浏览器。 from selenium import webdriver,driver = webdriver.Chrome(),driver.get(url), 然后 find_elements,最后 driver.quit()。 driver.quit() 一定要写。 不写的话浏览器进程会一直留着,跑几次就满屏都是窗口,而且吃内存。

用完 quit() 而不是 close():close() 只关当前标签页,quit() 才关整个浏览器并结束驱动进程。 Selenium 4 之后 Chrome 驱动不用自己下了,webdriver-manager 会处理。 但「浏览器版本和驱动版本对不上」依然是这个工具最常见的报错来源。 这一段在演示里标了「平台原样 · 本沙箱不执行」——沙箱里没有 Chrome。 代码是从教材里原样抄下来的,你可以照贴到本机跑。

六、动手 Demo:Selenium:让浏览器替你点

先说清静态页和动态页的差别,再列八种定位方式。 然后用本课自己写的查找函数,在上一个 Demo 那棵树上跑一遍各种定位。 Selenium 的部分标「平台原样」,等待那一节的时间是算出来的,不是实测。

from html.parser import HTMLParser

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)

def walk(n):
    yield n
    for k in n.kids:
        yield from walk(k)

def find(by, value):
    all_ = list(walk(b.root))
    if by == 'id':
        return [n for n in all_ if n.attrs.get('id') == value]
    if by == 'class':
        return [n for n in all_ if value in n.attrs.get('class', '').split()]
    if by == 'tag':
        return [n for n in all_ if n.tag == value]
    if by == 'css':
        tag, _, cls = value.partition('.')
        return [n for n in all_ if n.tag == tag and cls in n.attrs.get('class', '').split()]
    return []

print('① 为什么光有 requests 不够')
print('  静态页:服务器返回的 HTML 里就有内容')
print('  动态页:HTML 只有骨架,内容靠 JavaScript 跑完再填进去')

print('② 八种找元素的方式')
print('| 方式 | 写法(Selenium 4) | 什么时候用 |')
print('|---|---|---|')
for r in (('id', "By.ID, 'main'", '最稳,页面唯一'),
          ('name', "By.NAME, 'q'", '表单字段常用'),
          ('class', "By.CLASS_NAME, 'item'", '一类元素批量取'),
          ('tag', "By.TAG_NAME, 'a'", '捞全部链接'),
          ('链接文字', "By.LINK_TEXT, '下一页'", '翻页按钮'),
          ('部分文字', "By.PARTIAL_LINK_TEXT, '下一'", '文字有变动时'),
          ('CSS 选择器', "By.CSS_SELECTOR, 'div.item a'", '够用且快'),
          ('XPath', 'By.XPATH, //div[@class="item"]', '最灵活,能按文字找')):
    print('| %s | %s | %s |' % r)

print('③ 这几种定位在本机 HTML 上是什么效果')
print('| 定位方式 | 命中 | 前几个的内容 |')
print('|---|---|---|')
for tag, by, val in (('id=main', 'id', 'main'), ('class=item', 'class', 'item'),
                     ('class=price', 'class', 'price'), ('tag=a', 'tag', 'a'),
                     ('tag=img', 'tag', 'img'), ('div.item', 'css', 'div.item')):
    hit = find(by, val)
    names = [n.text for n in hit if n.text][:3] or ['<img>']
    print('| %s | %d 个 | %s |' % (tag, len(hit), '、'.join(names)))

print('④ 等,是这件事最大的坑')
print('  隐式等待:driver.implicitly_wait(10)')
print('    → 每次找元素,找不到就先等,最多等 10 秒')
print('  显式等待:WebDriverWait(driver, 10).until(...)')
print('    → 等到某个具体条件成立,比如「这个元素出现了」')
import time
delays = [0.02, 0.05, 0.1, 0.2]
total = 0.0
for i, d in enumerate(delays, 1):
    total += d
print('  模拟轮询:按 20、50、100、200 毫秒的间隔重试')
print('  第 %d 次才拿到元素,累计 %.2f 秒' % (len(delays), total))
print('  这里的时间是上面那组间隔加起来算的,不是实测——'
      '本沙箱没有浏览器,测不出真实页面的加载时间。')

print('⑤ 真机上长这样')
print('  [平台原样 · 本沙箱不执行]')
print('  | # 本书第 6-4 节(平台原样,本沙箱不执行:没有 Chrome 与驱动)')
print('  | from selenium import webdriver')
print('  | from selenium.webdriver.common.by import By')
print('  | from selenium.webdriver.support.ui import WebDriverWait')
print('  | from selenium.webdriver.support import expected_conditions as EC')
print('  |')
print('  | driver = webdriver.Chrome()')
print("  | driver.get('https://example.com/list')")
print('  | WebDriverWait(driver, 10).until(')
print("  |     EC.presence_of_element_located((By.CSS_SELECTOR, 'div.item')))")
print("  | for el in driver.find_elements(By.CSS_SELECTOR, 'div.item a'):")
print("  |     print(el.text, el.get_attribute('href'))")
print('  | driver.quit()')
print('  driver.quit() 一定要写,不然浏览器进程会一直留着')

实跑输出:

--------------------------------------------------------------------
Selenium:让浏览器替你点
--------------------------------------------------------------------

  ① 为什么光有 requests 不够
  --------------------------------------------------------------------
    静态页:服务器返回的 HTML 里就有内容
    动态页:HTML 只有骨架,内容靠 JavaScript 跑完再填进去
  购物车、无限滚动、点「加载更多」才出来的列表,都属于第二种。
  用 requests 抓回来的是骨架,看不到数据。
  Selenium 的思路是反过来的:真的开一个浏览器,让它把页面跑完,再问浏览器「现在长什么样」。
  慢,但什么都能看。

  ② 八种找元素的方式
  --------------------------------------------------------------------

| 方式 | 写法(Selenium 4) | 什么时候用 |
|---|---|---|
| id | By.ID, 'main' | 最稳,页面唯一 |
| name | By.NAME, 'q' | 表单字段常用 |
| class | By.CLASS_NAME, 'item' | 一类元素批量取 |
| tag | By.TAG_NAME, 'a' | 捞全部链接 |
| 链接文字 | By.LINK_TEXT, '下一页' | 翻页按钮 |
| 部分文字 | By.PARTIAL_LINK_TEXT, '下一' | 文字有变动时 |
| CSS 选择器 | By.CSS_SELECTOR, 'div.item a' | 够用且快 |
| XPath | By.XPATH, '//div[@class="item"]' | 最灵活,能按文字找 |

  前六种是后两种的特例。
  真写起来,CSS 选择器和 XPath 覆盖了九成场景,其余六种只是让常见情况好读一点。

  ③ 这几种定位在本机 HTML 上是什么效果
  --------------------------------------------------------------------

| 定位方式 | 命中 | 前几个的内容 |
|---|---|---|
| id=main | 1 个 | 六角螺栓 M8 |
| class=item | 3 个 | 六角螺栓 M8、膨胀螺栓 10mm、不锈钢垫片 |
| class=price | 3 个 | 1.50、2.30、0.40 |
| tag=a | 3 个 | 六角螺栓 M8、膨胀螺栓 10mm、不锈钢垫片 |
| tag=img | 1 个 | <img> |
| div.item | 3 个 | 六角螺栓 M8、膨胀螺栓 10mm、不锈钢垫片 |

  这段是本课自己写的查找函数,跑在刚才那棵树上,语义和 Selenium 一致:id 找唯一,class 找一批,
  标签名找全部。
  真机上换成 driver.find_elements(By.ID, 'main') 就行,只是查找由浏览器在它自己那份 DOM 上做。

  ④ 等,是这件事最大的坑
  --------------------------------------------------------------------
    隐式等待:driver.implicitly_wait(10)
      → 每次找元素,找不到就先等,最多等 10 秒
    显式等待:WebDriverWait(driver, 10).until(...)
      → 等到某个具体条件成立,比如「这个元素出现了」
    模拟轮询:按 20、50、100、200 毫秒的间隔重试
    第 4 次才拿到元素,累计 0.37 秒
  这里的时间是上面那组间隔加起来算的,不是实测——本沙箱没有浏览器,测不出真实页面的加载时间。
  要说的是这个结构:重试次数不确定,但最坏情况有个上限。
  页面加载快慢不确定,写死 sleep(5) 要么太短要么白等。
  正确做法是「条件成立就往下走,最多等 N 秒」——这就是显式等待。
  隐式等待是全局的,设一次影响所有查找;显式等待只对那一句生效。
  混着用容易把等待时间叠起来,实际等了 20 秒还以为设的是 10 秒。

  ⑤ 真机上长这样
  --------------------------------------------------------------------

  [平台原样 · 本沙箱不执行]
  | # 本书第 6-4 节(平台原样,本沙箱不执行:没有 Chrome 与驱动)
  | from selenium import webdriver
  | from selenium.webdriver.common.by import By
  | from selenium.webdriver.support.ui import WebDriverWait
  | from selenium.webdriver.support import expected_conditions as EC
  |
  | driver = webdriver.Chrome()
  | driver.get('https://example.com/list')
  | WebDriverWait(driver, 10).until(
  |     EC.presence_of_element_located((By.CSS_SELECTOR, 'div.item')))
  | for el in driver.find_elements(By.CSS_SELECTOR, 'div.item a'):
  |     print(el.text, el.get_attribute('href'))
  | driver.quit()
  driver.quit() 一定要写,不然浏览器进程会一直留着

  Selenium 4 之后 Chrome 驱动不用自己下了,webdriver-manager 会处理。
  但「浏览器版本和驱动版本对不上」依然是这个工具最常见的报错来源。

第一段那句「动态页:HTML 只有骨架」是本篇的动机。 用 requests 抓这种页面不报错,只是抓出来是空的——这才是最麻烦的地方。 第三段那张表是本文自己写的 find 函数跑出来的,不是真 Selenium。 区别只在「由谁来找」:这里在 Python 建的树上找,真机上是浏览器在它那份 DOM 上找。 第四段那个 0.37 秒是把 20、50、100、200 毫秒四个间隔加起来算的,不是实测。 要说的是结构:重试次数不确定,但最坏情况有上限。 第五段最后那句 quit() 是个真坑。不写它,浏览器进程会一直留着,跑几次就满屏窗口,还吃内存。 注意是 quit 不是 close——close 只关标签页。

30 秒实验

  1. 把演示第三段的 ('tag=img', 'tag', 'img') 改成 ('tag=span', 'tag', 'span'),看命中几个。
  2. 在本机装一次 Selenium 和 Chrome,把第五段那段代码贴出来跑一遍,看能不能拿到真实的三个链接。
  3. 把演示第四段的 delays 改成 [0.1, 0.5, 1, 2],看累计时间变成多少, 再想想为什么显式等待比「写死 sleep」好。

常见误区

  1. 「requests 抓不到内容,一定是网站反爬了。」也可能内容本来就是 JavaScript 填的。 先看抓回来的 HTML 里有没有你要的数据,再判断是不是反爬。
  2. 「等待就写 time.sleep(5)。」写死的时间永远在「太短」和「白等」之间二选一。 显式等待是「条件成立就走,最多等 N 秒」。
  3. 「find_element 和 find_elements 只差一个字母。 」差别很大:单数找不到抛异常,复数找不到返回空列表。 选错了代码的写法就完全不一样。
自测三问(点开对答案) 1. 同一个网址,浏览器里能看到内容,`requests` 抓回来却是空的,最可能是什么原因? —— 内容是在页面跑起来之后由 JavaScript 填进去的,服务器发过来的 HTML 里本来就没有。 `requests` 只拿服务器发的那段文本,不会执行 JavaScript。 解决办法是换 Selenium 这类真开浏览器的方式。 2. 为什么推荐显式等待,而不是 `time.sleep(5)`? —— `sleep(5)` 是写死的:网快时白等五秒,网慢时五秒不够。 显式等待是「条件成立立刻往下走,最多等 N 秒」,两种情况都合适,而且不用猜时间。 3. 隐式等待和显式等待混着用,会出什么问题? —— 等待时间会叠加。隐式等 10 秒、显式又等 10 秒,实际可能等到 20 秒,而你以为上限是 10 秒。 调试时会觉得「怎么这么慢」,还找不到原因。

下一篇讲 XPath——那个「能按文字找、还能往上找」的定位方式,还有网址里那一串参数。

相关笔记