12 周课程 / 第 8 周 10 分钟阅读

第 8 周:正则表达式与批量文件管理

本周解决两类任务:从文字中按规则提取内容,以及用程序创建目录、复制文件、重命名文件和检查结果。正则表达式负责“找文本”,os 与 shutil 负责“管理文件”。

建议用时:8-10 小时。

本周目标#

学完本周,你应该能够:

  • 写出匹配数字、字母、空格和固定文字的基础正则表达式。
  • 使用 re.findall() 提取全部匹配内容。
  • 使用 re.search() 与分组读取第一个匹配。
  • 根据题目要求控制匹配边界,避免多提取或漏提取。
  • 使用 os.path 拼接路径、判断文件和目录。
  • 使用 os.makedirs() 创建目录。
  • 使用 os.listdir()、os.rename() 批量处理文件。
  • 使用 shutil.copy()、shutil.move() 复制和移动文件。
  • 完成标签编号提取和目录归档工具。

8.1 为什么需要正则表达式#

假设要从下面的文本提取所有 PY-2026- 后面的三位数字:

A-PY-2026-015
B-PY-2026-102
C-NET-2026-007
D-PY-2026-015

逐字符判断也可以做,但规则稍复杂后就容易写乱。正则表达式直接描述“文本长什么样”:

PY-2026- 后面跟三位数字

对应模式:

r"PY-2026-\d{3}"

8.2 原始字符串#

正则模式通常写成原始字符串:

pattern = r"\d{3}"

前面的 r 表示反斜杠按原样处理。若使用普通字符串 "\\d{3}",也必须写两个反斜杠。正则中包含 \d、\w、\s 时,优先使用 r"...",减少转义混乱。

8.3 常用正则符号#

符号 含义 示例匹配
. 任意一个字符,默认不含换行 a、7
\d 一个数字 0-9
\D 一个非数字字符 A、-
\w 字母、数字或下划线 P、8、_
\W 非字母数字下划线 -、空格
\s 一个空白字符 空格、制表符、换行
\S 一个非空白字符 A、7
[abc] 括号中任意一个字符 a、b、c
[^abc] 不是括号中字符的任意字符 d、1
[A-Z] 大写字母范围 A-Z
[0-9] 数字范围 0-9

8.4 数量词#

数量词 含义
* 前面的内容出现 0 次或多次
+ 出现 1 次或多次
? 出现 0 次或 1 次
{3} 恰好 3 次
{2,5} 2 到 5 次
{3,} 至少 3 次

例子:

\d{3}       # 恰好三位数字
[A-Z]+      # 一个或多个大写字母
colou?r     # color 或 colour

8.5 锚点和边界#

符号 含义
^ 默认表示字符串开头
$ 默认表示字符串结尾,或末尾换行前
\b 单词边界

示例:

r"^\d{3}$"  # 整个字符串恰好是三位数字

re.fullmatch(r"\d{3}", text) 也可以要求整个字符串完全匹配。

注意:re.search() 只要求模式在某处出现,不要求覆盖整个字符串。

8.6 分组#

圆括号可以把一部分模式作为分组:

r"PY-2026-(\d{3})"

括号内是“末尾三位数字”。使用 findall() 时,如果模式有分组,通常只返回分组内容:

import re

text = "A-PY-2026-015"
result = re.findall(r"PY-2026-(\d{3})", text)

print(result)  # ['015']

如果模式有两个分组,findall() 返回元组列表:

text = "A-15, B-27"
result = re.findall(r"([A-Z])-(\d{2})", text)

print(result)  # [('A', '15'), ('B', '27')]

第一阶段会考 re.findall 直接返回什么,必须根据有没有分组判断。

8.7 re.findall():找出全部匹配#

import re

text = "电话 13800138000,备用 13900139000"
numbers = re.findall(r"\d{11}", text)

print(numbers)
# ['13800138000', '13900139000']

没有分组时,findall() 返回匹配到的完整字符串列表。没有匹配时返回空列表 []。

找所有单词#

text = "Python makes data processing easier"
words = re.findall(r"[A-Za-z]+", text)
print(words)

[A-Za-z]+ 表示连续一个或多个英文字母。

找所有三位数字#

text = "A12 345 B6789 C001"
numbers = re.findall(r"\d{3}", text)
print(numbers)  # ['345', '678', '001']

6789 中会匹配前三个字符 678,这不是错误。若只希望独立的三位数字,需要添加边界条件。

8.8 re.search():只找第一个#

import re

text = "编号:PY-2026-015"
match = re.search(r"PY-2026-(\d{3})", text)

if match:
    print(match.group(0))  # PY-2026-015,完整匹配
    print(match.group(1))  # 015,第一个分组
else:
    print("没有找到")

区别:

  • 没有匹配时,search() 返回 None。
  • 有匹配时返回 Match 对象。
  • group(0) 是完整匹配。
  • group(1) 是第一个分组。

直接调用 match.group(1) 前必须确认 match 不为 None,否则会报 AttributeError。

import re

print(bool(re.match(r"\d{3}", "12345")))     # True,从开头匹配到 123
print(bool(re.fullmatch(r"\d{3}", "12345"))) # False,整个字符串不是三位
print(bool(re.search(r"\d{3}", "A123B")))    # True,中间找到 123
  • match() 从字符串开头尝试匹配,但不要求到结尾。
  • fullmatch() 要求整个字符串完全匹配。
  • search() 在任意位置查找第一个匹配。

考试若说“查找是否包含”,通常是 search;若说“整个字符串格式必须为”,用 fullmatch 或加首尾锚点。

8.10 匹配模式中的特殊字符#

以下字符在正则中有特殊含义:

. ^ $ * + ? { } [ ] \ | ( )

如果只想匹配文字里的点:

# 错误理解:. 会匹配任意字符
# 正确:转义点
pattern = r"\.py$"

匹配文件名以 .py 结尾:

import re

print(bool(re.search(r"\.py$", "task1.py")))  # True

也可以使用 re.escape("特殊文字") 自动处理特殊字符。

8.11 贪婪匹配#

数量词默认尽量多匹配:

import re

text = "<A><B>"
print(re.findall(r"<.+>", text))  # ['<A><B>']
print(re.findall(r"<.+?>", text)) # ['<A>', '<B>']

在数量词后加 ? 会变成非贪婪,尽量少匹配。基础题中先明确题目到底要整段还是最小片段,不要无脑全部加 ?。

8.12 本周作品一:提取标签编号#

输入文件示例:

A-PY-2026-015
B-PY-2026-102
C-NET-2026-007
D-PY-2026-015
E-ART-2026-088
F-PY-2026-031
import re

text = """A-PY-2026-015
B-PY-2026-102
C-NET-2026-007
D-PY-2026-015
E-ART-2026-088
F-PY-2026-031"""

numbers = re.findall(r"PY-2026-(\d{3})", text)
unique_numbers = sorted(set(numbers), key=int)

print(numbers)        # ['015', '102', '015', '031']
print(unique_numbers) # ['015', '031', '102']

set(numbers) 去重;key=int 按数值排序,因此不会出现按字符串排序时 "102" 跑到 "15" 前面的问题。"015" 仍保留前导零。

8.13 os.path:路径判断和拼接#

import os

base = "模拟赛"
data_file = os.path.join(base, "data", "stage2_scores.txt")

print(data_file)
print(os.path.exists(data_file))
print(os.path.isfile(data_file))
print(os.path.isdir(base))

os.path.join() 根据操作系统使用合适的分隔符,比手工写 "/" 或 "\\" 更稳妥。

常用函数:

函数 作用
os.getcwd() 当前工作目录
os.listdir(path) 列出目录中的名称
os.path.exists(path) 路径是否存在
os.path.isfile(path) 是否是文件
os.path.isdir(path) 是否是目录
os.path.join(a, b) 拼接路径
os.path.basename(path) 取最后文件名
os.path.dirname(path) 取上层目录
os.path.splitext(path) 拆主文件名和扩展名
path = "模拟赛/data/stage2_scores.txt"
name, extension = os.path.splitext(os.path.basename(path))

print(name)       # stage2_scores
print(extension)  # .txt

8.14 创建目录#

import os

output_dir = os.path.join("模拟赛", "output", "归档", "原始数据")
os.makedirs(output_dir, exist_ok=True)

makedirs() 会创建路径中缺少的多个层级,不只最后一级。

exist_ok=True 的含义是:如果目录已经存在,不报错;如果不存在,就创建。

  • os.mkdir() 通常只能创建一个层级,父目录必须已存在。
  • os.makedirs() 可以创建多层目录。

8.15 列出、筛选和重命名#

import os

folder = "模拟赛/output"

for name in os.listdir(folder):
    full_path = os.path.join(folder, name)
    if os.path.isfile(full_path) and name.endswith(".txt"):
        print(name)

os.listdir() 只返回名称,不自动返回完整路径。进入子目录或判断文件时,应使用 os.path.join()。

重命名#

old_path = "模拟赛/output/old.txt"
new_path = "模拟赛/output/new.txt"

if os.path.exists(old_path):
    os.rename(old_path, new_path)

os.rename() 的目标可以是同一目录中的新名字,也可以是另一个已有目录下的新路径。跨文件系统时可能失败,适合基础本地文件操作。

批量重命名#

import os

folder = "模拟赛/output/renamed"
os.makedirs(folder, exist_ok=True)

for index, name in enumerate(sorted(os.listdir(folder)), start=1):
    old_path = os.path.join(folder, name)
    if not os.path.isfile(old_path):
        continue

    extension = os.path.splitext(name)[1]
    new_name = f"file_{index:02d}{extension}"
    new_path = os.path.join(folder, new_name)

    if old_path != new_path:
        os.rename(old_path, new_path)

{index:02d} 会补成两位数字。批量重命名前应先明确排序规则,并且避免一次运行中把文件改成另一个尚未处理文件的名字,造成冲突。

8.16 复制与移动:shutil#

import os
import shutil

source = os.path.join("模拟赛", "data", "stage2_scores.txt")
target_dir = os.path.join("模拟赛", "output", "归档", "原始数据")
os.makedirs(target_dir, exist_ok=True)

shutil.copy(source, target_dir)

target_file = os.path.join(target_dir, "stage2_scores.txt")
backup_file = os.path.join(target_dir, "scores_backup.txt")

if os.path.exists(target_file):
    os.rename(target_file, backup_file)

常用函数:

函数 作用
shutil.copy(src, dst) 复制文件内容到目标
shutil.copy2(src, dst) 复制内容并尽量保留时间等元数据
shutil.move(src, dst) 移动文件或目录
shutil.rmtree(path) 递归删除目录

shutil.rmtree() 会删除整个目录树,风险很高。考试和练习中不要对未确认的目录使用,尤其不要拼接用户输入的任意路径调用。

8.17 本周作品二:第二阶段文件管理#

下面是第二阶段任务四的核心步骤:

import os
import re
import shutil

labels_path = "模拟赛/data/stage2_labels.txt"
output_dir = "模拟赛/output"
archive_dir = os.path.join(output_dir, "归档", "原始数据")
number_output = os.path.join(output_dir, "第二阶段_编号.txt")
source_scores = "模拟赛/data/stage2_scores.txt"

os.makedirs(archive_dir, exist_ok=True)

with open(labels_path, "r", encoding="utf-8") as file:
    text = file.read()

numbers = re.findall(r"PY-2026-(\d{3})", text)
numbers = sorted(set(numbers), key=int)

with open(number_output, "w", encoding="utf-8") as file:
    for number in numbers:
        print(number, file=file)

shutil.copy(source_scores, archive_dir)

copied_file = os.path.join(archive_dir, "stage2_scores.txt")
backup_file = os.path.join(archive_dir, "scores_backup.txt")
os.rename(copied_file, backup_file)

print("目录存在:", os.path.isdir(archive_dir))
print("原始副本存在:", os.path.exists(copied_file))
print("备份文件存在:", os.path.exists(backup_file))

预期三个布尔结果:

True
False
True

重命名以后,旧路径 copied_file 不存在,新路径 backup_file 才存在。

8.18 文件操作前的安全顺序#

  1. 确定源路径和输出目录。
  2. 检查源文件是否存在。
  3. 创建输出目录,并设置 exist_ok=True。
  4. 只读原始文件。
  5. 复制或生成新文件。
  6. 重命名后使用新路径检查。
  7. 输出目录、旧文件名和新文件名分别验证。

调试文件问题时,不要只打印 "done"。打印实际路径和每个存在性检查,能快速定位是逻辑错还是路径错。

8.19 独立练习#

A. 快速判断题#

  1. \d{3} 表示什么?
  2. re.findall(r"\d", "A1B2") 返回什么?
  3. re.findall(r"(\d+)", "A12B345") 返回什么?
  4. re.search() 找不到时返回什么?
  5. \. 在正则中表示什么?
  6. 创建多层目录用哪个函数?
  7. os.path.join("a", "b.txt") 的作用是什么?
  8. os.listdir() 返回的是完整路径还是目录中的名称?
  9. shutil.copy() 与 os.rename() 的主要区别是什么?
  10. 重命名后应该检查旧路径还是新路径?

B. 编程题#

  1. 从 PY-2026-015 中提取 015。
  2. 从文本中提取所有五位数字。
  3. 提取所有形如 S01、S99 的编号。
  4. 对重复编号去重并按数值升序输出。
  5. 创建 output/归档/原始数据 三层目录。
  6. 复制一个文本文件到归档目录,再重命名为备份文件。
  7. 列出目录中的所有 .py 文件并输出完整路径。
  8. 把目录中的文本文件按当前名称排序,并批量加前缀 backup_。

8.20 练习答案与解释#

A. 快速判断题答案#

  1. 恰好连续三位数字。
  2. ['1', '2']。
  3. ['12', '345']。有分组时只返回分组内容。
  4. None。
  5. 一个普通的小数点字符,不是“任意字符”。
  6. os.makedirs(path, exist_ok=True)。
  7. 按当前操作系统规则把两个路径片段拼接起来。
  8. 目录中的名称,通常不是完整路径。
  9. shutil.copy() 复制一份,源文件保留;os.rename() 改变原路径,本质上移动或改名。
  10. 新路径。旧路径在成功重命名后通常已不存在。

B. 编程题参考答案#

  1. 提取末尾编号:

    import re
    
    text = "PY-2026-015"
    result = re.search(r"PY-2026-(\d{3})", text)
    if result:
        print(result.group(1))
    
  2. 提取五位数字:

    import re
    
    text = "A12345 B67890 C123"
    print(re.findall(r"\d{5}", text))
    
  3. 提取 S 加两位数字:

    import re
    
    text = "S01 S99 X100 S7"
    print(re.findall(r"S\d{2}", text))  # ['S01', 'S99']
    
  4. 去重并数值排序:

    numbers = ["015", "102", "015", "031"]
    result = sorted(set(numbers), key=int)
    print(result)  # ['015', '031', '102']
    
  5. 创建多层目录:

    import os
    
    path = os.path.join("output", "归档", "原始数据")
    os.makedirs(path, exist_ok=True)
    
  6. 复制和重命名:

    import os
    import shutil
    
    target_dir = os.path.join("output", "归档", "原始数据")
    os.makedirs(target_dir, exist_ok=True)
    
    shutil.copy("scores.txt", target_dir)
    old_path = os.path.join(target_dir, "scores.txt")
    new_path = os.path.join(target_dir, "scores_backup.txt")
    os.rename(old_path, new_path)
    
  7. 列出 .py 文件完整路径:

    import os
    
    folder = "."
    for name in os.listdir(folder):
        path = os.path.join(folder, name)
        if os.path.isfile(path) and name.endswith(".py"):
            print(path)
    
  8. 批量添加前缀:

    import os
    
    folder = "output"
    for name in sorted(os.listdir(folder)):
        old_path = os.path.join(folder, name)
        if not os.path.isfile(old_path) or not name.endswith(".txt"):
            continue
    
        new_name = "backup_" + name
        new_path = os.path.join(folder, new_name)
        os.rename(old_path, new_path)
    

    运行前应确认目标名称不冲突,并在可恢复的测试目录中执行。

8.21 常见错误清单#

  • 正则字符串中忘记转义点,.py 把任意字符加 py 也匹配。
  • 使用分组后误以为 findall() 返回完整匹配。
  • 在任意四位数字中截取前三位,却以为只匹配独立三位。
  • 对字符串编号直接排序,导致前导零和长度不同的排序不符合要求。
  • search() 结果可能为 None,未检查就调用 group()。
  • 拼接路径时直接手工拼分隔符。
  • listdir() 只拿到名称,却当成完整路径使用。
  • 不检查源文件是否存在就复制。
  • 重命名后仍去旧路径找文件。
  • 对未确认目录使用 shutil.rmtree()。

8.22 过关检查#

  • 能解释 .、\d、[]、+、*、?、{3} 的含义。
  • 能写模式提取指定前缀后的三位数字。
  • 能判断 findall() 在有分组和无分组时的返回形式。
  • 能安全使用 search() 的 Match 对象。
  • 能使用 os.path.join()、exists()、isfile()、isdir()。
  • 能使用 os.makedirs(..., exist_ok=True) 创建多层目录。
  • 能复制、重命名并分别验证新旧路径。
  • 能按数值排序编号,而不是按字符串顺序。
  • 独立完成第二阶段任务四并达到满分。
  • 任何文件删除或批量重命名操作前,都能说明影响范围。

本周进度

完成讲义、练习和验收后再标记。