自动化办公与鼠标键盘模拟
本篇的运行环境
- 代码全是 Windows 桌面程序接口,要动注册表、动鼠标键盘、要真的弹窗口。所以每条结论都在下面这台机器上跑过,输出直接贴在代码块后面,没有的是「跑不出来」,我也会把报错原样写出来。
-
实测机器与解释器:
1
2
3
4
5Windows-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-python1
2
3
4
5
6pywin32 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
2ERROR: 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
15import 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
8import 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
14import 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
2b'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
3with 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
7import 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
11import 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
48import 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
8is_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'末尾那个空格是它加的,不是原文里的。
- 图片不是「读不出来」,而是 pdfminer 的定位是版面文字抽取,
换个更好用的库
-
现在读 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
5False 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
51 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 | Successfully installed pygame-ce-2.5.8 |
- 官方
pygame在 Python 3.14 上没有 wheel(见本篇开头),本篇用 pygame-ce。它是同一个import pygame,pygame.mixer的接口完全一致。
播放音乐
1 | import time |
-
原笔记这段是能跑的(把
filePath换成真实存在的音频文件)。加几行打印,本机实测的结果:1
2
3
4
5init 后 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
3set_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
3pause 后 get_busy() = False
暂停 0.3 秒后 get_pos() 停在 394 不变
unpause 0.3 秒后 get_pos() = 701 -
music这一套没有get_length(),别照抄网上教程:1
2AttributeError: 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
4snd = pygame.mixer.Sound("punch.wav")
print(snd.get_length(), pygame.mixer.get_num_channels())
ch = snd.play()
print(ch.get_busy())1
20.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里放歌的脚本会一直播到被杀掉为止,见下一节。
- 报错里的「working directory」提示很有用——九成这种错是相对路径没对上,
-
一句话选型:只抽文字用
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
3RegQueryValueEx('WallPaper') -> C:\Users\Loong\AppData\Local\WinDynamicDesktop\themes\Catalina\Catalina_2.jpg
RegQueryValueEx('Wallpaper') -> 同上
RegQueryValueEx('wallpaper') -> 同上
用代码改壁纸
1 | # win+r -> regedit -> HKEY_CURRENT_USER -> Control Panel -> Desktop -> WallPaper |
-
这段在本机跑通了,壁纸真的换掉了。跑完要用下面两条把原状恢复回来(也是这段代码唯一的「副作用」):
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
9reserved 传 整数 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。
- 结论:pywin32 根本不检查这个保留参数,它被完全忽略。所以「能跑」不代表「写对了」,习惯上还是要写
只改注册表不会立刻换壁纸
-
一个很容易踩的顺序问题:把新路径只写进注册表、不调
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
8import 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
21
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 | import time |
-
把死循环改成跑两轮,本机实测线程部分:
1
2
3
4
5
6pygame-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 | import win32con |
-
这段是「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保证抬起那行一定执行。
- 真实场景里更要命的是 Ctrl 没抬:系统会以为 Ctrl 一直按着,之后的鼠标点击变成「多选」,键盘输入变成快捷键。写组合键请用
让按键真的送进目标窗口
-
这是键盘模拟最容易「代码没错但没反应」的原因。按键是发给前台窗口的,所以两件事必须成立:目标窗口在前台;目标窗口所在的消息循环在转。
-
实测过程(在自己创建的一个 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
9import 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
26import 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
2sizeof(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
7import 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
5Size(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
2typewrite('中文abc') 返回 None(无异常),编辑框里一个中文都没有
write('中文') 也没抛,press('中') 也没抛 -
FAILSAFE = True是紧急刹车,但它检查的是「当前鼠标在不在屏幕角落」,不是「这次要去哪」。实测顺序很能说明问题:1
2
3
4moveTo(0, 0) -> 没抛,鼠标停在左上角
moveTo(右下一个角) -> FailSafeException(因为进入这次调用时鼠标已经在角落)
moveTo(500, 500) -> 仍然 FailSafeException(同上)
FAILSAFE = False 后 moveTo(900, 600) -> 成功,这才把鼠标救出来- 所以真出事时的处理顺序是:先把鼠标甩到四个角(触发刹车),再在代码里
pyautogui.FAILSAFE = False把它挪走,否则脚本会一直抛异常。 - 默认每个动作之间还会
PAUSE0.1 秒,就是留给你把这一下甩出去的时间。
- 所以真出事时的处理顺序是:先把鼠标甩到四个角(触发刹车),再在代码里
-
import pyautogui会顺手把进程设成 DPI aware,实测同一进程里GetSystemMetrics从2048 1152变成2560 1440。它和pywin32混用时坐标体系会跳变,脚本开头先统一声明(见「修改背景图片」那一节最后一条)。 -
pyautogui.alert()/confirm()/prompt()会弹窗等你点,脚本会一直卡在那里,无人值守的自动化别用它们。
-
语音控制游戏
思路是「系统说出左/右/上/下 → 程序识别出关键字 → 用 keybd_event 按方向键」。前半段走 Windows 自带的 SAPI(Microsoft Speech API)COM 接口,后半段就是上一节的内容。
先确认本机有没有识别器
1 | import win32com.client |
1 | 合成语音 = ['Microsoft Huihui Desktop - Chinese (Simplified)', |
-
也就是说:文本转语音和中文语音识别引擎这台机器都装了,合成能直接听到。
Speak()默认是同步的,实测一句「语音合成测试」要 1.5 秒才返回;第二个参数给1(SVSAsync)就是异步,实测 0.01 秒返回、声音还在后面播:1
2默认 Speak 耗时 = 1.5 秒
SVSAsync(1) 耗时 = 0.01 秒 -
但识别必须有麦克风输入,这一步没法在脚本里自证。我还试过绕开麦克风——用 SAPI 的文件流把一段语音存成 wav 再喂给识别器:
1
2
3TTS 生成 say_zuo.wav 51392 字节, 头部 = b'RIFF\xc8\x00\x00\x00WAVE' # 这半段成功
lis.AudioInput = 那个 SpFileStream
-> pywintypes.com_error: (-2147352571, '类型不匹配。', None, 1) # 这半段失败AudioInput属性要的是「音频输入设备的 Token 对象」,直接塞文件流是塞不进去的,所以想免麦克风跑通,得改用虚拟音频设备(比如「立体声混音」当输入),这已经超出本篇范围。- 结论:下面这段代码要在插着麦克风、Windows 语音识别已启用的机器上,真的对着说「左」才能看到效果;我能验的是「语法/事件/按键这三段接得上」和「原笔记那条报错的根因」。
原笔记那段代码与它的报错
1 | from win32com.client import constants |
-
原笔记对报错只留了一句「
TypeError: NoneType takes no arguments,解决方案见某博客」。这条错我在本机完整复现并找到了根因:-
最后一行
class ContextEvents(win32com.client.getevents("SAPI.SpSharedRecoContext")),当getevents(...)返回 None 时,等价于class ContextEvents(None),实测抛的就是那句:1
2getevents('SAPI.SpSharedRecoContext') -> None
class Fake(None): ... -> TypeError: NoneType takes no arguments -
getevents返回 None 的原因是:win32com 还没有 SAPI 类型库的本地缓存(gen_py目录)。没有缓存它就拿不到事件接口的定义,于是给你 None。 -
连带的第二个症状也说明同一件事:那句
constants.SRATopLevel + constants.SRADynamic在没有缓存时直接AttributeError:1
2constants.SRATopLevel -> AttributeError: SRATopLevel
constants.SRADynamic -> AttributeError: SRADynamic
-
-
修法(实测有效,一行):先让 win32com 把「Microsoft Speech Object Library」的类型信息生成出来,之后
getevents有返回值、常量也能解析:1
2
3
4
5
6from 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
6class 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 | import win32con |
-
这段是「把光标移到 (30, 40),然后连点两下左键」。((30, 40) 在大多数机器上是任务栏左下角,实际会点到开始按钮,自己试的时候记得把坐标换成一个安全的位置。)
-
mouse_event的四个参数是标志, dx, dy, mouseData。本机实测的合法性:1
2
3
4
54 个参数(原笔记写法) -> 合法
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
3win32api.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 | win32api.mouse_event(win32con.MOUSEEVENTF_WHEEL, 0, 0, -120) # 向下滚一格 |
-
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 | import pyautogui |
1 | Size(width=2560, height=1440) Point(x=581, y=394) |
-
它把
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 | import win32com |
1 | Word Version = 16.0 Build = 16.0.20527 Visible(默认) = False |
-
原笔记这段直接能跑。两个要留意的点:
- 段落结尾是
\r而不是\n,Word 内部段落标记就是回车符,print出来看着像空行,做字符串处理时要line.rstrip('\r')。 - 最后多出来一个空段落(
'\r'),Word 文档结尾总有一个段落标记,所以Paragraphs.Count会比肉眼看到的段数多 1。
- 段落结尾是
-
doc.Close()和mw.Quit()一定要执行到,否则 Word 进程留在后台。本机实测崩过一次脚本(异常发生在Quit()之前),任务管理器里就多了一个WINWORD.EXE:1
2tasklist /FI "IMAGENAME eq WINWORD.EXE"
WINWORD.EXE 64912 Console 8 267,560 K # 看不见窗口,但进程在,文件还被它锁着- 正确写法是
try/finally,把Close()、Quit()放进finally。
- 正确写法是
-
Word 打开文档时会在同目录写一个属主锁文件(
~$加文件名),实测:1
2打开期间目录里的锁文件 = ['~$01.docx']
关闭之后 = []- 别人打不开你的 docx,往往就是这个
~$文件残留(进程被强杀时不会清理),删掉它即可。
- 别人打不开你的 docx,往往就是这个
读取doc与docx文件并写入到其他文件中
1 | import win32com |
-
「2 表示 txt」是对的(
wdFormatText = 2),但这里藏着两个真实的坑,本机实测结果贴出来:-
导出的 txt 是 GBK 编码,不是 UTF-8:
1
2txt 原始字节: 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
2SaveAs(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
2text = 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
70 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 | import win32com |
-
这段跑完在本机生成的文件名是
张三.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
14import 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
11import 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
19from 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
74
3
安力博发
[1, 2, 3]
[4, 5, 6]
['姓名', '年龄', '城市']
['张三', 18, '北京']-
这段在 openpyxl 3.1.5 上不但能跑,而且没有报错——原笔记说「新版已经不建议使用」是对的,但「不建议」不等于「删掉了」。真正的代价是两条 DeprecationWarning:
1
2DeprecationWarning: 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 | import openpyxl |
1 | ['安力博发', '第二张表'] |
- 三个替换关系记住就够了:
get_sheet_names()换成sheetnames属性;get_sheet_by_name('x')换成wb['x'];get_active_sheet()换成active属性。 - 读整表最快的两条是
iter_rows(values_only=True)(拿到的是元组)和sheet.values(同样是生成器),实测两者内容一致。
返回整体xlsx数据(所有表)
1 | from openpyxl.reader.excel import load_workbook |
1 | 2 |
-
用现代写法同样的事只有 4 行:
1
2
3
4import 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 存在,默认 Falsewrite_only 存在,默认 Falseencoding 存在,实测值 'utf-8'(xlsx 内部就是 UTF-8,这个属性基本不用管)properties 存在,是 DocumentProperties对象(标题、创建者、创建时间在这里)sheetnames 存在,返回表名字符串列表 -
Workbook 的方法:
方法 实测结果 get_sheet_names 存在但已废弃,调用会打 DeprecationWarning get_sheet_by_name 存在但已废弃,同上 get_active_sheet 不存在, AttributeErrorremove_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 读到 Nonedata_only=True的意思是「取 Excel 上次计算并缓存的结果」,文件必须被 Excel(或 WPS)打开保存过才有值,用 openpyxl 自己写出来的公式文件读出来一定是None。- 只读模式(
read_only=True)省内存,适合几十万行的大文件,代价是dimensions之类属性没了、只能顺序遍历。
-
写侧的两个常用姿势:
1
2
3
4
5
6
7
8
9
10
11
12import 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
2Successfully 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
16from 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
2SupportingPluginAvailableButNotInstalled:
Please install one of these plugins for read data in 'xlsx': pyexcel-xls,pyexcel-xlsx-
装上
pyexcel-xlsx之后同一句就通了,实测:1
2get_data('sunck.xlsx') -> {'安力博发': [[1, 2, 3], [4, 5, 6], ['姓名', '年龄', '城市'], ['张三', 18, '北京']],
'第二张表': [['标题'], ['x', 'x']]}
-
-
底层的
xlrd现在只能读.xls,读 xlsx 会被拒(xlrd 2.0 起砍掉了 xlsx 支持):1
2
3
4
5xlrd 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 | from collections import OrderedDict |
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
3pyexcel 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
2pip uninstall xlwt-future
pip install --force-reinstall xlwt1
恢复后 save_data 成功: 5632 字节 OrderedDict({'S': [[1, 2], [3, 4]]})
-
如果
pip uninstall之后提示找不到xlwt,就再补一次pip install xlwt。
-
PPT自动化办公
用 win32com 驱动 PowerPoint
1 | import win32com |
-
原笔记这段能跑通,实测生成的文件是 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 | from pptx import Presentation |
1 | python-pptx 1.0.2 |
- 两边的编号体系不一样: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 桌面上跑。
参考
- pywin32 项目主页(win32api、win32con、win32gui、win32com 的来源)
- pygame-ce(Python 3.14 可用的 pygame 分支)
- pdfminer3k 在 PyPI 上的页面、pdfminer.six、pypdf、PyMuPDF
- openpyxl 官方文档(Workbook、Worksheet、Cell 的属性方法都在这里)
- pyexcel 插件机制说明
- python-docx 文档、python-pptx 文档
- pyautogui 官方文档(含 FAILSAFE、locateOnScreen 与 OpenCV 的关系)
- Microsoft Speech API(SAPI 5.4)对象模型
- Win32 键盘输入:keybd_event 与 SendInput
- Win32 系统参数:SystemParametersInfoW 与 SPI_* 常量