From 94f03ba9e85f35a2bf169f510645c816f8dc965f Mon Sep 17 00:00:00 2001 From: gitea_eternal <401029566@qq.com> Date: Thu, 2 Apr 2026 13:49:30 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=AD=A3=E5=88=99=E8=A1=A8?= =?UTF-8?q?=E8=BE=BE=E5=BC=8F=E8=AE=B2=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1543 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1543 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..89fff76 --- /dev/null +++ b/README.md @@ -0,0 +1,1543 @@ +# 📚 正则表达式:爬虫内容提取利器 + +--- + +# 目录 + +1. [先导知识:为什么需要正则表达式?](#1-先导知识为什么需要正则表达式) +2. [正则表达式快速入门](#2-正则表达式快速入门) +3. [Python re 模块详解](#3-python-re-模块详解) +4. [贪婪 vs 非贪婪匹配](#4-贪婪-vs-非贪婪匹配) +5. [分组与捕获](#5-分组与捕获) +6. [实战:爬虫中的正则表达式](#6-实战爬虫中的正则表达式) +7. [BeautifulSoup + 正则表达式:黄金组合](#7-beautifulsoup--正则表达式黄金组合) +8. [正则表达式 vs XPath:两种提取方式对比](#8-正则表达式-vs-xpath两种提取方式对比) +9. [常见问题与解决方案](#9-常见问题与解决方案) +10. [动手练习](#10-动手练习) + +--- + +# 1. 先导知识:为什么需要正则表达式? + +## 1.1 回顾:BeautifulSoup 的局限 + +上节课我们学习了 BeautifulSoup,它可以方便地通过 CSS 选择器提取 HTML 内容。但它有一个前提:**网页内容必须是标准的 HTML 结构**。 + +```python +# BeautifulSoup 适合这种结构清晰的 HTML +
+ 流浪地球 + 8.5 +
+ +soup.select('.movie .title') # ✅ 完美提取 +``` + +**但是**,有时候我们需要的内容并不在标准的 HTML 标签里: + +```html + +流浪地球 + + + + + +联系我们:010-12345678,邮箱:help@example.com +``` + +这时候,**正则表达式**就能大显身手! + +## 1.2 什么是正则表达式? + +**正则表达式**(Regular Expression,简称 regex)是一种强大的**文本模式匹配**工具。 + +- 它不是 Python 独有的,而是一种通用的文本处理技术 +- 它使用特殊的语法描述文本的规律 +- 可以精确地从杂乱文本中提取我们需要的内容 + +> 💡 **打个比方** +> +> 如果把 BeautifulSoup 比作"用筷子夹菜"(适合有规则的 HTML 结构),那正则表达式就是"用筛子筛豆子"(适合从任意文本中精准提取目标内容)。 + +--- + +# 2. 正则表达式快速入门 + +## 2.1 字符字面量:精确匹配 + +最简单的正则表达式就是直接匹配我们看到的字符: + +```python +import re + +pattern = r'python' # 匹配字母 "python" +text = 'I love python programming' + +result = re.findall(pattern, text) +print(result) # ['python'] +``` + +**注意**:`r'python'` 中的 `r` 表示 **raw string(原始字符串)**,Python 不会处理其中的转义字符。 + +```python +# 没有 r:\n 被解释为换行符 +print('\n') + +# 有 r:\n 就是两个字符 +print(r'\n') +``` + +## 2.2 元字符:特殊意义的字符 + +正则表达式中有一些字符有特殊含义: + +| 元字符 | 含义 | 示例 | +|--------|------|------| +| `.` | 匹配**任意**单个字符(换行符除外) | `r't.m'` 匹配 'tom'、'tam' | +| `\d` | 匹配任意数字 | `r'\d'` 匹配 '0'-'9' | +| `\D` | 匹配任意非数字 | `r'\D'` 匹配非数字字符 | +| `\w` | 匹配字母、数字、下划线 | `r'\w'` 匹配 a-z, A-Z, 0-9, _ | +| `\W` | 匹配非单词字符 | `r'\W'` 匹配空格、标点等 | +| `\s` | 匹配空白字符 | `r'\s'` 匹配空格、\t、\n | +| `\S` | 匹配非空白字符 | `r'\S'` 匹配非空白字符 | + +```python +import re + +# 示例:匹配电话号码(假设格式为 138-1234-5678) +phone_text = '张三的电话是138-1234-5678,李四是139-5678-1234' +pattern = r'\d{3}-\d{4}-\d{4}' +phones = re.findall(pattern, phone_text) +print(phones) # ['138-1234-5678', '139-5678-1234'] +``` + +## 2.3 量词:指定匹配次数 + +| 量词 | 含义 | 示例 | +|------|------|------| +| `*` | 匹配0次或多次 | `r'ab*'` 匹配 'a'、'ab'、'abbb' | +| `+` | 匹配1次或多次 | `r'ab+'` 匹配 'ab'、'abbb'(不匹配 'a') | +| `?` | 匹配0次或1次 | `r'ab?'` 匹配 'a' 或 'ab' | +| `{n}` | 匹配恰好n次 | `r'\d{4}'` 匹配4位数字 | +| `{n,}` | 匹配至少n次 | `r'\d{4,}'` 匹配4位及以上数字 | +| `{n,m}` | 匹配n到m次 | `r'\d{4,6}'` 匹配4到6位数字 | + +```python +import re + +text = '我有 2 个苹果,她有 10 个橘子,他有 100 个香蕉' + +# 匹配一个或多个数字 +numbers = re.findall(r'\d+', text) +print(numbers) # ['2', '10', '100'] + +# 匹配4位数字(年份) +year_text = '北京奥运会是2008年,上海世博会2010年' +years = re.findall(r'\d{4}', year_text) +print(years) # ['2008', '2010'] +``` + +## 2.4 字符类:`[]` + +用方括号 `[]` 定义一个字符集合,匹配其中**任意一个**字符: + +```python +import re + +# 匹配所有颜色名称 +colors = re.findall(r'red|green|blue', 'red apple green leaf blue sky') +print(colors) # ['red', 'green', 'blue'] + +# 匹配数字或字母 +alphanum = re.findall(r'[A-Za-z0-9]', 'Hello123!') +print(alphanum) # ['H', 'e', 'l', 'l', 'o', '1', '2', '3'] + +# 使用范围 +letters = re.findall(r'[a-z]', 'Hello123') +print(letters) # ['e', 'l', 'l', 'o'] + +# 排除:[^...] 表示"不是..." +non_digit = re.findall(r'[^0-9]', 'abc123') +print(non_digit) # ['a', 'b', 'c'] +``` + +**常用字符类简写:** + +| 字符类 | 等价 | 含义 | +|--------|------|------| +| `[0-9]` | `\d` | 数字 | +| `[a-zA-Z]` | - | 字母 | +| `[a-zA-Z0-9]` | `\w` | 字母或数字 | + +## 2.5 边界匹配 + +| 边界 | 含义 | 示例 | +|------|------|------| +| `^` | 匹配字符串**开头** | `r'^Hello'` 匹配开头是 Hello 的字符串 | +| `$` | 匹配字符串**结尾** | `r'World$'` 匹配结尾是 World 的字符串 | +| `\b` | 匹配单词边界 | `r'\bword\b'` 精确匹配 "word" | + +```python +import re + +# 匹配以 138 开头的手机号 +phones = ['138-1234-5678', '139-5678-1234', '100-1234-5678'] +for phone in phones: + if re.match(r'^138', phone): + print(f'{phone} 是移动号码') + +# 匹配 .com 结尾的域名 +domains = ['example.com', 'test.org', 'hello.com.cn', 'site.com'] +for d in domains: + if re.search(r'\.com$', d): + print(f'{d} 是商业网站') +``` + +## 2.6 转义字符 `\` + +如果我们要匹配元字符本身(如 `.`、`*`、`?`),需要用 `\` 转义: + +```python +import re + +# 匹配 IP 地址(注意要转义点号) +ip_text = '服务器IP: 192.168.1.1,子控IP: 10.0.0.1' +pattern = r'\d+\.\d+\.\d+\.\d+' +ips = re.findall(pattern, ip_text) +print(ips) # ['192.168.1.1', '10.0.0.1'] + +# 匹配邮箱(@ 本身不需要转义) +email_text = '联系邮箱: user@example.com,备用: admin@test.org' +pattern = r'[a-zA-Z0-9_]+@[a-zA-Z0-9]+\.[a-zA-Z]+' +emails = re.findall(pattern, email_text) +print(emails) # ['user@example.com', 'admin@test.org'] +``` + +--- + +# 3. Python re 模块详解 + +## 3.1 `re.findall()` - 查找所有匹配 + +**最常用**:返回所有匹配项的列表。 + +```python +import re + +text = '我的邮箱是 user@python.com,另一个是 admin@java.com' + +# 提取所有邮箱 +pattern = r'[a-zA-Z0-9_]+@[a-zA-Z0-9]+\.[a-zA-Z]+' +emails = re.findall(pattern, text) +print(emails) # ['user@python.com', 'admin@java.com'] +``` + +## 3.2 `re.search()` - 查找第一个匹配 + +返回第一个匹配的对象(Match Object),如果没找到返回 `None`。 + +```python +import re + +text = '电话号码:138-1234-5678 或 139-5678-1234' + +# 找第一个电话号码 +result = re.search(r'\d{3}-\d{4}-\d{4}', text) +if result: + print(f'找到: {result.group()}') # 138-1234-5678 + print(f'位置: {result.start()}-{result.end()}') # 6-18 +``` + +## 3.3 `re.match()` - 从字符串开头匹配 + +只在字符串**开头**匹配,匹配成功返回 Match 对象,失败返回 `None`。 + +```python +import re + +# 检查是否是以 "http" 开头的 URL +urls = ['http://example.com', 'https://test.org', 'ftp://files.net'] + +for url in urls: + result = re.match(r'http', url) + print(f'{url}: {"匹配" if result else "不匹配"}') +# http://example.com: 匹配 +# https://test.org: 不匹配(因为是 https,不是 http) +``` + +## 3.4 `re.finditer()` - 返回迭代器 + +适合大规模匹配,节省内存: + +```python +import re + +text = '3.14是圆周率,1.414是根号2,9.8是重力加速度' + +# 找出所有小数 +for match in re.finditer(r'\d+\.\d+', text): + print(f'找到: {match.group()}, 位置: {match.start()}-{match.end()}') + +# 输出: +# 找到: 3.14, 位置: 0-4 +# 找到: 1.414, 位置: 8-13 +# 找到: 9.8, 位置: 20-23 +``` + +## 3.5 Match 对象的方法 + +```python +import re + +text = '时间:2024-03-15 地点:北京' +result = re.search(r'(\d{4})-(\d{2})-(\d{2})', text) + +print(result.group()) # 2024-03-15(完整匹配) +print(result.group(1)) # 2024(第一个分组) +print(result.group(2)) # 03(第二个分组) +print(result.group(3)) # 15(第三个分组) +print(result.groups()) # ('2024', '03', '15') +print(result.span()) # (3, 13) +``` + +## 3.6 `re.sub()` - 替换 + +```python +import re + +text = '我的手机号是138-1234-5678,请记住!' + +# 把手机号中间四位用 * 替换(脱敏) +pattern = r'(\d{3})-(\d{4})-(\d{4})' +result = re.sub(pattern, r'\1-****-\3', text) +print(result) # 我的手机号是138-****-5678,请记住! +``` + +--- + +# 4. 贪婪 vs 非贪婪匹配 + +## 4.1 什么是贪婪匹配? + +正则表达式的量词(`*`、`+`、`{n,}`)默认是**贪婪**的——它们会尽可能多地匹配字符。 + +```python +import re + +html = '
流浪地球
你好,李焕英
' + +# 用 .* 贪婪匹配 +pattern = r'
.*
' +result = re.findall(pattern, html) +print('贪婪匹配结果:') +print(result) +# 输出: ['
流浪地球
你好,李焕英
'] +# ⚠️ 错误!它把两个 div 合并成一个了! +``` + +**原因**:`.*` 会一直匹配到**最后一个** ``,而不是第一个。 + +## 4.2 什么是非贪婪匹配? + +在量词后面加一个 `?`,变成**非贪婪**(也叫**最小匹配**): + +```python +import re + +html = '
流浪地球
你好,李焕英
' + +# 用 .*? 非贪婪匹配 +pattern = r'
.*?
' +result = re.findall(pattern, html) +print('非贪婪匹配结果:') +print(result) +# 输出: ['
流浪地球
', '
你好,李焕英
'] +# ✅ 正确!每个 div 单独匹配 +``` + +## 4.3 贪婪 vs 非贪婪对比 + +| 量词 | 贪婪 | 非贪婪 | +|------|------|--------| +| `*` | `*` | `*?` | +| `+` | `+` | `+?` | +| `{n,}` | `{n,}` | `{n,}?` | + +```python +import re + +text = 'abc123def456' + +# 贪婪:匹配尽可能多 +print(re.findall(r'\d+', text)) # ['123456'] - 匹配所有数字 + +# 非贪婪:匹配尽可能少 +print(re.findall(r'\d+?', text)) # ['1', '2', '3', '4', '5', '6'] - 每个数字单独匹配 +``` + +## 4.4 `[^<]+` 技巧 + +在爬虫中,有一个经典技巧:用 `[^<]+` 代替 `.+?` + +```python +import re + +html = '流浪地球你好,李焕英' + +# 方法1:.*? 非贪婪 +pattern1 = r'.*?' +print(re.findall(pattern1, html)) + +# 方法2:[^<]+ 更好! +pattern2 = r'([^<]+)' +titles = re.findall(pattern2, html) +print(titles) # ['流浪地球', '你好,李焕英'] +``` + +**为什么 `[^<]+` 更好?** +- `.` 需要考虑换行符,默认不匹配 `\n`(除非用 `re.DOTALL` 模式) +- `[^<]` 明确排除了 `<`,性能更好,也更直观 + +--- + +# 5. 分组与捕获 + +## 5.1 捕获分组 `()` + +用圆括号 `()` 创建分组,匹配到的内容会被**捕获**,可以通过 `groups()` 获取。 + +```python +import re + +text = '2024-03-15' + +pattern = r'(\d{4})-(\d{2})-(\d{2})' +result = re.findall(pattern, text) +print(result) # [('2024', '03', '15')] + +# 如果只想要捕获的内容,用 findall 会直接返回分组列表 +``` + +## 5.2 命名分组 `(?P...)` + +给分组起个名字,更清晰: + +```python +import re + +text = '2024-03-15' + +pattern = r'(?P\d{4})-(?P\d{2})-(?P\d{2})' +result = re.search(pattern, text) + +if result: + print(result.group('year')) # 2024 + print(result.group('month')) # 03 + print(result.group('day')) # 15 +``` + +## 5.3 非捕获分组 `(?:...)` + +有时候分组但不想要捕获,用 `(?:...)` 可以避免创建分组编号: + +```python +import re + +# 捕获分组:会创建 group(1) +result1 = re.search(r'(\d{4})-(\d{2})', '2024-03') +print(result1.groups()) # ('2024', '03') + +# 非捕获分组:不创建额外 group +result2 = re.search(r'(?:\d{4})-(\d{2})', '2024-03') +print(result2.groups()) # ('03',) - 只有 group(1) +``` + +## 5.4 分组在替换中的应用 + +```python +import re + +text = '2024-03-15' + +# 交换年月日的顺序 +pattern = r'(\d{4})-(\d{2})-(\d{2})' +result = re.sub(pattern, r'\2/\3/\1', text) +print(result) # 03/15/2024 +``` + +--- + +# 6. 实战:爬虫中的正则表达式 + +## 6.1 提取电影信息 + +```python +import re + +# 模拟从某电影网站抓取的 HTML +html = ''' +
+
流浪地球
+
8.5
+
导演:郭帆 | 2024 | 科幻
+
+
+
你好,李焕英
+
7.9
+
导演:贾玲 | 2024 | 喜剧
+
+''' + +# 提取电影名 +title_pattern = r'
([^<]+)
' +titles = re.findall(title_pattern, html) +print('电影名:', titles) + +# 提取评分 +score_pattern = r'
([^<]+)
' +scores = re.findall(score_pattern, html) +print('评分:', scores) + +# 提取导演 +director_pattern = r'导演:([^|]+)' +directors = re.findall(director_pattern, html) +print('导演:', [d.strip() for d in directors]) +``` + +**输出:** +``` +电影名: ['流浪地球', '你好,李焕英'] +评分: ['8.5', '7.9'] +导演: ['郭帆', '贾玲'] +``` + +## 6.2 豆瓣电影 Top250:逐行代码详解 + +> 💡 **本节说明** +> +> 上节课同学们已经用过这几段代码来抓取豆瓣电影 Top250。很多同学对正则表达式的细节还有疑问,这节我们逐行讲解,逐一拆解每个 pattern 是怎么工作的。 + +### 6.2.1 提取电影标题(含英文名过滤) + +```python +import requests +import re + +url = 'https://www.douban.com/doulist/3936288/' +headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'} +response = requests.get(url, headers=headers) +html = response.text + +# 匹配 电影名 +title_pattern = r'([^<]+)' +titles = re.findall(title_pattern, html) + +# 过滤掉英文名(以 / 开头) +chinese_titles = [t for t in titles if not t.startswith('/')] + +print('电影名称(前10部):') +for i, title in enumerate(chinese_titles[:10], 1): + print(f'{i}. {title}') +``` + +**逐行解析:** + +| 代码行 | 含义 | +|--------|------| +| `html = response.text` | 把服务器返回的 HTML 页面内容转换成字符串 | +| `title_pattern = r'([^<]+)'` | 定义正则表达式 | +| `re.findall(title_pattern, html)` | 在整个 HTML 页面中找出所有匹配的部分 | + +**正则表达式拆解:** `r'([^<]+)'` + +``` + → 匹配HTML标签,字面意思,一个字符一个字符地匹配 +( → 开始捕获分组——把匹配到的内容"抓"出来 +[^<]+ → 核心!一个或多个"不是<"的字符 +) → 结束捕获分组 + → 匹配闭合标签,字面意思 +``` + +`[^<]+` 是关键: +- `[^...]` 是字符集,表示"不是括号里任意一个字符" +- `[^<]` = 不是 `<` 的任意字符 +- `+` = 出现一次或多次 +- 所以 `[^<]+` 的意思是:**一直匹配,直到遇到 `<` 为止** + +为什么要这样?来看豆瓣的 HTML 真实结构: + +```html +肖申克的救赎 +/The Shawshank Redemption +千与千寻 +``` + +每部电影有两个 ``: +1. **第一个**:中文名,如 `肖申克的救赎` +2. **第二个**:英文名,以 `/` 开头,如 `/The Shawshank Redemption` + +所以后面需要过滤: + +```python +chinese_titles = [t for t in titles if not t.startswith('/')] +``` + +这就是列表推导式,等价于: + +```python +chinese_titles = [] +for t in titles: + if not t.startswith('/'): + chinese_titles.append(t) +``` + +**想一想:** 如果不用 `[^<]+`,而是用 `.+` 代替,会发生什么? + +```python +# 贪婪匹配版 +bad_pattern = r'.+' +# .+ 会尽可能多地匹配,从第一个 一直匹配到... +# 整个HTML里最后一个 !把所有标题合并成了一大段文本! +``` + +--- + +### 6.2.2 提取电影评分 + +```python +rating_pattern = r']*>(\d+\.\d)' +ratings = re.findall(rating_pattern, html) + +print('评分(前10部):') +for i, rating in enumerate(ratings[:10], 1): + print(f'{i}. 评分: {rating}') +``` + +**正则表达式拆解:** `r']*>(\d+\.\d)'` + +``` +]* → [^>] = 不是">"的字符,* = 出现0次或多次 + 含义:标签内可能有其他属性(id、style等),全部跳过 +> → 匹配到标签结束符号 > +(\d+\.\d) → 捕获评分数字 + \d+ = 一个或多个数字 + \. = 小数点(点号需要转义!) + \d = 一个小数位 + → 匹配闭合标签 +``` + +**为什么要用 `[^>]*`?** + +豆瓣的真实评分 HTML 可能长这样: + +```html + +9.7 + + +9.7 +``` + +`[^>]*` 可以灵活处理这两种情况——不管标签里有多少属性(甚至没有属性),都能正常匹配。 + +**为什么要转义 `\.`?** + +在正则表达式里,`.` 是元字符,代表"任意字符"。但我们需要匹配**小数点本身**,而不是"任意字符",所以要写成 `\.`。 + +```python +# 错误:\d+. 会把小数点也匹配掉 +r'(\d+.\d)' # . 匹配任意字符,危险! + +# 正确:\d+\.\d 小数点用 \. 转义 +r'(\d+\.\d)' # 只有小数点才能被匹配 +``` + +--- + +### 6.2.3 提取经典台词(引言) + +```python +quote_pattern = r'([^<]+)' +quotes = re.findall(quote_pattern, html) + +print('经典台词:') +for i, quote in enumerate(quotes, 1): + print(f'{i}. "{quote}"') +``` + +**正则表达式拆解:** `r'([^<]+)'` + +这个 pattern 和标题的非常像,结构完全一样,只是 class 名不同: + +``` + → 匹配开始标签 +([^<]+) → 捕获标签内的文字(不能有<) + → 匹配闭合标签 +``` + +**豆瓣的真实引言 HTML:** + +```html +希望让人自由。 +暗恋一个人,是最安静的秘密。 +``` + +每句引言都被一个 `` 包裹,正则直接按标签匹配即可。 + +**注意:** 并不是每部电影都有引言,所以 `quotes` 列表的长度可能小于电影数量,这是正常的。 + +--- + +### 6.2.4 三段代码完整版 + +```python +import requests +import re + +url = 'https://www.douban.com/doulist/3936288/' +headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'} +response = requests.get(url, headers=headers) +response.encoding = 'utf-8' +html = response.text + +# 1. 提取电影名 +title_pattern = r'([^<]+)' +titles = re.findall(title_pattern, html) +chinese_titles = [t for t in titles if not t.startswith('/')] + +# 2. 提取评分 +rating_pattern = r']*>(\d+\.\d)' +ratings = re.findall(rating_pattern, html) + +# 3. 提取经典台词 +quote_pattern = r'([^<]+)' +quotes = re.findall(quote_pattern, html) + +# 合并打印(前10部) +print(f'{"排名":<4} {"电影名":<20} {"评分":<6} {"引言"}') +print('-' * 70) +for i in range(min(10, len(chinese_titles))): + title = chinese_titles[i] + rating = ratings[i] if i < len(ratings) else '无' + quote = f'"{quotes[i]}"' if i < len(quotes) else '无' + print(f'{i+1:<4} {title:<20} {rating:<6} {quote}') +``` + +**输出示例:** +``` +排名 电影名 评分 引言 +---------------------------------------------------------------------- +1 肖申克的救赎 9.7 "希望让人自由。" +2 霸王别姬 9.6 无 +3 阿甘正传 9.5 "生活就像一盒巧克力..." +4 泰坦尼克号 9.4 无 +5 千与千寻 9.3 "我只能送你到这里了..." +... +``` + +--- + +### 6.2.5 调试技巧:先打印原始匹配 + +如果匹配不到内容,先不要急着改 pattern,先打印原始匹配看看: + +```python +# ❌ 找不到就放弃了 +titles = re.findall(title_pattern, html) +print(titles) # [] + +# ✅ 调试:打印原始匹配结果 +raw_matches = re.findall(title_pattern, html) +print(f'原始匹配数: {len(raw_matches)}') +print(f'前3个: {raw_matches[:3]}') +# 输出: 原始匹配数: 250 +# 前3个: ['肖申克的救赎', '/The Shawshank Redemption', '霸王别姬'] +``` + +调试思路: +1. 先确认原始匹配数量是否合理(豆瓣 Top250,理论上应该有 500 个匹配,因为每部电影有中英文两个 title) +2. 再看内容是否符合预期 +3. 最后再过滤/处理 + +--- + +## 6.3 提取 JSON 数据中的字段 + +有时候网页中嵌入了 JSON 数据,正则表达式可以快速提取: + +```python +import re +import json + +# 模拟嵌入页面的 JSON +page_html = ''' + +''' + +# 提取 JSON 字符串 +json_pattern = r'window\.__INIT_DATA__\s*=\s*({.*?});' +match = re.search(json_pattern, page_html, re.DOTALL) + +if match: + json_str = match.group(1) + data = json.loads(json_str) + print(f"用户名: {data['user']['name']}") + print(f"邮箱: {data['user']['email']}") +``` + +## 6.4 提取手机号、邮箱、URL + +```python +import re + +text = ''' +张三的手机号:138-1234-5678,邮箱:zhangsan@python.com +李四的手机号:139-5678-1234,邮箱:lisi@java.com +他们的网站:http://example.com 和 https://test.org +地址:北京市海淀区中关村1号 +''' + +# 手机号:3位-4位-4位 +phones = re.findall(r'\d{3}-\d{4}-\d{4}', text) +print('手机号:', phones) + +# 邮箱 +emails = re.findall(r'[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+', text) +print('邮箱:', emails) + +# URL(http 或 https) +urls = re.findall(r'https?://[a-zA-Z0-9./-]+', text) +print('网址:', urls) +``` + +## 6.5 提取视频/音频链接 + +```python +import re + +html = ''' + + + +''' + +# 提取 MP4 视频链接 +video_pattern = r'' +ratings = re.findall(rating_pattern, html) + +# 合并输出 +for i, title in enumerate(titles[:10]): + rating = ratings[i] if i < len(ratings) else '无评分' + print(f'{i+1}. {title} - 评分: {rating}') +``` + +**输出:** +``` +1. 肖申克的救赎 - 评分: 9.7 +2. 霸王别姬 - 评分: 9.6 +3. 阿甘正传 - 评分: 9.5 +4. 泰坦尼克号 - 评分: 9.4 +5. 千与千寻 - 评分: 9.3 +... +``` + +## 8.3 方法三:使用 XPath 提取 + +XPath 是另一种在 XML/HTML 中定位节点的语言。以下是等效的 XPath 写法: + +> ⚠️ **注意**:Python 原生不支持 XPath,需要配合 `lxml` 库使用: +> `pip install lxml` + +```python +import requests +from lxml import etree + +headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' +} + +url = 'https://www.douban.com/doulist/3936288/' +response = requests.get(url, headers=headers) +response.encoding = 'utf-8' +html = response.text + +# 解析 HTML +tree = etree.HTML(html) + +# XPath 提取电影名 +# //div[@class='doulist-item'] 表示任意位置的 div,class 属性为 doulist-item +# .//a[contains(@href,'/subject/')] 表示在该 div 内的 a 标签,href 包含 /subject/ +title_xpath = '//div[@class="doulist-item"]//a[contains(@href,"/subject/")]/text()' +titles = tree.xpath(title_xpath) + +# XPath 提取评分 +rating_xpath = '//div[@class="doulist-item"]//span[@class="rating_nums"]/text()' +ratings = tree.xpath(rating_xpath) + +# 合并输出 +for i, title in enumerate(titles[:10]): + rating = ratings[i] if i < len(ratings) else '无评分' + print(f'{i+1}. {title.strip()} - 评分: {rating}') +``` + +**输出:** +``` +1. 肖申克的救赎 - 评分: 9.7 +2. 霸王别姬 - 评分: 9.6 +3. 阿甘正传 - 评分: 9.5 +4. 泰坦尼克号 - 评分: 9.4 +5. 千与千寻 - 评分: 9.3 +... +``` + +## 8.4 三种方法对比 + +| 对比维度 | BeautifulSoup (CSS选择器) | 正则表达式 | XPath | +|----------|---------------------------|------------|-------| +| **学习难度** | ⭐ 低,容易上手 | ⭐⭐⭐ 较高,语法复杂 | ⭐⭐ 中等,有自己的语法体系 | +| **提取单位** | HTML 标签、属性 | 文本片段(任意位置) | XML/HTML 节点树 | +| **适用场景** | 结构规范的 HTML | 不规则文本、字符串处理 | 结构规范的 XML/HTML | +| **代码可读性** | 高,类似 CSS | 中,符号多 | 中,路径表达式较长 | +| **性能** | 较慢 | 快(编译后) | 快 | +| **灵活性** | 一般,依赖标签结构 | 极高,可匹配任意模式 | 较高,依赖节点结构 | +| **需要外部库** | beautifulsoup4 / lxml | 不需要(re 是内置) | lxml | + +## 8.5 何时用哪种方法? + +### 推荐用 **正则表达式** 的场景: + +1. **文本内容分散在标签内外** + ```html + + ``` + BeautifulSoup 和 XPath 很难处理,但正则可以轻松提取 `name: "(.+?)"` + +2. **需要精确的文本格式匹配** + - 手机号:`\d{3}-\d{4}-\d{4}` + - 邮箱:`[a-z]+@[a-z]+\.[a-z]+` + - IP 地址:`\d+\.\d+\.\d+\.\d+` + +3. **从日志或非结构化文本中提取** + ``` + [2024-03-15 10:30:45] ERROR: connection failed + [2024-03-15 10:31:00] INFO: retry successful + ``` + +4. **数据清洗和替换** + ```python + # 把手机号脱敏 + re.sub(r'(\d{3})-(\d{4})-(\d{4})', r'\1-****-\3', text) + ``` + +### 推荐用 **XPath** 的场景: + +1. **HTML 结构清晰、层次分明** + ```html + + + +
Python90
Math85
+ ``` + `//table//tr/td[1]/text()` 即可提取第一列 + +2. **需要遍历树状结构** + XPath 支持 `parent::*`、`following-sibling::*` 等轴向选择器 + +3. **结构化文档(如 XML)** + XPath 是为 XML 设计的,处理 RSS、SVG 等格式更自然 + +### 推荐用 **BeautifulSoup (CSS)** 的场景: + +1. **初学者入门**,语法最简单 +2. **快速原型开发**,不需要精确控制 +3. **CSS 选择器已够用**的简单场景 + +## 8.6 进阶:正则 + XPath 组合使用 + +实际上,最强大的做法是将两者结合: + +```python +import requests +from lxml import etree +import re + +headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' +} + +url = 'https://www.douban.com/doulist/3936288/' +response = requests.get(url, headers=headers) +response.encoding = 'utf-8' +html = response.text + +# 第一步:用 XPath 快速定位到所有电影条目(结构筛选) +tree = etree.HTML(html) +movie_blocks = tree.xpath('//div[@class="doulist-item"]') + +results = [] +for block in movie_blocks[:10]: + block_html = etree.tostring(block, encoding='unicode') + + # 第二步:在每个 block 内用正则精确提取 + movie_id = re.search(r'/subject/(\d+)/', block_html) + movie_id = movie_id.group(1) if movie_id else '未知' + + title = block.xpath('.//a[contains(@href,"/subject/")]/text()') + title = title[0].strip() if title else '未知' + + rating = block.xpath('.//span[@class="rating_nums"]/text()') + rating = rating[0] if rating else '无评分' + + results.append(f'{movie_id}. {title} - 评分: {rating}') + +for r in results: + print(r) +``` + +**思路总结:** +``` +XPath 负责:结构定位 → 快速找到目标区域(省去大量无关 HTML) +正则负责:精确提取 → 在区域内精准匹配目标文本 +``` + +## 8.7 语法对照速查表 + +| 任务 | 正则表达式 | XPath | +|------|-----------|-------| +| 匹配所有 div 标签 | `r'
'` | `//div` | +| 匹配 class="title" 的元素 | `r'class="title"'` | `//*[@class="title"]` | +| 匹配包含 /subject/ 的链接 | `r'href="/subject/[^"]+"'` | `//a[contains(@href,"/subject/")]` | +| 提取标签文本内容 | `r'>([^<]+)'` → group(1) | `//span/text()` | +| 匹配任意字符 | `.+?`(非贪婪) | `//text()[contains(.,'关键词')]` | +| 匹配数字 | `\d+` | `//span[number(text())]` | +| 匹配开头/结尾 | `^内容`、`内容$` | `//div[starts-with(@class,"title")]` | + +--- + + +# 9. 常见问题与解决方案 + +## 9.1 匹配不到内容 + +```python +import re + +text = 'Hello\nWorld' + +# 问题:. 不匹配换行符 +print(re.findall(r'.+', text)) # ['Hello', 'World'] - 被拆成了两行? + +# 解决1:用 re.DOTALL 模式(让 . 匹配换行) +print(re.findall(r'.+', text, re.DOTALL)) # ['Hello\nWorld'] + +# 解决2:用 [\s\S] 代替 . +print(re.findall(r'[\s\S]+', text)) # ['Hello\nWorld'] +``` + +## 9.2 中文匹配 + +```python +import re + +text = 'Python是一门很棒的语言,C语言也是' + +# 匹配中文 +chinese = re.findall(r'[\u4e00-\u9fff]+', text) +print(chinese) # ['很棒的语言', '也是'] + +# 或者直接用 unicode +pattern = r'[\u4e00-\u9fff]+' +print(re.findall(pattern, text)) +``` + +## 9.3 大小写不敏感匹配 + +```python +import re + +text = 'Python PYTHON pYtHoN' + +# 默认区分大小写 +print(re.findall(r'python', text)) # [] + +# 用 re.IGNORECASE(可简写为 re.I) +print(re.findall(r'python', text, re.I)) # ['Python', 'PYTHON', 'pYtHoN'] +``` + +## 9.4 多行模式 + +```python +import re + +text = '''第一行内容 +第二行内容 +第三行内容''' + +# 问题:^ 只匹配字符串开头 +print(re.findall(r'^第.+', text)) # ['第一行内容'] + +# 解决:用 re.MULTILINE(可简写为 re.M) +print(re.findall(r'^第.+', text, re.M)) # ['第一行内容', '第二行内容', '第三行内容'] +``` + +## 9.5 编译正则表达式提高性能 + +如果一个正则表达式要匹配多次,预先编译可以提高性能: + +```python +import re +import time + +# 需要匹配 10000 次的文本 +text = '订单号:20240315-001,总额:999.50元' + +# 方法1:每次调用都编译 +start = time.time() +for _ in range(10000): + re.findall(r'\d{4}-\d+', text) +print(f'未编译: {time.time() - start:.4f}秒') + +# 方法2:预先编译 +pattern = re.compile(r'\d{4}-\d+') +start = time.time() +for _ in range(10000): + pattern.findall(text) +print(f'已编译: {time.time() - start:.4f}秒') +``` + +--- + +# 10. 动手练习 + +## 练习 1:提取天气预报 + +**目标**:从以下文本中提取日期、天气和温度。 + +```python +text = ''' +2024-03-15 天气:晴 温度:15-25°C +2024-03-16 天气:多云 温度:12-20°C +2024-03-17 天气:小雨 温度:10-18°C +''' + +# 提示:使用分组捕获日期、天气、温度 +# pattern = r'你的正则表达式' + +import re +pattern = r'(\d{4}-\d{2}-\d{2})\s*天气:([^ ]+)\s*温度:(\d+)-(\d+)°C' +matches = re.findall(pattern, text) + +for match in matches: + date, weather, low, high = match + print(f'{date}: {weather}, {low}°C-{high}°C') +``` + +**预期输出:** +``` +2024-03-15: 晴, 15°C-25°C +2024-03-16: 多云, 12°C-20°C +2024-03-17: 小雨, 10°C-18°C +``` + +--- + +## 练习 2:爬取豆瓣电影信息 + +**目标**:编写正则表达式,从模拟的 HTML 中提取电影信息。 + +```python +import re + +html = ''' +
+

《流浪地球》

+ (2024) + 8.5 + 导演:郭帆 +
+
+

《你好,李焕英》

+ (2024) + 7.9 + 导演:贾玲 +
+''' + +# 编写正则表达式,提取所有电影信息 +# pattern = r'你的正则表达式' + +# 提示:可以用多个正则分别提取,或者用一个复杂的正则提取所有 +name_pattern = r'

《([^》]+)》

' +year_pattern = r'\((\d{4})\)' +rating_pattern = r'([^<]+)' +director_pattern = r'导演:([^<]+)' + +names = re.findall(name_pattern, html) +years = re.findall(year_pattern, html) +ratings = re.findall(rating_pattern, html) +directors = re.findall(director_pattern, html) + +for i in range(len(names)): + print(f"{names[i]} | {years[i]} | 评分:{ratings[i]} | {directors[i]}") +``` + +--- + +## 练习 3:日志分析 + +**目标**:从服务器日志中提取 IP 地址、请求时间和状态码。 + +```python +import re + +log = ''' +192.168.1.100 - - [15/Mar/2024:10:15:30 +0800] "GET /index.html HTTP/1.1" 200 1234 +10.0.0.50 - - [15/Mar/2024:10:15:31 +0800] "POST /api/login HTTP/1.1" 200 256 +192.168.1.101 - - [15/Mar/2024:10:15:32 +0800] "GET /notfound.html HTTP/1.1" 404 512 +172.16.0.200 - - [15/Mar/2024:10:15:33 +0800] "GET /images/logo.png HTTP/1.1" 200 4096 +''' + +# 提取 IP、时间和状态码 +pattern = r'(\d+\.\d+\.\d+\.\d+).*?\[([^\]]+)\].*?" (\d{3}) \d+' + +for match in re.finditer(pattern, log): + ip, time, status = match.groups() + print(f'IP: {ip:15} | 时间: {time:25} | 状态: {status}') +``` + +--- + +## 练习 4:电话号码脱敏 + +**目标**:将手机号中间四位用 `*` 替换,保护隐私。 + +```python +import re + +phone_book = ''' +张三:138-1234-5678 +李四:139-5678-1234 +王五:138-0000-1111 +''' + +# 脱敏:将 138-****-5678 格式输出 +# 提示:使用分组和 re.sub + +pattern = r'(\d{3})-(\d{4})-(\d{4})' + +def mask_phone(match): + return f'{match.group(1)}-****-{match.group(3)}' + +masked = re.sub(pattern, mask_phone, phone_book) +print(masked) +``` + +--- + +## 练习 5:综合挑战(选做) + +从以下课程表 HTML 中,用正则表达式提取所有课程信息: + +```python +import re + +html = ''' + + + + + + +
时间课程教室
周一 1-2节Python程序设计A101
周一 3-4节数据结构B205
周二 1-2节高等数学C301
周三 5-6节Python程序设计A102
+''' + +# 分别提取时间、课程、教室 +time_pattern = r'([^<]+)([^<]+)([^<]+)' +courses = re.findall(time_pattern, html) + +print('课程表:') +for time, course, room in courses: + print(f'{time} | {course} | {room}') +``` + +--- + +# 📋 课程小结 + +## 核心要点 + +1. **正则表达式是强大的文本匹配工具**,可以精确提取任意文本中的目标内容 + +2. **元字符是基础**: + - `.` 匹配任意字符 + - `\d` 匹配数字,`\w` 匹配字母数字下划线 + - `[]` 定义字符集,`[^...]` 排除字符集 + +3. **量词控制次数**: + - `+` 一次或多次,`*` 零次或多次,`?` 零次或一次 + - `{n,m}` 指定范围 + +4. **贪婪 vs 非贪婪**: + - 贪婪(`.*`、`\d+`)尽可能多匹配 + - 非贪婪(`.*?`、`\d+?`)尽可能少匹配 + - `[^<]+` 是爬虫中常用的非贪婪技巧 + +5. **分组 `()`**: + - 捕获匹配内容 + - 用 `\1`、`\2` 在替换中引用分组 + - `(?P...)` 命名分组更清晰 + +6. **BeautifulSoup + 正则 = 黄金组合**: + - BeautifulSoup 负责结构定位 + - 正则负责精确提取 + +7. **re 模块常用函数**: + - `findall()` 返回所有匹配列表 + - `search()` 找第一个匹配 + - `match()` 只匹配字符串开头 + - `finditer()` 返回迭代器,节省内存 + - `sub()` 替换匹配内容 + +## 常见错误速查 + +| 错误 | 原因 | 解决方法 | +|------|------|----------| +| 匹配结果为空 | `.` 不匹配换行符 | 用 `re.DOTALL` 或 `[^\n]` | +| 匹配太多 | 贪婪匹配 | 改为非贪婪 `*?`、`+?` | +| 匹配错误 | 元字符未转义 | 用 `\.` 匹配点号本身 | +| 中文匹配失败 | 字符集不对 | 用 `[\u4e00-\u9fff]` | + +## 下节课预告 +- XPath 选择器详解 +- Selenium 动态页面抓取 +- 反反爬策略与实战 + +--- + +*本讲义由 AI 助教生成,如有问题请随时提问。*