🐍 第11章 多线程下载、正则表达式与定时排程
📄 一句话概括

正则描述「长什么样」,不描述「是什么」。 \d\d\d 说的是「三个数字」,不管那三个数字代表年份还是价格。 四个函数覆盖九成用法:search 全文找第一个、match 只认开头、findall 找全部、 sub 替换。 写正则一律加 r 前缀。r'\d+' 和 '\\d+' 是同一个字符串,但前者一眼看出是正则。

📝 本章和前面几章的关系

上一篇抓图片时用了三条正则,那只是「能用」。这一篇把它讲清楚。 教材第 11 章中间一节是【规则表达式】,篇幅不小——它是这一章唯一的理论部分。 正则的学习曲线很陡: 看别人的能读懂,自己写就卡住。 破解办法只有一个——把那张元字符表背下来, 然后一个字符一个字符对着念。 本篇最后一条结论值得先记住:正则不是解析 HTML 的正确工具。 能凑合的地方凑合, 复杂页面还是用第 6 章那套建树的办法。

一、四个函数,覆盖九成用法

函数 干什么 给什么
search 全文找第一个 匹配对象或 None
match 只从开头试 匹配对象或 None
findall 找全部 字符串列表
finditer 找全部 匹配对象序列
sub 替换 新字符串

演示第一段那句原文是「订单 PO-20261102-001 金额 328.50 元,订单 PO-20261102-002…」。 search('PO-\d+-\d+') 拿到第一个单号;findall 拿到两个;sub 把金额换成了 ###.##。 match 只从第 0 个字符开始试。 所以 match('订单', 原文) 能中, match('PO', 原文) 中不了——虽然原文里确实有 PO。

这条差别坑过很多人。 要全文找一律用 search, match 只在「我确定要从头匹配」时用(比如校验一个输入框里整串是不是手机号)。 findall 和 finditer 的区别:前者直接给你字符串列表,后者给你匹配对象。 要分组信息就用 finditer,只要文本就用 findall。

二、元字符表:正则的全部基础

写法 意思 例子
\d 一个数字 \d\d\d 匹配 001
\w 一个字母数字下划线 \w+ 匹配 abc_1
\s 一个空白 a\sb 匹配 a b
[abc] 这三个里任意一个 [abc]+ 匹配 cab
[^abc] 不是这三个的任意一个 [^\d]+ 匹配字母
* 前面那个重复 0 次或多次 \d* 连空的都算
+ 前面那个重复 1 次或多次 \d+ 至少要有一位
? 前面那个出现 0 或 1 次 colou?r 匹配 color
{2,4} 重复 2 到 4 次 \d{2,4} 匹配 2026
. 任意一个字符(默认不含换行) a.c 匹配 abc

这一张表就是正则的全部基础,剩下的都是把它们拼起来。 看不懂别人的正则时,一个字符一个字符对这张表念,基本都能读懂。 比如 \d{4}-\d{2}-\d{2} 念出来就是「四位数字、横杠、两位数字、横杠、两位数字」,那就是日期。 反过来写的时候也一样:先在心里把「我要的东西长什么样」念一遍,再一段一段对着表写。 别一上来就写整条。

那个「默认不含换行」要记住。. 匹配不了换行符,要跨行得加 re.S 标志,或者写 [\s\S]。 这一点在抓多行 HTML 时最容易忘。

三、分组:把要的那一截单独留出来

圆括号的意思是「我要这一截」。 PO-(\d{8})-(\d{3}) 能匹配 PO-20261102-001,同时把日期和序号单独留出来。 取值用 group(0)(整段匹配)、group(1)、group(2)。注意编号从 1 开始,0 是全部。 分组一多,数编号就容易错。 用命名分组 (?P<名字>...) 更好,取值写 group('date'),一眼知道拿的是什么。

groupdict() 直接给你一个字典,写报表、填模板的时候很省事。 还有一个不占编号的分组写法 (?:...)——只用来分组,不要这一截。 它的作用是让你在需要括号但不需要捕获的地方(比如 (?:ab)+)不出多余的编号。

四、贪婪和非贪婪

原文是 <a href="/a">1</a><a href="/b">2</a>。 <a href="(.*)"> 抓出来是 ['/a">1</a><a href="/b']——一行的开头配到了最后一行的位置。 <a href="(.*?)"> 抓出来才是 ['/a', '/b']。

.* 会尽量往后吃,吃到最后一个还能匹配的位置为止;.*? 则是「能少就少」。 抓 HTML 时基本都要加问号,否则整段都被吞掉。 这也是「正则不该用来解析 HTML」的直接证据——它在跟一个嵌套结构较劲, 而正则根本不知道「嵌套」是什么。 结构简单的页面可以凑合,复杂页面还是老老实实用第 6 章那套 html.parser 建树。 树知道层级,正则只知道字符。

五、一个真用得上的组合

演示第五段解析一行表格文本:2026-11-02 | 六角螺栓 M8 | 120 | 1.50。 正则写成 (\d{4}-\d{2}-\d{2})\s*\|\s*(.+?)\s*\|\s*(\d+)\s*\|\s*([\d.]+)。 里面那个 \s*\|\s* 的意思是「竖线两边不管有没有空格」。 这种松散写法比 split('|') 稳——split 出来会带着前后空格,还得再 strip 一遍。

但也要说实话:格式固定、字段不多的时候,split 更简单也更快。 正则的价值在「格式有点乱但模式明确」的地方:日志行、各种不统一的报表、别人手工填的表格。 别为了炫技把简单事写复杂。

六、三个最常见的坑

坑 错误写法 原文 结果
忘了转义点 1.5 135 能匹配(点当成了任意字符)
忘了非贪婪 <b>.*</b> <b>a</b><b>b</b> ['<b>a</b><b>b</b>']
忘了原始字符串 '\\d+' abc123 能匹配,但要数反斜杠

第一条尤其常见。 想匹配字面上的点,要写 \.。 不加反斜杠,那个点表示「任意字符」,于是 1.5 会把 135 也匹配上。 第二条是抓 HTML 必踩的,上一节讲过。 第三条不是「错」,是「不好读」。 r'\d+' 和 '\\d+' 在 Python 里是同一个字符串,但前者一眼就能看出「这是正则」,后者要数反斜杠。

所以规矩是:永远写 r。 没有例外。正则里反斜杠太多了,不写 r 迟早出错。

七、动手 Demo:正则:描述「长什么样」

先用一句原文把五个函数各跑一遍,看清 search 和 match 的差别。 然后试分组、命名分组、贪婪与非贪婪。 最后跑三个常见的坑,看它们各自错在哪。

import re

TEXT = '订单 PO-20261102-001 金额 328.50 元,订单 PO-20261102-002 金额 1780.00 元'

print('① 四个函数各干什么')
print('  原文 ' + TEXT)
print("  search  → %r" % re.search(r'PO-\d+-\d+', TEXT).group(0))
print("  match   → %r(只从开头试)" % re.match(r'订单', TEXT).group(0))
print("  match PO → %r" % re.match(r'PO', TEXT))
print("  findall → %r" % re.findall(r'PO-\d+-\d+', TEXT))
print('  finditer 个数 %d' % len(list(re.finditer(r'PO-\d+-\d+', TEXT))))
print("  sub     → %s" % re.sub(r'\d+\.\d+', '###.##', TEXT))

print('② 方括号、星号、问号、反斜杠')
print('| 写法 | 意思是 | 例子 |')
print('|---|---|---|')
for r_, d_, e_ in (('\\d', '一个数字', '\\d\\d\\d 匹配 001'),
                   ('\\w', '一个字母数字下划线', '\\w+ 匹配 abc_1'),
                   ('\\s', '一个空白', 'a\\sb 匹配 a b'),
                   ('[abc]', '这三个里任意一个', '[abc]+ 匹配 cab'),
                   ('[^abc]', '不是这三个的任意一个', '[^\\d]+ 匹配字母'),
                   ('*', '前面那个重复 0 次或多次', '\\d* 连空的都算'),
                   ('+', '前面那个重复 1 次或多次', '\\d+ 至少要有一位'),
                   ('?', '前面那个出现 0 或 1 次', 'colou?r 匹配 color'),
                   ('{2,4}', '重复 2 到 4 次', '\\d{2,4} 匹配 2026'),
                   ('.', '任意一个字符(默认不含换行)', 'a.c 匹配 abc')):
    print('| `%s` | %s | `%s` |' % (r_, d_, e_))

print('③ 分组:把要的那截单独留出来')
m = re.search(r'(?P<prefix>[A-Z]+)-(?P<date>\d{8})-(?P<seq>\d{3})', TEXT)
print('  整体 %s' % m.group(0))
print('  第1组 %s  第2组 %s  第3组 %s' % (m.group(1), m.group(2), m.group(3)))
print('  命名分组 %s' % {k: v for k, v in m.groupdict().items() if k != 'seq'})

print('④ 贪婪和非贪婪')
H = '<a href="/a">1</a><a href="/b">2</a>'
print('  原文 %s' % H)
print('  贪婪   <a href="(.*)">  → %r' % re.findall(r'<a href="(.*)">', H))
print('  非贪婪 <a href="(.*?)">  → %r' % re.findall(r'<a href="(.*?)">', H))
print('  .* 会尽量往后吃,吃到最后一个能匹配的位置为止;.*? 则是「能少就少」。')

print('⑤ 一个真用得上的组合')
LINE = '2026-11-02 | 六角螺栓 M8 | 120 | 1.50'
m = re.search(r'(\d{4}-\d{2}-\d{2})\s*\|\s*(.+?)\s*\|\s*(\d+)\s*\|\s*([\d.]+)', LINE)
print('  原文 %s' % LINE)
print('  解析出:日期 %s,品名 %s,数量 %s,单价 %s' % m.groups())
print('  \\s*\\|\\s* 的意思是「竖线两边不管有没有空格」。')

print('⑥ 三个最常见的坑')
print('    忘了转义点          %r   %r   → %r' % ('1.5', '135', re.findall(r'1.5', '135')))
print('    忘了非贪婪          %r   %r → %r'
      % ('<b>.*</b>', '<b>a</b><b>b</b>', re.findall(r'<b>.*</b>', '<b>a</b><b>b</b>')))
print('    忘了加原始字符串       %r   %r → %r'
      % (r'\d+', 'abc123', re.findall('\\d+', 'abc123')))
print('  第一条尤其常见:想匹配字面上的点,要写 \\.;第二条是抓 HTML 必踩的;')
print('  第三条是写正则一律加 r 前缀的原因。')

实跑输出:

--------------------------------------------------------------------
正则:描述「长什么样」
--------------------------------------------------------------------

  ① 四个函数各干什么
  --------------------------------------------------------------------
    原文 订单 PO-20261102-001 金额 328.50 元,订单 PO-20261102-002 金额 1780.00 元
    search  → 'PO-20261102-001'
    match   → '订单'(只从开头试)
    findall → ['PO-20261102-001', 'PO-20261102-002']
    finditer 个数 2
    sub     → 金额 ###.##
  search 全文找第一个,match 只认开头,findall 找全部,sub 替换。这四个覆盖了九成用法。
  match 只从第 0 个字符开始试,所以 re.match('订单', ...) 能中,
  re.match('PO', ...) 就中不了——要全文找一律用 search。

  ② 方括号、星号、问号、反斜杠
  --------------------------------------------------------------------

| 写法 | 意思是 | 例子 |
|---|---|---|
| \d | 一个数字 | \d\d\d 匹配 001 |
| \w | 一个字母数字下划线 | \w+ 匹配 abc_1 |
| \s | 一个空白 | a\sb 匹配 a b |
| [abc] | 这三个里任意一个 | [abc]+ 匹配 cab |
| [^abc] | 不是这三个的任意一个 | [^\d]+ 匹配字母 |
| * | 前面那个重复 0 次或多次 | \d* 连空的都算 |
| + | 前面那个重复 1 次或多次 | \d+ 至少要有一位 |
| ? | 前面那个出现 0 或 1 次 | colou?r 匹配 color |
| {2,4} | 重复 2 到 4 次 | \d{2,4} 匹配 2026 |
| . | 任意一个字符(默认不含换行) | a.c 匹配 abc |

  这一张表就是正则的全部基础。剩下的都是把它们拼起来。
  看不懂别人的正则时,一个字符一个字符对这张表念,基本都能读懂。

  ③ 分组:把要的那截单独留出来
  --------------------------------------------------------------------
    整体 PO-20261102-001
    第1组 PO  第2组 20261102  第3组 001
    命名分组 {'prefix': 'PO', 'date': '20261102'}
  圆括号就是「我要这一截」。group(0) 是整段匹配,group(1) 起是括号里的内容。
  分组一多,用 ?P<名字> 起名比数编号靠谱。groupdict() 直接给你一个字典,写报表的时候很省事。

  ④ 贪婪和非贪婪
  --------------------------------------------------------------------
    原文 <a href="/a">1</a><a href="/b">2</a>
    贪婪   <a href="(.*)">  → ['/a">1</a><a href="/b']
    非贪婪 <a href="(.*?)">  → ['/a', '/b']
  .* 会尽量往后吃,吃到最后一个能匹配的位置为止;.*? 则是「能少就少」。
  抓 HTML 时基本都要加问号,否则一行的开头会配上最后一行。
  这也说明正则不是解析 HTML 的正确工具。
  结构简单的可以凑合,复杂页面还是老老实实用第 6 章那套 html.parser 建树。

  ⑤ 一个真用得上的组合
  --------------------------------------------------------------------
    解析出:日期 2026-11-02,品名 六角螺栓 M8,数量 120,单价 1.50
  \s*\|\s* 的意思是「竖线两边不管有没有空格」。
  这种松散写法比 split('|') 稳——split 出来会带着前后空格,还得再 strip 一遍。
  但也要说实话:格式固定、字段不多的时候,split 更简单也更快。
  正则的价值在「格式有点乱但模式明确」的地方,别为了炫技把简单事写复杂。

  ⑥ 三个最常见的坑
  --------------------------------------------------------------------
    忘了转义点          '1.5'          '135'              → ['135']
    忘了非贪婪          '<b>.*</b>'    '<b>a</b><b>b</b>' → ['<b>a</b><b>b</b>']
    忘了加原始字符串       '\\d+'         'abc123'           → ['123']
  第一条尤其常见:想匹配字面上的点,要写 \.;第二条是抓 HTML 必踩的;
  第三条是写正则一律加 r 前缀的原因。
  r'\d+' 和 '\\d+' 在 Python 里是同一个字符串,但前者一眼就能看出「这是正则」,后者要数反斜杠。
  永远写 r。

第一段那个 match PO 打印出来是 None,这就是「只从开头试」的意思。 原文里确实有 PO,但它不在第 0 个位置。 第二段那张表是整篇的基础。看不懂正则的时候,一个字符一个字符对这张表念,基本都能读懂。 第三段那个 (?P<名字>...) 是命名分组的写法,取值写 group('date') 比 group(2) 清楚得多。 第四段那个贪婪结果最能说明问题:它把一行的开头配到了最后一行的位置,中间的两段全被吞进第一组去了。 第六段第一条最阴:想匹配字面上的点得写反斜杠加点,不写的话那个点表示「任意字符」, 于是 1.5 会把 135 也匹配上。

30 秒实验

  1. 把演示第一段的 re.match(r'订单', TEXT) 改成 re.search(r'订单', TEXT),看结果一样不一样。
  2. 把演示第四段的 (.*?) 改成 ([^\"]+),看结果跟非贪婪是不是一样,再想想哪种写法更不容易出错。
  3. 写一条正则匹配 2026-11-02 这种日期,要求月份只能是 01 到 12 之间,提示: 月份那两位写成 (0[1-9]|1[0-2])。

常见误区

  1. 「match 和 search 差不多。」match 只从第 0 个字符试。 原文里有你要的东西,但不在开头,match 就给 None。 全文找一律用 search。
  2. 「. 匹配任何字符。」默认不匹配换行。要跨行得加 re.S,或者写 [\s\S]。
  3. 「正则能解析 HTML。」能凑合简单的,复杂页面必翻车。 正则不知道「嵌套」是什么,一遇到嵌套结构就会贪到底。复杂页面用建树的方式。
自测三问(点开对答案) 1. `re.match(r'PO', 原文)` 返回 `None`,但原文里明明有 `PO`,为什么? —— 因为 `match` 只从第 0 个字符开始尝试。 原文的开头是「订单」两个字,不是 `PO`,所以直接不匹配。要全文找得用 `search`。 2. `` 为什么会把整段 HTML 吞掉?怎么改? —— 因为 `.*` 是贪婪的,它会尽量往后吃, 一直吃到最后一个还能匹配的位置——也就是最后一个 `">` 之前。 改成 `(.*?)` 让它非贪婪,「能少就少」,就会在第一个 `">` 前停下。 3. 为什么写正则一律要加 `r` 前缀? —— 因为正则里反斜杠特别多。 不加 r 的话,`'\d+'` 里的 `\d` 要先经过 Python 字符串转义, 得写成 `'\\d+'` 才是你要的那个意思。 加了 r,反斜杠原样传给正则引擎,**而且一眼能看出这是个正则。**

下一篇让程序一心多用:多线程下载,还有定时任务怎么算下一次。

  • ☐ (本篇完,下一篇 11.3 多线程与定时运行)

相关笔记