Windows 自动打印服务(AutoPrint)
它做什么
在一台常年开机的 Windows 电脑上跑一个后台服务,盯着一个文件夹(另可选微信收件目录、IMAP 邮箱)。有新文件就按类型挑对引擎打出来,打完把文件归档。
三个通道:
| 通道 | 用法 | 适用 |
|---|---|---|
| 本地队列 | 文件拖进"打印队列"文件夹 | 就坐在这台电脑前 |
| 邮箱远程 | 手机/任何设备发一封带附件的邮件到专用邮箱 | 人不在电脑前,纸要在某处出 |
| 微信收件目录 | 盯 PC 微信收到的文件(可选,见下方警告) | 全自动,但无法分辨会话 |
触发场景
用户说出这类意思时用本技能:想远程打印、想手机发邮件就出纸、想"文件一进文件夹就自动打"、打印机在 A 处而人在 B 处、要一台机器替自己收件并出纸。
部署流程
-
先体检,再动手。 把
deploy/整个拷到目标目录(建议%USERPROFILE%\Documents\AutoPrint),在该目录执行:
python install.py --check
只读,会打印:Python 版本、打印机清单 + 系统默认机、Office/WPS 检测结果、SumatraPDF 是否就位、微信收件目录与账号数。先看清楚再装。 -
正式安装。
python install.py
安装器会依次完成:下载 SumatraPDF 到bin/→ 建 venv → 装依赖(pywin32 Pillow openpyxl python-docx pypdf,默认源失败自动切清华镜像)→ 建队列与日志目录 → 读注册表列出本机所有打印机让用户选 → 写config.json→ 建桌面快捷方式 → 写开机自启.vbs→ 可选关闭睡眠。
python install.py --yes 全自动(用默认打印机、不配邮箱)。
-
交付给用户的操作只剩一个:双击桌面的「启动打印守护」。
-
端到端验证:往队列里放一个 PDF/PNG/DOCX/XLSX,或给自己发一封带附件的邮件。看日志里出现
>>> 开始打印与<<< 打印任务已提交。
硬前提
- 只支持 Windows(依赖注册表、Office COM、GDI 打印)。
- 目标机自备 Python 3.8+,且安装时勾选 "Add python.exe to PATH"。
- 要打 Word/Excel 必须装 Office 或 WPS;没有就只能打 PDF 和图片。
- 电脑必须常年开机、联网、不休眠——它要替用户收邮件。
打印引擎分派
- PDF → SumatraPDF 便携版,
-print-to "<printer>" -silent -exit-when-done。没有装 Adobe 时,比 ShellExecute 的 print 动词可靠得多。 - Word / Excel → COM:ProgID 依次尝试
Word.Application→KWPS.Application(WPS)、Excel.Application→KET.Application/ET.Application;Visible=False、DisplayAlerts=False、只读打开、PrintOut、停 3 秒、Close(False)、Quit。 - 图片 → PIL + win32ui 打印机 DC。必须先把 alpha 压到白底,否则透明 PNG 会打成一片黑;再用
GetDeviceCaps(HORZRES/VERTRES)做等比居中。
配置(config.json)
{
"printer": "打印机名称",
"print_queue_dir": "被监视的目录",
"poll_interval_sec": 2,
"stability_checks": 2,
"watch_wechat_inbox": false,
"email": {"enabled": false, "imap_server": "imap.qq.com", "imap_port": 993,
"user": "", "auth_code": "", "check_interval_sec": 30}
}
配置每 30 秒热重载——改邮箱、换打印机不需要重启服务。但改代码必须重启。
坑(这部分是本技能的核心价值)
微信通道
- 微信 4.0 把所有会话收到的文件混在同一个月份目录
msg/file/<yyyy-MM>/里。所以纯靠看文件无法分辨是哪个会话发来的。不要承诺"只打某个群的文件"。 - 不要把"转发给文件传输助手"当甄别手段:PC 微信会自动下载所有会话收到的文件到同一个月份目录,落在那里就区分不出来。
- 微信收藏不能作为打印源:
business/favorite/data/<2hex>/<md5hex>存的是加密块(头部07 08 56 32),msg/attach/<hash>/<yyyy-MM>/Img/*.dat的图片也是加密的。只有msg/file/<yyyy-MM>/是明文文档。 - 地雷:直接盯微信目录,第一次运行会把历史积压全部打出来(几百页)。必须(1)首次接管时把现有文件登记进
state["inbox_baseline"]作为基线,(2)跳过修改时间早于wechat_inbox_max_age_min(默认 30 分钟)的文件。
邮箱通道
- 一台机器一个邮箱。 两台常驻服务轮询同一个 IMAP 收件箱会抢:各自
UNSEEN,谁先轮询到谁打、谁标记已读,文件落到哪台不可预测。要么用不同邮箱,要么先加主题关键词分流。 - QQ 邮箱:
imap.qq.com:993;网易:imap.163.com。用户需在网页邮箱设置里开启 IMAP 并生成16 位授权码(不是登录密码)。 - 新注册的 QQ 邮箱有 14 天冷却期,期间 IMAP 开不了,设置页里连"开启服务"按钮都看不见。
- 轮询要加
SINCE近 3 天过滤 + 每轮封数上限,否则广告邮件会把队列刷爆。
进程与守护(最容易被误判的一节)
- 任务管理器里看到两个
pythonw.exe是正常的,不要清理。 venv 的Scripts\pythonw.exe是 CPython 的转发器:它找到真正的解释器作为子进程跑起来,然后等待。所以每个程序都是一对:约 5.7MB 的存根父进程 + 真正干活的大内存子进程。守护 + 服务共 4 个进程是正确状态。 - 判断哪个是真的:跑过一次 Office COM 打印后,真进程会带窗口标题
OleMainThreadWndName,内存随 PIL/pypdf/imap 使用涨到 40–90MB。启动日志里记了os.getpid(),与tasklist /v对应。 - 服务没有窗口是设计如此,不是失败。
- 守护进程
watchdog.py:服务靠持有命名互斥体AutoPrint_PrintService_Mutex声明存活;守护每 30 秒win32event.OpenMutex探测(抛异常=进程已死),掉了就用subprocess.Popen([pythonw, watcher], creationflags=DETACHED_PROCESS|CREATE_NO_WINDOW)拉起,子进程比守护更长寿。守护自身用AutoPrint_Watchdog_Mutex单实例,日志logs/watchdog.log。 - 服务的单实例检查必须放在
main()最前面(在 setup_logging / load_config / cleanup_temp 之前),重复实例在碰日志和配置之前就退出。 - 桌面快捷方式要直接指向
venv\Scripts\pythonw.exe、把watchdog.py作为参数、WindowStyle=7。不要用 vbs 拉快捷方式(pythonw 本来就没窗口)。开机自启用 Startup 目录里的.vbs;互斥体保证重复启动无害。 - 启动 Startup 的
.vbs必须用mbcs编码写出,不能用 UTF-8——WScript 按 ANSI 代码页读.vbs,UTF-8 会让中文安装路径乱码。
去重(踩过一次真实误伤,务必区分两种"重复")
- 去重指纹按文件内容 MD5 计算,改文件名骗不过——这是对的,防的是"同封邮件被反复处理"。
- 但绝不能把"内容相同"直接等同于"该跳过"。 真实误伤:用户重发同一份卷子想再打一份(给孩子一份、自己留一份),被静默吃掉,只打出一份,用户以为丢邮件了。
- 正确设计(
email.duplicate_mode): permissive(默认)—— 内容相同但来自新的邮件,照打;同一封邮件内部的重复附件仍只打一次(用seen_in_mail集合)。strict—— 内容相同一律跳过(老行为)。- 实现要点:
process_file(..., force=True)时才跳过printed_md5检查;邮件通道按模式传force;本地队列不force(同一个文件躺在队列里反复扫到,不该反复出纸)。 - 判据是"这次是不是一次新的、人的投递",不是"内容是否见过"。设计去重时先问:用户重发,是想再要一份,还是不想再要一份?
打印池(有过真实事故,务必遵守)
- 绝对不要按文件名关键字批量取消打印任务。 关键字匹配会同时杀掉"半截的旧任务"和"刚提交的完整任务"。真实事故:一个 8 页 PDF 只出来 7 页——服务在渲染中被强杀留下半截任务(打了 7 页后卡住等数据),重启后完整任务被当成"重复"取消掉了。正确做法:
EnumJobs→ 打印完整任务列表(JobId/TotalPages/pDocument)→ 只取消明确的某一个 JobId。永远不要自动取消。 - 半截 spool 任务的
TotalPages是真实的已提交页数,所以 8 页源文件显示pages=7是"提交被打断"的铁证,不是渲染 bug。 - 每次提交前先清理同名残留任务(
EnumJobs里匹配Path(pDocument).name,JOB_CONTROL_CANCEL),否则崩溃重试会"半截 + 整份"连出两遍。PDF 提交时用 pypdf 记下页数,日志写成>>> 开始打印: x.pdf (共 N 页)——这是事后判断漏页来自源文件还是打印池最省事的办法。 - 不要在打印池里有活动任务时强杀服务。 先
EnumJobs;如果任务状态是 PRINTING(0x10),等它打完,或接受这份半截。 - 随包提供
tools/reprint.py:reprint.py <文件> [页码范围],用 pypdf 抽页到temp/再交给 SumatraPDF 打,完全绕过去重库。因为去重是在提交时落盘的,被取消或失败的任务否则永远补不回来了。配套tools/jobtool.py(默认只列任务;cancel <JobId>需显式写 JobId;purge需手输 YES)。
打印机硬件故障(软件侧看不见)
- 入门级 host-based 打印机(如部分 HP LaserJet 型号)是单向的:没有状态回传。所以
win32print.GetPrinter(h,2)["Status"]、cJobs、WMIWin32_Printer(PrinterStatus=3空闲、DetectedErrorState=0)在全都会读出"一切正常",哪怕面板上正在闪灯报警。 - 随包提供
tools/printer_status.py(spooler 状态位 + EnumJobs + WMI)作为"是软件还是纸"的分诊工具:队列空 + 状态空闲 +WorkOffline=False⇒ 软件已经全交出去了。 - 面板闪琥珀灯时的常见原因(以惠普为例):顶盖未关好 / 缺纸 / 碳粉盒缺失或没装到位 / 卡纸——按这个顺序查,最后一步是断电整整 60 秒再上电,实操中这一步才真正清掉报警。
- 一定让用户数一下实际打了多少张,与日志核对。"没少页"这个确认能排除"卡纸静默吃掉一张",也决定了要不要用
reprint.py补打。 - 故障期间到达的文件不会丢——它们留在打印池里等打印机恢复。
兼容性与安全边界
- 全部路径基于
BASE = Path(__file__).resolve().parent推导,无写死路径,可整个目录搬走。 venv\不可移植:pyvenv.cfg里写死了本机的 base 解释器路径(home = ...、executable = ...)。换机器必须现场重建,不能拷贝。- 不写注册表(只读)、不装驱动、不改系统设置(除可选的
powercfg关睡眠)。 - 唯一的网络出站:pip 装依赖、下载 SumatraPDF、IMAP 收信。
- 删除类操作只发生在自己的
temp/、venv/和临时下载目录内。
远程打印的手机端(用户最常卡住的一步)
- 安卓微信里,"用其他应用打开"不在右上角的 ⋯ 菜单(那个菜单只有"转发给朋友""收藏"),它在文件预览页面的按钮上。
- 手机必须先装 QQ 邮箱 App,否则系统的分享列表里根本没有"邮箱"这个选项。
- 分享时三个选项里只选「通过邮件发送」:另外两个(导入到QQ邮箱 / 上传到文件云盘)只是把文件存进邮箱,不发信,打印机不会动。附件必须是真实附件,不能是云盘链接。