Day40 - 正则表达式高级与综合项目

详细讲解

1. 分组高级用法

1.1 捕获分组与非捕获分组

import re

# 捕获分组 - 会保存到 groups() 中
text = "2024-05-01"
match = re.search(r'(\d{4})-(\d{2})-(\d{2})', text)
print(match.groups())  # ('2024', '05', '01')

# 非捕获分组 (?:...) - 不保存到 groups()
match = re.search(r'(\d{4})-(?:\d{2})-(\d{2})', text)
print(match.groups())  # ('2024', '01')

# 使用场景:提高效率(不需要的分组不用保存)
# 当分组多时,非捕获分组性能更好

1.2 命名分组

# (?P<name>pattern) - 命名分组
text = "John Doe <john@example.com>"

match = re.search(r'(?P<name>\w+) (?P<surname>\w+) <(?P<email>\w+@\w+\.\w+)>', text)
if match:
    print(match.groupdict())
    # {'name': 'John', 'surname': 'Doe', 'email': 'john@example.com'}

    # 按名称访问
    print(match.group('name'))   # 'John'
    print(match.group('email'))  # 'john@example.com'

# 命名分组在替换中也很方便
text = "2024-05-01"
result = re.sub(r'(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})',
                 r'\g<month>/\g<day>/\g<year>', text)
print(result)  # 05/01/2024

1.3 反向引用

# \1, \2 等 - 引用前面的分组

# 匹配重复的单词
text = "the the cat cat dog"
pattern = r'(\w+) \1'  # \1 引用第一个分组
print(re.findall(pattern, text))  # ['the', 'cat']

# 匹配 HTML 标签
text = "<div>content</div> <span>text</span>"
pattern = r'<(\w+)>.*?</\1>'  # \1 引用前面的标签名
print(re.findall(pattern, text))  # ['div', 'span']

# 匹配引号
pattern = r'(["\'])(.*?)\1'  # \1 引用开头的引号
print(re.findall(pattern, "'hello' and \"world\""))  # ["'", 'hello', '"', 'world']

1.4 条件匹配

# (?(id)yes_pattern|no_pattern) - 如果 id 存在则匹配 yes_pattern,否则匹配 no_pattern

# 匹配带引号或不带引号的单词
text = '"hello" world "python"'

# 如果有引号,则匹配引号内的内容;否则匹配单词
pattern = r'(["\'])?(?(1)(.*?)\1|(\w+))'
matches = re.findall(pattern, text)
print(matches)  # [('"', 'hello', ''), ('', '', 'world'), ('"', 'python', '')]

2. re.sub() 高级用法

2.1 基本替换

import re

# 基本替换
text = "Hello World"
result = re.sub(r'World', 'Python', text)
print(result)  # Hello Python

# 使用分组引用
text = "2024-05-01"
result = re.sub(r'(\d{4})-(\d{2})-(\d{2})', r'\2/\3/\1', text)
print(result)  # 05/01/2024

# 命名分组引用
result = re.sub(r'(?P<y>\d{4})-(?P<m>\d{2})-(?P<d>\d{2})',
                 r'\g<d>-\g<m>-\g<y>', text)
print(result)  # 01-05-2024

2.2 替换函数

# 传入函数作为替换参数

def expand_date(match):
    """日期转换函数"""
    year, month, day = match.groups()
    
    months = {
        '01': 'January', '02': 'February', '03': 'March',
        '04': 'April',   '05': 'May',      '06': 'June',
        '07': 'July',    '08': 'August',   '09': 'September',
        '10': 'October', '11': 'November', '12': 'December'
    }
    
    return f"{months[month]} {day}, {year}"

text = "Date: 2024-05-01"
result = re.sub(r'(\d{4})-(\d{2})-(\d{2})', expand_date, text)
print(result)  # Date: May 01, 2024

# 数字格式化
def format_number(match):
    num = int(match.group())
    return f"{num:,}"  # 添加千分位

text = "金额: 1234567"
result = re.sub(r'\d+', format_number, text)
print(result)  # 金额: 1,234,567

2.3 re.subn() - 返回替换次数

# subn() 返回 (新字符串, 替换次数)
text = "aaa bbb aaa ccc aaa"
result, count = re.subn(r'aaa', 'XXX', text)
print(f"替换后: {result}, 替换次数: {count}")
# 替换后: XXX bbb XXX ccc XXX, 替换次数: 3

3. re.split() 高级用法

import re

# 基本分割
text = "apple,banana,cherry"
parts = re.split(r',', text)
print(parts)  # ['apple', 'banana', 'cherry']

# 使用分组分割 - 分组会保留在结果中
text = "apple1banana2cherry"
parts = re.split(r'(\d)', text)
print(parts)  # ['apple', '1', 'banana', '2', 'cherry']

# 限制分割次数
text = "a:b:c:d:e"
parts = re.split(r':', text, maxsplit=2)
print(parts)  # ['a', 'b', 'c:d:e']

# 按多种分隔符分割
text = "apple,bana;na|cherry"
parts = re.split(r'[,;|]', text)
print(parts)  # ['apple', 'bana', 'na', 'cherry']

4. 零宽断言(Lookaround)

4.1 正向先行断言 (?=...)

# 匹配后面跟着特定内容的前缀
text = "Windows10 Windows11 Windows12"

# 匹配前面是 Windows,后面是数字的 Windows
result = re.findall(r'Windows(?=\d)', text)
print(result)  # ['Windows', 'Windows', 'Windows']

# 更实用的例子:获取价格(数字前的货币符号)
text = "价格: $100, €50, ¥200"
result = re.findall(r'[$€¥]\d+', text)
print(result)  # ['$100', '€50', '¥200']

# 匹配后面是 .com 的域名部分
text = "example.com test.org hello.com"
result = re.findall(r'\w+(?=\.com)', text)
print(result)  # ['example', 'hello']

4.2 负向先行断言 (?!...)

# 匹配后面不跟着特定内容的前缀
text = "Windows10 WindowsMac Windows11"

# 匹配后面不是数字的 Windows
result = re.findall(r'Windows(?!\d)', text)
print(result)  # ['WindowsMac']

# 排除特定结尾
text = ["file.txt", "file.log", "file.txt.bak", "data.csv"]
txt_files = [f for f in text if re.match(r'.*\.txt(?!\.)', f)]
print(txt_files)  # ['file.txt']

4.3 正向后行断言 (?<=...)

# 匹配前面跟着特定内容的后缀
text = "价格: 100元, 200元, 300美元"

# 匹配"元"前面的数字
result = re.findall(r'(?<=[元])\d+', text)
print(result)  # ['100', '200']

# 提取 HTML 中的文本内容
html = "<div>Hello</div><span>World</span>"
result = re.findall(r'(?<=<[a-z]+>)[^<]+', html)
print(result)  # ['Hello', 'World']

# 提取域名后的路径
text = "https://example.com/path1 http://test.org/path2"
result = re.findall(r'(?<=//[^/]+/).+', text)
print(result)  # ['/path1', '/path2']

4.4 负向后行断言 (?<!...)

# 匹配前面不跟着特定内容的后缀
text = "100元 200美元 300元 400日元"

# 匹配前面不是"元"的数字
result = re.findall(r'(?<![元])\d+', text)
print(result)  # ['200', '400']

# 更实用的例子:提取不是来自某个前缀的内容
text = "abc123 abc456 xyz789"
result = re.findall(r'(?<!abc)\d+', text)
print(result)  # ['3456', '789'] - abc123 中的 1 被排除

5. 综合项目实践

5.1 日志分析器

import re
from collections import Counter
from datetime import datetime

class LogAnalyzer:
    """日志分析器"""
    
    # 日志格式正则(示例:Apache 访问日志)
    LOG_PATTERN = re.compile(
        r'(?P<ip>[\d.]+) '  # IP 地址
        r'- - '
        r'\[(?P<timestamp>[^\]]+)\] '  # 时间戳
        r'"(?P<method>\w+) (?P<path>[^\s]+) [^"]+" '  # 请求方法和路径
        r'(?P<status>\d{3}) '  # 状态码
        r'(?P<size>\d+)'
    )
    
    # 错误日志正则
    ERROR_PATTERN = re.compile(
        r'\[(?P<timestamp>[^\]]+)\] '
        r'\[(?P<level>\w+)\] '
        r'(?P<message>.+)'
    )
    
    @classmethod
    def parse_access_log(cls, log_text):
        """解析访问日志"""
        results = []
        for line in log_text.strip().split('\n'):
            match = cls.LOG_PATTERN.search(line)
            if match:
                results.append(match.groupdict())
        return results
    
    @classmethod
    def analyze_traffic(cls, log_text):
        """流量分析"""
        entries = cls.parse_access_log(log_text)
        
        # 统计各状态码数量
        status_counts = Counter(e['status'] for e in entries)
        
        # 统计各路径访问量
        path_counts = Counter(e['path'] for e in entries)
        
        # 统计各 IP 访问量
        ip_counts = Counter(e['ip'] for e in entries)
        
        # 计算总流量
        total_bytes = sum(int(e['size']) for e in entries)
        
        return {
            'total_requests': len(entries),
            'status_codes': dict(status_counts),
            'top_paths': path_counts.most_common(10),
            'top_ips': ip_counts.most_common(10),
            'total_bytes': total_bytes
        }
    
    @classmethod
    def extract_errors(cls, log_text):
        """提取错误信息"""
        errors = []
        for line in log_text.strip().split('\n'):
            match = cls.ERROR_PATTERN.search(line)
            if match:
                errors.append(match.groupdict())
        return errors

# 使用示例
sample_log = '''
127.0.0.1 - - [01/May/2024:10:00:00 +0000] "GET /index.html HTTP/1.1" 200 1234
127.0.0.1 - - [01/May/2024:10:00:01 +0000] "GET /style.css HTTP/1.1" 200 5678
127.0.0.2 - - [01/May/2024:10:00:02 +0000] "GET /api/users HTTP/1.1" 404 0
192.168.1.1 - - [01/May/2024:10:00:03 +0000] "POST /login HTTP/1.1" 200 89
192.168.1.1 - - [01/May/2024:10:00:04 +0000] "POST /login HTTP/1.1" 401 23
'''

analyzer = LogAnalyzer()
result = analyzer.analyze_traffic(sample_log)

print(f"总请求数: {result['total_requests']}")
print(f"状态码统计: {result['status_codes']}")
print(f"Top 路径: {result['top_paths']}")
print(f"Top IPs: {result['top_ips']}")

5.2 文本数据清洗工具

import re

class TextCleaner:
    """文本数据清洗工具"""
    
    @staticmethod
    def clean_html(text):
        """移除 HTML 标签"""
        # 移除 script 和 style 内容
        text = re.sub(r'<script[^>]*>.*?</script>', '', text, flags=re.DOTALL | re.IGNORECASE)
        text = re.sub(r'<style[^>]*>.*?</style>', '', text, flags=re.DOTALL | re.IGNORECASE)
        # 移除所有 HTML 标签
        text = re.sub(r'<[^>]+>', '', text)
        # 解码 HTML 实体
        text = TextCleaner._decode_html_entities(text)
        return text.strip()
    
    @staticmethod
    def _decode_html_entities(text):
        """解码 HTML 实体"""
        entities = {
            '&nbsp;': ' ', '&amp;': '&', '&lt;': '<', '&gt;': '>',
            '&quot;': '"', '&#39;': "'", '&apos;': "'"
        }
        for entity, char in entities.items():
            text = text.replace(entity, char)
        # 处理数字形式的实体
        text = re.sub(r'&#(\d+);', lambda m: chr(int(m.group(1))), text)
        return text
    
    @staticmethod
    def normalize_whitespace(text):
        """规范化空白字符"""
        # 将所有连续空白替换为单个空格
        text = re.sub(r'\s+', ' ', text)
        # 移除首尾空白
        return text.strip()
    
    @staticmethod
    def remove_special_chars(text, keep_pattern=''):
        """移除特殊字符"""
        if keep_pattern:
            pattern = f'[^a-zA-Z0-9{re.escape(keep_pattern)}]'
        else:
            pattern = r'[^a-zA-Z0-9\u4e00-\u9fff]'  # 保留中文
        return re.sub(pattern, '', text)
    
    @staticmethod
    def extract_phones(text):
        """提取电话号码"""
        patterns = [
            r'1[3-9]\d{9}',           # 手机号
            r'\d{3,4}-\d{7,8}',       # 固话
            r'\+\d{1,3}-\d{3,4}-\d{7,8}'  # 国际电话
        ]
        phones = []
        for pattern in patterns:
            phones.extend(re.findall(pattern, text))
        return list(set(phones))  # 去重
    
    @staticmethod
    def extract_emails(text):
        """提取邮箱地址"""
        pattern = r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}'
        return re.findall(pattern, text)
    
    @staticmethod
    def mask_sensitive(text, mask_type='phone'):
        """脱敏处理"""
        if mask_type == 'phone':
            return re.sub(r'(1[3-9]\d{9})', 
                         lambda m: m.group(1)[:3] + '****' + m.group(1)[7:], text)
        elif mask_type == 'email':
            return re.sub(r'([a-zA-Z0-9]{2})[a-zA-Z0-9]*@',
                         r'\1***@', text)
        return text

# 使用示例
html_text = '''
<div class="content">
    <script>alert("bad");</script>
    <p>联系我们: 010-12345678 或 13812345678</p>
    <p>邮箱: contact@example.com</p>
    <p>地址: 北京市海淀区中关村大街1号</p>
</div>
'''

cleaner = TextCleaner()
text = cleaner.clean_html(html_text)
print(f"清洗后: {text}")

print(f"提取电话: {cleaner.extract_phones(text)}")
print(f"提取邮箱: {cleaner.extract_emails(text)}")
print(f"脱敏电话: {cleaner.mask_sensitive(text, 'phone')}")

5.3 URL 解析器

import re
from urllib.parse import urlparse, parse_qs

class URLParser:
    """URL 解析器"""
    
    # 更完整的 URL 正则
    URL_PATTERN = re.compile(
        r'^(?P<scheme>https?)://'  # 协议
        r'(?P<domain>[^/:]+)'       # 域名
        r'(?::(?P<port>\d+))?'      # 端口(可选)
        r'(?P<path>/[^?#]*)?'       # 路径
        r'(?:\?(?P<query>[^#]+))?'  # 查询字符串
        r'(?:#(?P<fragment>.+))?'   # 锚点
    )
    
    @classmethod
    def parse(cls, url):
        """解析 URL"""
        # 尝试用正则解析
        match = cls.URL_PATTERN.match(url)
        if match:
            return match.groupdict()
        
        # 回退到 urllib
        parsed = urlparse(url)
        return {
            'scheme': parsed.scheme,
            'domain': parsed.netloc,
            'port': parsed.port,
            'path': parsed.path,
            'query': parsed.query,
            'fragment': parsed.fragment
        }
    
    @classmethod
    def extract_params(cls, url):
        """提取 URL 参数"""
        parsed = urlparse(url)
        params = parse_qs(parsed.query)
        # 转换为简单字典(值列表转单值)
        return {k: v[0] if len(v) == 1 else v for k, v in params.items()}
    
    @classmethod
    def build_url(cls, scheme='https', domain='', path='/', params=None, fragment=''):
        """构建 URL"""
        url = f"{scheme}://{domain}{path}"
        if params:
            query = '&'.join(f"{k}={v}" for k, v in params.items())
            url += f"?{query}"
        if fragment:
            url += f"#{fragment}"
        return url
    
    @classmethod
    def extract_domain_info(cls, domain):
        """提取域名信息"""
        # 提取子域名
        parts = domain.split('.')
        if len(parts) >= 3:
            subdomain = parts[0]
            main_domain = '.'.join(parts[1:])
        else:
            subdomain = None
            main_domain = domain
        
        # 提取顶级域
        tld = parts[-1] if parts else ''
        
        return {
            'original': domain,
            'subdomain': subdomain,
            'domain': main_domain,
            'tld': tld
        }

# 使用示例
urls = [
    'https://example.com:8080/path/to/page?id=123&name=test#section',
    'http://sub.domain.co.uk/api/v1/users?page=1',
    'https://www.test.org'
]

parser = URLParser()
for url in urls:
    print(f"\n解析: {url}")
    info = parser.parse(url)
    print(f"  域名: {info['domain']}")
    print(f"  路径: {info.get('path', '/')}")
    print(f"  参数: {parser.extract_params(url)}")
    
    if info.get('domain'):
        domain_info = parser.extract_domain_info(info['domain'])
        print(f"  域名信息: {domain_info}")

5.4 数据验证器

import re

class Validator:
    """常用数据验证器"""
    
    @staticmethod
    def is_valid_email(email):
        """验证邮箱"""
        pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
        return re.match(pattern, email) is not None
    
    @staticmethod
    def is_valid_phone(phone, region='CN'):
        """验证手机号"""
        if region == 'CN':
            # 中国手机号(11位,以1开头,第2位3-9)
            pattern = r'^1[3-9]\d{9}$'
        elif region == 'US':
            # 美国电话(10位)
            pattern = r'^\d{10}$'
        else:
            pattern = r'^\+?\d{6,15}$'
        return re.match(pattern, phone) is not None
    
    @staticmethod
    def is_valid_url(url):
        """验证 URL"""
        pattern = r'^https?://[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(/.*)?$'
        return re.match(pattern, url) is not None
    
    @staticmethod
    def is_valid_ip(ip):
        """验证 IPv4 地址"""
        pattern = r'^(\d{1,3}\.){3}\d{1,3}$'
        if not re.match(pattern, ip):
            return False
        # 检查每段是否在 0-255
        return all(0 <= int(octet) <= 255 for octet in ip.split('.'))
    
    @staticmethod
    def is_valid_date(date_str, format='%Y-%m-%d'):
        """验证日期格式"""
        from datetime import datetime
        try:
            datetime.strptime(date_str, format)
            return True
        except ValueError:
            return False
    
    @staticmethod
    def is_strong_password(password):
        """验证密码强度"""
        if len(password) < 8:
            return False, "密码长度至少8位"
        
        has_upper = bool(re.search(r'[A-Z]', password))
        has_lower = bool(re.search(r'[a-z]', password))
        has_digit = bool(re.search(r'\d', password))
        has_special = bool(re.search(r'[!@#$%^&*(),.?":{}|<>]', password))
        
        if not has_upper:
            return False, "需要包含大写字母"
        if not has_lower:
            return False, "需要包含小写字母"
        if not has_digit:
            return False, "需要包含数字"
        if not has_special:
            return False, "需要包含特殊字符"
        
        return True, "密码强度合格"

# 使用示例
validator = Validator()

test_cases = [
    ('email', 'test@example.com'),
    ('phone', '13812345678'),
    ('url', 'https://www.example.com'),
    ('ip', '192.168.1.1'),
    ('date', '2024-05-01'),
]

for type_name, value in test_cases:
    method = getattr(validator, f'is_valid_{type_name}')
    result = method(value)
    status = "✓" if result else "✗"
    print(f"{status} {type_name}: {value}")

# 密码强度验证
password = "Test@1234"
valid, msg = validator.is_strong_password(password)
print(f"\n密码 '{password}': {msg}")

背诵版

高级正则速查

┌─────────────────────────────────────────────────────────────┐ │ 零宽断言(Lookaround) │ ├─────────────────────────────────────────────────────────────┤ │ (?=...) 正向先行 │ 后面跟着... │ │ (?!...) 负向先行 │ 后面不跟... │ │ (?<=...) 正向后行 │ 前面是... │ │ (?<!...) 负向后行 │ 前面不是... │ ├─────────────────────────────────────────────────────────────┤ │ (?P<name>...) 命名分组 │ │ (?:...) 非捕获分组 │ │ \1, \2 反向引用 │ └─────────────────────────────────────────────────────────────┘

re.sub 替换函数

# 基本替换
re.sub(pattern, replacement, string)

# 替换函数
def replace_func(match):
    return ...  # 返回替换文本

re.sub(pattern, replace_func, string)

# 返回替换次数
re.subn(pattern, replacement, string)  # 返回 (结果, 次数)

考前记忆

面试重点

  1. 零宽断言的区别

    • 先行:看后面((?=) 正向,(?!) 负向)
    • 后行:看前面((?<=) 正向,(?<!) 负向)
    • 不消耗字符,只做条件判断
  2. 分组类型

    • 捕获分组 (...):保存到 groups
    • 非捕获分组 (?:...):不保存
    • 命名分组 (?P<name>...):命名访问
  3. 反向引用 \1

    • 引用前面分组的内容
    • 用于匹配重复结构
  4. sub 替换函数

    • 接受 match 对象参数
    • 返回替换字符串
  5. lookaround 用途

    • 精确提取(前后有特定上下文的内容)
    • 条件匹配

记忆口诀

零宽断言不占位, 先行看后后看前。 分组捕获要记牢, 反向引用\1到。 sub函数替换强, 日志分析它最棒。

测试题

选择题

1. (?=...) 是什么类型的断言?

# A. 正向后行断言
# B. 负向后行断言
# C. 正向先行断言
# D. 负向先行断言

答案:C


2. re.sub() 的第二个参数可以是什么类型?

# A. 只能是字符串
# B. 只能是函数
# C. 字符串或函数
# D. 只能是列表

答案:C


3. 命名分组的语法是什么?

# A. (name:...)
# B. <name:...>
# C. (?P<name>...)
# D. (:name...) 

答案:C


4. (?<=...) 是什么类型的断言?

# A. 正向先行
# B. 负向先行
# C. 正向后行
# D. 负向后行

答案:C


5. 反向引用 \1 的作用是什么?

# A. 引用第一个分组
# B. 引用整个匹配
# C. 引用字符串开头
# D. 转义数字

答案:A


编程题

1. 实现一个模板引擎:

import re

class TemplateEngine:
    """简单的模板引擎"""
    
    def __init__(self, template):
        self.template = template
    
    def render(self, context):
        """渲染模板"""
        result = self.template
        
        # 替换 {{ variable }}
        result = re.sub(r'\{\{(\w+)\}\}', 
                       lambda m: str(context.get(m.group(1), '')), 
                       result)
        
        # 替换 {{ var|filter }}
        def apply_filter(m):
            var, filter_name = m.group(1), m.group(2)
            value = context.get(var, '')
            return self._apply_filter(value, filter_name)
        
        result = re.sub(r'\{\{(\w+)\|(\w+)\}\}', apply_filter, result)
        
        # 替换 {% if condition %}...{% endif %}
        result = self._process_conditionals(result, context)
        
        return result
    
    @staticmethod
    def _apply_filter(value, filter_name):
        """应用过滤器"""
        filters = {
            'upper': lambda v: v.upper(),
            'lower': lambda v: v.lower(),
            'capitalize': lambda v: v.capitalize(),
            'trim': lambda v: v.strip(),
            'length': lambda v: len(str(v))
        }
        return filters.get(filter_name, lambda v: v)(value)
    
    def _process_conditionals(self, template, context):
        """处理条件语句"""
        # 匹配 {% if var %}...{% endif %}
        pattern = r'\{%\s*if\s+(\w+)\s*%\}(.*?)\{%\s*endif\s*%\}'
        
        def replace_if(match):
            var = match.group(1)
            content = match.group(2)
            if context.get(var):
                return content
            return ''
        
        return re.sub(pattern, replace_if, template, flags=re.DOTALL)

# 使用
template = """
<h1>{{ title|upper }}</h1>
<p>欢迎, {{ name|capitalize }}</p>
{% if show_footer %}
<footer>版权信息</footer>
{% endif %}
"""

engine = TemplateEngine(template)
context = {
    'title': 'Welcome',
    'name': 'john doe',
    'show_footer': True
}

print(engine.render(context))

2. 实现一个爬虫数据提取器:

import re

class DataExtractor:
    """网页数据提取器"""
    
    @staticmethod
    def extract_articles(html):
        """提取文章列表"""
        # 提取文章标题和链接
        pattern = r'<article[^>]*>.*?<a[^>]*href=["\']([^"\']+)["\'][^>]*>([^<]+)</a>.*?</article>'
        
        articles = []
        for match in re.finditer(pattern, html, re.DOTALL):
            articles.append({
                'url': match.group(1),
                'title': match.group(2).strip()
            })
        return articles
    
    @staticmethod
    def extract_images(html):
        """提取图片"""
        pattern = r'<img[^>]+src=["\']([^"\']+)["\'][^>]*(?:alt=["\']([^"\']*)["\'])?[^>]*>'
        
        images = []
        for match in re.finditer(pattern, html):
            images.append({
                'src': match.group(1),
                'alt': match.group(2) if match.lastindex >= 2 else ''
            })
        return images
    
    @staticmethod
    def extract_metadata(html):
        """提取元数据"""
        meta = {}
        
        # title
        title_match = re.search(r'<title>([^<]+)</title>', html)
        if title_match:
            meta['title'] = title_match.group(1)
        
        # meta 标签
        meta_pattern = r'<meta[^>]+(?:name|property)=["\']([^"\']+)["\'][^>]+content=["\']([^"\']+)["\']'
        for match in re.finditer(meta_pattern, html, re.IGNORECASE):
            meta[match.group(1)] = match.group(2)
        
        # keywords
        kw_match = re.search(r'<meta[^>]+name=["\']keywords["\'][^>]+content=["\']([^"\']+)["\']', 
                            html, re.IGNORECASE)
        if kw_match:
            meta['keywords'] = [k.strip() for k in kw_match.group(1).split(',')]
        
        return meta

# 使用
sample_html = '''
<!DOCTYPE html>
<html>
<head>
    <title>示例页面</title>
    <meta name="description" content="这是一个示例页面">
    <meta name="keywords" content="Python, 正则, 爬虫">
</head>
<body>
    <article>
        <a href="/article/1">第一篇文章</a>
    </article>
    <article>
        <a href="/article/2">第二篇文章</a>
    </article>
    <img src="/images/1.jpg" alt="图片1">
    <img src="/images/2.jpg" alt="图片2">
</body>
</html>
'''

extractor = DataExtractor()
print("文章:", extractor.extract_articles(sample_html))
print("图片:", extractor.extract_images(sample_html))
print("元数据:", extractor.extract_metadata(sample_html))

问答题

Q1: 什么是零宽断言?请举例说明正向和负向的区别。

零宽断言是一种不消耗字符的断言,只用于判断位置前后的条件是否满足。

# 正向先行 (?=...) - 后面必须是...
text = "100元 200美元 300日元"
re.findall(r'\d+(?=元)', text)  # ['100'] - 只匹配后面是"元"的数字

# 负向先行 (?!...) - 后面必须不是...
re.findall(r'\d+(?!元)', text)  # ['200', '300'] - 后面不是"元"的数字

Q2: 捕获分组和非捕获分组有什么区别?

类型 语法 保存到 groups 性能
捕获分组 (...) 稍慢
非捕获分组 (?:...) 稍快

选择依据:

  • 需要使用分组结果 → 捕获分组
  • 不需要分组结果 → 非捕获分组(性能更好)

Q3: re.sub() 的替换函数参数是什么?有什么用?

替换函数接收一个 match 对象参数,返回替换字符串。

def replace_func(match):
    # match.group(0) - 整个匹配
    # match.group(1) - 第一个分组
    # match.groupdict() - 命名分组
    return replacement

re.sub(pattern, replace_func, text)

常用于:

  • 需要根据匹配内容动态计算替换值
  • 复杂的数据转换
  • 格式化输出

参考资料