自动化办公与鼠标键盘模拟

本篇的运行环境

  • 代码全是 Windows 桌面程序接口,要动注册表、动鼠标键盘、要真的弹窗口。所以每条结论都在下面这台机器上跑过,输出直接贴在代码块后面,没有的是「跑不出来」,我也会把报错原样写出来。
  • 实测机器与解释器:

    1
    2
    3
    4
    5
    Windows-11-10.0.26300-SP0        # platform.platform()
    Python 3.14.10 (MSC v.1942 64 bit AMD64)
    Office:Microsoft 365 ProPlus(WINWORD/EXCEL/POWERPNT 都在 C:\Program Files\Microsoft Office\root\Office16)
    Word COM Version = 16.0 Build = 16.0.20527
    屏幕:物理 2560x1440,系统缩放 125%(GetDpiForSystem 返回 120)
  • 第三方包都装在一个独立虚拟环境里,不动 Anaconda 的 base:

    1
    2
    3
    4
    5
    # 建环境 + 装本篇用到的包
    D:/ProgramData/anaconda3/python.exe -m venv E:/Desktop/Test/note-venv
    E:/Desktop/Test/note-venv/Scripts/python.exe -m pip install pywin32 pdfminer3k pygame-ce
    E:/Desktop/Test/note-venv/Scripts/python.exe -m pip install openpyxl xlrd pyexcel-xls python-docx python-pptx
    E:/Desktop/Test/note-venv/Scripts/python.exe -m pip install pyautogui pyperclip opencv-python
    1
    2
    3
    4
    5
    6
    pywin32 312         pdfminer3k 1.3.4     pdfminer.six 20260107
    pypdf 6.21.4 PyMuPDF 2.1 pygame-ce 2.5.8 (SDL 2.32.10)
    openpyxl 3.1.5 xlrd 2.0.2 xlwt 1.3.0
    pyexcel-xls 0.7.1 pyexcel-xlsx 0.6.1 pyexcel-io 0.6.8 pyexcel 0.7.6
    python-docx 1.2.0 python-pptx 1.0.2 pyautogui 0.9.54
    Pillow 13.0.0 opencv-python 5.0.0 lxml 6.1.3
  • pip install pygame 在 Python 3.14 上会失败,这一点必须提前说,否则第一节「播放音乐」就跑不通:

    1
    2
    ERROR: Failed to build 'pygame' when getting requirements to build wheel
    FileNotFoundError: [WinError 2] 系统找不到指定的文件。
    • 原因是官方 pygame 最新只到 2.6.1(2024 年发布),只打到 cp313 的 wheel,Python 3.14 没有预编译包,pip 只能下源码去编译,而编译需要 Visual Studio 工具链。
    • 解决办法是装社区分支 pygame-ce,它有 cp314 的 wheel,导入名仍然叫 pygame,本篇的音乐代码一行都不用改:pip install pygame-ce。

读写csv文件

读csv文件

  • CSV(Comma-Separated Values,逗号分隔值)就是「一行一条记录、字段之间用逗号隔开」的纯文本表格。csv 是标准库里的模块,不用安装。

  • 先造一个带中文、带逗号、带换行的样本文件,这样才能看出 csv 模块和「自己 split(',')」的区别:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    # 造样本:products.csv
    import csv

    rows = [
    ["编号", "名称", "单价", "库存"],
    ["1", "苹果, 红", "3.5", "100"],
    ["2", "香蕉", "2.8", "50"],
    ["3", "带换行\n的第二列", "1.0", "3"],
    ]
    with open("products.csv", "w", encoding="utf-8", newline="") as f:
    csv.writer(f).writerows(rows)
  • 原笔记的读取写法(在 Python 3.14 上直接可跑):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    import csv

    path = r'./products.csv'


    def readCsv(path):
    infoList = []
    with open(path, 'r', encoding='utf8') as f:
    allFileInfo = csv.reader(f)
    for row in allFileInfo:
    infoList.append(row)
    return infoList


    print(readCsv(path))
    1
    2
    3
    4
    [['编号', '名称', '单价', '库存'],
    ['1', '苹果, 红', '3.5', '100'],
    ['2', '香蕉', '2.8', '50'],
    ['3', '带换行\n的第二列', '1.0', '3']]
  • 注意输出的类型:每个字段都是字符串,'3.5' 不是数字、'100' 不是整数,要算数得自己 float() 或 int() 转。这是 csv 模块的基本设定——它只负责拆字段,不负责猜类型。

  • 「苹果, 红」这种字段里带逗号的,写出去的时候被加了引号,读回来还是完整一个字段;字段里的换行也一样保留。这正是用 csv 而不是 open().read().split(',') 的理由,实测对比:

    1
    2
    3
    4
    5
    6
    7
    8
    import csv, io

    buf = io.StringIO()
    csv.writer(buf).writerow(['1', '他说"你好"', 'A,B', '多\n行'])
    line = buf.getvalue()
    print("写出:", repr(line))
    print("csv.reader 读回:", list(csv.reader(io.StringIO(line))))
    print("split(',') 读回:", line.strip().split(','))
    1
    2
    3
    写出: '1,"他说""你好""","A,B","多\n行"\r\n'
    csv.reader 读回: [['1', '他说"你好"', 'A,B', '多\n行']]
    split(',') 读回: ['1', '"他说""你好"""', '"A', 'B"', '"多\n行"']

写csv文件

  • 原笔记的写入函数,注释只留了一句「会出现空行问题!」。这个问题是真实存在的,本机复现出来了,原因和解法如下。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    import csv

    path = r'./000002.csv'


    def writeCsv(path, data):
    with open(path, 'w', encoding='utf8') as f:
    writer = csv.writer(f)
    for rowData in data:
    writer.writerow(rowData)


    writeCsv(path, [[1, 2, 3], [4, 5, 6], [7, 8, 9]])
    print(open(path, 'rb').read())
    1
    2
    b'1,2,3\r\r\n4,5,6\r\r\n7,8,9\r\r\n'
    字节数 = 24,CRLF 有 3 个,splitlines() 之后是 6 行
  • 空行的来源:csv.writer 默认的换行符 lineterminator 已经是 \r\n 了(实测写进 StringIO 得到 'a,b\r\n'),而文本模式的 open() 在 Windows 上还会把 \n 再翻译成 \r\n,于是 \r\n 变成 \r\r\n。多出来的那个 \r 被 Excel 和记事本当成一个空行。

  • 修法就一个参数:open() 的时候加 newline='',让文本模式别再做换行翻译,这是官方文档要求的写法。

    1
    2
    3
    with open(path, 'w', encoding='utf-8', newline='') as f:
    csv.writer(f).writerows([[1, 2, 3], [4, 5, 6], [7, 8, 9]])
    print(open(path, 'rb').read())
    1
    b'1,2,3\r\n4,5,6\r\n7,8,9\r\n'      # 21 字节,3 行,干净
  • 读的时候同样建议 newline='':字段里含换行的 CSV 必须靠 csv.reader 自己判断边界,如果开了文本模式的换行翻译,读长文件时可能出现空行或断行。

  • 引号规则(QUOTE_MINIMAL,默认):字段里出现分隔符、引号或换行时,整个字段加双引号,字段内部的双引号写成两个。实测 '带"引号"' 写出去是 "带""引号"。

  • 中文 Excel 打开 UTF-8 的 CSV 会乱码,因为 Excel 按系统 ANSI(简体中文是 GBK)猜编码。给对方看的文件要带 BOM,读写用 utf-8-sig:

    1
    2
    3
    4
    5
    6
    7
    import csv

    with open("bom.csv", "wb") as f:
    f.write(b"\xef\xbb\xbf" + "姓名,年龄\n张三,18\n".encode("utf-8"))

    print(list(csv.reader(open("bom.csv", encoding="utf-8")))[0])
    print(list(csv.reader(open("bom.csv", encoding="utf-8-sig")))[0])
    1
    2
    ['\ufeff姓名', '年龄']        # 用 utf-8 读:BOM 变成第一个字段开头的隐形字符
    ['姓名', '年龄'] # utf-8-sig 会自动吃掉 BOM
  • 按列名读写比按下标好读,用 DictWriter 和 DictReader:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    import csv

    with open("d.csv", "w", encoding="utf-8", newline="") as f:
    w = csv.DictWriter(f, fieldnames=["id", "name"])
    w.writeheader()
    w.writerow({"id": 1, "name": "张三"})
    w.writerow({"id": 2, "name": "李四"})

    with open("d.csv", encoding="utf-8", newline="") as f:
    for row in csv.DictReader(f):
    print(dict(row))
    1
    2
    {'id': '1', 'name': '张三'}
    {'id': '2', 'name': '李四'}
    • 写出来的文件是:id,name\r\n1,张三\r\n2,李四\r\n,第一行是表头。
    • 读回来的值仍然是字符串('1' 而不是 1),要数字得自己转。
  • 处理 CSV 的通用节奏:open(..., newline='') 配合 encoding='utf-8'(或 'utf-8-sig'),读出是列表的列表、写入用 writerow 或 writerows,字段类型自己转。

读取PDF文件

安装所需的依赖包

1
pip install pdfminer3k
1
Successfully installed pdfminer3k-1.3.4 ply-3.11
  • pdfminer3k 是一个 2018 年冻结的分支(把老的 pdfminer 改成纯 Python 3),依赖只有一个 ply。它能装能跑,Python 3.14 也没问题——本篇实测如此。

  • 但它和后来有人在用的 pdfminer.six 同名冲突,两个包都往 site-packages/pdfminer/ 目录里放文件,后装的直接覆盖先装的:

    1
    2
    3
    4
    5
    6
    7
    # 先装 pdfminer3k,再装 pdfminer.six 之后,原笔记的第一行 import 就炸了
    ImportError: cannot import name 'PDFDocument' from 'pdfminer.pdfparser'
    (E:\Desktop\Test\note-venv\Lib\site-packages\pdfminer\pdfparser.py)

    pip list 里两个都还在:
    pdfminer3k 1.3.4
    pdfminer.six 20260107
    • 也就是说 pip list 看不出问题,只有 import 的时候才发现「装了的包被另一个包顶掉了」。
    • 选一个用就行;要用 pdfminer.six,写法完全不同(见下面「换个更好用的库」)。

读取pdf文件

  • 测试用的 PDF 是用 Word COM 把本篇生成的 01.docx 导出来的(ExportAsFixedFormat(path, 17),17 就是「导出为 PDF」),里面有三行中文。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    import sys
    import importlib

    importlib.reload(sys)

    from pdfminer.pdfparser import PDFParser, PDFDocument
    from pdfminer.pdfinterp import PDFResourceManager, PDFPageInterpreter
    from pdfminer.converter import PDFPageAggregator
    from pdfminer.layout import LTTextBoxHorizontal, LAParams
    from pdfminer.pdfinterp import PDFTextExtractionNotAllowed

    path = r'./test.pdf'
    toPath = r'./a.txt'


    def readPDF(path, toPath):
    # 以二进制形式打开PDF文件
    f = open(path, 'rb')
    # 创建一个pdf文档分析器
    parser = PDFParser(f)
    # 创建pdf文档
    pdfFile = PDFDocument()
    # 连接分析器与文档
    parser.set_document(pdfFile)
    pdfFile.set_parser(parser)
    # 提供初始化密码
    pdfFile.initialize()
    # 检测文档是否提供txt转换
    if not pdfFile.is_extractable:
    raise PDFTextExtractionNotAllowed
    else:
    # 解析数据
    manager = PDFResourceManager()
    laparams = LAParams()
    device = PDFPageAggregator(manager, laparams=laparams)
    interpreter = PDFPageInterpreter(manager, device)
    for page in pdfFile.get_pages():
    interpreter.process_page(page)
    layout = device.get_result()
    for x in layout:
    if (isinstance(x, LTTextBoxHorizontal)):
    with open(toPath, 'a', encoding='utf8') as f:
    str = x.get_text()
    print(str)
    f.write(str + '\n')


    readPDF(path, toPath)
    1
    2
    3
    4
    5
    6
    7
    8
    is_extractable = True
    亲爱的测试员

    我想你……

    第三段:中文、English、123。

    处理页数 = 1,输出 a.txt 93 字节
  • 这段 2020 年的代码到今天还是能原样跑的,但里面有三处需要知道的东西:

    • importlib.reload(sys) 是 Python 2 时代的残留(当时为了解开 sys.setdefaultencoding 的限制)。Python 3 里这句什么都不做,sys.setdefaultencoding 这个函数也根本不存在,可以直接删。
    • str = x.get_text() 把内置类型 str 覆盖成了局部变量,函数里后面再想用 str() 就废了。这种「拿内置名当变量名」的写法在循环里特别容易埋雷,改成 text = x.get_text()。
    • 写文件用 'a'(追加)而不是 'w',而且是在循环里反复 open。实测同一个 PDF 连跑两次,输出文件从 47 个字符变成 94 个字符——整篇内容被重复追加了一遍。正确做法是外面 open(toPath, 'w') 一次,循环里只写。

只能读文字,图片读不出来

  • 原笔记那句「只能处理PDF中的文本,图片无法读取」是对的,实测证据是这样:把一个 240x120 的红底图片插进 Word 再导成 PDF,用上面那段代码解析,版面元素里图片是 LTFigure,isinstance(x, LTTextBoxHorizontal) 不成立,于是被整段跳过。

    1
    2
    3
    解析 test.pdf(三行字)     版面元素: {'LTTextBoxHorizontal': 3, 'LTTextLineHorizontal': 1}
    解析 imgonly.pdf(一行字+一张图) 版面元素: {'LTTextBoxHorizontal': 1, 'LTFigure': 1, 'LTTextLineHorizontal': 1}
    文本: '图上方有一行字 \n'
    • 图片不是「读不出来」,而是 pdfminer 的定位是版面文字抽取,LTFigure 只是告诉你有块图形,像素要自己取。
    • 另外注意文本抽取的产物:每段后面 pdfminer 会补一个空格再换行,所以 '亲爱的测试员 \n' 末尾那个空格是它加的,不是原文里的。

换个更好用的库

  • 现在读 PDF 更常见的是这三个包,本机都装了并跑过同一个文件,可以按需要选:

    1
    2
    3
    # 1) pdfminer.six(维护版,API 和 pdfminer3k 不兼容)
    from pdfminer.high_level import extract_text
    print(repr(extract_text("test.pdf")[:60]))
    1
    '亲爱的测试员 \n\n                我想你…… \n\n第三段:中文、English、123。 \n\n \n\x0c'
    • 一句话就能取全文,extract_text() 是它的高层接口;末尾的 \x0c 是换页符。
    • 加密文档要先传密码:extract_text(path, pwd="1234")。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    # 2) pypdf:读元数据、页数、加密文档
    from pypdf import PdfReader
    r = PdfReader("test.pdf")
    print(r.is_encrypted, len(r.pages), dict(r.metadata))
    e = PdfReader("enc.pdf")
    try:
    e.pages[0].extract_text()
    except Exception as ex:
    print("不解锁就读:", type(ex).__name__, ex)
    print("decrypt('1234') ->", e.decrypt("1234"))
    print("decrypt('0000') ->", PdfReader("enc.pdf").decrypt("0000"))
    print("解锁后的文本:", repr(e.pages[0].extract_text()[:40]))
    1
    2
    3
    4
    5
    False 1 {'/Producer': 'Microsoft® Word 适用于 Microsoft 365', '/CreationDate': "D:20261104053230+08'00'"}
    不解锁就读: FileNotDecryptedError File has not been decrypted
    decrypt('1234') -> 2
    decrypt('0000') -> 0
    解锁后的文本: '亲爱的测试员 \n 我想你…… \n第三段:中文、Eng'
    • decrypt() 返回 0 表示密码错,返回非 0 表示成功,不抛异常,所以千万别拿「没报错」当「密码对了」。
    • is_encrypted 为真时不 decrypt 就直接读页会抛 FileNotDecryptedError。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    # 3) PyMuPDF(pip 包名 pymupdf,导入名 fitz):文本、坐标、图片都能拿
    import pymupdf as fitz
    d = fitz.open("test.pdf")
    print(d.page_count, d.metadata["producer"], d.metadata["format"])
    print(d[0].get_text("words")[0])
    di = fitz.open("imgonly.pdf")
    print("图片数 =", len(di[0].get_images(full=True)))
    info = di.extract_image(di.get_page_images(0)[0][0])
    print("抽出来的图:", info["ext"], info["width"], "x", info["height"], len(info["image"]), "字节")
    enc = fitz.open("enc.pdf")
    print("needs_pass =", enc.needs_pass, "错密码 =", enc.authenticate("0000"), "对密码 =", enc.authenticate("1234"))
    1
    2
    3
    4
    5
    1 Microsoft® Word 适用于 Microsoft 365 PDF 1.7
    (90.024, 75.3176, 156.1425, 86.8213, '亲爱的测试员', 0, 0, 0)
    图片数 = 1
    抽出来的图: png 240 x 120 425 字节
    needs_pass = 1 错密码 = 0 对密码 = 6
    • 要「把 PDF 里的图片存成文件」「按坐标取某一块文字」这类需求,pdfminer 系都不顺手,PyMuPDF 可以。
    • 2024 年之后它的导入名建议写 import pymupdf,fitz 是老名字,两者都能用。

播放音乐

安装所需的依赖包

1
pip install pygame-ce
1
2
Successfully installed pygame-ce-2.5.8
pygame-ce 2.5.8 (SDL 2.32.10, Python 3.14.10) # 导入 pygame 时打印的这一行
  • 官方 pygame 在 Python 3.14 上没有 wheel(见本篇开头),本篇用 pygame-ce。它是同一个 import pygame,pygame.mixer 的接口完全一致。

播放音乐

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import time
import pygame

filePath = r'./house_lo.mp3'

# 初始化
pygame.mixer.init()
# 加载音乐
track = pygame.mixer.music.load(filePath)
# 播放
pygame.mixer.music.play()
# 暂停一下
time.sleep(5)
# 停止
pygame.mixer.music.stop()
  • 原笔记这段是能跑的(把 filePath 换成真实存在的音频文件)。加几行打印,本机实测的结果:

    1
    2
    3
    4
    5
    init 后 get_init() = (44100, -16, 2)      # 采样率, 格式位标志, 声道数
    music.load 返回 = None # 那个 track 变量没有意义
    play 后 get_busy() = True get_pos() = 0 ms
    1 秒后 get_pos() = 1000 ms
    stop 后 get_busy() = False get_pos() = 2372

该知道的几个细节

  • time.sleep(5) 是硬等,曲子不到 5 秒就播完了,超过 5 秒又会被提前 stop。要等「真的播完」有两个办法:

    1
    2
    3
    4
    # 办法一:轮询 get_busy()
    pygame.mixer.music.play()
    while pygame.mixer.music.get_busy():
    time.sleep(0.05)
    1
    自造 1.2 秒 wav 实际播放耗时 = 1.21 秒
    1
    2
    3
    4
    5
    6
    7
    8
    9
    # 办法二:让它发结束事件(要先把显示子系统初始化,否则 pygame.event.get() 抛异常)
    pygame.display.init()
    END = pygame.USEREVENT + 1
    pygame.mixer.music.set_endevent(END)
    pygame.mixer.music.play()
    while True:
    for ev in pygame.event.get():
    if ev.type == END:
    ...
    1
    2
    3
    set_endevent 后 get_endevent() = 32866
    收到结束事件 = <Event(32866-UserEvent {})> 用时 = 1.19 秒
    清空 endevent 后 get_endevent() = 0
    • 实测只 pygame.mixer.init() 不 pygame.display.init() 时,pygame.event.get() 抛 error: video system not initialized;不用真的建窗口,display.init() 就够。
  • pause() 之后 get_busy() 会变成 False,所以「get_busy() 为假」不能区分「播完了」和「被暂停了」,实测:

    1
    2
    3
    pause 后 get_busy() = False
    暂停 0.3 秒后 get_pos() 停在 394 不变
    unpause 0.3 秒后 get_pos() = 701
  • music 这一套没有 get_length(),别照抄网上教程:

    1
    2
    AttributeError: module 'pygame.mixer_music' has no attribute 'get_length'
    AttributeError: module 'pygame.mixer_music' has no attribute 'get_queue'
    • 完整可用列表(pygame-ce 2.5.8 实测 dir 结果):load、play、stop、pause、unpause、rewind、set_pos、get_pos、set_volume、get_volume、fadeout、get_busy、get_endevent、set_endevent、get_metadata、queue、unload。

    • 想知道时长:wav 可以用 pygame.mixer.Sound(path).get_length();mp3 的时长 get_metadata() 不给,它只回标题之类:

      1
      get_metadata() = {'title': '                              ', 'album': '...', 'artist': '...', 'copyright': '...'}
  • 音量是给 0 到 1 的比例,而且会被量化,实测 set_volume(0.3) 读回 0.296875。

  • 播放中途 load() 新文件会直接把正在放的停掉,实测:

    1
    2
    3
    第一首: get_busy = True  get_pos = 502
    播放中 load 新曲后: get_busy = False get_pos = 504 # 已经不在播了,pos 只是残留值
    对新曲 play: get_busy = True get_pos = 411
    • 所以「切歌」的正确顺序是 stop() → load() → play(),别指望 load() 之后自动接着放。
  • 三种常见格式的加载实测都没问题:mp3、ogg、wav 都能 music.load。

  • 短促音效(按钮声、提示音)不该用 music,它一次只能放一首;用 Sound,能同时放 8 路:

    1
    2
    3
    4
    snd = pygame.mixer.Sound("punch.wav")
    print(snd.get_length(), pygame.mixer.get_num_channels())
    ch = snd.play()
    print(ch.get_busy())
    1
    2
    0.367 8
    True
  • 常见报错对照(本机原样复现):

    1
    2
    3
    文件不存在      FileNotFoundError: No file './not_exist.mp3' found in working directory 'E:\Desktop\Test\office-probe'.
    没 init 就 load pygame.error: mixer not initialized(或 audio device not initialized)
    quit 之后 stop pygame.error: mixer not initialized
    • 报错里的「working directory」提示很有用——九成这种错是相对路径没对上,r'./x.mp3' 是相对当前工作目录,不是相对 .py 文件所在目录,用 os.path.abspath(__file__) 拼绝对路径最稳。
    • 进程退出前不 stop() 也不会「声音留在后台」,混音器随进程一起没了;但反过来,while True 里放歌的脚本会一直播到被杀掉为止,见下一节。
  • 一句话选型:只抽文字用 pdfminer.six 的 extract_text();要页数、元数据、解密、拆分合并用 pypdf;要图片、坐标、渲染成位图用 PyMuPDF。pdfminer3k 除非必须沿用老代码,否则不建议新项目再引它(它和 pdfminer.six 冲突,而且早已停止演进)。

修改背景图片

手工路径

  • win+r 打开运行,输入 regedit 回车,进 HKEY_CURRENT_USER\Control Panel\Desktop,右边能看到壁纸相关的值。本机 regedit 里实际存的名字是 WallPaper(P 大写),还有 WallpaperStyle、TileWallpaper。

  • 注册表的值名查询不区分大小写,实测 Wallpaper、WallPaper、wallpaper 三种写法都能读到同一个值:

    1
    2
    3
    RegQueryValueEx('WallPaper')  -> C:\Users\Loong\AppData\Local\WinDynamicDesktop\themes\Catalina\Catalina_2.jpg
    RegQueryValueEx('Wallpaper') -> 同上
    RegQueryValueEx('wallpaper') -> 同上

用代码改壁纸

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# win+r -> regedit -> HKEY_CURRENT_USER -> Control Panel -> Desktop -> WallPaper

import win32api
import win32con
import win32gui

path = r'E:\Desktop\Test\office-probe\wall_test.png'


def setWallPaper(path):
# 打开注册表
reg_key = win32api.RegOpenKeyEx(win32con.HKEY_CURRENT_USER, r'Control Panel\Desktop', 0, win32con.KEY_SET_VALUE)
# 修改壁纸样式,2 拉伸、0 居中、6 适应、10 填充
win32api.RegSetValueEx(reg_key, 'WallpaperStyle', '0', win32con.REG_SZ, '2')
# 设置壁纸
# SPIF_SENDWININICHANGE 立即生效
win32gui.SystemParametersInfo(win32con.SPI_SETDESKWALLPAPER, path, win32con.SPIF_SENDWININICHANGE)


setWallPaper(path)
  • 这段在本机跑通了,壁纸真的换掉了。跑完要用下面两条把原状恢复回来(也是这段代码唯一的「副作用」):

    1
    win32gui.SystemParametersInfo(win32con.SPI_GETDESKWALLPAPER, 260, 0)
    1
    C:\Users\Loong\AppData\Local\WinDynamicDesktop\themes\Catalina\Catalina_2.jpg
  • 壁纸样式的数字对应关系(写进 WallpaperStyle,实测四个值都写成功,回读一致):

    WallpaperStyle TileWallpaper 效果
    0 0 居中
    2 0 拉伸
    6 0 适应(不裁剪,完整显示,可能留边)
    10 0 填充(等比放大后裁掉超出部分,Win10/11 默认)
    1 1 平铺(老式,靠 TileWallpaper=1 打开)

那句 RegSetValueEx 为什么「写错了也能跑」

  • 看第二行参数的位置:RegSetValueEx(键句柄, 值名, reserved, 类型, 数据)。第三个位置在 Win32 API 里叫 reserved(保留字),必须传 0,原笔记传的是字符串 '0',而且第四个位置给了 win32con.REG_SZ、第五个给 '2'——顺序其实是对的,只是把保留字写成了字符串。

  • 实测各种写法的结果:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    reserved 传 整数 0      -> 成功
    reserved 传 字符串 '0' -> 成功
    reserved 传 字符串 'x' -> 成功
    reserved 传 None -> 成功
    reserved 传 整数 12345 -> 成功
    只给 4 个参数 -> TypeError: RegSetValueEx() takes exactly 5 arguments (4 given)
    REG_DWORD 配 int 123 -> 成功,回读 (123, 4)
    REG_DWORD 配 str '123' -> TypeError: an integer is required
    用只有 KEY_READ 权限的句柄写入 -> pywintypes.error: (5, 'RegSetValueEx', '拒绝访问。')
    • 结论:pywin32 根本不检查这个保留参数,它被完全忽略。所以「能跑」不代表「写对了」,习惯上还是要写 0。
    • 真正会咬人的是最后两条:值类型要和 Python 类型匹配(REG_SZ 配字符串、REG_DWORD 配整数),以及打开键时给够权限——KEY_SET_VALUE 写漏了就是「拒绝访问」,错误码 5。

只改注册表不会立刻换壁纸

  • 一个很容易踩的顺序问题:把新路径只写进注册表、不调 SystemParametersInfo,桌面此时不会变。

    1
    2
    3
    只写注册表 Wallpaper=E:\...\wall_test2.png 后
    RegQueryValueEx 读回 = E:\...\wall_test2.png
    SPI_GETDESKWALLPAPER 读回 = E:\...\wall_test.png # 系统当前生效的还是旧的那张
    • 因为壁纸是由资源管理器(explorer.exe)画的,它只在收到 WM_SETTINGCHANGE 广播或者用户重新登录时才重新读注册表。
    • SPIF_SENDWININICHANGE(值 2)就是那个广播标志,所以要立即生效就得带上它。想连注册表一起落盘,再加 SPIF_UPDATEINIFILE(值 1),两个或起来写 win32con.SPIF_UPDATEINIFILE | win32con.SPIF_SENDWININICHANGE。
    • win32gui.SystemParametersInfo 不返回成功与否(实测恒为 None),要确认结果就再调一次 SPI_GETDESKWALLPAPER 读回来比对。

不装 pywin32 的等价写法

  • SystemParametersInfoW 是 user32.dll 里的函数,ctypes 就能调:

    1
    2
    3
    4
    5
    6
    7
    8
    import ctypes

    user32 = ctypes.windll.user32
    SPI_SETDESKWALLPAPER = 0x0014
    SPI_GETDESKWALLPAPER = 0x0073 # 注意不是 0x0015!
    buf = ctypes.create_unicode_buffer(512)
    print(user32.SystemParametersInfoW(SPI_SETDESKWALLPAPER, 0, path, 3))
    print(user32.SystemParametersInfoW(SPI_GETDESKWALLPAPER, 512, buf, 0), buf.value)
    1
    2
    1
    True C:\Users\Loong\AppData\Local\WinDynamicDesktop\themes\Catalina\Catalina_2.jpg
    • 两个数字要记牢:设置是 0x14、读取是 0x73(按 SET 加一来猜 GET 是错的,0x0016 是另一个功能,调用也返回 1,但缓冲区里啥都没有,很容易误判成功)。
    • 读取时第二个参数 uiParam 必须传缓冲区长度,传 0 会返回失败。
  • 顺带一条和坐标有关的坑(下一节鼠标模拟也会遇到):默认情况下 Python 进程不是 DPI 感知的,GetSystemMetrics 拿到的是缩放后的逻辑分辨率,SetCursorPos 用的也是那套坐标。

    1
    2
    3
    4
    未声明时 GetSystemMetrics = 2048 1152          # 2560x1440 在 125% 缩放下的逻辑值
    GetProcessDpiAwareness 返回 0(unaware)
    SetProcessDPIAware() 返回 1
    之后 GetSystemMetrics = 2560 1440,GetDpiForSystem = 120
    • 同一份代码里如果混用了 pywin32 和 pyautogui(后者会偷偷把进程设成 aware),前后拿到的屏幕尺寸会不一样,算出来的坐标就偏了。要跨库统一,程序开头先显式调用一次 ctypes.windll.user32.SetProcessDPIAware()。

整蛊小程序

  • 思路是「一个线程循环放歌 + 主线程循环换壁纸」,两个都是上一节的接口,代码本身没有新 API,但有几个真实的坑值得单独拎出来。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import time
import pygame

import win32api
import win32con
import win32gui

# 线程模块
import threading


def go():
# 初始化
pygame.mixer.init()
while True:
# 循环播放音乐
for i in range(10):
filePath = r'F:\Music' + '\\' + str(i) + '.mp3'
track = pygame.mixer.music.load(filePath)
pygame.mixer.music.play()
time.sleep(10)
pygame.mixer.music.stop()


def setWallPaper(path):
reg_key = win32api.RegOpenKeyEx(win32con.HKEY_CURRENT_USER, r'Control Panel\Desktop', 0, win32con.KEY_SET_VALUE)
win32api.RegSetValueEx(reg_key, 'WallpaperStyle', '0', win32con.REG_SZ, '10')
win32gui.SystemParametersInfo(win32con.SPI_SETDESKWALLPAPER, path, win32con.SPIF_SENDWININICHANGE)


th = threading.Thread(target=go, name='LoopThread')
th.start()
while True:
for i in range(10):
path = r'F:\Picture' + '\\' + str(i) + '.png'
setWallPaper(path)
time.sleep(10)
  • 把死循环改成跑两轮,本机实测线程部分:

    1
    2
    3
    4
    5
    6
    pygame-ce 2.5.8 (SDL 2.32.10, Python 3.14.10)
    线程已启动, is_alive = True name = LoopThread daemon = False
    主线程第1次采样: 子线程存活=True 已运行=0.8s
    主线程第2次采样: 子线程存活=True 已运行=1.3s
    主线程第3次采样: 子线程存活=False 已运行=1.8s
    join 之后 is_alive = False 总耗时 = 1.82
  • 这段脚本真正要注意的四件事:

    • while True 的线程不是 daemon,threading.Thread(...) 默认 daemon=False。这决定了进程关不掉:主线程结束时解释器会等所有非 daemon 线程跑完,而那个线程永远不会「跑完」,所以只能强杀(任务管理器结束进程,或 Ctrl+C 之后还要再按一次)。
      • 对比实测:daemon=True 的线程在主线程退出时被直接杀掉,进程立刻结束。整蛊脚本恰恰需要这种「杀不死」的效果,所以原作者没写 daemon 是有意还是无意都不重要,知道差别在哪就行。
    • str(i) 拼出来的文件名要求 F 盘真的有 0.mp3 到 9.mp3,少一个就抛 FileNotFoundError: No file 'F:\Music\0.mp3' found in working directory ...,而且这个异常在子线程里抛出,会让那个线程直接死掉(主线程继续换壁纸),音乐部分静默停止。要用 os.path.exists() 先过滤,或者放进 try/except。
    • pygame.mixer.music 只有一条通道,load() 会先把正在放的停掉,所以「循环播放」不需要 queue,但也不指望两首同时响。
    • 每 10 秒换一次壁纸会每次都往注册表写一遍路径,Windows 会重新加载图片文件,桌面闪一下是正常的;如果那张图在网络盘或 U 盘上,就会被卡成幻灯片。
  • 这类脚本发给自己之前想一下影响:换别人电脑的壁纸、锁住别人的显示器、开机自启放歌,都属于干扰他人正常使用,本机测试请只在自己的账号(HKEY_CURRENT_USER)下做,别往 HKEY_LOCAL_MACHINE 或「启动」目录里塞东西。

  • 想把它做成「开机就走」的恶作剧,标准做法是往启动目录放个快捷方式或注册表 HKCU\Software\Microsoft\Windows\CurrentVersion\Run 加一项;本篇不做实测,也不建议,因为这种自启项很难被普通用户发现,属于「容易做坏事」的那一类操作。

键盘模拟

键盘模拟

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import win32con
import win32api
import time

'''
win32api.keybd_event(91, 0, 0, 0)
time.sleep(0.1)
win32api.keybd_event(91, 0, win32con.KEYEVENTF_KEYUP, 0)
'''

for i in range(5):
win32api.keybd_event(91, 0, 0, 0)
time.sleep(0.1)
win32api.keybd_event(77, 0, 0, 0)
time.sleep(0.1)
win32api.keybd_event(77, 0, win32con.KEYEVENTF_KEYUP, 0)
win32api.keybd_event(91, 0, win32con.KEYEVENTF_KEYUP, 0)
time.sleep(3)
  • 这段是「Win + M」按 5 次,也就是把所有窗口最小化(Win+Shift+M 才是还原)。91 是 VK_LWIN,77 是 VK_M。

  • keybd_event 的四个参数依次是 虚拟键码, 扫描码, 标志, 保留。本机实测的取值与合法性:

    参数位置 名字 本篇常用值 说明
    1 bVk 91、77、37 到 40 虚拟键码(Virtual-Key Code),一张固定的编号表
    2 bScan 0 硬件扫描码,写 0 让系统按键码表自己查
    3 dwFlags 0 或 2 0 是按下;KEYEVENTF_KEYUP 等于 2 是抬起
    4 dwExtraInfo 0 附加信息,一般不给(可以整个省略)
    1
    2
    3
    4
    只给 3 个参数        -> 合法(第 4 个可省)
    给 5 个参数 -> TypeError: keybd_event() takes at most 4 arguments (5 given)
    flags 传字符串 -> TypeError: 'str' object cannot be interpreted as an integer
    KEYEVENTF_KEYUP = 2 MOUSEEVENTF_LEFTDOWN = 2 MOUSEEVENTF_ABSOLUTE = 32768
    • 键码别猜:win32con 里只有一部分常量,实测 VK_LWIN=91、VK_LEFT/UP/RIGHT/DOWN=37 38 39 40、VK_SHIFT、VK_NUMPAD1 有,但 VK_A、VK_M、VK_1 没有,这类字母数字键只能写十六进制数字。
    • 一个容易记错的区段:0x41 是字母 A 键,0x61 是小键盘的 1(VK_NUMPAD1)。实测 keybd_event(0x41) 打出 a,keybd_event(0x61) 打出 1。
  • 按下和抬起必须成对。写完 keybd_event(0x10, 0, 0, 0) 按 Shift 忘了抬,后面所有键都带 Shift,实测在编辑框里:

    1
    2
    3
    不按 Shift 按 0x41 0x42         -> 'ab'
    按住 Shift 再按 0x41 0x42 -> 'AB'
    Shift 还没抬起就按数字 1 键 -> '!' # 想要的是 1
    • 真实场景里更要命的是 Ctrl 没抬:系统会以为 Ctrl 一直按着,之后的鼠标点击变成「多选」,键盘输入变成快捷键。写组合键请用 try/finally 保证抬起那行一定执行。

让按键真的送进目标窗口

  • 这是键盘模拟最容易「代码没错但没反应」的原因。按键是发给前台窗口的,所以两件事必须成立:目标窗口在前台;目标窗口所在的消息循环在转。

  • 实测过程(在自己创建的一个 Win32 编辑框上做的,文本和光标位置都能用 WM_GETTEXT、EM_GETSEL 读回来核对):

    1
    2
    3
    4
    英文输入语言下按 VK 41 42 43 -> 编辑框 'abc',插入点 (3, 3)
    连按 3 次右方向键(0x25/0x27)后插入点 = (3, 3)
    Ctrl+A -> 选区 (0, 17),配合文本 'select all please' 正好 17 个字符
    再按 Delete(0x2E) -> 文本变空
  • 一个必须写对的细节:中文输入法会把字母键吃掉。把输入语言切到「中文(简体,中国)」再按 A、B,编辑框里什么都不出现——按键进了输入法的候选状态,还没被「提交」成文字:

    1
    2
    3
    4
    当前线程布局 0x8040804(0x0804 = 中文简体)
    按 a b -> 编辑框 '',再泵 0.8 秒 -> 仍是 ''
    切到 0x4090409(0x0409 = 英语-美国)
    按 A B C -> 编辑框 'abc'
    • 自动化脚本里最稳的做法是先把输入语言切成英文再按字母键:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      import ctypes

      u = ctypes.windll.user32
      n = u.GetKeyboardLayoutList(0, None)
      import ctypes.wintypes as wt
      arr = (wt.HKL * n)()
      u.GetKeyboardLayoutList(n, arr)
      en = next(h for h in arr if (ctypes.cast(h, ctypes.c_void_p).value or 0) & 0xFFFF == 0x0409)
      u.ActivateKeyboardLayout(en, 0)
    • 或者干脆不用虚拟键码,改用下一条说的 Unicode 注入,它不经过输入法。

打中文:用 SendInput 的 KEYEVENTF_UNICODE

  • keybd_event 没有「直接塞一个 Unicode 字符」的能力(它的 KEYEVENTF_UNICODE 得走 SendInput),所以打中文普遍的做法是下面这一小段 ctypes:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    import ctypes, ctypes.wintypes as wt

    u = ctypes.windll.user32


    class KEYBDINPUT(ctypes.Structure):
    _fields_ = [("wVk", wt.WORD), ("wScan", wt.WORD), ("dwFlags", wt.DWORD),
    ("time", wt.DWORD), ("dwExtraInfo", ctypes.c_void_p)]


    class INPUT(ctypes.Structure):
    class _U(ctypes.Union):
    _fields_ = [("ki", KEYBDINPUT), ("pad", ctypes.c_byte * 32)]
    _fields_ = [("type", wt.DWORD), ("u", _U)]


    def type_char(ch):
    i = INPUT()
    i.type = 1 # INPUT_KEYBOARD
    i.u.ki.wScan = ord(ch)
    i.u.ki.dwFlags = 0x0004 # KEYEVENTF_UNICODE
    return u.SendInput(1, ctypes.byref(i), ctypes.sizeof(INPUT))


    for ch in "直接输入中文OK":
    type_char(ch)
    1
    2
    sizeof(INPUT) = 40
    编辑框内容 = '直接输入中文OK'
    • dwFlags 里三个标志要背下来:KEYEVENTF_UNICODE = 4(用 Unicode 字符)、KEYEVENTF_SCANCODE = 8(用扫描码)、KEYEVENTF_KEYUP = 2(抬起)。

    • 只发「按下」那一次就够了。实测给每个字符再补一个 UNICODE | KEYUP 的抬起事件,结果每个字符出现两遍:

      1
      2
      只发按下            -> '直接输入中文OK'        # 正确
      按下和抬起都发 -> '你你好好世世界界AAbbcc' # 全部翻倍
    • SendInput 的返回值是真正注入成功的条数,不等于提交数就说明被拦了(批量提交 14 条实测返回 14)。

    • ctypes.sizeof(INPUT) 必须是 40,自己拼结构体时少一个填充字节就会静默失败(返回 0)。

    • 想走扫描码路线,用 MapVirtualKeyW 查:实测 MapVirtualKeyW(0x41, 0) 返回 30、MapVirtualKeyW(0x58, 0)(X 键)返回 45,把 dwFlags 设成 8 提交,编辑框里打出 'xyz',和虚拟键码路线等效。

用 pyautogui 少写一半代码

  • 上面的 ctypes 是底层做法;日常自动化更省事的是 pip install pyautogui(实测 0.9.54):

    1
    2
    3
    4
    5
    6
    7
    import pyautogui

    print(pyautogui.size(), pyautogui.position())
    pyautogui.moveTo(400, 325, duration=0.2)
    pyautogui.click()
    pyautogui.typewrite("hello", interval=0.03)
    pyautogui.hotkey("ctrl", "a")
    1
    2
    3
    4
    5
    Size(width=2560, height=1440) Point(x=581, y=394)
    click() 后插入点 = (12, 12)
    typewrite('hello') -> 'hello'
    hotkey ctrl+a 后选区 = (0, 5)
    FAILSAFE = True PAUSE = 0.1 MINIMUM_DURATION = 0.1
  • 三个必须知道的脾气(都实测过):

    • 它只认英文键盘上的键。isValidKey('中') 是 False,KEYBOARD_KEYS 一共 194 项、不含任何非 ASCII 键名。传中文给 typewrite/write/press 不报错也不输入——静默跳过,这种失败最难查:

      1
      2
      typewrite('中文abc') 返回 None(无异常),编辑框里一个中文都没有
      write('中文') 也没抛,press('中') 也没抛
    • FAILSAFE = True 是紧急刹车,但它检查的是「当前鼠标在不在屏幕角落」,不是「这次要去哪」。实测顺序很能说明问题:

      1
      2
      3
      4
      moveTo(0, 0)          -> 没抛,鼠标停在左上角
      moveTo(右下一个角) -> FailSafeException(因为进入这次调用时鼠标已经在角落)
      moveTo(500, 500) -> 仍然 FailSafeException(同上)
      FAILSAFE = False 后 moveTo(900, 600) -> 成功,这才把鼠标救出来
      • 所以真出事时的处理顺序是:先把鼠标甩到四个角(触发刹车),再在代码里 pyautogui.FAILSAFE = False 把它挪走,否则脚本会一直抛异常。
      • 默认每个动作之间还会 PAUSE 0.1 秒,就是留给你把这一下甩出去的时间。
    • import pyautogui 会顺手把进程设成 DPI aware,实测同一进程里 GetSystemMetrics 从 2048 1152 变成 2560 1440。它和 pywin32 混用时坐标体系会跳变,脚本开头先统一声明(见「修改背景图片」那一节最后一条)。

    • pyautogui.alert()/confirm()/prompt() 会弹窗等你点,脚本会一直卡在那里,无人值守的自动化别用它们。

语音控制游戏

思路是「系统说出左/右/上/下 → 程序识别出关键字 → 用 keybd_event 按方向键」。前半段走 Windows 自带的 SAPI(Microsoft Speech API)COM 接口,后半段就是上一节的内容。

先确认本机有没有识别器

1
2
3
4
5
6
import win32com.client

sp = win32com.client.Dispatch("SAPI.SpVoice")
print("合成语音 =", [v.GetDescription() for v in sp.GetVoices()])
lis = win32com.client.Dispatch("SAPI.SpSharedRecognizer")
print("识别器 =", lis.Recognizer.GetDescription())
1
2
3
合成语音 = ['Microsoft Huihui Desktop - Chinese (Simplified)',
'Microsoft Zira Desktop - English (United States)']
识别器 = Microsoft Speech Recognizer 8.0 for Windows (简体中文 - 中国)
  • 也就是说:文本转语音和中文语音识别引擎这台机器都装了,合成能直接听到。Speak() 默认是同步的,实测一句「语音合成测试」要 1.5 秒才返回;第二个参数给 1(SVSAsync)就是异步,实测 0.01 秒返回、声音还在后面播:

    1
    2
    默认 Speak 耗时 = 1.5 秒
    SVSAsync(1) 耗时 = 0.01 秒
  • 但识别必须有麦克风输入,这一步没法在脚本里自证。我还试过绕开麦克风——用 SAPI 的文件流把一段语音存成 wav 再喂给识别器:

    1
    2
    3
    TTS 生成 say_zuo.wav 51392 字节, 头部 = b'RIFF\xc8\x00\x00\x00WAVE'   # 这半段成功
    lis.AudioInput = 那个 SpFileStream
    -> pywintypes.com_error: (-2147352571, '类型不匹配。', None, 1) # 这半段失败
    • AudioInput 属性要的是「音频输入设备的 Token 对象」,直接塞文件流是塞不进去的,所以想免麦克风跑通,得改用虚拟音频设备(比如「立体声混音」当输入),这已经超出本篇范围。
    • 结论:下面这段代码要在插着麦克风、Windows 语音识别已启用的机器上,真的对着说「左」才能看到效果;我能验的是「语法/事件/按键这三段接得上」和「原笔记那条报错的根因」。

原笔记那段代码与它的报错

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
from win32com.client import constants
import os
import win32com.client
import pythoncom
import win32con
import win32api
import time


class SpeechRecognition:
def __init__(self, wordsToAdd):
self.speaker = win32com.client.Dispatch("SAPI.SpVoice")
self.listener = win32com.client.Dispatch("SAPI.SpSharedRecognizer")
self.context = self.listener.CreateRecoContext()
self.grammar = self.context.CreateGrammar()
self.grammar.DictationSetState(0)
self.wordsRule = self.grammar.Rules.Add("wordsRule", constants.SRATopLevel + constants.SRADynamic, 0)
self.wordsRule.Clear()
[self.wordsRule.InitialState.AddWordTransition(None, word) for word in wordsToAdd]
self.grammar.Rules.Commit()
self.grammar.CmdSetRuleState("wordsRule", 1)
self.grammar.Rules.Commit()
self.eventHandler = ContextEvents(self.context)
self.say("Started successfully")

def say(self, phrase):
self.speaker.Speak(phrase)


class ContextEvents(win32com.client.getevents("SAPI.SpSharedRecoContext")):
def OnRecognition(self, StreamNumber, StreamPosition, RecognitionType, Result):
newResult = win32com.client.Dispatch(Result)
print("说:", newResult.PhraseInfo.GetText())
s = newResult.PhraseInfo.GetText()
if s == "左":
win32api.keybd_event(37, 0, 0, 0)
time.sleep(0.1)
win32api.keybd_event(37, 0, win32con.KEYEVENTF_KEYUP, 0)
elif s == "上":
... # 38
elif s == "右":
... # 39
elif s == "下":
... # 40


if __name__ == '__main__':
wordsToAdd = ["上", "下", "左", "右"]
speechReco = SpeechRecognition(wordsToAdd)
while True:
pythoncom.PumpWaitingMessages()
  • 原笔记对报错只留了一句「TypeError: NoneType takes no arguments,解决方案见某博客」。这条错我在本机完整复现并找到了根因:

    • 最后一行 class ContextEvents(win32com.client.getevents("SAPI.SpSharedRecoContext")),当 getevents(...) 返回 None 时,等价于 class ContextEvents(None),实测抛的就是那句:

      1
      2
      getevents('SAPI.SpSharedRecoContext') -> None
      class Fake(None): ... -> TypeError: NoneType takes no arguments
    • getevents 返回 None 的原因是:win32com 还没有 SAPI 类型库的本地缓存(gen_py 目录)。没有缓存它就拿不到事件接口的定义,于是给你 None。

    • 连带的第二个症状也说明同一件事:那句 constants.SRATopLevel + constants.SRADynamic 在没有缓存时直接 AttributeError:

      1
      2
      constants.SRATopLevel -> AttributeError: SRATopLevel
      constants.SRADynamic -> AttributeError: SRADynamic
  • 修法(实测有效,一行):先让 win32com 把「Microsoft Speech Object Library」的类型信息生成出来,之后 getevents 有返回值、常量也能解析:

    1
    2
    3
    4
    5
    6
    from win32com.client import gencache
    voice = gencache.EnsureDispatch("SAPI.SpVoice") # 第一次调用会生成 gen_py 缓存
    print(voice)
    print(win32com.client.getevents("SAPI.SpSharedRecoContext"))
    from win32com.client import constants
    print(constants.SRATopLevel, constants.SRADynamic)
    1
    2
    3
    <win32com.gen_py.Microsoft Speech Object Library.ISpeechVoice instance ...>
    <class 'win32com.gen_py.C866CA3A-32F7-11D2-9602-00C04F8EE628x0x5x4._ISpeechRecoContextEvents._ISpeechRecoContextEvents'>
    1 32
    • 缓存文件在 C:\Users\<你>\AppData\Local\Temp\gen_py\3.14\{C866CA3A-...}\,脚本作者不用管它在哪,但换 Python 大版本或 pywin32 版本后要重新生成。

    • 反过来的情况也要会处理:如果 getevents 突然又变 None,或者报「类没有注册」之类,多半是这个缓存目录坏了,删掉整个 gen_py 目录再跑一次 EnsureDispatch 就行。

    • 不想依赖 getevents,可以用 DispatchWithEvents,实测同样能挂上事件(返回一个 EventsProxy):

      1
      2
      3
      4
      5
      6
      class H:
      def OnRecognition(self, sn, spos, rt, res):
      r = win32com.client.Dispatch(res)
      print("识别到:", r.PhraseInfo.GetText())

      ev = win32com.client.DispatchWithEvents(context, H)
  • 语法注册这一段(CreateGrammar 到 Rules.Commit,再加 CmdSetRuleState("wordsRule", 1))本机实测是通的:

    1
    2
    3
    语法已提交, Rules.Count = 1
    DispatchWithEvents -> EventsProxy
    6 秒静默等待结束, 命中 = [] # 没人说话,所以没有事件,符合预期
  • 方向键的键码记一下,和「键盘模拟」一节实测过的是同一套:VK_LEFT=37、VK_UP=38、VK_RIGHT=39、VK_DOWN=40。这部分不需要麦克风,实测在编辑框里按 3 次 VK_RIGHT 插入点从 0 走到 3。

  • 最后一句 while True: pythoncom.PumpWaitingMessages() 是必须的:SAPI 是 COM 事件模型,事件靠消息循环派发;这句换成 time.sleep(1) 的话程序看着在跑,一个识别结果都收不到。

  • 最后一句 while True: pythoncom.PumpWaitingMessages() 是必须的:SAPI 是 COM 事件模型,事件靠消息循环派发;这句换成 time.sleep(1) 的话程序看着在跑,一个识别结果都收不到。

鼠标模拟

原笔记的写法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import win32con
import win32api
import time

# 设置鼠标的位置
win32api.SetCursorPos([30, 40])
time.sleep(0.1)

# 鼠标左键按下
win32api.mouse_event(win32con.MOUSEEVENTF_LEFTDOWN, 0, 0, 0)
# 鼠标左键抬起
win32api.mouse_event(win32con.MOUSEEVENTF_LEFTUP, 0, 0, 0)
win32api.mouse_event(win32con.MOUSEEVENTF_LEFTDOWN, 0, 0, 0)
win32api.mouse_event(win32con.MOUSEEVENTF_LEFTUP, 0, 0, 0)
  • 这段是「把光标移到 (30, 40),然后连点两下左键」。((30, 40) 在大多数机器上是任务栏左下角,实际会点到开始按钮,自己试的时候记得把坐标换成一个安全的位置。)

  • mouse_event 的四个参数是 标志, dx, dy, mouseData。本机实测的合法性:

    1
    2
    3
    4
    5
    4 个参数(原笔记写法)  -> 合法
    3 个参数(省略 mouseData)-> 合法
    5 个参数 -> 合法(第 5 个是 dwExtraInfo)
    flags 传字符串 -> TypeError: 'str' object cannot be interpreted as an integer
    MOUSEEVENTF_MOVE=1 LEFTDOWN=2 LEFTUP=4 RIGHTDOWN=8 RIGHTUP=16 WHEEL=2048 ABSOLUTE=32768

移到哪里怎么确认

  • 只有一个 API 能验证:GetCursorPos()。

    1
    2
    3
    win32api.SetCursorPos([30, 40]);  print(win32api.GetCursorPos())
    win32api.SetCursorPos((500, 300)); print(win32api.GetCursorPos())
    win32api.SetCursorPos(500, 300)
    1
    2
    3
    (30, 40)
    (500, 300)
    TypeError: SetCursorPos() takes exactly 1 argument (2 given)
    • 参数是一个序列(list 或 tuple 都行),不能像 C 那样传两个整数,这是 pywin32 的封装习惯。
    • 超出屏幕的坐标不会报错,会被夹到边界:SetCursorPos((99999, 99999)) 之后 GetCursorPos() 返回 (2047, 1151)(未声明 DPI 感知时)或 (2559, 1439)(声明之后)。
    • DPI 感知会影响这一整套坐标,见「修改背景图片」一节最后那条:同一个 125% 缩放的屏幕,unaware 进程看到的是 2048x1152 这套坐标,aware 进程看到的是 2560x1440。截图识别、点击坐标对不上,八成是这个原因。

单击、双击、拖拽

  • 在自建的标准编辑框(类名 Edit,文本 alpha beta gamma)上实测,用 EM_GETSEL 读回插入点和选区来证明「点到了」:

    1
    2
    3
    4
    5
    单击(LEFTDOWN + LEFTUP)           -> 插入点 (18, 18)     # 光标落到点击处
    0.03 秒内连打两次 DOWN/UP -> 选区 (6, 11),选中 'beta '
    pyautogui.doubleClick() -> 同样是 (6, 11) 'beta '
    按住左键移动再抬起(拖拽) -> 选区 (2, 14),选中 'ag me please'
    GetDoubleClickTime() = 500 ms
    • 双击的判定标准是「两次点击在 GetDoubleClickTime()(本机 500 毫秒)之内、位置差不超过系统的双击宽度」,所以手写两次 DOWN/UP 中间不要 sleep 太久,也别把两次点击的坐标写歪。
    • 标准编辑框的双击行为是选中鼠标下那个词(含后面的空格),不是选中整行;想选中整行要用 Home + Shift+End。
    • 拖拽选文本的写法就是 LEFTDOWN → 多次 SetCursorPos → LEFTUP,中间每一步都要让消息循环转起来。

右键会「卡住」你的脚本

  • 这是一个非常隐蔽的现象,值得单独写:对窗口发右键,如果那个窗口弹出的是系统模态菜单,你的 DispatchMessage 会阻塞在菜单里出不来,脚本看起来像死循环或者卡死。

    1
    2
    3
    4
    5
    对编辑框发 RIGHTDOWN/RIGHTUP 后
    EnumWindows 里多了一个窗口类名 #32768(这就是弹出来的上下文菜单),而且 IsWindowVisible=True
    主线程此时正卡在 DispatchMessageW 里出不来
    再发一次 ESC(0x1B)
    #32768 消失,脚本继续往下跑
    • 结论:自动化脚本尽量少点右键;必须点的话,紧接着补一发 Esc 把菜单关掉,否则后面所有代码都不会执行。

滚轮与绝对坐标

1
2
3
win32api.mouse_event(win32con.MOUSEEVENTF_WHEEL, 0, 0, -120)     # 向下滚一格
win32api.mouse_event(win32con.MOUSEEVENTF_WHEEL, 0, 0, 120 * 3) # 向上滚三格
win32api.mouse_event(win32con.MOUSEEVENTF_ABSOLUTE | win32con.MOUSEEVENTF_MOVE, 32767, 32767, 0)
  • mouseData 在滚轮事件里表示滚动量,一格是 120,正数向上、负数向下。

  • 绝对坐标模式(MOUSEEVENTF_ABSOLUTE)下的 dx/dy 不是像素,而是 0 到 65535 的比例值,实测给 (32767, 32767) 光标落在屏幕中间附近:

    1
    2
    3
    # 像素坐标换算成 0 到 65535
    dx = int(x * 65535 / 屏幕宽)
    dy = int(y * 65535 / 屏幕高)

pyautogui 的鼠标接口

1
2
3
4
5
6
7
import pyautogui

print(pyautogui.size(), pyautogui.position())
pyautogui.moveTo(400, 325, duration=0.2)
pyautogui.click()
pyautogui.doubleClick()
pyautogui.scroll(-3)
1
2
3
4
Size(width=2560, height=1440) Point(x=581, y=394)
click() 后插入点 = (12, 12)
doubleClick() 后选区 = (6, 11),选中 'beta '
scroll(-3) 正常返回
  • 它把 mouse_event/SendInput 都封好了,还多几个很好用的:position()、moveRel()、mouseDown()/mouseUp()、dragTo()、pixel()、pixelMatchesColor()、screenshot()、locateOnScreen()。

  • pyautogui.doubleClick() 走的是 SendInput,两次点击之间由库自己控制时序,比手写两次 mouse_event 更稳;上面实测两者结果一致。

  • rightClick() 要当心:它同样会触发上一条说的那个模态菜单问题,脚本会卡在菜单里出不来。

  • 图像定位(locateOnScreen)在没有 OpenCV 时慢得离谱,实测同一张 60x60 的参考图:

    1
    2
    3
    未装 cv2:locateOnScreen 用时 14.61 秒,结果 Box(left=1200, top=900, width=60, height=60)
    带 confidence 参数:NotImplementedError The confidence keyword argument is only available if OpenCV is installed.
    装完 opencv-python 5.0.0 之后再用:秒级返回
    • 也就是说:要用「找图点鼠标」,pip install opencv-python 不是可选项;confidence(相似度阈值)只有装了 OpenCV 才有。
  • 窗口层面可以配合 pyautogui.getAllTitles()、getWindowsWithTitle()、getActiveWindow() 用,实测注意两点:

    • getAllTitles() 会返回很多空字符串(本机 108 个顶层窗口里前 8 个全是空标题),别拿它当「所有窗口名」。
    • Win32Window 对象没有 isFocused、isResponding 这两个属性(那是 pywinauto 的说法),实测可用的是 title、left、top、width、height、isActive、isMinimized、isMaximized,方法有 activate()、minimize()、maximize()、restore()、close()、moveTo()、resizeTo()。

Word自动化办公

  • 这一节有两条完全不同的技术路线,先说清楚怎么选:

    路线 依赖 能做什么 不能做什么
    win32com 驱动 Word 必须装了 Microsoft Word doc 和 docx 都能读能写,能另存为 PDF/txt,能碰 Word 的一切功能 服务器上没有 Word 就跑不了,速度慢,会有进程残留
    python-docx 纯 Python,无需 Office 离线读写 docx,加段落、表格、样式 打不开 .doc(老格式),排版能力有限

读取doc与docx文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import win32com
import win32com.client


def readWordFile(path):
# 调用系统word功能,可以处理doc和docx两种文件
mw = win32com.client.Dispatch("Word.Application")
# 打开文件
doc = mw.Documents.Open(path)
for paragraph in doc.Paragraphs:
line = paragraph.Range.Text
print(line)
# 关闭文件
doc.Close()
# 退出word
mw.Quit()


path = r'E:\Desktop\Test\office-probe\01.docx'
readWordFile(path)
1
2
3
4
5
6
Word Version = 16.0  Build = 16.0.20527  Visible(默认) = False
'亲爱的测试员\r'
' 我想你……\r'
'第三段:中文、English、123。\r'
'\r'
Paragraphs.Count = 4
  • 原笔记这段直接能跑。两个要留意的点:

    • 段落结尾是 \r 而不是 \n,Word 内部段落标记就是回车符,print 出来看着像空行,做字符串处理时要 line.rstrip('\r')。
    • 最后多出来一个空段落('\r'),Word 文档结尾总有一个段落标记,所以 Paragraphs.Count 会比肉眼看到的段数多 1。
  • doc.Close() 和 mw.Quit() 一定要执行到,否则 Word 进程留在后台。本机实测崩过一次脚本(异常发生在 Quit() 之前),任务管理器里就多了一个 WINWORD.EXE:

    1
    2
    tasklist /FI "IMAGENAME eq WINWORD.EXE"
    WINWORD.EXE 64912 Console 8 267,560 K # 看不见窗口,但进程在,文件还被它锁着
    • 正确写法是 try/finally,把 Close()、Quit() 放进 finally。
  • Word 打开文档时会在同目录写一个属主锁文件(~$ 加文件名),实测:

    1
    2
    打开期间目录里的锁文件 = ['~$01.docx']
    关闭之后 = []
    • 别人打不开你的 docx,往往就是这个 ~$ 文件残留(进程被强杀时不会清理),删掉它即可。

读取doc与docx文件并写入到其他文件中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import win32com
import win32com.client


def readWordFileToOtherFile(path, toPath):
mw = win32com.client.Dispatch("Word.Application")
doc = mw.Documents.Open(path)

# 将word的数据保存到另一个文件
doc.SaveAs(toPath, 2) # 2表示为txt文件

doc.Close()
mw.Quit()


path = r"E:\Desktop\Test\office-probe\01.docx"
toPath = r"E:\Desktop\Test\office-probe\b.txt"
readWordFileToOtherFile(path, toPath)
  • 「2 表示 txt」是对的(wdFormatText = 2),但这里藏着两个真实的坑,本机实测结果贴出来:

    • 导出的 txt 是 GBK 编码,不是 UTF-8:

      1
      2
      txt 原始字节: b'\xc7\xd7\xb0\xae\xb5\xc4...'      # 拿 utf-8 解:UnicodeDecodeError
      以 gbk 解码 -> '亲爱的测试员\r\n 我想你......\r\n第三段:中文、English、123。\r\n\r\n'
      • 所以那句「2 表示 txt」后面还要跟一句:txt 用系统 ANSI 码页写出(简体中文 Windows 就是 GBK),拿去和 UTF-8 的文件比对会全乱。
    • 更要紧的是内容会被改写:原文里的省略号「……」(两个 U+2026)被降级成六个英文句点 ......,因为 GBK 那次转换没能保住它。

    • FileFormat=7(wdFormatUnicodeText)在本机也没给出 UTF-16,结果和 2 一模一样;给 SaveAs 传 Encoding=65001 不报错但同样还是 GBK:

      1
      2
      SaveAs(FileFormat=7, Encoding=65001) -> 68 字节, 头 24: b'\xc7\xd7\xb0\xae...'(还是 GBK)
      SaveAs2(FileFormat=7, TextEncoding=65001) -> TypeError: unexpected keyword 'TextEncoding'
    • 想把 Word 内容存成 UTF-8 的 txt,别用另存为,读出来自己写:

      1
      2
      text = doc.Content.Text            # 段落之间是 \r
      open("b_utf8.txt", "w", encoding="utf-8", newline="").write(text.replace("\r", "\n"))
  • 顺带把常见 FileFormat 编号在本机验一遍,别踩「格式和扩展名不匹配」:

    1
    2
    3
    4
    5
    6
    7
    0  wdFormatDocument97   -> old.doc  24576 字节,文件头 D0CF11E0A1B11AE1(OLE 复合文档)
    2 wdFormatText -> GBK 纯文本,CRLF
    7 wdFormatUnicodeText -> 本机仍然写出 GBK(见上)
    12 wdFormatXMLDocument -> 标准 docx(zip,头两字节 PK)
    17 导出 PDF -> ExportAsFixedFormat(path, 17),实测 25919 字节的正常 PDF
    22 -> 实测拿到的是 XML/HTML(73809 字节),不是纯文本
    23 + 文件名 .txt -> com_error:不兼容的文件类型和文件扩展名

创建word文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import win32com
import win32com.client
import os
import time


def makeWordFile(path, name):
word = win32com.client.Dispatch("Word.Application")
# 让文档可见
word.Visible = True

# 创建文档
doc = word.Documents.Add()

# 写内容
# 从头开始写
r = doc.Range(0, 0)
r.InsertAfter("亲爱的" + name + "\n")
r.InsertAfter(" 我想你……\n")

# 存储文件
doc.SaveAs(path)
time.sleep(1)
# 关闭文件
doc.Close()
# 退出word
word.Quit()


names = ["张三", "李四", "王五"]
for name in names:
path = os.path.join(os.getcwd(), name)
print(path)
makeWordFile(path, name)
time.sleep(1)
  • 这段跑完在本机生成的文件名是 张三.docx,不是没有扩展名的 张三:

    1
    无扩展名保存后,实际磁盘文件 = ['noext_name.docx']
    • 原因是 SaveAs 不给 FileFormat 时按扩展名猜,扩展名也没有时就按 Word 的默认格式(docx)把扩展名补上。想精确控制就写 doc.SaveAs2(path, FileFormat=12)。
  • word.Visible = True 会真的弹一个 Word 窗口(这点和 Word 允许 Visible = False 不同,PowerPoint 不允许,见后面 PPT 一节)。批量生成时设成 False 更省事,也不会被弹窗抢焦点打断。

  • r = doc.Range(0, 0) 加 InsertAfter 是「在文档最开头插一段」,所以两次 InsertAfter 的先后顺序会反过来。更稳的写法是 doc.Content.Text = "..." 或者每次重新取 doc.Range(doc.Content.End - 1, ...)。

  • 不装 Word 的等价写法,python-docx:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    import docx

    d = docx.Document(r'E:\Desktop\Test\office-probe\01.docx')
    print("段落数 =", len(d.paragraphs))
    for p in d.paragraphs:
    print(repr(p.text))

    nd = docx.Document()
    nd.add_heading("标题", level=1)
    nd.add_paragraph("这是 python-docx 写的段落")
    t = nd.add_table(rows=2, cols=2)
    t.cell(0, 0).text = "a"
    t.cell(1, 1).text = "b"
    nd.save("docx_own.docx")
    1
    2
    3
    4
    5
    6
    7
    段落数 = 4
    '亲爱的测试员'
    ' 我想你……'
    '第三段:中文、English、123。'
    ''
    不装 Word 也能写: 36806 字节, 头 4 字节 = b'PK\x03\x04'
    再读回: ['标题', '这是 python-docx 写的段落']
    • 注意 python-docx 读出来的段落末尾没有 Word COM 那个 \r,文本更干净。

    • 它读不了 .doc,实测报:

      1
      ValueError: file '...old.doc' is not a Word file, content type is 'application/vnd.openxmlformats-officedocument...'
      • 遇到 .doc 只有两条路:用 Word COM 打开后另存为 docx,或者用专门的转换工具(LibreOffice 的 soffice --convert-to docx)。
  • 三条实战建议:批量处理前先用 pythoncom.CoInitialize()(线程里调 COM 必须初始化);文件名一律传绝对路径,COM 不吃相对路径的情况不少;跑批之前把 Word 里已打开的文档关掉,否则 Documents.Open 可能拿到的是那个已经打开的实例。

Excel自动化办公

读取xlsx文件(一张表)

  • 安装依赖:

    1
    pip install openpyxl
    1
    Successfully installed openpyxl-3.1.5 et-xmlfile-2.0.0
  • 先造一个两张表的 sunck.xlsx(表名沿用笔记里的「安力博发」):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    import openpyxl

    wb = openpyxl.Workbook()
    ws1 = wb.active
    ws1.title = "安力博发"
    for row in [[1, 2, 3], [4, 5, 6], ["姓名", "年龄", "城市"], ["张三", 18, "北京"]]:
    ws1.append(row)
    ws2 = wb.create_sheet("第二张表")
    ws2["A1"] = "标题"
    ws2.append(["x", "y"])
    wb.save("sunck.xlsx") # 5551 字节
  • 原笔记的读取代码(from openpyxl.reader.excel import load_workbook 这个导入路径今天仍然有效):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    from openpyxl.reader.excel import load_workbook


    def readXlsxFile(path):
    file = load_workbook(filename=path)
    sheets = file.get_sheet_names()
    sheet = file.get_sheet_by_name(sheets[0])
    print(sheet.max_row)
    print(sheet.max_column)
    print(sheet.title)
    for lineNum in range(1, sheet.max_row + 1):
    lineList = []
    for columnNum in range(1, sheet.max_column + 1):
    value = sheet.cell(row=lineNum, column=columnNum).value
    lineList.append(value)
    print(lineList)


    readXlsxFile(r"./sunck.xlsx")
    1
    2
    3
    4
    5
    6
    7
    4
    3
    安力博发
    [1, 2, 3]
    [4, 5, 6]
    ['姓名', '年龄', '城市']
    ['张三', 18, '北京']
    • 这段在 openpyxl 3.1.5 上不但能跑,而且没有报错——原笔记说「新版已经不建议使用」是对的,但「不建议」不等于「删掉了」。真正的代价是两条 DeprecationWarning:

      1
      2
      DeprecationWarning: Call to deprecated function get_sheet_names (Use wb.sheetnames).
      DeprecationWarning: Call to deprecated function get_sheet_by_name (Use wb[sheetname]).
    • 平时看不到它们是因为 Python 默认不打印 DeprecationWarning,加 -W always 就露出来了。

    • 但同一个表里列的 get_active_sheet 是真没了,实测:

      1
      AttributeError: 'Workbook' object has no attribute 'get_active_sheet'

现行写法(推荐直接背这一版)

1
2
3
4
5
6
7
8
9
import openpyxl

wb = openpyxl.load_workbook("sunck.xlsx")
print(wb.sheetnames)
print(wb.active.title)
sh = wb["安力博发"]
print(sh.max_row, sh.max_column, sh.dimensions)
for row in sh.iter_rows(values_only=True):
print(row)
1
2
3
4
5
6
7
['安力博发', '第二张表']
安力博发
4 3 A1:C4
(1, 2, 3)
(4, 5, 6)
('姓名', '年龄', '城市')
('张三', 18, '北京')
  • 三个替换关系记住就够了:get_sheet_names() 换成 sheetnames 属性;get_sheet_by_name('x') 换成 wb['x'];get_active_sheet() 换成 active 属性。
  • 读整表最快的两条是 iter_rows(values_only=True)(拿到的是元组)和 sheet.values(同样是生成器),实测两者内容一致。

返回整体xlsx数据(所有表)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
from openpyxl.reader.excel import load_workbook


def readXlsxFileToDic(path):
dic = {}
file = load_workbook(filename=path)
sheets = file.get_sheet_names()
print(len(sheets))
for sheetName in sheets:
sheet = file.get_sheet_by_name(sheetName)
sheetInfo = []
for lineNum in range(1, sheet.max_row + 1):
lineList = []
for columnNum in range(1, sheet.max_column + 1):
value = sheet.cell(row=lineNum, column=columnNum).value
lineList.append(value)
sheetInfo.append(lineList)
dic[sheetName] = sheetInfo
return dic


path = r"./sunck.xlsx"
dic = readXlsxFileToDic(path)
print(dic["安力博发"])
print(len(dic))
1
2
3
2
[[1, 2, 3], [4, 5, 6], ['姓名', '年龄', '城市'], ['张三', 18, '北京']]
2
  • 用现代写法同样的事只有 4 行:

    1
    2
    3
    4
    import openpyxl

    wb = openpyxl.load_workbook("sunck.xlsx")
    dic = {name: [list(r) for r in wb[name].iter_rows(values_only=True)] for name in wb.sheetnames}

openpyxl模块说明(把笔记那五张表逐条实测过一遍)

  • Workbook(一个 xlsx 文件就是一个 Workbook 对象):

    属性 实测结果
    active 存在,返回当前活动的 Worksheet 对象
    worksheets 存在,[<Worksheet "安力博发">, <Worksheet "第二张表">]
    read_only 存在,默认 False
    write_only 存在,默认 False
    encoding 存在,实测值 'utf-8'(xlsx 内部就是 UTF-8,这个属性基本不用管)
    properties 存在,是 DocumentProperties 对象(标题、创建者、创建时间在这里)
    sheetnames 存在,返回表名字符串列表
  • Workbook 的方法:

    方法 实测结果
    get_sheet_names 存在但已废弃,调用会打 DeprecationWarning
    get_sheet_by_name 存在但已废弃,同上
    get_active_sheet 不存在,AttributeError
    remove_sheet 存在
    create_sheet 存在
    copy_worksheet 存在
  • Worksheet(Workbook 里的一张表):

    属性 实测结果
    title 安力博发
    dimensions A1:C4(左上角:右下角,只算有数据的范围)
    max_row / min_row 4 / 1
    max_column / min_column 3 / 1
    rows / columns / values 都是生成器;columns 按列给,实测第一列是 ('A1','A2','A3','A4')
    freeze_panes 存在,没设过就是 None,设成 "A2" 即冻结首行
  • Worksheet 的方法:iter_rows、iter_cols、append、merged_cells 都存在;笔记表里的 iter_columns 和 unmerged_cells 不存在(前者叫 iter_cols,取消合并是 unmerge_cells),实测 hasattr 全为 False。

  • Cell 对象(一个格子):row、column、value、coordinate 四个都实测有效,B3 那个格子读出来是 row=3 column=2 coordinate=B3 value='年龄'。

  • 顺手记三个容易踩的行为(都实测过):

    1
    2
    3
    4
    读 .xls                    -> InvalidFileException: openpyxl does not support the old .xls file format,
    please use xlrd to read this file, or convert it to the more recent .xlsx file format.
    read_only=True 后取 dimensions -> AttributeError: 'ReadOnlyWorksheet' object has no attribute 'dimensions'
    公式单元格 -> 默认读到 '=A1+B1' 这个字符串;data_only=True 读到 None
    • data_only=True 的意思是「取 Excel 上次计算并缓存的结果」,文件必须被 Excel(或 WPS)打开保存过才有值,用 openpyxl 自己写出来的公式文件读出来一定是 None。
    • 只读模式(read_only=True)省内存,适合几十万行的大文件,代价是 dimensions 之类属性没了、只能顺序遍历。
  • 写侧的两个常用姿势:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    import openpyxl

    wb = openpyxl.Workbook()
    ws = wb.active
    ws.append([1, 2, 3]) # 自动找下一空行
    ws["B2"] = "文本"
    ws.freeze_panes = "A2"
    wb.save("out.xlsx")

    wo = openpyxl.Workbook(write_only=True) # 只写模式:只能 append,不能回读
    wo.create_sheet("只写").append([1, 2, 3])
    wo.save("wonly.xlsx")

返回xls和xlsx文件内容

  • .xls(97-2003 的二进制格式)openpyxl 不支持,原笔记用 pyexcel 系。那条安装清单今天需要修:ordereddict、future、xlwt-future 三个别装,理由见本节末尾。

    1
    pip install pyexcel-xls pyexcel-xlsx xlrd
    1
    2
    Successfully installed lml-0.2.0 pyexcel-io-0.6.8 pyexcel-xls-0.7.1 xlwt-1.3.0
    Successfully installed pyexcel-xlsx-0.6.1
  • 笔记里的读取函数原样跑 .xls:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    from collections import OrderedDict
    from pyexcel_xls import get_data


    def readXlsAndXlsxFile(path):
    dic = OrderedDict()
    xdata = get_data(path)
    for sheet in xdata:
    dic[sheet] = xdata[sheet]
    return dic


    path = r"./b.xls"
    dic = readXlsAndXlsxFile(path)
    print(dic["表1"])
    print(len(dic))
    1
    2
    3
    表名 = ['表1', '表2']
    表1 数据 = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
    len(dic) = 2
  • 同一个函数读 .xlsx 会直接失败,标题写的「xls 和 xlsx 都能读」需要额外装插件:

    1
    2
    SupportingPluginAvailableButNotInstalled:
    Please install one of these plugins for read data in 'xlsx': pyexcel-xls,pyexcel-xlsx
    • 装上 pyexcel-xlsx 之后同一句就通了,实测:

      1
      2
      get_data('sunck.xlsx') -> {'安力博发': [[1, 2, 3], [4, 5, 6], ['姓名', '年龄', '城市'], ['张三', 18, '北京']],
      '第二张表': [['标题'], ['x', 'x']]}
  • 底层的 xlrd 现在只能读 .xls,读 xlsx 会被拒(xlrd 2.0 起砍掉了 xlsx 支持):

    1
    2
    3
    4
    5
    xlrd 2.0.2
    open_workbook('sunck.xlsx') -> XLRDError: Excel xlsx file; not supported
    open_workbook('b.xls') -> sheet_names = ['表1', '表2']
    表1 nrows/ncols = (3, 3)
    cell(0,0).value = 1.0 cell_type = 2
    • 注意 cell.value 是 1.0 这个 float,xls 里数字全是浮点,要整数得自己转;cell_type 的编号对照:0 空、1 文本、2 数字、3 日期、4 布尔、5 错误。

    • 走 pyexcel_xls.get_data 会帮你把 1.0 收成 1,但空单元格会变成空字符串 '' 而不是 None,布尔值 True 会变成 1,实测:

      1
      2
      写入 ['文本', 3.5, 7, None, datetime(2020,8,5,19,54), True]
      读出 ['文本', 3.5, 7, '', datetime.datetime(2020, 8, 5, 19, 54), 1]

写入xls文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from collections import OrderedDict
from pyexcel_xls import save_data


def makeExcelFile(path, data):
dic = OrderedDict()
for sheetName, sheetValue in data.items():
d = {}
d[sheetName] = sheetValue
dic.update(d)
save_data(path, dic)


path = r"./b.xls"
makeExcelFile(path, {"表1": [[1, 2, 3], [4, 5, 6], [7, 8, 9]],
"表2": [[11, 22, 33], [44, 55, 66], [77, 88, 99]]})
1
生成 b.xls: 5632 字节, 头 8 字节 = b'\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1'
  • 那个文件头是 OLE2 复合文档的标志(D0 CF 11 E0 A1 B1 1A E1),一眼就能区分新老格式:.xls/.doc/.ppt 是这串,.xlsx/.docx/.pptx 开头是 PK(zip 包)。

  • 新格式直接交给 openpyxl 更省事,没必要再生产 .xls。

  • pyexcel 这个总包也测了一下(pyexcel.get_sheet、pyexcel.iget_array),能用,但参数名换了:

    1
    2
    3
    pyexcel 0.7.6
    get_sheet(file_name='sunck.xlsx').rows() -> 现在是生成器,要 list() 才看得到内容
    get_dict(book_file='sunck.xlsx') -> UnknownParameters: 请检查参数名,现在是 file_name

笔记那条安装清单里,现在不能再装的三个包

笔记里的命令 现在的事实
pip install ordereddict 能装上(1.1),但 import ordereddict 报 No module named 'UserDict'。它是 Python 2 的补丁包,Python 3 直接 from collections import OrderedDict
pip install future 能装上(1.0.0),但本篇所有代码都不需要它
pip install xlwt-future 千万别装。它会顶替 xlwt,之后任何写 xls 的代码全崩
  • xlwt-future 的崩法实测长这样,值得原样记下来:

    1
    2
    3
    >>> import xlwt
    >>> xlwt.Workbook()
    ValueError: cannot use LOCALE flag with a str pattern
    • 原因是它内部用 re.compile(模式, VERBOSE+LOCALE+IGNORECASE),而 Python 3 不允许 LOCALE 标志配 str 模式。这是 Python 2 代码的典型残留。

    • 恢复方法(亲测有效):

      1
      2
      pip uninstall xlwt-future
      pip install --force-reinstall xlwt
      1
      恢复后 save_data 成功: 5632 字节  OrderedDict({'S': [[1, 2], [3, 4]]})
    • 如果 pip uninstall 之后提示找不到 xlwt,就再补一次 pip install xlwt。

PPT自动化办公

用 win32com 驱动 PowerPoint

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import win32com
import win32com.client


def makePPT(path):
ppt = win32com.client.Dispatch("PowerPoint.Application")
ppt.Visible = True

# 增加一个文件
pptFile = ppt.Presentations.Add()

# 创建页 参数1为页数(从1开始) 参数2为类型
page1 = pptFile.Slides.Add(1, 1)
t1 = page1.Shapes[0].TextFrame.TextRange
t1.Text = "嬴政曰"
t2 = page1.Shapes[1].TextFrame.TextRange
t2.Text = "我心中的九十九,应该是法之天下,儒之教化!"

page2 = pptFile.Slides.Add(2, 1)
t3 = page2.Shapes[0].TextFrame.TextRange
t3.Text = "嬴政曰"
t4 = page2.Shapes[1].TextFrame.TextRange
t4.Text = "我欲铸一把天子之剑,以七国为锋,山海为锷,开以阴阳,持以春夏,行以秋冬,举世无双,天下归服,为天子之剑!"

page3 = pptFile.Slides.Add(3, 2)
t5 = page3.Shapes[0].TextFrame.TextRange
t5.Text = "韩非曰"
t6 = page3.Shapes[1].TextFrame.TextRange
t6.Text = "尚公子所言深得我心!"

# 保存
pptFile.SaveAs(path)
pptFile.Close()
ppt.Quit()


path = r"E:\Desktop\Test\office-probe\sunck.ppt"
makePPT(path)
  • 原笔记这段能跑通,实测生成的文件是 282624 字节、文件头 D0CF11E0...(也就是老 .ppt 二进制格式,因为 SaveAs 按扩展名选了格式)。

  • 「参数2为类型」这一句是重点,本机把 1 到 12 全试了一遍,得到的形状构成表(新建空白演示文稿的默认模板):

    Slides.Add 的第 2 个参数 回读 Slide.Layout 生成的 Shapes
    1 1 Title 1 + Subtitle 2(标题页)
    2 2 Title 1 + Text Placeholder 2(标题加正文)
    3 3 Title + Text + Text(两栏正文)
    4 4 Title + Table Placeholder(表格页)
    5 5 Title + Text + Chart Placeholder(文字加图表)
    6 6 Title + Chart + Text
    7 7 Title + SmartArt
    8 8 Title + Chart(仅图表)
    9 9 Title + Text + ClipArt
    10 10 Title + ClipArt + Text
    11 11 只有 Title
    12 12 空白页,0 个形状
    • 所以笔记里 Shapes[0]、Shapes[1] 这种下标写法完全依赖版式号:换成 11 或 12 就没有 Shapes[1],直接 IndexError 或者 COM 报错。
  • 三条实测到的坑:

    • Presentations.Add() 建出来的演示文稿是 0 页,Slides.Count = 0,所以 Slides[1] 会抛 com_error (-2147188160, 'OLE error 0x80048240'),必须先 Slides.Add。

    • PowerPoint 不允许隐藏窗口,Word 那套 Visible = False 在它这儿会直接报错:

      1
      com_error: Application.Visible : Invalid request. Hiding the application window is not allowed.
      • 也就是说 PPT 自动化一定会看到窗口在闪,这是 PowerPoint 自身的限制,不是你写错了。
    • Presentation 对象没有 SaveAs2:

      1
      AttributeError: Add.SaveAs2
      • 用 SaveAs(path) 或 SaveAs(path, 格式编号)。实测格式编号(24 = ppSaveAsOpenXMLPresentation 给 PK 开头的 pptx,1 = ppSaveAsPresentation 给 D0CF11E0 开头的老 ppt)。
      • 更危险的是编号优先于扩展名:SaveAs("x.ppt", 24) 不报错,写出来的却是 zip 内容的 .ppt,PowerPoint 之后打开它会提示文件损坏。Word 那边同理,SaveAs("x.txt", 23) 直接报「不兼容的文件类型和文件扩展名」。所以要么只给扩展名让它自己推,要么保证扩展名和编号一致。
    • 新建演示文稿的页面尺寸实测是 960 x 540 点(16:9)。

用 python-pptx 离线生成(不需要装 Office)

1
2
3
4
5
6
7
8
9
10
11
from pptx import Presentation

prs = Presentation()
print(len(prs.slide_layouts))
s1 = prs.slides.add_slide(prs.slide_layouts[1]) # Title and Content
s1.shapes.title.text = "嬴政曰"
s1.placeholders[1].text = "我心中的九十九,应该是法之天下,儒之教化!"
s2 = prs.slides.add_slide(prs.slide_layouts[1])
s2.shapes.title.text = "韩非曰"
s2.placeholders[1].text = "尚公子所言深得我心!"
prs.save("python_pptx.pptx")
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
python-pptx 1.0.2
默认模板 slide_layouts 数量 = 11
layout[0] Title Slide 占位符 idx = [0, 1, 10, 11, 12]
layout[1] Title and Content 占位符 idx = [0, 1, 10, 11, 12]
layout[2] Section Header 占位符 idx = [0, 1, 10, 11, 12]
layout[3] Two Content 占位符 idx = [0, 1, 2, 10, 11, 12]
layout[4] Comparison 占位符 idx = [0, 1, 2, 3, 4, 10, 11, 12]
layout[5] Title Only 占位符 idx = [0, 10, 11, 12]
layout[6] Blank 占位符 idx = [10, 11, 12]
layout[7] Content with Caption 占位符 idx = [0, 1, 2, 10, 11, 12]
layout[8] Picture with Caption 占位符 idx = [0, 1, 2, 10, 11, 12]
layout[9] Title and Vertical Text 占位符 idx = [0, 1, 10, 11, 12]
layout[10] Vertical Title and Text 占位符 idx = [0, 1, 10, 11, 12]
已保存 python_pptx.pptx 29241 字节
读回: 页数 = 2 第1页文本 = ['嬴政曰', '我心中的九十九,应该是法之天下,儒之教化!']
  • 两边的编号体系不一样:COM 用的是 ppLayout... 那套 1 到 12 的数字,python-pptx 用 slide_layouts[下标] 对象,两套不能直接换算,别把 1 当成同一个东西。
  • python-pptx 取占位符比 COM 的下标稳:shapes.title 一定拿到标题,正文用 placeholders[1](10/11/12 是日期、页脚、页码)。
  • 部署角度:python-pptx 是纯 Python,能在没有 Office 的服务器/容器里跑;COM 路线只能在装了 PowerPoint 的 Windows 桌面上跑。

参考