Skip to content

Releases: fanyuantaier/wechatauto-replica

Release v1.2.8.0

Choose a tag to compare

@fanyuantaier fanyuantaier released this 24 Oct 14:35

v1.2.8.0 (2026-10-23)

新增「群成员加好友」,顺手修了 open_chat 搜错框的老问题,并适配微信 4.1.15.56 的图片气泡类名。

新增:群成员加好友

wx.AddFriendFromGroup("群名", "小明") / wechatauto 加好友 群名 成员名。

界面路径:打开群 → 右上角「聊天信息」→「搜索群成员」→ 点结果卡片 →「添加到通讯录」→ 提交申请。

  • 搜索结果按屏幕位置取第一行,不按控件树顺序。 一个名字可能命中多行,而 Qt 回收列表的子节点顺序和肉眼看到的顺序不一致;旧写法会打开最下面那张卡。现在按 BoundingRectangle.top 排序后取最上面一行。
  • 「添加到通讯录」同时是好友判据:非好友的资料卡只有这一颗按钮;已是好友的卡片是「发消息 / 语音聊天 / 视频聊天」,不会给通讯录里已有的人重复发申请。
  • 好友申请框不写死类名:点完「添加到通讯录」后按「新出现的顶层窗 + 提交按钮」定位。申请框是 mmui::VerifyFriendWindow(标题「申请添加朋友」),提交按钮叫「确定」而不是「发送」,两个文案都认;发完在 general.db:FMessageTable 新增一行 is_sender_=1、scene_=14 的记录,可独立回读确认。
  • dry_run=True / --dry-run 只走到资料卡,一颗按钮都不点。

修复

open_chat 会把词打进错误的「搜索」框

4.1.15 树里最多同时有三颗 Name='搜索' 的输入框(会话搜索、聊天内搜索、群成员搜索),旧代码取树里第一颗;开着会话或群信息面板时搜索词落进聊天内搜索,会话自然打不开。现在按祖先类名优先会话搜索框;遍历时撞上微信重绘产生的失效元素(COMError)也不再打断 open_chat。客户端停在朋友圈页时,常规导航栏点击会被透明渲染层吃掉,追加一次直接点击「微信」tab 的兜底(仍走拟人随机落点 + 曲线轨迹)。

微信 4.1.15.56 图片气泡类名适配

4.1.15.56 起图片气泡改用独立类名 mmui::ChatImageItemView,旧版 mmui::ChatBubbleReferItemView 也继续认——否则 MediaDownloader 会把图片行整片判成「看不见」,行对齐/取原件报「可视区里没有图片气泡」。两个类名都支持,跨版本可用;tools/selftest.py 已加回归。

验证

新增 30 项离线自检(取最上面一行、已是好友、dry-run、申请框缺失/无留言框/无提交按钮),全部离线套件绿;真机对 1 名非好友群成员完整发出好友申请,并在 FMessageTable 回读确认。

1.2.6.1:重要修复 — 发送者按消息所在分片的 Name2Id 解析

Choose a tag to compare

@fanyuantaier fanyuantaier released this 10 Oct 10:18

重要修复:发送者名称错位(issue #34)

  • ⚠️ 重要修复:群消息的发送者会被换成无关联系人——real_sender_id 必须按「消息所在分片」的 Name2Id 解析。(issue #34)

    • 现象(实际使用中人工发现):读取消息时发送者名称错位,显示成无关的联系人或群名称。
    • 成因一(编号空间用错了表):real_sender_id 是消息所在分片 message_N.db 里
      Name2Id 的 rowid,而代码一直拿 message_resource.db 的 SenderName2Id 去解析。
      这两张表的编号互不相干,同一个数字解析成的就是另一个人。
    • 成因二(「编号 2 就是自己」不成立):本机自己账号在 message_0/1/2.db 里的 rowid
      实测是 2/4/1,写死 == 2 只在其中那一片成立;1.2.4 为了绕开它加的
      「查不到就兜底按 1」在另外两片上同样必错。
    • 判据(不依赖任何 id 表):群聊文本行的正文里,微信自己写了 发送者: 前缀。本机 12 个群
      3662 条可比对的行——按分片解析 3662 条全对,按旧的全局表 0 条对(3656 条指到别人身上、
      6 条落空)。文件传输助手 856 行全是这台机器发的:新口径 855 行给出本机 wxid(另 1 行的
      发送者确实就是 filehelper 本身),旧口径会把其中 639 行说成某个常联系的好友或一个群。
    • 影响范围:v1.0.0 ~ v1.2.6 全部版本(按错表解析的 _sender_id_index() 在初始提交里就有,
      最早带标签的 1.0.3 已含)。可见面随版本变宽:export_history 的 sender_name 一直受影响;
      get_messages / 监听回调里的 sender_username 自 v1.1.8 起走这条解析;群消息
      「非文本也能认出人」自 v1.2.4.1 起也是它。
    • 修法:查询时把每个分片自己的 Name2Id 一并取出(_name2id_map() / _shard_maps()),
      _msg_row_to_dict、get_message_row 和导出的 _resolve_sender 都按该行的 shard 查本片映射;
      删掉 _sender_id_index();新增公开口 WeChatDB.sender_username(shard, sender_id);
      wx._db_row_to_message 的方向判定与 MediaDownloader._sent_by_self 改成
      「解析出来的 username 等不等于本机 wxid」,去掉 sender_id == 2 和「兜底按 1」。
      解析不到就返回空串(未知),不回落到别的库去猜——这正是报告的建议。
    • 行为变化:sender_username 以前几乎每行都有值(其中很多是错的),现在本机 60243 行里约
      0.2%(129 行,集中在同一个分片) 是空串;同一个 sender_id 在不同分片可以解析成不同的
      人(这才是正确行为);解析不到时 _sent_by_self 按「不是本机发的」处理并打一条 debug。
    • 判据:新增 tools/test_sender_namespace.py 36 项——两片库的 Name2Id 对同一个编号刻意
      给出不同的人、行还故意撞号,所以这些断言只有「按行所在分片解析」才可能全绿;带反向验
      (把命名空间退回「两片共用一张全局表」,sid=2 立刻变成同一个陌生人);真机段要求文件传输
      助手的发送者 ⊆ {本机, filehelper} 且 ≥95% 是本机,群聊按分片解析与正文前缀判据的不一致数
      必须为 0。
  • 其余内容同 v1.2.6(跨分片消息的 shard 可寻址 + 分片读失败不再被静默当成「这个分片没消息」)。

升级:pip install -i https://pypi.org/simple --upgrade wechatauto-replica

1.2.6:跨分片消息可寻址(shard)+ 分片读失败不再静默

Choose a tag to compare

@fanyuantaier fanyuantaier released this 09 Oct 15:56

重要修复:跨分片消息的可寻址性(issue #20)

  • ⚠️ 重要修复:跨分片会话里 (会话, local_id) 不唯一,按号回读会拿到别的分片的另一条消息。(issue #20)

    • 背景:微信把一个会话的历史横向拆进多个 message_N.db(分片),而 local_id 是
      每个分片各自从 1 开始计数的。合并读取(v1.2.2 起)之后,(user, local_id) 就不再是唯一键了。
    • 本机实测:200 个会话表中 54 个跨多片;20008 组 (会话, local_id) 同时命中多个分片,
      其中 10477 组连 local_type 都相同而正文/图片不同(抽样里光图片就有 35 组同号但 md5 不同)。
    • 后果:get_message_row() 以前不打任何日志,直接取 sort_seq 最新的那一条。语音/图片/文件/视频
      下载全以它为入口,于是表现为"按 A 那条的号,下到了 B 那张图"。
    • 修法:消息行统一带上 shard(分片库文件名),合并视图里 (local_id, shard) 才是唯一键;
      get_message_row(user, local_id, local_type=…, shard=…) 可以钉死某一片;不指定 shard 而命中
      多片时先告警再按旧口径取最新;shard 写错返回 None 并告警,不瞎猜。
    • 顺带铺开:list_voice_status() / list_image_status() 的行也带 shard;image_status /
      download_image / download_image_original / download_voice / download_video /
      download_file 全部接受 shard,download_media() 分发时自动带上候选行自己的 shard。
    • 兼容性:只给返回 dict 增加 shard 键、给方法增加可选关键字参数,旧调用形状一字不变。
    • 判据:新增 tools/test_db_shard.py 46 项——双分片临时库夹具验合并读取、shard 寻址、
      新加的 ? AS shard 不挤掉 WHERE 参数、以及分片读失败的可见性;真机部分逐片回读一个真实的
      「同号 + 同类型 + 跨分片」行(不带 shard 拿到的是最新那片,带上之后各拿本片那一行),
      并核对 get_messages() 条数 = 各分片 count(*) 之和、覆盖分片数 = 该会话的分片数。
  • 修复:某个分片查询报 database disk image is malformed 时被当成"这个分片没有消息"。
    损坏现在抛给 _run_msg_query 走解密缓存重建重试;其它错误仍跳过该片,但会点名是哪个分片
    (同一条只说一次,轮询里不刷屏)。这是"读不全"的另一个静默来源——少了一半还返回成功。

  • 删除:_find_msg_table() / _msg_conn() 两个单分片桩函数(零调用者,docstring 写着
    "只返回第一个命中分片")。issue #20 里那份逐行对照就是照着它们断定 get_messages() 只读
    一片——那段代码自 v1.2.2 起已经没人调用了,但墓碑还挂在文件里。

  • 其余内容同 v1.2.5.1(微信数据目录设在盘符根时的自动检测修复、回车前回读校验、
    WxParam.ENABLE_OCR 总开关、通讯录标签 + 右键转发)。

升级:pip install -i https://pypi.org/simple --upgrade wechatauto-replica

1.2.5.1:重要修复 — 微信数据目录在盘符根时自动检测依赖启动目录

Choose a tag to compare

@fanyuantaier fanyuantaier released this 07 Oct 11:59

重要修复(issue #32)

  • ⚠️ 重要修复:微信数据目录设在盘符根(配置里写 d:\)时,自动检测开始依赖启动目录。(issue #32)
    • 现象:同一个 Python、同一份依赖、同一个微信配置,只换个工作目录就从"能读"变成
      RuntimeError: 未找到微信数据库目录,请通过 db_dir 参数手动指定。
    • 原因:_locate_account_root() 用 rstrip("\/") 去掉尾分隔符,把 d:\ 变成了 d:;
      而 d: 在 Windows 上是盘符相对路径,os.path.join('d:', 'xwechat_files') 得到
      d:xwechat_files,会按该盘自己的当前目录解析(不是进程工作目录,更反直觉)。
    • 影响范围:v1.0.0 ~ v1.2.5 全部版本(这行代码从初始提交就是现在这个写法,不是回归)。
    • 为什么多数人没遇到:需要两个条件同时成立——① 存储位置选成了裸盘根(选 E:\xwechat_files
      这类"盘符+子目录"的,rstrip 什么都不改);② 恰好在那个盘的非根目录里工作(数据放 E 盘
      而一直在 C 盘跑 Python 的人,E 盘的该盘当前目录还停在 E:\,畸形路径反而解析正确)。
      所以它表现为"我没改任何东西,昨天能读今天读不到"。
    • 修法:新增 _abs_root(),用 os.path.normpath 保留盘根语义,并把只写盘符没写分隔符的
      输入(d:)补成 d:\;_locate_account_root() 与 _extract_path_from_config() 里三处
      同族写法全部改用它,候选保证是绝对路径。UNC 根不受影响。
    • 暂时不能升级的绕法:给 WeChatDB(db_dir=...) 显式传账号目录的父目录(绝对路径),
      或不要把微信存储位置设在裸盘根。
    • 判据:新增 tools/test_db_path_root.py 40 项——假文件系统下以 d:\ 为 root 必须定位到
      d:\xwechat_files(含反向验:把实现换回旧 rstrip,同一用例必须返回 None,证明断言真能
      咬住回归);A–F 与 Z 六个盘符逐个验规范行为;本机真实盘上做「该盘当前目录漂移」对照,
      证明旧写法在两个启动目录下解析到不同位置;本机真库在 4 个启动目录下结果一致。
  • 其余内容同 v1.2.5(通讯录标签 + 右键转发、发送前回读校验、WxParam.ENABLE_OCR 总开关、
    会话名占位符尾巴、置顶/前台锁/剪贴板三处静默失效)。

升级:pip install -i https://pypi.org/simple --upgrade wechatauto-replica

1.2.5:通讯录标签 + 右键转发 + 发送前回读校验

Choose a tag to compare

@fanyuantaier fanyuantaier released this 07 Oct 09:15

本版要点

  • 新增:通讯录标签 + 右键转发。 走法按 4.1.15.13 真机实测的锚点实现(通讯录 → 通讯录管理 → 标签)。
    • WeChat 增 12 个方法:ListLabels(prefer='db'/'ui') / CreateLabel / RenameLabel / AddLabelMembers / RemoveLabelMembers / DeleteLabel / LabelMembers / SendToLabel / ForwardMessage / ProbeLabels;wxid 自动换成界面显示名,RenameLabel 只改名字不动成员和 label_id。
    • 先预演再真发:SendToLabel(text, label, dry_run=True) 只解析收件人、一次界面都不碰,limit=1 先试一个人;WeChatDB().list_labels() 是纯读库。ProbeLabels 只导航不写,报断在哪一步。
    • CLI 加 labels / forward 两个子命令和菜单两项(9 标签、10 转发),中英都收(标签/打标签、转发/转给)。指南新增 §10.5 通讯录标签、§10.6 右键转发。
  • 新增:发送前回读校验 WxParam.SEND_CONTENT_RATIO(默认 0.6,<=0 关闭)。 Ctrl+V 有可能整个落空(微信不在前台、焦点没落到输入框上),这时按键照样打下去,发出去的是空消息或上一轮残留的内容——老代码还把那个异常吞了、照样返回成功。现在 UIA 主路径与 OCR/坐标路径两条都在回车前回读输入框比对,判不达标就不回车、清空输入框、返回失败让上层重试,并且不占用 rhythm 的写动作额度。
    • 阈值为什么不照抄更高的 0.9:真机实测一条带表情码的正常消息只有 0.884,8 字中文差一个字是 0.875——定 0.9 会把好消息拦死;而读回空是 0.0、残留内容是 0.11,0.6 都拦得住。
    • 表情码口径(实测):[微笑] 在输入框里是 1 个 U+FFFC,落库正文才是原样 [微笑](发送时才转)。比对前两侧折算,折算后 1.0。代价:折算后 [微笑] 与 [发怒] 不可分辨,表情码打错不会被这道闸拦下。
  • 新增:WxParam.ENABLE_OCR(默认 True,行为不变)。 OCR 识别不准时一键停用。总闸放在引擎入口 ScreenOCR.recognize()(ocr() / ocr_zoomed() 额外在截图前短路,连抓屏都省掉)——调用点散在 guia / moment / uia_driver 三个模块共 20 处,逐处加开关迟早漏,所以另加一条静态断言锁住「全库只有 guia.py 直接引用 OcrEngine」。关掉后能走 UIA 的改走 UIA,没有 UIA 等价物的操作明确失败而不是盲点坐标:侧栏查找、搜索下拉点选、「发送」按钮兜底、朋友圈元素、转发/多选菜单。像素探针类(输入框定位、面板非空白、高亮行检测)不是 OCR,照常工作。
  • 新增:前台收尾 API。 ensure_visible(keep_topmost=…) 参数化;_minimize_blockers() 现在给被它最小化的遮挡窗口记账,新增 restore_blockers()(只还原自己按下去的那些,且用不抢前台的方式)和 release_foreground()(取消置顶 + 还原遮挡)。
  • 修复:current_chat() 一直带着输入框的占位符尾巴。 微信把「会话名 + 占位符」拼在控件 Name 里(本机实测「文件传输助手」→ 文件传输助手按住鼠标 语音输入文字),于是全库 current_chat() == who 一次都没成立过:会话明明已经打开,open_chat 照样返回 False,发送被推去走慢且更易错的坐标路径,连续发送的快速路径也永不触发。现在剥掉已知尾巴;未收录的尾巴原样返回(行为同旧版)。
  • 修复:置顶在 64 位 Python 下从来没生效过。 SetWindowPos(hwnd, -1, …) 把 -1 当 32 位整数塞进 HWND 参数,调用直接返回 0(last_error 1400),改用 ctypes.c_void_p(-1) / c_void_p(-2)。⚠️ 行为变化:置顶现在真的会生效——ensure_visible() 默认 keep_topmost=True,批量发送期间微信会压在桌面上所有窗口之上,收尾请调 release_foreground()。
  • 修复:前台锁设成 0 之后不再还原。 SPI_SETFOREGROUNDLOCKTIMEOUT 是全机设置,一直留着 0 会削弱所有程序的前台保护(不只微信)。现在先读原值、结束时恢复;顺带发现数值必须放在 pvParam 才写得进去(放 uiParam 直接返回 FALSE、值不变)。
  • 修复:剪贴板被别的进程占用时异常冒出 send_msg 变成 traceback(真机撞到过一次 PyperclipWindowsException: Error calling OpenClipboard)。set_clipboard() 改为返回 bool 并兜住异常,调用方按「这次输入失败」重试或明确失败。
  • 改进:_chat_is_open 改为 UIA 优先、OCR 兜底——UIA 命中时不再多花一次放大 OCR 和一次截图。
  • 兼容性:接口签名一字未改;set_clipboard() 返回值从 None 变成 bool,调用点判据写的是 is False 而不是 not …,所以覆盖它、仍返回 None 的老代码不会被误判成失败。无新增依赖。
  • 回归:tools/selftest.py 496 项 0 失败。新增 4 个离线套件——test_send_verify 21、test_ocr_send_gate 26、test_ocr_switch 21、test_chat_identity 21;标签/转发沿用 test_labels 98、test_forward 59。真机:UIA 路径与 UIA-only(ENABLE_OCR=False)各发一条,数据库回读逐字一致;模拟「送给空气」→ 读回 ''、相似度 0.000、一次都没回车;置顶设置与解除闭环真机验过。
  • 未测(如实说明):① 其它微信版本的占位符尾巴(未命中就退回旧行为,不会崩);② ENABLE_OCR=False 时朋友圈 / 转发的实际降级表现,离线只证明了「不会去调 OCR」;③ SendToLabel 的批量真发(--go),会发给别人,没跑过;④ 闸持续判不符时 send_msg 要跑满 3 次尝试(实测 56.9s)才失败,没做提前短路;⑤ bring_to_front 中途抛异常时前台锁不恢复(包 try/finally 要给整段循环改缩进,风险大于收益)。

安装:pip install -i https://pypi.org/simple --upgrade wechatauto-replica

1.2.4.4

Choose a tag to compare

@fanyuantaier fanyuantaier released this 03 Oct 04:27

微信 4.x 自动化库 wechatauto-replica 1.2.4.4:回应 issue #31——装完直接敲 wechatauto 就能用,不用再记命令和参数。

pip install -U wechatauto-replica==1.2.4.4
wechatauto

v1.2.4.4(2026-10-03)

  • 新增:一条命令的入口 wechatauto / python -m wechatauto(issue #31「能不能把代码调用搞简单一点?太麻烦了真的不会,比如一条命令」)。原有 Python 接口一字未改——命令行只是 WeChatDB / MediaDownloader / guia.quick_send / MomentDB 的调用方,不想用它的人照旧写 Python。
    • 只记一条:装完直接敲 wechatauto(不带任何参数)。它先做一次体检(账号 / 数据库密钥 / 图片密钥 / 控件树——哪一项是 0 就直说下一步干什么),再给编号菜单:看消息 / 导出 / 下载图片 / 发消息 / 监听 / 朋友圈 / 列会话 / 体检。挑会话时列出最近 10 个(昵称 + 未读数)按序号选,不需要知道 username 长什么样。菜单只在终端里进;脚本/管道里 python -m wechatauto 照旧打帮助退 2,不会把 CI 挂在 input() 上。
    • 子命令中英都认:消息=messages、导出=export、图片=images、发=send、听=listen、体检=doctor、会话=sessions、朋友圈=moments。只是一层查表翻译,参数定义仍然只有 argparse 那一份。
    • 它替新手吸收的三个坑,正是以前「麻烦」的来源:① 读库要 username、驱动界面要显示名——resolve_chat() 让昵称/备注/wxid/群号随便传,同名多人时明确要你用 wxid 而不随便挑一个(把 wxid 填进微信搜索框是「搜不到 → 白等几十秒 → 返回一个 None」的头号原因);② 下载图片前先确认图片 AES 密钥,缺了会说清怎么办,而不是一排 None 看着像库坏了;③ Windows 控制台默认 GBK,中文正文直接 UnicodeEncodeError——入口第一件事把 stdout/stderr 重设 UTF-8,export 永远写 UTF-8 文件。
    • 显示层两条:视频/文件/引用这类正文是整段 XML,刮成 [非文本正文] playlength=151 length=4211669 一行提示(要看原文用 --json);--type 中英文都收(库里类型名是中文,只收中文会让人以为「这个会话没有图片」)。
    • pyproject.toml 加了 [project.scripts],装完就有 wechatauto 这个命令,不必敲 python -m。发消息默认带 --verify(发完自己回读数据库确认)。
  • 未测(如实说明):菜单的真实按键路径——自动化里只能用脚本喂输入,真键盘那一路没跑过;send 子命令的真机发送(会驱动窗口,需要真人客户端和 rhythm 闸)。其余都有离线回归:tools/selftest.py 全量 490 项 0 失败,其中 cli 组 41 项(含「cli 顶部不许 import 界面栈」这条——把 guia 提到顶层它就会报红,反向验过)。

1.2.4.3

Choose a tag to compare

@fanyuantaier fanyuantaier released this 01 Oct 13:15

微信 4.x 自动化库 wechatauto-replica 1.2.4.3:原图路径按用户反馈重做,窄窗口(竖屏)下的两处点击一并修掉。

pip install -U wechatauto-replica==1.2.4.3

v1.2.4.3(2026-10-01)

  • 修复:「我发的图片提示找不到原图」/「点开了还在瞎点,最后报失败」/「下到的还是预览图」——同一批反馈,四个互相独立的原因,没一个是解密问题:
    • 「是不是我发的」以前写死 real_sender_id == 2。real_sender_id 是 message_resource.db 里 SenderName2Id 的 rowid,本机账号落在哪个 rowid 每台机器不一样——那个 2 是 1.1.8 从别人机器上带来的取值。本机实测自己是 1(文件传输助手 400 条全是 1),2 反而是某个常联系的好友(图片消息 512 条)。按常数判的代价很直接:自己发的图被当成别人发的,先去点左边,永远点不中气泡。现在按「解析出来等不等于本机 wxid」判,索引查不到时才兜底。
    • download_image_original() 从此只认原件 _h.dat,本机没有就一定驱动界面去下载。 中间那一档 .dat 不再当成果:它是「微信下发的那一份」,可能是完整图也可能本身就是预览版——实机例:一条 .dat 44,002 字节、预览图 2,961 字节,那张仍是预览图。本机三档里没有任何一档能证明「这张不是预览版」,所以也不再假装有 has_full 这种答案(改成 has_mid:有没有 .dat 这一份是事实,它是不是完整图本机答不了)。只想「本机有什么就交什么」的用 download_image(tier='full'/'best'),那条路一次界面都不碰。
    • 微信写盘比我们的判定晚:「报错说取不到,下一轮同一句又说已在本地」和「明明已经拿到了还在一张一张瞎点,最后报下载失败」是同一个因。现在每个决策点(进门 / 点下一张之前 / 等档超时之后 / 判失败之前)都回查一次本机,查到就交文件并停止后续界面动作。
    • 预览窗那颗下载键弹的是 Windows 通用保存对话框(标题是「保存」而不是「另存为」),而且第一遍「保存」写出的可能就是当前显示的那幅预览图(用户实测「UI 点下载存下来的仍然是预览图,要重新下载才得到原图」)。现在按本机档位核产物尺寸,判为预览版就先点「图片原始大小」再存一遍,几遍里取最大的那份;挑按钮会再验 ControlTypeName 真是按钮(那颗「选择文件格式」下拉框在 UIA 里的 Name 也叫「保存」,实机点到过一次),点完要确认对话框真的关掉(没关就是没点中),任何失败路径都会点「取消」把模态框收掉。
  • 新增:目标图片不在可视区时按数据库算出的行差滚过去(download_image_original(..., scroll=True, max_scrolls=6))。「哪一行才是这条图」用可视区 UIA 行序列和数据库「文本/图片」序列滑窗对齐来认,三道门槛缺一不可:至少两条文本锚点且吻合度 ≥0.6(可视区只剩一行时「吻合度 1.00」是假高分)、并列最优偏移必须给出唯一答案、对齐结果那一行在 UIA 里确实是图片行。每格滚几行是滚完看实际推进修正出来的,不预设 DPI/窗口高度常量。滚动的硬前提是拿得到微信进程 id——滚轮是按光标出队那一刻的位置投递的,落点无法校验时可能打进压在微信上面的别的程序,所以拿不到 pid 就退化成「只认当前屏、绝不滚」并警告。滚不动报 scroll-stuck、滚满报 scroll-limit、认不出退回「数它下面压了几张更新的图」,再不行逐个试——不会无限滚、不会一直瞎点。
  • 修复两处窄窗口(竖屏)下的点击:
    • 点一次导航栏「微信」栏退不出会话。 窄窗口是单栏布局,开着会话时 session_list 和搜索框整片不在树里,第一下只把主窗带回聊天页、会话还开着,要再点一次才退出。只点一下的旧写法让紧接着找搜索框的 open_chat() 必然落空——报出来就是「会话打不开」。现在 back_to_chat_tab() 改成点一下 → 查聊天页那一片出来没有 → 没出来且还有额度就再点一次(max_clicks=2);本来就出来就一点都不点(那一下正好把用户正在看的会话切走)。
    • 点不中图片气泡。 气泡位置不随行宽等比缩放(头像列加边距是固定像素):宽窗口行宽 2598 时气泡在行左 +44..+632,「行宽 12%」= +311 命中;竖屏行宽 720 时气泡在 +141..+459,同样算法只有 +86,落在底色上。改成「离发送方那一侧边缘 300px 的绝对锚点 → 旧的 12%」两个候选,宽窗口里两者重合、行为与已发布版本逐字一致。
    • 顺带两条同一路径上的形状:预览窗有两种(桌面直接子节点就是 mmui::PreviewWindow,或顶层是 Qt51514QWindowIcon 标题「图片和视频」而 mmui::PreviewWindow 在里面一层,只按顶层筛第二种永远找不到);那颗缩放键的 Name 会随显示状态在「图片原始大小」⇄「图片适应窗口大小」之间变,只写死一个名字找不到就提前返回,于是「保存」兜底根本没机会跑。
  • 修复:demo_original_image.py 里那道已经删掉的判据还留着——它用 > 100KB 当「原图已经下载」的前置判断,会把压缩版报成原图。
  • 本轮仍未做真机复验(如实说明):点气泡 → 预览窗 → 按钮 → _h.dat 这一段需要真人客户端和 rhythm 闸;竖屏自发图那一侧的气泡区间目前只有镜像推算。它上面的所有逻辑(档位判据、认行与对齐、滚动决策、对话框点选、点击点几何)都有离线回归。
  • 回归覆盖:tools/selftest.py 全量 449 项 0 失败(image 组 196、tree 组 25)。纹身验证:media.py 69 刀 + uia_driver.py 6 刀,本轮实跑 15 刀全部报红(把 _harvest 放宽到接受 .dat → 5 红;自发图又提前返回 → 1 红;has_mid 恒真 → 1 红;等档等的是 .dat → 1 红;进门那次查盘取消 → 1 红;气泡点击退回纯百分比 → 2 红;去掉行宽上限 → 1 红(极窄行会点到 x=0);不分发送方 → 5 红;候选不去重 → 1 红;导航栏补点那一组 6 刀各 1–5 红)。

1.2.4.2 图片分三档 + 「控件树拿不到」给出可定位的原因

Choose a tag to compare

@fanyuantaier fanyuantaier released this 30 Sep 07:21

这一版解决什么 / What this fixes

1) 「无法下载原图,只能下载缩略图」

一条图片消息在本机最多有三档:<md5>_t.dat 缩略图、<md5>.dat 微信默认下发的压缩版、
<md5>_h.dat 真原图(只有在微信里点过「查看原图 / 图片原始大小」才会落盘)。反馈里
「只要原图,先不用缩略图」之后一轮一轮不收敛的重试,是四个各自独立的坑叠在一起,
没一个是解密问题:

  • download_image() 压根不看 _h.dat;而且拿到压缩版时文件名和原图一模一样
    (<user>_<local_id>.jpg),调用方只能靠文件大小猜自己拿到了哪一档。
  • download_image_original() 用 getsize(_h.dat) > 102400 判「原图下好了没」。本机 705 个
    _h.dat 实测 min=575B、p10=4.3KB、p50=91.5KB、p75=563KB、max=18MB——51.3% 的真原图
    被这条绝对门槛当成「还没下载」拒掉了
    。
  • 同一个函数的 timeout 参数是摆设:点完固定 sleep(3) 取一次,慢机上文件还在长就判失败。
  • 档位报告也在撒谎:图片的本地文件名来自正文里的 32 位 hex,而通用的消息查询不带
    packed_info_data,于是每一行都被判成「不是图片」。
out = md.download_image(user, local_id, tier="original")  # 本机没有原图 → None,不悄悄降级
out = md.download_image(user, local_id, tier="best")      # 原图 > 压缩 > 缩略图,档位标进文件名
out = md.download_image(user, local_id)                   # 不传 tier = 与旧版逐字一致

md.image_status(user, local_id)            # 单条:有哪几档 / 最高一档 / reason
md.list_image_status(user, limit=300)      # 整个会话一次报清(消息表只查一次,同 md5 只扫一次盘)
MediaDownloader.original_ready(h_size, mid_size)   # 判据公开,调用方可用同一条规则

reason 取 ok / mid_only / only_thumbnail / original_partial(有 _h.dat 但是空壳或
比压缩版还小,即下载中断)/ no_local_copy / no_md5 / no_message_row。原图判据改成相对的:
非空、≥1KB、且不比压缩版小 10% 以上。

download_image_original() 现在:本机已有合格 _h.dat 时直接解密落盘,一步界面都不碰;
要动界面时按「这条图片下面压了几张更新的图」把最可能的气泡排在第一个试,每点一张都回来核对
md5
(点错不可能冒充成功),等待改成轮询到「可用且大小不再变化」或到 timeout;
预览窗里没有「图片原始大小」按钮时单独警告并指向 tier='best',不再和「下载没完成」混成同一种失败。

实测(只读):211 个会话 4271 条图片消息 → mid_only 2210、only_thumbnail 1428、
ok 626、original_partial 5、no_local_copy 2。大约 7 条里只有 1 条本机真有原图,
所以这类反馈多数是微信的存储策略;先查状态再决定要不要触发下载,能省掉一整轮无效重试。

2) 「控件树被屏蔽」现在会说清原因

mmui 控件树只有在 Weixin.dll 里那个字节被翻开之后才存在,而微信每次重启、升级、重新登录
都会把它清 0。库会热写它,但写不成时以前是静默返回空树:_wechat_hwnds() 把「取不到
Weixin.dll 模块」的窗口全部过滤掉,ensure_materialized() 遇到空列表直接 return False,
ensure_window() 悄悄改走「设读屏标志 + wake」——一句日志都不打。而「窗口全被过滤光」
恰恰是 32 位解释器枚举 64 位进程模块的特征(TH32CS_SNAPMODULE 从 32 位打 64 位只会得到
ERROR_PARTIAL_COPY),用户看不到任何线索,只能描述成「被屏蔽」。

现在按场景给四种可执行的说法,一个进程每种只警告一次(长驻监听每轮都扫,刷屏比不说更糟):
没扫到可见窗口(没启动/没登录/在托盘)、扫到但读不到该进程模块(32 位 → 换 64 位 Python;
64 位 → 指向权限与安全软件白名单)、pywin32 缺失,以及有窗口正常留下时完全不打扰。
过滤逻辑与返回值一字未改,老调用方不受影响。

验证 / Verification

  • 自检 283 项 0 失败(纯离线:临时目录 + 假 db + 假 win32gui,不碰真实客户端)。本版新增
    image 组 39 项(90% 线的两侧、七种 reason、五种 tier 路由与文件名、不许悄悄降级、
    气泡排序、轮询等待、db.get_image_rows() 字段对齐、同 md5 不重复扫盘)与 tree 组 16 项
    (扫描计数、四种文案、健康场景零输出、去重、两条真实路径确实都过了诊断)。
  • 变异验证七刀都会咬:>102400 绝对门槛放回去 → 5 红;去掉比例判据 → 2 红;原图文件名不标
    _h → 2 红;排序不看「比这条新几张」→ 3 红;tier='original' 允许悄悄降级 → 1 红;
    同一 md5 重复扫盘 → 1 红;去掉 ensure_materialized 的诊断调用 → 2 红。
  • 构建在干净的 worktree(c030e8f)里完成,逐文件核对包内 moment.py 与提交版一致,
    确认不含工作区里其它未提交改动。

已知边界 / Known limits

  • 本轮没有跑真机界面路线:点气泡 → 预览窗 → 「图片原始大小」需要真人客户端,且按 9/21 之后的
    约定必须走 rhythm 拟人节奏,因此那一段是未测;它上面的判据、路由、等待、排序与消息库
    查询都由离线回归覆盖。有实机环境的欢迎回报结果。
  • 原图只能在微信里点开才会落盘,本库不会替你点(download_image_original() 是唯一会点的那条,
    它是显式触发下载的写动作)。
  • 可视区里有多张图时按「下面压了几张更新的图」排序后逐个试,不做自动滚动——滚动一条消息会
    改变整个虚拟列表的命中面,误点别的图比不点更糟。需要 scroll-to-message 的话可以提 issue。
  • 「热写字节成功但控件树仍空」的场景本版没做主动复检(get_accessibility_features() 里那个值
    是写进去的副本,不能自证)。真遇到请带 Weixin.dll 版本 + 日志开 issue。

安装 / Install

pip install -U wechatauto-replica

文档见 GUIDE.md §6.2.2(图片三档与 reason 对照表)、§6.3(群聊图片)、§11 Q3、
§12 API 速查表,以及 Q8(控件树被屏蔽)。

1.2.4.1 群消息发送者身份 + 语音取不到的原因

Choose a tag to compare

@fanyuantaier fanyuantaier released this 25 Sep 14:17

这一版解决什么 / What this fixes

1) 群消息除了文本,都看不出是谁发的

real_sender_id 是数字 rowid,库早就通过 message_resource.db 的 SenderName2Id 把它换成了
真 wxid(放在 sender_username 里),但转换结果从没传到 Message 上,msg.wxid 存的还是那个数字,
唯一能用的路子是从文本正文里刮 wxid_xxx:\n 前缀——图片/语音/文件/表情全是匿名的。

def on_msg(msg, chat):
    msg.sender_wxid     # 真 wxid(非文本消息也有)
    msg.sender          # 备注或昵称;解析不到退回 wxid,再退回会话名
    msg.wxid            # 自己发的 = 真实 wxid(不再是常量 2)

chat.GetGroupMembers()   # 群成员表:群里不在你通讯录的人只有这里能给名字
db.nickname_map()        # 带缓存的 wxid→名字,批量换算用它

实测 8 个真实群 / 1249 条历史消息:带发送者身份 95.8%,非文本消息 0% → 99.6%(451/453)。
剩下 4.2% 是微信自己没留身份(SenderName2Id 只有 655 条、不含那些 id,抽查 20 条的 source
里也没有任何用户名标签)——造不出来的就不造,sender_wxid 保持空串。
顺手删掉一条陈旧兜底:拿数字 rowid 去查 contact.username(永远查不到,还把数字冒充成用户名)。

2) 语音「识别不全」的真相:不是查找键错(issue #20「26 条只识别 19 条」)

先把查找键验了一遍:独立实现与库在 958/958 条语音上逐条一致,「svr_id 存在但挂在别的
chat_name_id 下」为 0 条。真因是微信只在界面上播放/接收过之后才把 voice_data 写进
media_*.db
,而 download_voice() 一律返回 None,调用方分不清「本地没有」和「库读坏了」。

判据其实一直躺在消息表里:download_status。975 条语音的交叉表——
ds=1(515) 与 ds=5(400) 全部有音频,ds=0 的 60 条全部确实没有,一一对应,无一例外。

for v in md.list_voice_status("wxid_xxx"):
    v['available'], v['reason'], v['bytes'], v['self_sent']
md.voice_status("wxid_xxx", local_id)      # 单条

reason:ok / audio_not_downloaded(本地确实没有,去微信里播放一次就会落盘)/
audio_missing_from_media_db(状态说该有却没有,这种才值得开 issue)/
session_not_in_media_index / no_server_id / no_voice_row。
本机可用率 93.7%(898 可取 / 54 未落盘 / 6 会话无 media 索引)。download_voice() 签名与行为不变。

3) 一处需要知道的行为变更

群里 msg.sender 以前是正文里刮出来的原始 wxid_xxx,现在是备注/昵称;要原始 id 请读
msg.sender_wxid。

验证 / Verification

  • 新 API 与独立复算在 958 条语音上逐条一致;抽一条 audio_not_downloaded 实测确实下载不到。
  • 自检 226 项 0 失败:新增 sender 组 22 项、voice 组 28 项(后者基于真实的临时
    SQLite 消息库与 media 库)。变异验证:身份解析退回老写法 → 5 项红;假数字兜底放回去 →
    源码检查红;语音查询的缺列退化改回通用 continue → 「不丢行」红(只回来 0 条)并崩组。
  • 构建在一个干净的 worktree(c350cd6)里完成,确认包里不含工作区中其它未提交改动。

已知边界 / Known limits

那 6% 补不齐:只能在界面里把语音播放一遍,微信才会落盘(属于驱动真实客户端的写动作,本库没有自动化它)。
另外「自己发出的语音本地是否留音频」在本机数据不足(958 条里只有 2 条自发言),没有下结论;
list_voice_status 已把 self_sent 标出来,欢迎有数据的机器帮忙分自己/对方统计。

安装 / Install

pip install -U wechatauto-replica

1.2.4 微信 4.1.15.13 适配 + 朋友圈滚动/点赞/评论修好 + 全局监听补齐

Choose a tag to compare

@fanyuantaier fanyuantaier released this 24 Sep 10:44

这一版解决什么 / What this fixes

1) 适配微信 4.1.15.13(客户端会自动升级,旧版照常可用)

三件事叠在一起,从外面看都长成「版本不兼容」:

  • 搜索入口改成默认收起。原来输入框的位置现在只有一个叫「搜索」的 mmui::XButton
    (实测 [314,84,370,140]),点了之后 mmui::XValidatorTextEdit 才出现。
    _search_box(expand=True) 会先点一次再等输入框;search_box_rect() 在收起状态下仍给得出
    坐标(返回按钮矩形,作为只读锚点,故意不点——取坐标不该有副作用)。输入框本来就常驻的
    老版本一步都不多点。
  • gate RVA 挪到 0xb135c38,已进版本表,常用路径不必再跑向量化扫描。
  • 进朋友圈需要显式唤醒 mmui 树。没物化时报的是「找不到导航按钮」,现在会先查就绪状态、
    调 ensure_materialized(timeout=6.0),并在唤醒后重新锚定根控件(旧句柄指向唤醒前的空壳)。

2) 朋友圈滚动其实从来没滚过——find_moment 只是看起来能用

_send_scroll() 把 MOVE 和 WHEEL 两个 SendInput 连着发,中间零停顿。滚轮事件是在
出队那一刻按光标位置投递的,于是它落在旧坐标、也就是完全错误的窗口上;而且微信不是前台窗口时
mmui 时间线根本不接收滚轮。现在先用 SetCursorPos + GetCursorPos 轮询确认光标落位(最多约 0.4 秒)、
再停 0.3 秒,滚动前把窗口提到前台。实机:find_moment 连跑两次都命中(51.4s / 14.2s),后来 17.2s;
改之前是碰运气,运气好时看起来像时序抖动。

3) 点赞 / 评论合并成一条实现,老评论窗口补上节流

之前有两条并行路线,而真正跑通的那条(定位 「…」 → 点浮层里的 赞/评论)并不是 LikeMoment /
CommentMoment 走的路。现在 Like / Comment 先走浮层、失败才退回老的反向菜单,
LikeMoment / CommentMoment 改为委托,不再各留一份副本。老评论窗口(MomentCommentDialog.send)
上一版的节流把它漏了,现在补上,且放在所有前置校验之后(校验没过不占用写动作额度)。
教训:一个功能两份实现,被审过的那份往往不是正在跑的那份。

4) find_moment 不再交出一条点不动的 cell(BoundingRectangle 是 (0,0,0,0))

cell 滚出视口后 UIA 会回收句柄,回收后的节点矩形全是 0,在它上面做任何坐标动作都抛
Can not move cursor——表现成「明明定位成功了,却报未能打开朋友圈操作菜单」。现在命中后先验再返回:
同屏用「昵称 + 正文前缀 + 时间」重新认领同一条并原地换 control(调用方手里的对象继续有效),
同屏没有可用分身就先滚回视野再试一次。认领刻意保守:绝不认领同一个人的另一条(那等于给别的动态
点赞),既没正文又没时间的只剩昵称直接拒绝;正文按「前 10 字互含」比对而不是精确签名,因为 UIA 摘要
会截断正文、DB 校正过的正文反而更长,精确签名会连自己那条都认不出。

5) 全局监听 AddListenAll() 真的覆盖所有会话了

全局回调过去是按会话注册的,任何已经单独 AddListenChat 过的会话都被跳过、永远收不到全局回调。
现在按回调去重,轮询中新出现的会话自动纳管,交给回调的伪会话可用了(nickname 能取到、chat 懒建、
SendMsg 能发),监听器重启后回调会重新挂上,RemoveListenAll() 也真的摘得掉。文档见 GUIDE.md §4.3。

另外两个真实缺陷:并发——两个线程同时打开同一份库会踩掉同一个 dst.tmp 中间产物,从监听线程里抛
FileNotFoundError;现在按库加构建锁 + 中间文件名带进程号/线程号 + 清理超过 600 秒的残留。
分页入参——get_messages(limit=0) 会返回一整页、offset=-1 会返回最后一条(Python 切片行为),
本该报错的调用拿到一个看似合理的答案;现在 limit<=0 或 offset<0 一律返回 []。

实机复验 / Live verification

真实客户端、calm 节奏档、逐项拉开间隔:向文件传输助手发送并逐字回读通过两次(新 sort_seq 精确命中);
back_to_chat_tab() + open_chat() 端到端通过(整窗像素变化 7.2% / 8.3%,作为「页面确实切了」的客观判据);
朋友圈定位 → 点赞 → 评论,界面与本地库两侧都确认到位。

离线自检 175 项 0 失败(本项修复之前同一条命令读到 147 项;sessions / messages 两组会随账号数据多几项少几项):
新增 moment 组 30 项(假控件和真 MomentItem 实例各跑
一遍,不碰微信也不点击),外加 click(8)、listen(7)两组。两处变异验证确认新组真的会咬:
_rect_usable 改成永远返回 True → 15 项红;认领时只比昵称 → 5 项红,红的正是「不许认领另一条」那几条。

已知代价 / Known cost

AddListenAll 在 200 会话左右的账号上注册约 11 秒、每轮轮询约 10 秒(每个会话都要打开再读)。本轮未优化,
它同时是 issue #25「IO 偏高」报告的头号嫌疑。导航栏坐标点击仍待查(说法已在 1.2.3 更正过,本轮没碰)。

安装 / Install

pip install -U wechatauto-replica