feat: Initialize wxauto WeChat automation project with job extraction tools

- Add wxauto package with WeChat UI automation and message handling capabilities
- Implement job_extractor.py for automated job posting extraction from WeChat groups
- Add job_extractor_gui.py providing graphical interface for job extraction tool
- Create comprehensive documentation in Chinese covering GUI usage, multi-group support, and quick start guides
- Add build configuration files (build_exe.py, build_exe.spec) for packaging as standalone executable
- Include utility scripts for WeChat interaction (auto_send_msg.py, get_history.py, receive_file_transfer.py)
- Add project configuration files (pyproject.toml, setup.cfg, requirements.txt)
- Include test files (test_api.py, test_com_fix.py) for API and compatibility validation
- Add Apache 2.0 LICENSE and comprehensive README documentation
- Configure .gitignore to exclude build artifacts, logs, and temporary files
This commit is contained in:
2026-02-11 14:49:38 +08:00
commit b66bac7ca8
52 changed files with 15318 additions and 0 deletions

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
wxauto_logs
wxauto文件下载
dist/
build/
*.egg-info/
msgs.txt
__pycache__/
*.ipynb
*.bat
@AutomationLog.txt

177
GUI版本使用说明.md Normal file
View File

@@ -0,0 +1,177 @@
# 微信群岗位信息提取工具 - GUI版本使用说明
## 功能特点
- 图形化界面,操作简单直观
- 实时监听微信群消息
- 自动提取岗位结构化信息
- 岗位列表实时显示
- 支持查看岗位详情
- 数据自动保存和导出
## 打包成EXE
### 方法一:使用批处理脚本(推荐)
1. 确保已安装Python和依赖
```bash
pip install -e .
pip install requests pyinstaller
```
2. 双击运行 `build.bat` 文件
3. 等待打包完成生成的exe文件在 `dist` 目录
### 方法二使用Python脚本
```bash
python build_exe.py
```
### 方法三:手动打包
```bash
pyinstaller build_exe.spec
```
## 使用步骤
### 1. 启动程序
- 双击 `微信岗位提取工具.exe` 启动程序
- 或者直接运行 `python job_extractor_gui.py`
### 2. 配置参数
在程序界面顶部配置区域:
- **目标群组**:输入要监听的微信群名称(必须完全匹配)
- **API密钥**输入阿里云百炼API密钥
- 点击"保存配置"按钮保存设置
### 3. 开始任务
1. 确保微信已登录并打开主窗口
2. 点击"开始任务"按钮
3. 程序会自动连接微信并开始监听指定群组
4. 状态栏显示"运行中"表示监听成功
### 4. 查看岗位信息
- 提取到的岗位会实时显示在列表中
- 双击任意岗位可查看详细信息
- 包括:岗位名称、公司、地点、薪资、联系方式、原始消息等
### 5. 数据管理
- **停止任务**:停止监听微信群
- **清空列表**:清空所有岗位数据(会删除数据文件)
- **导出数据**导出当前所有岗位数据为JSON文件
### 6. 运行日志
程序底部的日志区域会显示:
- 连接状态
- 消息接收情况
- 岗位提取结果
- 错误信息等
## 界面说明
```
┌─────────────────────────────────────────────┐
│ 配置区域 │
│ - 目标群组: [输入框] │
│ - API密钥: [输入框] [保存配置] │
├─────────────────────────────────────────────┤
│ 控制按钮 │
│ [开始任务] [停止任务] [清空列表] [导出数据] │
├─────────────────────────────────────────────┤
│ 状态栏 │
│ 状态: 运行中 已提取岗位: 5 │
├─────────────────────────────────────────────┤
│ 岗位列表(双击查看详情) │
│ ┌───────────────────────────────────────┐ │
│ │序号│岗位│公司│地点│薪资│联系│时间 │ │
│ │ 1 │...│...│...│...│...│... │ │
│ │ 2 │...│...│...│...│...│... │ │
│ └───────────────────────────────────────┘ │
├─────────────────────────────────────────────┤
│ 运行日志 │
│ [10:30:00] 正在连接微信... │
│ [10:30:01] ✓ 已连接微信 │
│ [10:30:05] 收到消息 - 发送者: 张三 │
│ [10:30:06] ✓ 提取到岗位信息 │
└─────────────────────────────────────────────┘
```
## 提取的信息字段
- 工作名称
- 工作描述
- 工作地点
- 月薪最低/最高
- 公司名称
- 联系人
- 联系方式
## 数据存储
- 配置文件:`config.json`
- 岗位数据:`jobs_data.json`
- 导出文件:`jobs_export_YYYYMMDD_HHMMSS.json`
## 注意事项
1. **微信版本**:必须使用微信 3.9.x 版本
2. **微信状态**:微信必须保持登录状态,主窗口可以最小化
3. **群名称**:目标群组名称必须完全匹配(区分大小写)
4. **API密钥**:请妥善保管,不要泄露
5. **网络连接**需要网络连接以调用百炼API
6. **程序关闭**:关闭程序前建议先停止任务
## 常见问题
### Q: 提示"连接微信失败"
A: 请检查:
- 微信是否已登录
- 微信版本是否为 3.9.x
- 是否已安装wxauto依赖
### Q: 提示"添加监听失败"
A: 请检查:
- 群名称是否完全正确(区分大小写)
- 该群是否在微信的会话列表中
- 尝试先在微信中打开该群聊
### Q: API调用失败
A: 请检查:
- API密钥是否正确
- 网络连接是否正常
- API额度是否充足
### Q: 没有提取到岗位信息
A: 可能原因:
- 消息内容不包含招聘信息
- 消息格式不规范
- API识别失败查看日志了解详情
### Q: 打包后的exe无法运行
A: 请检查:
- 是否缺少依赖尝试在有Python环境的电脑上运行
- 杀毒软件是否拦截
- 尝试以管理员身份运行
## 技术支持
如遇到问题,请查看运行日志中的错误信息,或提供日志内容以便排查。
## 更新日志
### v1.0 (2026-02-11)
- 初始版本
- 支持微信群消息监听
- 支持岗位信息自动提取
- 图形化界面
- 数据导出功能

201
LICENSE Normal file
View File

@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2024 Cluic
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

123
README.md Normal file
View File

@@ -0,0 +1,123 @@
[![plus](https://plus.wxauto.org/images/wxauto_plus_logo3.png)](https://plus.wxauto.org)
# wxautoV2版本
**文档**
[使用文档](https://docs.wxauto.org) |
[云服务器wxauto部署指南](https://docs.wxauto.org/other/deploy)
| 环境 | 版本 |
| :----: | :--: |
| OS | [![Windows](https://img.shields.io/badge/Windows-10\|11\|Server2016+-white?logo=windows&logoColor=white)](https://www.microsoft.com/) |
| 微信 | [![Wechat](https://img.shields.io/badge/%E5%BE%AE%E4%BF%A1-3.9.X-07c160?logo=wechat&logoColor=white)](https://pan.baidu.com/s/1FvSw0Fk54GGvmQq8xSrNjA?pwd=vsmj) |
| Python | [![Python](https://img.shields.io/badge/Python-3.9\+-blue?logo=python&logoColor=white)](https://www.python.org/)|
[![Star History Chart](https://api.star-history.com/svg?repos=cluic/wxauto&type=Date)](https://star-history.com/#cluic/wxauto)
## 使用示例
### 1. 基本使用
```python
from wxauto import WeChat
# 初始化微信实例
wx = WeChat()
# 发送消息
wx.SendMsg("你好", who="张三")
# 获取当前聊天窗口消息
msgs = wx.GetAllMessage()
for msg in msgs:
print(f"消息内容: {msg.content}, 消息类型: {msg.type}")
```
### 2. 监听消息
```python
from wxauto import WeChat
from wxauto.msgs import FriendMessage
import time
wx = WeChat()
# 消息处理函数
def on_message(msg, chat):
text = f'[{msg.type} {msg.attr}]{chat} - {msg.content}'
print(text)
with open('msgs.txt', 'a', encoding='utf-8') as f:
f.write(text + '\n')
if msg.type in ('image', 'video'):
print(msg.download())
if isinstance(msg, FriendMessage):
time.sleep(len(msg.content))
msg.quote('收到')
...# 其他处理逻辑配合Message类的各种方法可以实现各种功能
# 添加监听监听到的消息用on_message函数进行处理
wx.AddListenChat(nickname="张三", callback=on_message)
# ... 程序运行一段时间后 ...
# 移除监听
wx.RemoveListenChat(nickname="张三")
```
## 交流
[微信交流群](https://plus.wxauto.org/plus/#%E8%8E%B7%E5%8F%96plus)
## 最后
如果对您有帮助希望可以帮忙点个Star如果您正在使用这个项目可以将右上角的 Unwatch 点为 Watching以便在我更新或修复某些 Bug 后即使收到反馈,感谢您的支持,非常感谢!
## 免责声明
代码仅用于对UIAutomation技术的交流学习使用禁止用于实际生产项目请勿用于非法用途和商业用途如因此产生任何法律纠纷均与作者无关
### 3. 岗位信息提取(新增功能)
自动监听指定微信群使用阿里云百炼API智能提取招聘岗位的结构化信息。
**v1.1 新功能:**
- ✓ 支持多群组同时监听
- ✓ 使用UUID作为岗位唯一标识
- ✓ 界面显示岗位来源群组
- ✓ API密钥打包时配置
```python
# 1. 配置 config.json
{
"target_groups": [
"招聘信息群1",
"招聘信息群2"
],
"bailian_api_url": "https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation",
"api_key": "your-api-key",
"output_file": "jobs_data.json"
}
# 2. 配置API密钥打包前
配置API密钥.bat
# 3. 运行岗位提取程序
python job_extractor_gui.py
# 4. 查看提取的岗位数据
python view_jobs.py
# 5. 测试API连接
python test_api.py
```
提取的信息包括:工作名称、工作描述、工作地点、薪资范围、公司名称、联系人、联系方式等。
详细使用说明请查看:
- [多群组版本使用说明.md](多群组版本使用说明.md) ⭐推荐
- [GUI版本使用说明.md](GUI版本使用说明.md)
- [岗位提取使用说明.md](岗位提取使用说明.md)

View File

169
README_打包修复.md Normal file
View File

@@ -0,0 +1,169 @@
# 打包修复完成 ✓
## 问题已解决
原问题:`[WinError -2147221008] 尚未调用 CoInitialize`
已通过以下修改解决:
### 1. 代码修复
- ✓ 在 `job_extractor_gui.py` 中添加 `pythoncom` 导入
- ✓ 在工作线程中添加 `CoInitialize()``CoUninitialize()`
- ✓ 正确处理COM组件的生命周期
### 2. 依赖更新
- ✓ 添加 `pywin32>=306` 到 requirements.txt
- ✓ 更新打包脚本自动安装 pywin32
- ✓ 更新 spec 文件包含所有必要模块
### 3. 打包配置
- ✓ 在 `build_exe.spec` 中添加 pythoncom 等隐藏导入
- ✓ 更新 `build.bat` 自动检查依赖
- ✓ 创建一键修复脚本
## 快速开始
### 方法一:一键修复并打包(推荐)
```bash
# 双击运行
修复并重新打包.bat
```
### 方法二:使用启动工具
```bash
# 双击运行
启动工具.bat
# 然后选择:
# [6] 安装/更新依赖
# [5] 打包成 EXE 文件
```
### 方法三:手动操作
```bash
# 1. 安装依赖
pip install pywin32 pyinstaller
# 2. 验证修复
python test_com_fix.py
# 3. 打包
pyinstaller build_exe.spec
# 4. 测试
dist\微信岗位提取工具.exe
```
## 文件说明
### 核心文件
- `job_extractor_gui.py` - GUI主程序已修复
- `build_exe.spec` - 打包配置(已更新)
- `requirements.txt` - 依赖列表已添加pywin32
### 打包脚本
- `修复并重新打包.bat` - 一键修复脚本 ⭐推荐
- `build.bat` - 标准打包脚本
- `build_exe.py` - Python打包脚本
### 测试工具
- `test_com_fix.py` - COM修复验证脚本
- `test_api.py` - API连接测试
### 文档
- `快速修复指南.txt` - 快速参考
- `打包问题修复说明.md` - 详细技术说明
- `GUI版本使用说明.md` - 使用手册
## 验证清单
打包完成后,请验证:
- [ ] exe能正常启动
- [ ] 界面显示正常
- [ ] 能保存配置
- [ ] 能连接微信不再报COM错误
- [ ] 能添加监听
- [ ] 能接收和处理消息
- [ ] 能提取岗位信息
- [ ] 能查看详情
- [ ] 能导出数据
## 技术细节
### COM组件初始化
```python
import pythoncom
def run_task(self):
# 在线程开始时初始化
pythoncom.CoInitialize()
try:
# 你的代码
pass
finally:
# 在线程结束时清理
pythoncom.CoUninitialize()
```
### 打包配置
```python
# build_exe.spec
hiddenimports=[
'pythoncom', # COM支持
'pywintypes', # Windows类型
'win32com', # Win32 COM
'win32api', # Win32 API
# ... 其他模块
]
```
## 常见问题
### Q: 仍然报COM错误
A:
1. 确认已安装 pywin32`pip show pywin32`
2. 重新运行修复脚本
3. 检查是否使用了最新的代码
### Q: 打包失败?
A:
1. 检查磁盘空间
2. 关闭杀毒软件
3. 以管理员身份运行
4. 查看错误日志
### Q: exe文件很大
A: 正常现象包含了Python运行时和所有依赖通常30-50MB
### Q: 启动很慢?
A: 首次启动会解压文件,后续会快一些
## 下一步
1. 测试打包后的exe
2. 配置你的目标群组
3. 开始提取岗位信息
4. 查看提取结果
## 支持
如遇问题,请查看:
- 快速修复指南.txt
- 打包问题修复说明.md
- GUI版本使用说明.md
---
修复完成时间2026-02-11
版本v1.0
状态:✓ 已修复并测试

72
auto_send_msg.py Normal file
View File

@@ -0,0 +1,72 @@
# -*- coding: utf-8 -*-
"""
wxauto 自动发消息示例
运行前请确保1) 已安装依赖 pip install -e .
2) 电脑已登录微信 3.9 版本,且主窗口已打开
"""
import sys
import time
# 确保能导入当前项目的 wxauto以脚本所在目录为项目根
import os
_script_dir = os.path.dirname(os.path.abspath(__file__))
if _script_dir not in sys.path:
sys.path.insert(0, _script_dir)
from wxauto import WeChat
def send_to_one(wx, who: str, msg: str):
"""给指定联系人/群发一条消息"""
ret = wx.SendMsg(msg, who=who)
if ret:
print(f"[成功] 已向 [{who}] 发送: {msg}")
else:
print(f"[失败] 发送给 [{who}] 失败: {ret}")
return ret
def send_to_many(wx, items: list, interval_sec: float = 2.0):
"""
批量发消息
items: [(who, msg), ...] 或 [{"who": "张三", "msg": "你好"}, ...]
interval_sec: 每条消息间隔秒数,避免操作过快
"""
for i, item in enumerate(items):
if isinstance(item, (list, tuple)):
who, msg = item[0], item[1]
else:
who, msg = item["who"], item["msg"]
send_to_one(wx, who, msg)
if i < len(items) - 1:
time.sleep(interval_sec)
def main():
print("正在连接微信...")
try:
wx = WeChat()
except Exception as e:
print("连接失败,请确保:")
print(" 1. 已安装依赖: pip install -e .")
print(" 2. 微信 3.9 已登录并保持主窗口打开")
print(f" 错误: {e}")
return
# ========== 示例1给一个人发一条消息 ==========
# 把 "文件传输助手" 改成你要发的联系人/群名
who = "文件传输助手"
msg = "这是一条由 wxauto 自动发送的消息"
send_to_one(wx, who, msg)
# ========== 示例2批量发可注释掉上面示例1只保留下面 ==========
# send_to_many(wx, [
# ("文件传输助手", "第一条"),
# ("文件传输助手", "第二条"),
# ], interval_sec=2.0)
print("执行完毕。")
if __name__ == "__main__":
main()

80
build_exe.py Normal file
View File

@@ -0,0 +1,80 @@
# -*- coding: utf-8 -*-
"""
打包脚本 - 将GUI程序打包成exe
使用方法:
1. 安装 PyInstaller: pip install pyinstaller
2. 运行此脚本: python build_exe.py
"""
import os
import sys
import shutil
import subprocess
def build():
print("=" * 60)
print("开始打包微信群岗位信息提取工具")
print("=" * 60)
# 检查 PyInstaller
try:
import PyInstaller
print(f"✓ PyInstaller 版本: {PyInstaller.__version__}")
except ImportError:
print("× PyInstaller 未安装")
print("请运行: pip install pyinstaller")
return
# 清理旧的构建文件
print("\n清理旧文件...")
for folder in ["build", "dist"]:
if os.path.exists(folder):
shutil.rmtree(folder)
print(f" 已删除: {folder}")
# 构建命令
cmd = [
"pyinstaller",
"--name=微信岗位提取工具",
"--windowed", # 不显示控制台
"--onefile", # 打包成单个exe
"--icon=NONE",
"--add-data=wxauto;wxauto", # 包含wxauto模块
"--hidden-import=wxauto",
"--hidden-import=wxauto.wx",
"--hidden-import=wxauto.ui",
"--hidden-import=wxauto.msgs",
"--hidden-import=wxauto.utils",
"--hidden-import=PIL",
"--hidden-import=PIL._imagingtk",
"--hidden-import=PIL._tkinter_finder",
"--collect-all=wxauto",
"job_extractor_gui.py"
]
print("\n开始打包...")
print(f"命令: {' '.join(cmd)}\n")
try:
result = subprocess.run(cmd, check=True)
print("\n" + "=" * 60)
print("✓ 打包完成!")
print("=" * 60)
print(f"可执行文件位置: dist/微信岗位提取工具.exe")
print("\n使用说明:")
print("1. 将 dist/微信岗位提取工具.exe 复制到任意位置")
print("2. 确保微信 3.9 已登录")
print("3. 双击运行程序")
print("4. 配置目标群组和API密钥")
print("5. 点击'开始任务'开始监听")
except subprocess.CalledProcessError as e:
print(f"\n× 打包失败: {e}")
return
except Exception as e:
print(f"\n× 发生错误: {e}")
return
if __name__ == "__main__":
build()

85
build_exe.spec Normal file
View File

@@ -0,0 +1,85 @@
# -*- mode: python ; coding: utf-8 -*-
block_cipher = None
a = Analysis(
['job_extractor_gui.py'],
pathex=[],
binaries=[],
datas=[('wxauto', 'wxauto')],
hiddenimports=[
'wxauto',
'wxauto.wx',
'wxauto.ui',
'wxauto.ui.main',
'wxauto.ui.base',
'wxauto.ui.chatbox',
'wxauto.ui.component',
'wxauto.ui.navigationbox',
'wxauto.ui.sessionbox',
'wxauto.msgs',
'wxauto.msgs.base',
'wxauto.msgs.msg',
'wxauto.msgs.friend',
'wxauto.msgs.self',
'wxauto.msgs.type',
'wxauto.msgs.attr',
'wxauto.utils',
'wxauto.utils.tools',
'wxauto.utils.win32',
'wxauto.param',
'wxauto.logger',
'wxauto.languages',
'wxauto.exceptions',
'wxauto.uiautomation',
'PIL',
'PIL._imagingtk',
'PIL._tkinter_finder',
'requests',
'json',
'threading',
'tkinter',
'tkinter.ttk',
'tkinter.scrolledtext',
'pythoncom',
'pywintypes',
'win32com',
'win32com.client',
'win32api',
'win32con',
'win32gui',
'win32process',
],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name='微信岗位提取工具',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False, # 不显示控制台窗口
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)

9
config.json Normal file
View File

@@ -0,0 +1,9 @@
{
"target_groups": [
"招聘信息群1",
"招聘信息群2"
],
"bailian_api_url": "https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation",
"api_key": "sk-46cb053d75eb4ad88713917ba0f1c81a",
"output_file": "jobs_data.json"
}

31
get_current_user.py Normal file
View File

@@ -0,0 +1,31 @@
"""
获取当前登录微信的用户名称
"""
from wxauto import WeChat
def get_current_wechat_user():
"""获取当前登录微信的用户名称
Returns:
str: 当前登录微信的用户名称如果获取失败则返回None
"""
try:
# 初始化微信实例
wx = WeChat()
# 获取当前登录用户的昵称
username = wx.nickname
print(f"当前登录的微信用户: {username}")
return username
except Exception as e:
print(f"获取微信用户名称失败: {e}")
return None
if __name__ == "__main__":
user = get_current_wechat_user()
if user:
print(f"\n成功获取用户名称: {user}")
else:
print("\n获取用户名称失败,请确保微信已登录")

69
get_history.py Normal file
View File

@@ -0,0 +1,69 @@
# -*- coding: utf-8 -*-
"""
获取「文件传输助手」历史记录并输出到控制台
运行前请确保1) 已安装依赖 pip install -e .
2) 电脑已登录微信 3.9 版本,且主窗口已打开
"""
import sys
import os
_script_dir = os.path.dirname(os.path.abspath(__file__))
if _script_dir not in sys.path:
sys.path.insert(0, _script_dir)
from wxauto import WeChat
# 要获取历史的聊天(可改成其他好友/群名)
TARGET_CHAT = "文件传输助手"
# 向上加载更多页数每页约一批消息0 表示只取当前已加载的
LOAD_MORE_PAGES = 10
def format_msg(msg, index: int):
"""单条消息格式化为一行输出"""
msg_type = getattr(msg, "type", "text")
content = getattr(msg, "content", "")
sender = getattr(msg, "sender_remark", "") or getattr(msg, "sender", "")
content = (content or "").replace("\n", " ").strip()
if msg_type != "text":
content = f"[{msg_type}] " + (content or "(非文本)")
return f" {index}. [{sender}] {content}"
def main():
print("正在连接微信...")
try:
wx = WeChat()
except Exception as e:
print("连接失败,请确保:")
print(" 1. 已安装依赖: pip install -e .")
print(" 2. 微信 3.9 已登录并保持主窗口打开")
print(f" 错误: {e}")
return
print(f"正在切换到「{TARGET_CHAT}」并获取历史记录...\n")
if not wx.ChatWith(TARGET_CHAT):
print(f"无法切换到「{TARGET_CHAT}」,请检查名称是否正确")
return
# 向上滚动加载更多历史(可选)
if LOAD_MORE_PAGES > 0:
for i in range(LOAD_MORE_PAGES):
ret = wx.LoadMoreMessage(interval=0.3)
if not ret:
break
msgs = wx.GetAllMessage()
if not msgs:
print("当前没有获取到任何消息。")
return
# 列表通常是时间正序(从旧到新),直接按序输出
print(f"---------- 「{TARGET_CHAT}」 历史记录(共 {len(msgs)} 条)----------")
for i, msg in enumerate(msgs, 1):
print(format_msg(msg, i))
print("---------- 结束 ----------")
if __name__ == "__main__":
main()

223
job_extractor.py Normal file
View File

@@ -0,0 +1,223 @@
# -*- coding: utf-8 -*-
"""
从指定微信群拉取消息使用百炼API提取岗位结构化数据
运行前请确保1) 已安装依赖 pip install -e .
2) 电脑已登录微信 3.9 版本,且主窗口已打开
3) 已配置 config.json 文件
"""
import sys
import os
import json
import time
import requests
from datetime import datetime
_script_dir = os.path.dirname(os.path.abspath(__file__))
if _script_dir not in sys.path:
sys.path.insert(0, _script_dir)
from wxauto import WeChat
def load_config():
"""加载配置文件"""
config_path = os.path.join(_script_dir, "config.json")
if not os.path.exists(config_path):
print(f"配置文件不存在: {config_path}")
return None
with open(config_path, "r", encoding="utf-8") as f:
return json.load(f)
def extract_job_info(message_content, api_url, api_key):
"""使用百炼API提取岗位信息"""
prompt = f"""请从以下消息中提取招聘岗位信息并以JSON格式返回。如果消息不包含招聘信息返回空对象。
要提取的字段:
- job_name: 工作名称
- job_description: 工作描述
- job_location: 工作地点
- salary_min: 月薪最低(数字,单位:元)
- salary_max: 月薪最高(数字,单位:元)
- company_name: 公司名称
- contact_person: 联系人
- contact_info: 联系方式(电话/微信等)
消息内容:
{message_content}
请直接返回JSON格式不要包含其他说明文字。"""
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}"
}
payload = {
"model": "qwen-plus",
"input": {
"messages": [
{
"role": "system",
"content": "你是一个专业的招聘信息提取助手,擅长从文本中提取结构化的岗位信息。"
},
{
"role": "user",
"content": prompt
}
]
},
"parameters": {
"result_format": "message"
}
}
try:
response = requests.post(api_url, headers=headers, json=payload, timeout=30)
response.raise_for_status()
result = response.json()
if "output" in result and "choices" in result["output"]:
content = result["output"]["choices"][0]["message"]["content"]
# 尝试解析JSON
try:
job_data = json.loads(content)
return job_data if job_data else None
except json.JSONDecodeError:
print(f"API返回内容无法解析为JSON: {content}")
return None
else:
print(f"API返回格式异常: {result}")
return None
except requests.exceptions.RequestException as e:
print(f"API请求失败: {e}")
return None
except Exception as e:
print(f"提取岗位信息时发生错误: {e}")
return None
def save_job_data(job_data, output_file):
"""保存岗位数据到文件"""
output_path = os.path.join(_script_dir, output_file)
# 读取现有数据
existing_data = []
if os.path.exists(output_path):
try:
with open(output_path, "r", encoding="utf-8") as f:
existing_data = json.load(f)
except:
existing_data = []
# 添加新数据
existing_data.append(job_data)
# 保存
with open(output_path, "w", encoding="utf-8") as f:
json.dump(existing_data, f, ensure_ascii=False, indent=2)
print(f"岗位数据已保存到: {output_path}")
def on_message(msg, chat, config):
"""消息处理回调函数"""
print(f"\n[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] 收到新消息")
print(f"发送者: {msg.sender}")
print(f"消息类型: {msg.type}")
print(f"消息内容: {msg.content}")
# 只处理文本消息
if msg.type != "text" or not msg.content:
print("跳过非文本消息")
return
# 使用百炼API提取岗位信息
print("正在分析消息内容...")
job_info = extract_job_info(
msg.content,
config["bailian_api_url"],
config["api_key"]
)
if job_info and any(job_info.values()):
print("✓ 提取到岗位信息:")
print(json.dumps(job_info, ensure_ascii=False, indent=2))
# 添加元数据
job_info["_metadata"] = {
"source": "wechat_group",
"group_name": config["target_group"],
"sender": msg.sender,
"extract_time": datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
"original_message": msg.content
}
# 保存数据
save_job_data(job_info, config["output_file"])
else:
print("× 未提取到有效岗位信息")
def main():
print("=" * 60)
print("微信群岗位信息提取工具")
print("=" * 60)
# 加载配置
config = load_config()
if not config:
return
target_group = config.get("target_group", "")
if not target_group:
print("错误: 配置文件中未指定 target_group")
return
print(f"\n配置信息:")
print(f" 目标群组: {target_group}")
print(f" 输出文件: {config.get('output_file', 'jobs_data.json')}")
print(f" 检查间隔: {config.get('check_interval', 5)}")
# 连接微信
print("\n正在连接微信...")
try:
wx = WeChat()
print(f"✓ 已连接微信,当前用户: {wx.nickname}")
except Exception as e:
print(f"× 连接失败: {e}")
print("请确保:")
print(" 1. 已安装依赖: pip install -e .")
print(" 2. 微信 3.9 已登录并保持主窗口打开")
return
# 添加监听
print(f"\n正在添加监听: {target_group}")
result = wx.AddListenChat(
nickname=target_group,
callback=lambda msg, chat: on_message(msg, chat, config)
)
if isinstance(result, str) and "失败" in result:
print(f"× 添加监听失败: {result}")
print(f"提示: 请确保群名称 '{target_group}' 正确")
return
print(f"✓ 成功监听群组: {target_group}")
print("\n开始监听消息...")
print("按 Ctrl+C 停止监听\n")
print("-" * 60)
# 保持运行
try:
wx.KeepRunning()
except KeyboardInterrupt:
print("\n\n正在停止监听...")
wx.StopListening()
print("程序已退出")
if __name__ == "__main__":
main()

622
job_extractor_gui.py Normal file
View File

@@ -0,0 +1,622 @@
# -*- coding: utf-8 -*-
"""
微信群岗位信息提取工具 - GUI版本
支持多群组监听使用UUID作为岗位ID
"""
import sys
import os
import json
import time
import requests
import threading
import tkinter as tk
from tkinter import ttk, scrolledtext, messagebox
from datetime import datetime
from pathlib import Path
import uuid
# 初始化COM组件修复打包后的错误
import pythoncom
_script_dir = os.path.dirname(os.path.abspath(__file__))
if _script_dir not in sys.path:
sys.path.insert(0, _script_dir)
from wxauto import WeChat
class JobExtractorGUI:
def __init__(self, root):
self.root = root
self.root.title("微信群岗位信息提取工具 v1.1")
self.root.geometry("1200x700")
# 变量
self.wx = None
self.is_running = False
self.job_count = 0
self.config_file = "config.json"
self.output_file = "jobs_data.json"
self.active_groups = {} # 存储活跃的群组监听
# 加载配置
self.load_config()
# 创建界面
self.create_widgets()
# 绑定关闭事件
self.root.protocol("WM_DELETE_WINDOW", self.on_closing)
def load_config(self):
"""加载配置"""
if os.path.exists(self.config_file):
try:
with open(self.config_file, "r", encoding="utf-8") as f:
self.config = json.load(f)
except:
self.config = self.get_default_config()
else:
self.config = self.get_default_config()
def get_default_config(self):
"""获取默认配置"""
return {
"target_groups": [],
"bailian_api_url": "https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation",
"api_key": "",
"output_file": "jobs_data.json"
}
def save_config(self):
"""保存配置"""
try:
with open(self.config_file, "w", encoding="utf-8") as f:
json.dump(self.config, f, ensure_ascii=False, indent=2)
return True
except Exception as e:
messagebox.showerror("错误", f"保存配置失败: {e}")
return False
def create_widgets(self):
"""创建界面组件"""
# 顶部配置区域
config_frame = ttk.LabelFrame(self.root, text="配置", padding=10)
config_frame.pack(fill=tk.X, padx=10, pady=5)
# 目标群组(支持多个,用逗号分隔)
ttk.Label(config_frame, text="目标群组:").grid(row=0, column=0, sticky=tk.W, pady=5)
ttk.Label(config_frame, text="(多个群组用逗号分隔)", font=("", 8), foreground="gray").grid(
row=0, column=1, sticky=tk.W, padx=5, pady=5
)
self.group_entry = ttk.Entry(config_frame, width=60)
self.group_entry.grid(row=1, column=0, columnspan=2, sticky=tk.W, padx=5, pady=5)
# 从配置加载群组
groups = self.config.get("target_groups", [])
if groups:
self.group_entry.insert(0, ", ".join(groups))
# API密钥只读显示打包时配置
ttk.Label(config_frame, text="API密钥:").grid(row=2, column=0, sticky=tk.W, pady=5)
api_key = self.config.get("api_key", "")
if api_key:
masked_key = api_key[:10] + "..." + api_key[-10:] if len(api_key) > 20 else api_key
ttk.Label(config_frame, text=masked_key, foreground="green").grid(
row=2, column=1, sticky=tk.W, padx=5, pady=5
)
else:
ttk.Label(config_frame, text="未配置", foreground="red").grid(
row=2, column=1, sticky=tk.W, padx=5, pady=5
)
# 保存配置按钮
ttk.Button(config_frame, text="保存群组配置", command=self.save_config_click).grid(
row=1, column=2, padx=10
)
# 控制区域
control_frame = ttk.Frame(self.root, padding=10)
control_frame.pack(fill=tk.X, padx=10)
self.start_btn = ttk.Button(control_frame, text="开始任务", command=self.start_task, width=15)
self.start_btn.pack(side=tk.LEFT, padx=5)
self.stop_btn = ttk.Button(control_frame, text="停止任务", command=self.stop_task,
width=15, state=tk.DISABLED)
self.stop_btn.pack(side=tk.LEFT, padx=5)
ttk.Button(control_frame, text="清空列表", command=self.clear_jobs, width=15).pack(
side=tk.LEFT, padx=5
)
ttk.Button(control_frame, text="导出数据", command=self.export_data, width=15).pack(
side=tk.LEFT, padx=5
)
# 状态栏
status_frame = ttk.Frame(self.root)
status_frame.pack(fill=tk.X, padx=10, pady=5)
ttk.Label(status_frame, text="状态:").pack(side=tk.LEFT)
self.status_label = ttk.Label(status_frame, text="未启动", foreground="gray")
self.status_label.pack(side=tk.LEFT, padx=5)
ttk.Label(status_frame, text="已提取岗位:").pack(side=tk.LEFT, padx=(20, 0))
self.count_label = ttk.Label(status_frame, text="0", foreground="blue")
self.count_label.pack(side=tk.LEFT, padx=5)
# 岗位列表区域
list_frame = ttk.LabelFrame(self.root, text="提取的岗位信息", padding=10)
list_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)
# 创建Treeview
columns = ("group", "job_name", "company", "location", "salary", "contact", "time")
self.tree = ttk.Treeview(list_frame, columns=columns, show="tree headings", height=15)
# 设置列
self.tree.heading("#0", text="序号")
self.tree.heading("group", text="来源群组")
self.tree.heading("job_name", text="岗位名称")
self.tree.heading("company", text="公司")
self.tree.heading("location", text="地点")
self.tree.heading("salary", text="薪资")
self.tree.heading("contact", text="联系方式")
self.tree.heading("time", text="提取时间")
# 设置列宽
self.tree.column("#0", width=50)
self.tree.column("group", width=120)
self.tree.column("job_name", width=150)
self.tree.column("company", width=130)
self.tree.column("location", width=100)
self.tree.column("salary", width=100)
self.tree.column("contact", width=130)
self.tree.column("time", width=140)
# 滚动条
scrollbar = ttk.Scrollbar(list_frame, orient=tk.VERTICAL, command=self.tree.yview)
self.tree.configure(yscrollcommand=scrollbar.set)
self.tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
# 双击查看详情
self.tree.bind("<Double-1>", self.show_job_detail)
# 日志区域
log_frame = ttk.LabelFrame(self.root, text="运行日志", padding=10)
log_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)
self.log_text = scrolledtext.ScrolledText(log_frame, height=8, wrap=tk.WORD)
self.log_text.pack(fill=tk.BOTH, expand=True)
# 加载已有数据
self.load_existing_jobs()
def log(self, message):
"""添加日志"""
timestamp = datetime.now().strftime("%H:%M:%S")
log_msg = f"[{timestamp}] {message}\n"
self.log_text.insert(tk.END, log_msg)
self.log_text.see(tk.END)
self.root.update()
def save_config_click(self):
"""保存配置按钮点击"""
groups_text = self.group_entry.get().strip()
if not groups_text:
messagebox.showwarning("警告", "请输入至少一个目标群组名称")
return
# 解析群组列表(支持逗号、分号、换行分隔)
import re
groups = re.split(r'[,;\n]+', groups_text)
groups = [g.strip() for g in groups if g.strip()]
if not groups:
messagebox.showwarning("警告", "请输入至少一个有效的群组名称")
return
self.config["target_groups"] = groups
if self.save_config():
messagebox.showinfo("成功", f"已保存 {len(groups)} 个群组配置:\n" + "\n".join(f"- {g}" for g in groups))
self.log(f"配置已保存: {len(groups)} 个群组")
def start_task(self):
"""开始任务"""
# 验证配置
groups_text = self.group_entry.get().strip()
api_key = self.config.get("api_key", "")
if not groups_text:
messagebox.showwarning("警告", "请输入目标群组名称")
return
if not api_key:
messagebox.showerror("错误", "API密钥未配置请在config.json中配置后重新打包")
return
# 解析群组列表
import re
groups = re.split(r'[,;\n]+', groups_text)
groups = [g.strip() for g in groups if g.strip()]
if not groups:
messagebox.showwarning("警告", "请输入至少一个有效的群组名称")
return
# 更新配置
self.config["target_groups"] = groups
self.save_config()
# 启动监听线程
self.is_running = True
self.start_btn.config(state=tk.DISABLED)
self.stop_btn.config(state=tk.NORMAL)
self.status_label.config(text="正在启动...", foreground="orange")
threading.Thread(target=self.run_task, daemon=True).start()
def run_task(self):
"""运行任务(在线程中)"""
# 初始化COM组件每个线程都需要
pythoncom.CoInitialize()
try:
self.log("正在连接微信...")
self.wx = WeChat()
self.log(f"✓ 已连接微信,当前用户: {self.wx.nickname}")
# 获取要监听的群组列表
groups = self.config.get("target_groups", [])
if not groups:
self.log("× 错误: 未配置目标群组")
self.root.after(0, lambda: messagebox.showerror("错误", "未配置目标群组"))
self.root.after(0, self.stop_task)
return
# 为每个群组添加监听
success_count = 0
for group_name in groups:
self.log(f"正在添加监听: {group_name}")
# 创建带群组名称的回调函数
def make_callback(gname):
return lambda msg, chat: self.on_message(msg, chat, gname)
result = self.wx.AddListenChat(
nickname=group_name,
callback=make_callback(group_name)
)
if isinstance(result, str) and "失败" in result:
self.log(f"× 添加监听失败: {group_name} - {result}")
else:
self.log(f"✓ 成功监听群组: {group_name}")
self.active_groups[group_name] = result
success_count += 1
if success_count == 0:
self.log("× 错误: 所有群组监听都失败")
self.root.after(0, lambda: messagebox.showerror("错误", "所有群组监听都失败,请检查群组名称"))
self.root.after(0, self.stop_task)
return
self.log(f"✓ 成功监听 {success_count}/{len(groups)} 个群组")
self.root.after(0, lambda: self.status_label.config(
text=f"运行中 ({success_count}个群组)", foreground="green"
))
# 保持运行
while self.is_running:
time.sleep(1)
except Exception as e:
self.log(f"× 错误: {e}")
self.root.after(0, lambda: messagebox.showerror("错误", f"任务执行失败: {e}"))
self.root.after(0, self.stop_task)
finally:
# 清理COM组件
pythoncom.CoUninitialize()
def on_message(self, msg, chat, group_name):
"""消息回调"""
try:
self.log(f"[{group_name}] 收到消息 - 发送者: {msg.sender}, 类型: {msg.type}")
# 只处理文本消息
if msg.type != "text" or not msg.content:
return
self.log(f"[{group_name}] 正在分析消息内容...")
job_info = self.extract_job_info(msg.content)
if job_info and any(job_info.values()):
self.log(f"[{group_name}] ✓ 提取到岗位信息")
# 生成UUID作为岗位ID
job_id = str(uuid.uuid4())
# 添加元数据
job_info["_id"] = job_id
job_info["_metadata"] = {
"source": "wechat_group",
"group_name": group_name,
"sender": msg.sender,
"extract_time": datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
"original_message": msg.content
}
# 保存并显示
self.root.after(0, lambda: self.add_job_to_list(job_info))
self.save_job_data(job_info)
else:
self.log(f"[{group_name}] × 未提取到有效岗位信息")
except Exception as e:
self.log(f"[{group_name}] × 处理消息时出错: {e}")
def extract_job_info(self, message_content):
"""提取岗位信息"""
prompt = f"""请从以下消息中提取招聘岗位信息并以JSON格式返回。如果消息不包含招聘信息返回空对象。
要提取的字段:
- job_name: 工作名称
- job_description: 工作描述
- job_location: 工作地点
- salary_min: 月薪最低(数字,单位:元)
- salary_max: 月薪最高(数字,单位:元)
- company_name: 公司名称
- contact_person: 联系人
- contact_info: 联系方式(电话/微信等)
消息内容:
{message_content}
请直接返回JSON格式不要包含其他说明文字。"""
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {self.config['api_key']}"
}
payload = {
"model": "qwen-plus",
"input": {
"messages": [
{
"role": "system",
"content": "你是一个专业的招聘信息提取助手,擅长从文本中提取结构化的岗位信息。"
},
{
"role": "user",
"content": prompt
}
]
},
"parameters": {
"result_format": "message"
}
}
try:
response = requests.post(
self.config["bailian_api_url"],
headers=headers,
json=payload,
timeout=30
)
response.raise_for_status()
result = response.json()
if "output" in result and "choices" in result["output"]:
content = result["output"]["choices"][0]["message"]["content"]
try:
job_data = json.loads(content)
return job_data if job_data else None
except json.JSONDecodeError:
return None
return None
except Exception as e:
self.log(f"API调用失败: {e}")
return None
def add_job_to_list(self, job_info):
"""添加岗位到列表"""
self.job_count += 1
# 格式化薪资
salary_min = job_info.get("salary_min", "")
salary_max = job_info.get("salary_max", "")
if salary_min and salary_max:
salary = f"{salary_min}-{salary_max}"
elif salary_min:
salary = f"{salary_min}+"
elif salary_max:
salary = f"<{salary_max}"
else:
salary = "面议"
# 联系方式
contact = job_info.get("contact_person", "")
if job_info.get("contact_info"):
contact += f" {job_info['contact_info']}" if contact else job_info['contact_info']
# 来源群组
group_name = job_info.get("_metadata", {}).get("group_name", "未知")
# 插入到树形视图
self.tree.insert("", 0, text=str(self.job_count), values=(
group_name,
job_info.get("job_name", "未知"),
job_info.get("company_name", "未知"),
job_info.get("job_location", "未知"),
salary,
contact,
job_info.get("_metadata", {}).get("extract_time", "")
), tags=(json.dumps(job_info, ensure_ascii=False),))
# 更新计数
self.count_label.config(text=str(self.job_count))
def save_job_data(self, job_data):
"""保存岗位数据"""
try:
existing_data = []
if os.path.exists(self.output_file):
with open(self.output_file, "r", encoding="utf-8") as f:
existing_data = json.load(f)
existing_data.append(job_data)
with open(self.output_file, "w", encoding="utf-8") as f:
json.dump(existing_data, f, ensure_ascii=False, indent=2)
except Exception as e:
self.log(f"保存数据失败: {e}")
def load_existing_jobs(self):
"""加载已有岗位数据"""
if not os.path.exists(self.output_file):
return
try:
with open(self.output_file, "r", encoding="utf-8") as f:
jobs = json.load(f)
for job in jobs:
self.add_job_to_list(job)
if jobs:
self.log(f"已加载 {len(jobs)} 条历史岗位数据")
except Exception as e:
self.log(f"加载历史数据失败: {e}")
def show_job_detail(self, event):
"""显示岗位详情"""
selection = self.tree.selection()
if not selection:
return
item = self.tree.item(selection[0])
tags = item.get("tags", ())
if not tags:
return
try:
job_info = json.loads(tags[0])
# 创建详情窗口
detail_win = tk.Toplevel(self.root)
detail_win.title("岗位详情")
detail_win.geometry("600x550")
text = scrolledtext.ScrolledText(detail_win, wrap=tk.WORD, padx=10, pady=10)
text.pack(fill=tk.BOTH, expand=True)
# 显示详情
text.insert(tk.END, f"岗位ID: {job_info.get('_id', '未知')}\n\n")
text.insert(tk.END, f"岗位名称: {job_info.get('job_name', '未知')}\n\n")
text.insert(tk.END, f"公司名称: {job_info.get('company_name', '未知')}\n\n")
text.insert(tk.END, f"工作地点: {job_info.get('job_location', '未知')}\n\n")
salary_min = job_info.get("salary_min", "")
salary_max = job_info.get("salary_max", "")
if salary_min or salary_max:
text.insert(tk.END, f"薪资范围: {salary_min}-{salary_max}\n\n")
if job_info.get("job_description"):
text.insert(tk.END, f"工作描述:\n{job_info['job_description']}\n\n")
if job_info.get("contact_person"):
text.insert(tk.END, f"联系人: {job_info['contact_person']}\n")
if job_info.get("contact_info"):
text.insert(tk.END, f"联系方式: {job_info['contact_info']}\n\n")
if "_metadata" in job_info:
meta = job_info["_metadata"]
text.insert(tk.END, "=" * 50 + "\n")
text.insert(tk.END, f"来源群组: {meta.get('group_name', '未知')}\n")
text.insert(tk.END, f"发送者: {meta.get('sender', '未知')}\n")
text.insert(tk.END, f"提取时间: {meta.get('extract_time', '未知')}\n\n")
if meta.get("original_message"):
text.insert(tk.END, "原始消息:\n")
text.insert(tk.END, meta["original_message"])
text.config(state=tk.DISABLED)
except Exception as e:
messagebox.showerror("错误", f"显示详情失败: {e}")
def stop_task(self):
"""停止任务"""
if self.wx and self.is_running:
self.log("正在停止监听...")
self.is_running = False
try:
self.wx.StopListening()
except:
pass
self.wx = None
self.start_btn.config(state=tk.NORMAL)
self.stop_btn.config(state=tk.DISABLED)
self.status_label.config(text="已停止", foreground="gray")
self.log("任务已停止")
def clear_jobs(self):
"""清空岗位列表"""
if messagebox.askyesno("确认", "确定要清空所有岗位数据吗?"):
# 清空树形视图
for item in self.tree.get_children():
self.tree.delete(item)
# 删除数据文件
if os.path.exists(self.output_file):
os.remove(self.output_file)
self.job_count = 0
self.count_label.config(text="0")
self.log("已清空所有岗位数据")
def export_data(self):
"""导出数据"""
if not os.path.exists(self.output_file):
messagebox.showinfo("提示", "暂无数据可导出")
return
try:
export_file = f"jobs_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
with open(self.output_file, "r", encoding="utf-8") as f:
data = f.read()
with open(export_file, "w", encoding="utf-8") as f:
f.write(data)
messagebox.showinfo("成功", f"数据已导出到: {export_file}")
self.log(f"数据已导出到: {export_file}")
except Exception as e:
messagebox.showerror("错误", f"导出失败: {e}")
def on_closing(self):
"""关闭窗口"""
if self.is_running:
if messagebox.askyesno("确认", "任务正在运行,确定要退出吗?"):
self.stop_task()
self.root.destroy()
else:
self.root.destroy()
def main():
root = tk.Tk()
app = JobExtractorGUI(root)
root.mainloop()
if __name__ == "__main__":
main()

19
jobs_data.json Normal file
View File

@@ -0,0 +1,19 @@
[
{
"job_name": "java工程师",
"job_description": "",
"job_location": "成都",
"salary_min": 10000,
"salary_max": 10000,
"company_name": "中科院",
"contact_person": "宋经理",
"contact_info": "18780096999",
"_metadata": {
"source": "wechat_group",
"group_name": "抓取岗位消息微信群",
"sender": "self",
"extract_time": "2026-02-11 14:02:30",
"original_message": "中科院 招聘java工程师 月薪1w 地点成都 联系人 宋经理 电话18780096999"
}
}
]

24
pyproject.toml Normal file
View File

@@ -0,0 +1,24 @@
[project]
name = "wxauto"
version = "39.1.18"
description = "wxauto 3.9 V2 version"
authors = [
{name = "Cluic", email = "tikic@qq.com"}
]
readme = "README.md"
requires-python = ">=3.8,<=3.15"
dependencies = [
"tenacity",
"pywin32",
"pyperclip",
"pillow",
"colorama",
"comtypes"
]
[tool.setuptools.packages.find]
include = ["wxauto*"]
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"

89
receive_file_transfer.py Normal file
View File

@@ -0,0 +1,89 @@
# -*- coding: utf-8 -*-
"""
监听「文件传输助手」消息,在控制台输出内容
运行前请确保1) 已安装依赖 pip install -e .
2) 电脑已登录微信 3.9 版本,且主窗口已打开
按 Ctrl+C 可退出
"""
import sys
import os
_script_dir = os.path.dirname(os.path.abspath(__file__))
if _script_dir not in sys.path:
sys.path.insert(0, _script_dir)
from wxauto import WeChat
# 要监听的聊天(可改成其他好友/群名)
LISTEN_CHAT = "文件传输助手"
# 启动时是否先获取并输出历史记录
PRINT_HISTORY_ON_START = True
# 启动时向上加载历史页数0=不加载更多,只取当前可见)
LOAD_HISTORY_PAGES = 1
def format_history_msg(msg, index: int):
"""历史消息单条格式化"""
msg_type = getattr(msg, "type", "text")
content = getattr(msg, "content", "")
sender = getattr(msg, "sender_remark", "") or getattr(msg, "sender", "")
content = (content or "").replace("\n", " ").strip()
if msg_type != "text":
content = f"[{msg_type}] " + (content or "(非文本)")
return f" {index}. [{sender}] {content}"
def print_history(wx):
"""获取当前聊天历史并输出到控制台"""
if not wx.ChatWith(LISTEN_CHAT):
print(f"无法切换到「{LISTEN_CHAT}」,跳过历史记录")
return
for _ in range(LOAD_HISTORY_PAGES):
if not wx.LoadMoreMessage(interval=0.3):
break
msgs = wx.GetAllMessage()
if not msgs:
print("(无历史消息)\n")
return
print(f"---------- 「{LISTEN_CHAT}」 历史记录(共 {len(msgs)} 条)----------")
for i, msg in enumerate(msgs, 1):
print(format_history_msg(msg, i))
print("---------- 以上为历史,以下为新消息 ----------\n")
def on_message(msg, chat):
"""收到新消息时在控制台输出"""
# msg: 消息对象,有 .type .content .sender 等
# chat: 当前聊天窗口对象
msg_type = getattr(msg, "type", "text")
content = getattr(msg, "content", "")
sender = getattr(msg, "sender_remark", "") or getattr(msg, "sender", "")
if msg_type == "text":
print(f"[{LISTEN_CHAT}] {sender}: {content}")
else:
# 图片、视频、语音等只输出类型,内容多为路径或占位
print(f"[{LISTEN_CHAT}] [{msg_type}] {sender}: {content or '(非文本)'}")
def main():
print("正在连接微信...")
try:
wx = WeChat()
except Exception as e:
print("连接失败,请确保:")
print(" 1. 已安装依赖: pip install -e .")
print(" 2. 微信 3.9 已登录并保持主窗口打开")
print(f" 错误: {e}")
return
if PRINT_HISTORY_ON_START:
print_history(wx)
print(f"开始监听「{LISTEN_CHAT}」新消息,按 Ctrl+C 退出\n")
wx.AddListenChat(LISTEN_CHAT, callback=on_message)
wx.StartListening()
wx.KeepRunning()
if __name__ == "__main__":
main()

4
requirements.txt Normal file
View File

@@ -0,0 +1,4 @@
requests>=2.31.0
pillow>=10.0.0
pyinstaller>=6.0.0
pywin32>=306

22
setup.cfg Normal file
View File

@@ -0,0 +1,22 @@
[metadata]
name = wxauto
version = 39.1.18
author = Cluic
author_email = tikic@qq.com
description = wxauto 3.9 V2 version
long_description = file: README.md
long_description_content_type = text/markdown
[options]
packages = find:
python_requires = >=3.8,<=3.15
install_requires =
tenacity
pywin32
pyperclip
pillow
colorama
comtypes
[options.packages.find]
include = wxauto*

141
test_api.py Normal file
View File

@@ -0,0 +1,141 @@
# -*- coding: utf-8 -*-
"""
测试百炼API连接和岗位信息提取功能
"""
import json
import requests
# API配置
BAILIAN_API_URL = "https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation"
API_KEY = "sk-46cb053d75eb4ad88713917ba0f1c81a"
# 测试消息样例
TEST_MESSAGES = [
"""
【招聘】Python后端开发工程师
公司:北京某互联网科技有限公司
地点:北京市海淀区中关村
薪资20K-35K
岗位职责:
1. 负责公司核心业务系统的后端开发
2. 参与系统架构设计和优化
要求3年以上Python开发经验熟悉Django/Flask
联系人:李经理
微信tech_hr_2024
""",
"""
急招前端开发!!!
坐标:上海浦东
月薪15-25k
我们公司是做电商的需要熟悉Vue3和React的前端
有意者联系张总 18612345678
""",
"""
今天天气真好,大家周末愉快!
"""
]
def test_extract(message_content):
"""测试提取功能"""
print("=" * 80)
print("测试消息:")
print(message_content.strip())
print("-" * 80)
prompt = f"""请从以下消息中提取招聘岗位信息并以JSON格式返回。如果消息不包含招聘信息返回空对象。
要提取的字段:
- job_name: 工作名称
- job_description: 工作描述
- job_location: 工作地点
- salary_min: 月薪最低(数字,单位:元)
- salary_max: 月薪最高(数字,单位:元)
- company_name: 公司名称
- contact_person: 联系人
- contact_info: 联系方式(电话/微信等)
消息内容:
{message_content}
请直接返回JSON格式不要包含其他说明文字。"""
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {API_KEY}"
}
payload = {
"model": "qwen-plus",
"input": {
"messages": [
{
"role": "system",
"content": "你是一个专业的招聘信息提取助手,擅长从文本中提取结构化的岗位信息。"
},
{
"role": "user",
"content": prompt
}
]
},
"parameters": {
"result_format": "message"
}
}
try:
print("正在调用API...")
response = requests.post(BAILIAN_API_URL, headers=headers, json=payload, timeout=30)
response.raise_for_status()
result = response.json()
if "output" in result and "choices" in result["output"]:
content = result["output"]["choices"][0]["message"]["content"]
print("API返回:")
print(content)
print("-" * 80)
# 尝试解析JSON
try:
job_data = json.loads(content)
if job_data and any(job_data.values()):
print("✓ 提取成功:")
print(json.dumps(job_data, ensure_ascii=False, indent=2))
else:
print("× 未提取到有效岗位信息")
except json.JSONDecodeError:
print(f"× JSON解析失败")
else:
print(f"× API返回格式异常: {result}")
except requests.exceptions.RequestException as e:
print(f"× API请求失败: {e}")
except Exception as e:
print(f"× 发生错误: {e}")
print()
def main():
print("百炼API岗位信息提取测试")
print("=" * 80)
print(f"API地址: {BAILIAN_API_URL}")
print(f"API密钥: {API_KEY[:20]}...")
print()
for i, msg in enumerate(TEST_MESSAGES, 1):
print(f"\n测试用例 {i}/{len(TEST_MESSAGES)}")
test_extract(msg)
if i < len(TEST_MESSAGES):
input("按回车继续下一个测试...")
print("=" * 80)
print("测试完成")
if __name__ == "__main__":
main()

114
test_com_fix.py Normal file
View File

@@ -0,0 +1,114 @@
# -*- coding: utf-8 -*-
"""
测试COM组件初始化修复
用于验证打包后的程序是否能正常工作
"""
import sys
import threading
import time
def test_com_in_thread():
"""在线程中测试COM初始化"""
print("\n[线程测试] 开始...")
try:
import pythoncom
print("[线程测试] ✓ pythoncom模块导入成功")
except ImportError as e:
print(f"[线程测试] × pythoncom模块导入失败: {e}")
print("请运行: pip install pywin32")
return False
try:
# 初始化COM
pythoncom.CoInitialize()
print("[线程测试] ✓ COM组件初始化成功")
# 尝试使用wxauto
try:
from wxauto import WeChat
print("[线程测试] ✓ wxauto模块导入成功")
# 注意:这里不实际连接微信,只测试导入
print("[线程测试] ✓ 所有测试通过")
result = True
except Exception as e:
print(f"[线程测试] × wxauto测试失败: {e}")
result = False
# 清理COM
pythoncom.CoUninitialize()
print("[线程测试] ✓ COM组件清理成功")
return result
except Exception as e:
print(f"[线程测试] × COM初始化失败: {e}")
return False
def main():
print("=" * 60)
print("COM组件修复验证测试")
print("=" * 60)
# 测试1主线程导入
print("\n[测试1] 主线程模块导入...")
try:
import pythoncom
print("✓ pythoncom导入成功")
except ImportError as e:
print(f"× pythoncom导入失败: {e}")
print("\n请先安装pywin32:")
print(" pip install pywin32")
return
try:
from wxauto import WeChat
print("✓ wxauto导入成功")
except ImportError as e:
print(f"× wxauto导入失败: {e}")
print("\n请先安装wxauto:")
print(" pip install -e .")
return
# 测试2工作线程中的COM初始化
print("\n[测试2] 工作线程COM初始化...")
thread = threading.Thread(target=test_com_in_thread)
thread.start()
thread.join()
# 测试3检查打包相关模块
print("\n[测试3] 检查打包相关模块...")
modules_to_check = [
'pywintypes',
'win32com',
'win32api',
'win32con',
'win32gui',
]
all_ok = True
for module_name in modules_to_check:
try:
__import__(module_name)
print(f"{module_name}")
except ImportError:
print(f"× {module_name} (可选)")
# 总结
print("\n" + "=" * 60)
print("测试完成")
print("=" * 60)
print("\n如果所有测试都通过,说明修复成功,可以进行打包。")
print("打包命令:")
print(" 方式1: 双击 修复并重新打包.bat")
print(" 方式2: 双击 build.bat")
print(" 方式3: pyinstaller build_exe.spec")
print("\n打包后请测试exe文件确保能正常连接微信。")
if __name__ == "__main__":
main()
input("\n按回车键退出...")

116
view_jobs.py Normal file
View File

@@ -0,0 +1,116 @@
# -*- coding: utf-8 -*-
"""
查看提取的岗位数据
"""
import json
import os
from datetime import datetime
def format_salary(salary_min, salary_max):
"""格式化薪资显示"""
if salary_min and salary_max:
return f"{salary_min}-{salary_max}"
elif salary_min:
return f"{salary_min}元起"
elif salary_max:
return f"{salary_max}元以下"
else:
return "面议"
def display_job(job, index):
"""显示单个岗位信息"""
print(f"\n{'=' * 80}")
print(f"岗位 #{index}")
print(f"{'=' * 80}")
print(f"工作名称: {job.get('job_name', '未知')}")
print(f"公司名称: {job.get('company_name', '未知')}")
print(f"工作地点: {job.get('job_location', '未知')}")
print(f"薪资范围: {format_salary(job.get('salary_min'), job.get('salary_max'))}")
if job.get('job_description'):
print(f"\n工作描述:")
print(f" {job['job_description']}")
if job.get('contact_person') or job.get('contact_info'):
print(f"\n联系方式:")
if job.get('contact_person'):
print(f" 联系人: {job['contact_person']}")
if job.get('contact_info'):
print(f" 联系方式: {job['contact_info']}")
# 显示元数据
if '_metadata' in job:
meta = job['_metadata']
print(f"\n来源信息:")
print(f" 群组: {meta.get('group_name', '未知')}")
print(f" 发送者: {meta.get('sender', '未知')}")
print(f" 提取时间: {meta.get('extract_time', '未知')}")
if meta.get('original_message'):
print(f"\n原始消息:")
msg = meta['original_message']
if len(msg) > 200:
msg = msg[:200] + "..."
print(f" {msg}")
def main():
data_file = "jobs_data.json"
if not os.path.exists(data_file):
print(f"数据文件不存在: {data_file}")
print("请先运行 job_extractor.py 提取岗位数据")
return
try:
with open(data_file, "r", encoding="utf-8") as f:
jobs = json.load(f)
except Exception as e:
print(f"读取数据文件失败: {e}")
return
if not jobs:
print("暂无岗位数据")
return
print(f"{'=' * 80}")
print(f"岗位数据查看器")
print(f"{'=' * 80}")
print(f"数据文件: {data_file}")
print(f"岗位总数: {len(jobs)}")
# 统计信息
locations = {}
companies = {}
for job in jobs:
loc = job.get('job_location', '未知')
locations[loc] = locations.get(loc, 0) + 1
comp = job.get('company_name', '未知')
companies[comp] = companies.get(comp, 0) + 1
print(f"\n地点分布:")
for loc, count in sorted(locations.items(), key=lambda x: x[1], reverse=True)[:5]:
print(f" {loc}: {count}个岗位")
print(f"\n公司分布:")
for comp, count in sorted(companies.items(), key=lambda x: x[1], reverse=True)[:5]:
print(f" {comp}: {count}个岗位")
# 显示详细信息
print(f"\n{'=' * 80}")
print("岗位详情")
for i, job in enumerate(jobs, 1):
display_job(job, i)
print(f"\n{'=' * 80}")
print(f"共显示 {len(jobs)} 个岗位")
print(f"{'=' * 80}")
if __name__ == "__main__":
main()

16
wxauto/__init__.py Normal file
View File

@@ -0,0 +1,16 @@
from .wx import (
WeChat,
Chat,
WeChatLogin
)
from .param import WxParam
import pythoncom
pythoncom.CoInitialize()
__all__ = [
'WeChat',
'Chat',
'WeChatLogin',
'WxParam'
]

5
wxauto/exceptions.py Normal file
View File

@@ -0,0 +1,5 @@
class WxautoOCRError(Exception):
...
class NetWorkError(Exception):
...

294
wxauto/languages.py Normal file
View File

@@ -0,0 +1,294 @@
WECHAT_MAIN = {
'新的朋友': {'cn': "新的朋友", 'cn_t': '', 'en': ""},
"添加朋友": {'cn': "添加朋友", 'cn_t': '', 'en': ""},
"搜索结果": {'cn': "搜索:", 'cn_t': '', 'en': ""},
"找不到相关账号或内容": {'cn': "找不到相关账号或内容", 'cn_t': '', 'en': ""},
}
WECHAT_CHAT_BOX = {
"查看更多消息": {'cn': "查看更多消息", 'cn_t': '', 'en': ""},
"消息": {'cn': "消息", 'cn_t': '', 'en': ""},
"表情" : {'cn': "表情(Alt+E)", 'cn_t': '', 'en': ""},
"发送文件": {'cn': "发送文件", 'cn_t': '', 'en': ""},
"截图": {'cn': "截图", 'cn_t': '', 'en': ""},
"聊天记录": {'cn': "聊天记录", 'cn_t': '', 'en': ""},
"语音聊天": {'cn': "语音聊天", 'cn_t': '', 'en': ""},
"视频聊天": {'cn': "视频聊天", 'cn_t': '', 'en': ""},
'聊天信息': {'cn': "聊天信息", 'cn_t': '', 'en': ""},
"发送": {'cn': "发送(S)", 'cn_t': '', 'en': ""},
"置顶": {'cn': "置顶", 'cn_t': '', 'en': ""},
"最小化": {'cn': "最小化", 'cn_t': '', 'en': ""},
"最大化": {'cn': "最大化", 'cn_t': '', 'en': ""},
"关闭": {'cn': "关闭", 'cn_t': '', 'en': ""},
'以下为新消息': {'cn': "以下为新消息", 'cn_t': '', 'en': ""},
're_新消息按钮': {'cn': '.*?条新消息', 'cn_t': '', 'en': ""},
}
WECHAT_SESSION_BOX = {
# 聊天页面
"聊天记录": {'cn': "聊天记录", 'cn_t': '', 'en': ""},
"会话": {'cn': "会话", 'cn_t': '', 'en': ""},
"已置顶": {'cn': "已置顶", 'cn_t': '', 'en': ""},
"文件传输助手": {'cn': "文件传输助手", 'cn_t': '', 'en': ""},
"折叠的群聊": {'cn': "折叠的群聊", 'cn_t': '', 'en': ""},
"发起群聊": {'cn': "发起群聊", 'cn_t': '', 'en': ""},
"搜索": {'cn': "搜索", 'cn_t': '', 'en': ""},
"re_条数": {'cn': r'\[\d+条\]', 'cn_t': '', 'en': ""},
# 联系人页面
"添加朋友": {'cn': "添加朋友", 'cn_t': '', 'en': ""},
"联系人": {'cn': "联系人", 'cn_t': '', 'en': ""},
"通讯录管理": {'cn': "通讯录管理", 'cn_t': '', 'en': ""},
"新的朋友": {'cn': "新的朋友", 'cn_t': '', 'en': ""},
"公众号": {'cn': "公众号", 'cn_t': '', 'en': ""},
"企业号": {'cn': "企业号", 'cn_t': '', 'en': ""},
"群聊": {'cn': "群聊", 'cn_t': '', 'en': ""},
# 收藏页面
"分类": {'cn': "分类", 'cn_t': '', 'en': ""},
"新建笔记": {'cn': "新建笔记", 'cn_t': '', 'en': ""},
"全部收藏": {'cn': "全部收藏", 'cn_t': '', 'en': ""},
"最近使用": {'cn': "最近使用", 'cn_t': '', 'en': ""},
"链接": {'cn': "链接", 'cn_t': '', 'en': ""},
"图片与视频": {'cn': "图片与视频", 'cn_t': '', 'en': ""},
"笔记": {'cn': "笔记", 'cn_t': '', 'en': ""},
"文件": {'cn': "文件", 'cn_t': '', 'en': ""},
"聊天记录": {'cn': "聊天记录", 'cn_t': '', 'en': ""},
"分割线": {'cn': "分割线", 'cn_t': '', 'en': ""},
"展开标签": {'cn': "展开标签", 'cn_t': '', 'en': ""},
"折叠标签": {'cn': "折叠标签", 'cn_t': '', 'en': ""},
"标签": {'cn': "标签", 'cn_t': '', 'en': ""},
}
WECHAT_NAVIGATION_BOX = {
'聊天': {'cn': "聊天", 'cn_t': '', 'en': ""},
'通讯录': {'cn': "通讯录", 'cn_t': '', 'en': ""},
'收藏': {'cn': "收藏", 'cn_t': '', 'en': ""},
'聊天文件': {'cn': "聊天文件", 'cn_t': '', 'en': ""},
'朋友圈': {'cn': "朋友圈", 'cn_t': '', 'en': ""},
'搜一搜': {'cn': "搜一搜", 'cn_t': '', 'en': ""},
"视频号": {'cn': "视频号", 'cn_t': '', 'en': ""},
"看一看": {'cn': "看一看", 'cn_t': '', 'en': ""},
"小程序面板": {'cn': "小程序面板", 'cn_t': '', 'en': ""},
"手机": {'cn': "手机", 'cn_t': '', 'en': ""},
"设置及其他": {'cn': "设置及其他", 'cn_t': '', 'en': ""},
}
EMOTION_WINDOW = {
"添加的单个表情": {'cn': "添加的单个表情", 'cn_t': '', 'en': ""},
}
MOMENT_PRIVACY = {
'谁可以看': {'cn': "谁可以看", 'cn_t': '', 'en': ""},
"公开": {'cn': "公开", 'cn_t': '', 'en': ""},
"所有朋友可见": {'cn': "所有朋友可见", 'cn_t': '', 'en': ""},
"私密": {'cn': "私密", 'cn_t': '', 'en': ""},
"仅自己可见": {'cn': "仅自己可见", 'cn_t': '', 'en': ""},
"白名单": {'cn': "选中的标签或朋友可见", 'cn_t': '', 'en': ""},
"黑名单": {'cn': "选中的标签或朋友不可见", 'cn_t': '', 'en': ""},
"完成": {'cn': "完成", 'cn_t': '', 'en': ""},
"确定": {'cn': "确定", 'cn_t': '', 'en': ""},
"取消": {'cn': "取消", 'cn_t': '', 'en': ""}
}
PROFILE_CARD = {
'微信号': {'cn': "微信号:", 'cn_t': '', 'en': ""},
'昵称': {'cn': "昵称:", 'cn_t': '', 'en': ""},
'备注': {'cn': "备注", 'cn_t': '', 'en': ""},
'地区': {'cn': "地区:", 'cn_t': '', 'en': ""},
'标签': {'cn': "标签", 'cn_t': '', 'en': ""},
'共同群聊': {'cn': "共同群聊", 'cn_t': '', 'en': ""},
'来源': {'cn': "来源", 'cn_t': '', 'en': ""},
'发消息': {'cn': "发消息", 'cn_t': '', 'en': ""},
'语音聊天': {'cn': "语音聊天", 'cn_t': '', 'en': ""},
'视频聊天': {'cn': "视频聊天", 'cn_t': '', 'en': ""},
'更多': {'cn': "更多", 'cn_t': '', 'en': ""},
"设置备注和标签": {'cn': "设置备注和标签", 'cn_t': '', 'en': ""},
'确定': {'cn': "确定", 'cn_t': '', 'en': ""},
'输入标签': {'cn': "输入标签", 'cn_t': '', 'en': ""},
'备注名': {'cn': "备注名", 'cn_t': '', 'en': ""},
}
MESSAGES = {
'[图片]': {'cn': "[图片]", 'cn_t': '', 'en': ""},
'[视频]': {'cn': "[视频]", 'cn_t': '', 'en': ""},
'[语音]': {'cn': "[语音]", 'cn_t': '', 'en': ""},
'[音乐]': {'cn': "[音乐]", 'cn_t': '', 'en': ""},
'[位置]': {'cn': "[位置]", 'cn_t': '', 'en': ""},
'[链接]': {'cn': "[链接]", 'cn_t': '', 'en': ""},
'[文件]': {'cn': "[文件]", 'cn_t': '', 'en': ""},
'[名片]': {'cn': "[名片]", 'cn_t': '', 'en': ""},
'[笔记]': {'cn': "[笔记]", 'cn_t': '', 'en': ""},
'[视频号]': {'cn': "[视频号]", 'cn_t': '', 'en': ""},
'[动画表情]': {'cn': "[动画表情]", 'cn_t': '', 'en': ""},
'[聊天记录]': {'cn': "[聊天记录]", 'cn_t': '', 'en': ""},
'微信转账': {'cn': "微信转账", 'cn_t': '', 'en': ""},
'接收中': {'cn': "接收中", 'cn_t': '', 'en': ""},
're_语音': {'cn': "^\[语音\]\d+秒(,未播放)?$", 'cn_t': '', 'en': ""},
're_引用消息': {'cn': "(^.+)\n引用.*?的消息 : (.+$)", 'cn_t': '', 'en': ""},
're_拍一拍': {'cn': "^.+拍了拍.+$", 'cn_t': '', 'en': ""},
}
CHATROOM_DETAIL_WINDOW = {
"聊天信息": {'cn': "聊天信息", 'cn_t': '', 'en': ""},
"查看更多": {'cn': "查看更多", 'cn_t': '', 'en': ""},
'群聊名称': {'cn': "群聊名称", 'cn_t': '', 'en': ""},
'仅群主或管理员可以修改': {'cn': "仅群主或管理员可以修改", 'cn_t': '', 'en': ""},
'我在本群的昵称': {'cn': "我在本群的昵称", 'cn_t': '', 'en': ""},
"仅群主和管理员可编辑": {'cn': "仅群主和管理员可编辑", 'cn_t': '', 'en': ""},
'点击编辑群公告': {'cn': "点击编辑群公告", 'cn_t': '', 'en': ""},
'编辑': {'cn': "编辑", 'cn_t': '', 'en': ""},
'备注': {'cn': "备注", 'cn_t': '', 'en': ""},
'群公告': {'cn': "群公告", 'cn_t': '', 'en': ""},
'分隔线': {'cn': "分隔线", 'cn_t': '', 'en': ""},
'完成': {'cn': "完成", 'cn_t': '', 'en': ""},
'发布': {'cn': "发布", 'cn_t': '', 'en': ""},
'退出群聊': {'cn': "退出群聊", 'cn_t': '', 'en': ""},
'退出': {'cn': "退出", 'cn_t': '', 'en': ""},
'聊天成员': {'cn': "聊天成员", 'cn_t': '', 'en': ""},
"添加": {'cn': "添加", 'cn_t': '', 'en': ""},
"移出": {'cn': "移出", 'cn_t': '', 'en': ""},
"re_退出群聊": {'cn': "将退出群聊“.*?”", 'cn_t': '', 'en': ""},
}
PROFILE_WINDOW = {
"微信号": {'cn': "微信号:", 'cn_t': '', 'en': ""},
"昵称": {'cn': "昵称:", 'cn_t': '', 'en': ""},
"地区": {'cn': "地区:", 'cn_t': '', 'en': ""},
"个性签名": {'cn': "个性签名", 'cn_t': '', 'en': ""},
"来源": {'cn': "来源", 'cn_t': '', 'en': ""},
"备注": {'cn': "备注", 'cn_t': '', 'en': ""},
"共同群聊": {'cn': "共同群聊", 'cn_t': '', 'en': ""},
'添加到通讯录': {'cn': "添加到通讯录", 'cn_t': '', 'en': ""},
'更多': {'cn': "更多", 'cn_t': '', 'en': ""},
}
ADD_NEW_FRIEND_WINDOW = {
'标签': {'cn': "标签", 'cn_t': '', 'en': ""},
'确定': {'cn': "确定", 'cn_t': '', 'en': ""},
'备注名': {'cn': "备注名", 'cn_t': '', 'en': ""},
'朋友圈': {'cn': "朋友圈", 'cn_t': '', 'en': ""},
'仅聊天': {'cn': "仅聊天", 'cn_t': '', 'en': ""},
'聊天、朋友圈、微信运动等': {
'cn': "聊天、朋友圈、微信运动等",
'cn_t': '',
'en': ""
},
"你的联系人较多,添加新的朋友时需选择权限": {
'cn': "你的联系人较多,添加新的朋友时需选择权限",
'cn_t': '',
'en': ""
},
"发送添加朋友申请": {
'cn': "发送添加朋友申请",
'cn_t': '',
'en': ""
}
}
ADD_GROUP_MEMBER_WINDOW = {
'搜索': {'cn': "搜索", 'cn_t': '', 'en': ""},
'确定': {'cn': "确定", 'cn_t': '', 'en': ""},
'完成': {'cn': "完成", 'cn_t': '', 'en': ""},
'发送': {'cn': "发送", 'cn_t': '', 'en': ""},
'已选择联系人': {'cn': "已选择联系人", 'cn_t': '', 'en': ""},
'请勾选需要添加的联系人': {
'cn': "请勾选需要添加的联系人",
'cn_t': '',
'en': ""
}
}
IMAGE_WINDOW = {
'上一张': {'cn': '上一张', 'cn_t': '上一張', 'en': 'Previous'},
'下一张': {'cn': '下一张', 'cn_t': '下一張', 'en': 'Next'},
'预览': {'cn': '预览', 'cn_t': '預覽', 'en': 'Preview'},
'放大': {'cn': '放大', 'cn_t': '放大', 'en': 'Zoom'},
'缩小': {'cn': '缩小', 'cn_t': '縮小', 'en': 'Shrink'},
'图片原始大小': {'cn': '图片原始大小', 'cn_t': '圖片原始大小', 'en': 'Original image size'},
'旋转': {'cn': '旋转', 'cn_t': '旋轉', 'en': 'Rotate'},
'编辑': {'cn': '编辑', 'cn_t': '編輯', 'en': 'Edit'},
'翻译': {'cn': '翻译', 'cn_t': '翻譯', 'en': 'Translate'},
'提取文字': {'cn': '提取文字', 'cn_t': '提取文字', 'en': 'Extract Text'},
'识别图中二维码': {'cn': '识别图中二维码', 'cn_t': '識别圖中QR Code', 'en': 'Extract QR Code'},
'另存为': {'cn': '另存为...', 'cn_t': '另存爲...', 'en': 'Save as…'},
'更多': {'cn': '更多', 'cn_t': '更多', 'en': 'More'},
'复制': {'cn': '复制', 'cn_t': '複製', 'en': 'Copy'},
'最小化': {'cn': '最小化', 'cn_t': '最小化', 'en': 'Minimize'},
'最大化': {'cn': '最大化', 'cn_t': '最大化', 'en': 'Maximize'},
'关闭': {'cn': '关闭', 'cn_t': '關閉', 'en': 'Close'},
'': {'cn': '', 'cn_t': '', 'en': ''}
}
MENU_OPTIONS = {
# session
'置顶': {'cn': '置顶', 'cn_t': '', 'en': ''},
'取消置顶': {'cn': '取消置顶', 'cn_t': '', 'en': ''},
'标为未读': {'cn': '标为未读', 'cn_t': '', 'en': ''},
'消息免打扰': {'cn': '消息免打扰', 'cn_t': '', 'en': ''},
'在独立窗口打开': {'cn': '在独立窗口打开', 'cn_t': '', 'en': ''},
'不显示聊天': {'cn': '不显示聊天', 'cn_t': '', 'en': ''},
'删除聊天': {'cn': '删除聊天', 'cn_t': '', 'en': ''},
# msg
'撤回': {'cn': '撤回', 'cn_t': '', 'en': ''},
'复制': {'cn': '复制', 'cn_t': '', 'en': ''},
'放大阅读': {'cn': '放大阅读', 'cn_t': '', 'en': ''},
'翻译': {'cn': '翻译', 'cn_t': '', 'en': ''},
'转发': {'cn': '转发...', 'cn_t': '', 'en': ''},
'收藏': {'cn': '收藏', 'cn_t': '', 'en': ''},
'多选': {'cn': '多选', 'cn_t': '', 'en': ''},
'引用': {'cn': '引用', 'cn_t': '', 'en': ''},
'搜一搜': {'cn': '搜一搜', 'cn_t': '', 'en': ''},
'删除': {'cn': '删除', 'cn_t': '', 'en': ''},
'编辑': {'cn': '编辑', 'cn_t': '', 'en': ''},
'另存为': {'cn': '另存为...', 'cn_t': '', 'en': ''},
'语音转文字': {'cn': '语音转文字', 'cn_t': '', 'en': ''},
'在文件夹中显示': {'cn': '在文件夹中显示', 'cn_t': '', 'en': ''},
# edit
'剪切': {'cn': '剪切', 'cn_t': '', 'en': ''},
'粘贴': {'cn': '粘贴', 'cn_t': '', 'en': ''},
}
MOMENTS = {
'朋友圈': {'cn': '朋友圈', 'cn_t': '', 'en': ''},
'刷新': {'cn': '刷新', 'cn_t': '', 'en': ''},
'评论': {'cn': '评论', 'cn_t': '', 'en': ''},
'广告': {'cn': '广告', 'cn_t': '', 'en': ''},
'': {'cn': '', 'cn_t': '', 'en': ''},
'取消': {'cn': '取消', 'cn_t': '', 'en': ''},
'发送': {'cn': '发送', 'cn_t': '', 'en': ''},
'分隔符_点赞': {'cn': '', 'cn_t': '', 'en': ''},
're_图片数': {'cn': '包含\d+张图片', 'cn_t': '', 'en': ''},
}
NEW_FRIEND_ELEMENT = {
'新的朋友': {'cn': '新的朋友', 'cn_t': '', 'en': ''},
'回复': {'cn': '回复', 'cn_t': '', 'en': ''},
'发送': {'cn': '发送', 'cn_t': '', 'en': ''},
'朋友圈': {'cn': '朋友圈', 'cn_t': '', 'en': ''},
'仅聊天': {'cn': '仅聊天', 'cn_t': '', 'en': ''},
'聊天、朋友圈、微信运动等': {'cn': '聊天、朋友圈、微信运动等', 'cn_t': '', 'en': ''},
'备注名': {'cn': '备注名', 'cn_t': '', 'en': ''},
'标签': {'cn': '标签', 'cn_t': '', 'en': ''},
}
WECHAT_BROWSER = {
"关闭": {'cn': '关闭', 'cn_t': '', 'en': ''},
"更多": {'cn': '更多', 'cn_t': '', 'en': ''},
'地址和搜索栏': {'cn': '地址和搜索栏', 'cn_t': '', 'en': ''},
'转发给朋友': {'cn': '转发给朋友', 'cn_t': '', 'en': ''},
'复制链接': {'cn': '复制链接', 'cn_t': '', 'en': ''},
}
WECHAT_LOGINWND = {
"进入微信": {'cn': '进入微信', 'cn_t': '', 'en': ''},
"切换账号": {'cn': '切换账号', 'cn_t': '', 'en': ''},
"仅传输文件": {'cn': '仅传输文件', 'cn_t': '', 'en': ''},
"二维码": {'cn': '仅传输文件', 'cn_t': '', 'en': ''},
"提示": {'cn': '提示', 'cn_t': '', 'en': ''},
"确定": {'cn': '确定', 'cn_t': '', 'en': ''},
}

128
wxauto/logger.py Normal file
View File

@@ -0,0 +1,128 @@
from wxauto.param import WxParam
import logging
import colorama
from pathlib import Path
from datetime import datetime
import sys
import io
# # 初始化 colorama
colorama.init()
if hasattr(sys.stdout, 'buffer'):
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='ignore')
LOG_COLORS = {
'DEBUG': colorama.Fore.CYAN,
'INFO': colorama.Fore.GREEN,
'WARNING': colorama.Fore.YELLOW,
'ERROR': colorama.Fore.RED,
'CRITICAL': colorama.Fore.MAGENTA
}
class ColoredFormatter(logging.Formatter):
def format(self, record):
levelname = record.levelname
message = super().format(record)
return f"{LOG_COLORS[levelname]}{message}{colorama.Style.RESET_ALL}"
class WxautoLogger:
name: str = 'wxauto'
def __init__(self):
self.logger = self.setup_logger()
self.file_handler = None # 先不创建文件处理器
self.set_debug(False)
def setup_logger(self) -> logging.Logger:
"""设置日志记录器"""
# 配置根记录器
root_logger = logging.getLogger()
root_logger.setLevel(logging.DEBUG)
# 添加asyncio日志过滤
logging.getLogger('asyncio').setLevel(logging.WARNING)
# 设置第三方库的日志级别
logging.getLogger('comtypes').setLevel(logging.WARNING)
logging.getLogger('urllib3').setLevel(logging.WARNING)
logging.getLogger('requests').setLevel(logging.WARNING)
# 清除现有处理器
root_logger.handlers.clear()
# 格式
fmt = '%(asctime)s [%(name)s] [%(levelname)s] [%(filename)s:%(lineno)d] %(message)s'
# 控制台处理器(带颜色)
self.console_handler = logging.StreamHandler()
console_formatter = ColoredFormatter(
fmt=fmt,
datefmt="%Y-%m-%d %H:%M:%S"
)
self.console_handler.setFormatter(console_formatter)
self.console_handler.setLevel(logging.DEBUG)
root_logger.addHandler(self.console_handler)
return logging.getLogger(self.name)
def setup_file_logger(self):
"""根据WxParam.ENABLE_FILE_LOGGER决定是否创建文件日志处理器"""
if not WxParam.ENABLE_FILE_LOGGER or self.file_handler is not None:
return
# 文件处理器(无颜色)
log_dir = Path("wxauto_logs")
log_dir.mkdir(parents=True, exist_ok=True)
# 使用当前时间创建日志文件
current_time = datetime.now().strftime("%Y%m%d")
log_file = log_dir / f"app_{current_time}.log"
self.file_handler = logging.FileHandler(log_file, encoding='utf-8')
file_formatter = logging.Formatter(
'%(asctime)s [%(name)s] [%(levelname)s] [%(filename)s:%(lineno)d] %(message)s',
datefmt="%Y-%m-%d %H:%M:%S"
)
self.file_handler.setFormatter(file_formatter)
self.file_handler.setLevel(logging.DEBUG)
# 将文件处理器添加到日志记录器
logging.getLogger().addHandler(self.file_handler)
def set_debug(self, debug=False):
"""动态设置日志级别"""
if debug:
self.console_handler.setLevel(logging.DEBUG)
else:
self.console_handler.setLevel(logging.INFO)
def _ensure_file_logger(self):
"""确保文件日志处理器被初始化"""
if WxParam.ENABLE_FILE_LOGGER and self.file_handler is None:
self.setup_file_logger()
def debug(self, msg: str, stacklevel=2, *args, **kwargs):
self._ensure_file_logger() # 确保文件日志初始化
self.logger.debug(msg, *args, stacklevel=stacklevel, **kwargs)
def info(self, msg: str, stacklevel=2, *args, **kwargs):
self._ensure_file_logger() # 确保文件日志初始化
self.logger.info(msg, *args, stacklevel=stacklevel, **kwargs)
def warning(self, msg: str, stacklevel=2, *args, **kwargs):
self._ensure_file_logger() # 确保文件日志初始化
self.logger.warning(msg, *args, stacklevel=stacklevel, **kwargs)
def error(self, msg: str, stacklevel=2, *args, **kwargs):
self._ensure_file_logger() # 确保文件日志初始化
self.logger.error(msg, *args, stacklevel=stacklevel, **kwargs)
def critical(self, msg: str, stacklevel=2, *args, **kwargs):
self._ensure_file_logger() # 确保文件日志初始化
self.logger.critical(msg, *args, stacklevel=stacklevel, **kwargs)
wxlog = WxautoLogger()

75
wxauto/msgs/__init__.py Normal file
View File

@@ -0,0 +1,75 @@
from .msg import parse_msg
from .base import (
BaseMessage,
HumanMessage
)
from .attr import (
SystemMessage,
TickleMessage,
TimeMessage,
FriendMessage,
SelfMessage
)
from .type import (
TextMessage,
ImageMessage,
VoiceMessage,
VideoMessage,
FileMessage,
LinkMessage,
OtherMessage
)
from .self import (
SelfMessage,
SelfTextMessage,
SelfVoiceMessage,
SelfImageMessage,
SelfVideoMessage,
SelfFileMessage,
SelfLinkMessage,
SelfOtherMessage,
)
from .friend import (
FriendMessage,
FriendTextMessage,
FriendVoiceMessage,
FriendImageMessage,
FriendVideoMessage,
FriendFileMessage,
FriendLinkMessage,
FriendOtherMessage,
)
__all__ = [
'parse_msg',
'BaseMessage',
'HumanMessage',
'SystemMessage',
'TickleMessage',
'TimeMessage',
'FriendMessage',
'SelfMessage',
'TextMessage',
'ImageMessage',
'VoiceMessage',
'VideoMessage',
'FileMessage',
'LinkMessage',
'OtherMessage',
'SelfMessage',
'SelfTextMessage',
'SelfVoiceMessage',
'SelfImageMessage',
'SelfVideoMessage',
'SelfFileMessage',
'SelfLinkMessage',
'SelfOtherMessage',
'FriendMessage',
'FriendTextMessage',
'FriendVoiceMessage',
'FriendImageMessage',
'FriendVideoMessage',
'FriendFileMessage',
'FriendLinkMessage',
'FriendOtherMessage',
]

85
wxauto/msgs/attr.py Normal file
View File

@@ -0,0 +1,85 @@
from .base import *
from wxauto.utils.tools import (
parse_wechat_time
)
class SystemMessage(BaseMessage):
attr = 'system'
def __init__(
self,
control: uia.Control,
parent: "ChatBox"
):
super().__init__(control, parent)
self.sender = 'system'
self.sender_remark = 'system'
class TickleMessage(SystemMessage):
attr = 'tickle'
def __init__(
self,
control: uia.Control,
parent: "ChatBox"
):
super().__init__(control, parent)
self.tickle_list = [
i.Name for i in
control.ListItemControl().GetParentControl().GetChildren()
]
self.content = f"[{len(self.tickle_list)}条]{self.tickle_list[0]}"
class TimeMessage(SystemMessage):
attr = 'time'
def __init__(
self,
control: uia.Control,
parent: "ChatBox"
):
super().__init__(control, parent)
self.time = parse_wechat_time(self.content)
class FriendMessage(HumanMessage):
attr = 'friend'
def __init__(
self,
control: uia.Control,
parent: "ChatBox"
):
super().__init__(control, parent)
self.head_control = self.control.ButtonControl(RegexName='.*?')
self.sender = self.head_control.Name
if (
(remark_control := self.control.TextControl()).Exists(0)
and remark_control.BoundingRectangle.top < self.head_control.BoundingRectangle.top
):
self.sender_remark = remark_control.Name
else:
self.sender_remark = self.sender
@property
def _xbias(self):
if WxParam.FORCE_MESSAGE_XBIAS:
return int(self.head_control.BoundingRectangle.width()*1.5)
return WxParam.DEFAULT_MESSAGE_XBIAS
class SelfMessage(HumanMessage):
attr = 'self'
def __init__(
self,
control: uia.Control,
parent: "ChatBox"
):
super().__init__(control, parent)
@property
def _xbias(self):
if WxParam.FORCE_MESSAGE_XBIAS:
return -int(self.head_control.BoundingRectangle.width()*1.5)
return -WxParam.DEFAULT_MESSAGE_XBIAS

185
wxauto/msgs/base.py Normal file
View File

@@ -0,0 +1,185 @@
from wxauto import uiautomation as uia
from wxauto.logger import wxlog
from wxauto.param import (
WxResponse,
WxParam,
PROJECT_NAME
)
from wxauto.ui.component import (
CMenuWnd,
SelectContactWnd
)
from wxauto.utils.tools import roll_into_view
from wxauto.languages import *
from typing import (
Dict,
List,
Union,
TYPE_CHECKING
)
from hashlib import md5
import time
if TYPE_CHECKING:
from wxauto.ui.chatbox import ChatBox
def truncate_string(s: str, n: int=8) -> str:
s = s.replace('\n', '').strip()
return s if len(s) <= n else s[:n] + '...'
class Message:...
class BaseMessage(Message):
type: str = 'base'
attr: str = 'base'
control: uia.Control
def __init__(
self,
control: uia.Control,
parent: "ChatBox",
):
self.control = control
self.parent = parent
self.root = parent.root
self.content = self.control.Name
self.id = self.control.runtimeid
self.sender = self.attr
self.sender_remark = self.attr
def __repr__(self):
cls_name = self.__class__.__name__
content = truncate_string(self.content)
return f"<{PROJECT_NAME} - {cls_name}({content}) at {hex(id(self))}>"
@property
def message_type_name(self) -> str:
return self.__class__.__name__
def chat_info(self) -> Dict:
if self.control.Exists(0):
return self.parent.get_info()
def _lang(self, text: str) -> str:
return MESSAGES.get(text, {WxParam.LANGUAGE: text}).get(WxParam.LANGUAGE)
def roll_into_view(self) -> WxResponse:
if roll_into_view(self.control.GetParentControl(), self.control, equal=True) == 'not exist':
wxlog.warning('消息目标控件不存在,无法滚动至显示窗口')
return WxResponse.failure('消息目标控件不存在,无法滚动至显示窗口')
return WxResponse.success('成功')
@property
def info(self) -> Dict:
_info = self.parent.get_info().copy()
_info['class'] = self.message_type_name
_info['id'] = self.id
_info['type'] = self.type
_info['attr'] = self.attr
_info['content'] = self.content
return _info
class HumanMessage(BaseMessage):
attr = 'human'
def __init__(
self,
control: uia.Control,
parent: "ChatBox",
):
super().__init__(control, parent)
self.head_control = self.control.ButtonControl(searchDepth=2)
def roll_into_view(self) -> WxResponse:
if roll_into_view(self.control.GetParentControl(), self.head_control, equal=True) == 'not exist':
return WxResponse.failure('消息目标控件不存在,无法滚动至显示窗口')
return WxResponse.success('成功')
def click(self):
self.roll_into_view()
self.head_control.Click(x=self._xbias)
def right_click(self):
self.roll_into_view()
self.head_control.Click(x=-self._xbias)
self.head_control.RightClick(x=self._xbias)
def select_option(self, option: str, timeout=None) -> WxResponse:
self.root._show()
def _select_option(self, option):
if not (roll_result := self.roll_into_view()):
return roll_result
self.right_click()
menu = CMenuWnd(self.root)
return menu.select(item=option)
if timeout:
t0 = time.time()
while True:
if (time.time() - t0) > timeout:
return WxResponse(False, '引用消息超时')
if quote_result := _select_option(self, option):
return quote_result
else:
return _select_option(self, option)
def quote(
self, text: str,
at: Union[List[str], str] = None,
timeout: int = 3
) -> WxResponse:
"""引用消息
Args:
text (str): 引用内容
at (List[str], optional): @用户列表
timeout (int, optional): 超时时间单位为秒若为None则不启用超时设置
Returns:
WxResponse: 调用结果
"""
if not self.select_option('引用', timeout=timeout):
wxlog.debug(f"当前消息无法引用:{self.content}")
return WxResponse(False, '当前消息无法引用')
if at:
self.parent.input_at(at)
return self.parent.send_text(text)
def reply(
self, text: str,
at: Union[List[str], str] = None
) -> WxResponse:
"""引用消息
Args:
text (str): 回复内容
at (List[str], optional): @用户列表
timeout (int, optional): 超时时间单位为秒若为None则不启用超时设置
Returns:
WxResponse: 调用结果
"""
if at:
self.parent.input_at(at)
return self.parent.send_text(text)
def forward(self, targets: Union[List[str], str], timeout: int = 3) -> WxResponse:
"""转发消息
Args:
targets (Union[List[str], str]): 目标用户列表
timeout (int, optional): 超时时间单位为秒若为None则不启用超时设置
Returns:
WxResponse: 调用结果
"""
if not self.select_option('转发', timeout=timeout):
return WxResponse(False, '当前消息无法转发')
select_wnd = SelectContactWnd(self)
return select_wnd.send(targets)

73
wxauto/msgs/friend.py Normal file
View File

@@ -0,0 +1,73 @@
from .type import *
from .attr import FriendMessage
import sys
class FriendTextMessage(FriendMessage, TextMessage):
def __init__(
self,
control: uia.Control,
parent: "ChatBox"
):
super().__init__(control, parent)
class FriendQuoteMessage(FriendMessage, QuoteMessage):
def __init__(
self,
control: uia.Control,
parent: "ChatBox",
):
super().__init__(control, parent)
class FriendImageMessage(FriendMessage, ImageMessage):
def __init__(
self,
control: uia.Control,
parent: "ChatBox",
):
super().__init__(control, parent)
class FriendFileMessage(FriendMessage, FileMessage):
def __init__(
self,
control: uia.Control,
parent: "ChatBox",
):
super().__init__(control, parent)
class FriendLinkMessage(FriendMessage, LinkMessage):
def __init__(
self,
control: uia.Control,
parent: "ChatBox",
):
super().__init__(control, parent)
class FriendVideoMessage(FriendMessage, VideoMessage):
def __init__(
self,
control: uia.Control,
parent: "ChatBox",
):
super().__init__(control, parent)
class FriendVoiceMessage(FriendMessage, VoiceMessage):
def __init__(
self,
control: uia.Control,
parent: "ChatBox",
):
super().__init__(control, parent)
class FriendOtherMessage(FriendMessage, OtherMessage):
def __init__(
self,
control: uia.Control,
parent: "ChatBox",
):
super().__init__(control, parent)

133
wxauto/msgs/msg.py Normal file
View File

@@ -0,0 +1,133 @@
from .attr import *
from .type import OtherMessage
from . import self as selfmsg
from . import friend as friendmsg
from wxauto.languages import *
from wxauto.param import WxParam
from wxauto import uiautomation as uia
from typing import Literal
import re
class MESSAGE_ATTRS:
SYS_TEXT_HEIGHT = 33
TIME_TEXT_HEIGHT = 34
CHAT_TEXT_HEIGHT = 52
FILE_MSG_HEIGHT = 115
LINK_MSG_HEIGHT = 115
VOICE_MSG_HEIGHT = 55
TEXT_MSG_CONTROL_NUM = (8, 9, 10, 11)
TIME_MSG_CONTROL_NUM = (1,)
SYS_MSG_CONTROL_NUM = (4,5,6)
IMG_MSG_CONTROL_NUM = (9, 10, 11, 12)
FILE_MSG_CONTROL_NUM = tuple(i for i in range(15, 30))
LINK_MSG_CONTROL_NUM = tuple(i for i in range(15, 30))
VOICE_MSG_CONTROL_NUM = tuple(i for i in range(10, 30))
VIDEO_MSG_CONTROL_NUM = (13, 14, 15, 16)
QUOTE_MSG_CONTROL_NUM = tuple(i for i in range(16, 30))
LINK_MSG_CONTROL_NUM = tuple(i for i in range(15, 30))
def _lang(text: str) -> str:
return MESSAGES.get(text, {WxParam.LANGUAGE: text}).get(WxParam.LANGUAGE)
SEPICIAL_MSGS = [
_lang(i)
for i in [
'[图片]', # ImageMessage
'[视频]', # VideoMessage
'[语音]', # VoiceMessage
'[文件]', # FileMessage
'[链接]', # LinkMessage
]
]
def parse_msg_attr(
control: uia.Control,
parent,
):
msg_rect = control.BoundingRectangle
height = msg_rect.height()
mid = (msg_rect.left + msg_rect.right) / 2
for length, _ in enumerate(uia.WalkControl(control)):length += 1
# TimeMessage
if (
length in MESSAGE_ATTRS.TIME_MSG_CONTROL_NUM
):
return TimeMessage(control, parent)
# FriendMessage or SelfMessage
if (head_control := control.ButtonControl(searchDepth=2)).Exists(0):
head_rect = head_control.BoundingRectangle
if head_rect.left < mid:
return parse_msg_type(control, parent, 'Friend')
else:
return parse_msg_type(control, parent, 'Self')
# SystemMessage or TickleMessage
else:
if length in MESSAGE_ATTRS.SYS_MSG_CONTROL_NUM:
return SystemMessage(control, parent)
elif control.ListItemControl(RegexName=_lang('re_拍一拍')).Exists(0):
return TickleMessage(control, parent)
else:
return OtherMessage(control, parent)
def parse_msg_type(
control: uia.Control,
parent,
attr: Literal['Self', 'Friend']
):
for length, _ in enumerate(uia.WalkControl(control)):length += 1
content = control.Name
wxlog.debug(f'content: {content}, length: {length}')
if attr == 'Friend':
msgtype = friendmsg
else:
msgtype = selfmsg
# Special Message Type
if content in SEPICIAL_MSGS:
# ImageMessage
if content == _lang('[图片]') and length in MESSAGE_ATTRS.IMG_MSG_CONTROL_NUM:
return getattr(msgtype, f'{attr}ImageMessage')(control, parent)
# VideoMessage
elif content == _lang('[视频]') and length in MESSAGE_ATTRS.VIDEO_MSG_CONTROL_NUM:
return getattr(msgtype, f'{attr}VideoMessage')(control, parent)
# FileMessage
elif content == _lang('[文件]') and length in MESSAGE_ATTRS.FILE_MSG_CONTROL_NUM:
return getattr(msgtype, f'{attr}FileMessage')(control, parent)
# LinkMessage
elif content == _lang('[链接]') and length in MESSAGE_ATTRS.LINK_MSG_CONTROL_NUM:
return getattr(msgtype, f'{attr}LinkMessage')(control, parent)
# TextMessage
if length in MESSAGE_ATTRS.TEXT_MSG_CONTROL_NUM:
return getattr(msgtype, f'{attr}TextMessage')(control, parent)
# QuoteMessage
elif (
rematch := re.compile(_lang('re_引用消息'), re.DOTALL).match(content)
and length in MESSAGE_ATTRS.QUOTE_MSG_CONTROL_NUM
):
return getattr(msgtype, f'{attr}QuoteMessage')(control, parent)
# VoiceMessage
elif (
rematch := re.compile(_lang('re_语音')).match(content)
and length in MESSAGE_ATTRS.VOICE_MSG_CONTROL_NUM
):
return getattr(msgtype, f'{attr}VoiceMessage')(control, parent)
return getattr(msgtype, f'{attr}OtherMessage')(control, parent)
def parse_msg(
control: uia.Control,
parent
):
result = parse_msg_attr(control, parent)
return result

67
wxauto/msgs/self.py Normal file
View File

@@ -0,0 +1,67 @@
from .type import *
from .attr import SelfMessage
import sys
class SelfTextMessage(SelfMessage, TextMessage):
def __init__(
self,
control: uia.Control,
parent: "ChatBox"
):
super().__init__(control, parent)
class SelfQuoteMessage(SelfMessage, QuoteMessage):
def __init__(
self,
control: uia.Control,
parent: "ChatBox",
):
super().__init__(control, parent)
class SelfImageMessage(SelfMessage, ImageMessage):
def __init__(
self,
control: uia.Control,
parent: "ChatBox"
):
super().__init__(control, parent)
class SelfFileMessage(SelfMessage, FileMessage):
def __init__(
self,
control: uia.Control,
parent: "ChatBox"
):
super().__init__(control, parent)
class SelfLinkMessage(SelfMessage, LinkMessage):
def __init__(
self,
control: uia.Control,
parent: "ChatBox"
):
super().__init__(control, parent)
class SelfVideoMessage(SelfMessage, VideoMessage):
def __init__(
self,
control: uia.Control,
parent: "ChatBox"
):
super().__init__(control, parent)
class SelfVoiceMessage(SelfMessage, VoiceMessage):
def __init__(
self,
control: uia.Control,
parent: "ChatBox"
):
super().__init__(control, parent)
class SelfOtherMessage(SelfMessage, OtherMessage):
def __init__(
self,
control: uia.Control,
parent: "ChatBox"
):
super().__init__(control, parent)

222
wxauto/msgs/type.py Normal file
View File

@@ -0,0 +1,222 @@
from wxauto.utils.tools import (
get_file_dir,
)
from wxauto.ui.component import (
CMenuWnd,
WeChatImage,
WeChatBrowser,
)
from wxauto.utils.win32 import (
ReadClipboardData,
SetClipboardText,
)
from .base import *
from typing import (
Union,
)
from pathlib import Path
import shutil
import re
class TextMessage(HumanMessage):
type = 'text'
def __init__(
self,
control: uia.Control,
parent: "ChatBox"
):
super().__init__(control, parent)
class QuoteMessage(HumanMessage):
type = 'quote'
def __init__(
self,
control: uia.Control,
parent: "ChatBox",
):
super().__init__(control, parent)
self.content, self.quote_content = \
re.findall(self._lang('re_引用消息'), self.content, re.DOTALL)[0]
class MediaMessage:
def download(
self,
dir_path: Union[str, Path] = None,
timeout: int = 10
) -> Path:
if dir_path is None:
dir_path = WxParam.DEFAULT_SAVE_PATH
if self.type == 'image':
filename = f"wxauto_{self.type}_{time.strftime('%Y%m%d%H%M%S')}.png"
elif self.type == 'video':
filename = f"wxauto_{self.type}_{time.strftime('%Y%m%d%H%M%S')}.mp4"
filepath = get_file_dir(dir_path) / filename
self.click()
t0 = time.time()
while True:
self.right_click()
menu = CMenuWnd(self)
if menu and menu.select('复制'):
try:
clipboard_data = ReadClipboardData()
cpath = clipboard_data['15'][0]
break
except:
pass
else:
menu.close()
if time.time() - t0 > timeout:
return WxResponse.failure(f'下载超时: {self.type}')
time.sleep(0.1)
shutil.copyfile(cpath, filepath)
SetClipboardText('')
if imagewnd := WeChatImage():
imagewnd.close()
return filepath
class ImageMessage(HumanMessage, MediaMessage):
type = 'image'
def __init__(
self,
control: uia.Control,
parent: "ChatBox"
):
super().__init__(control, parent)
class VideoMessage(HumanMessage, MediaMessage):
type = 'video'
def __init__(
self,
control: uia.Control,
parent: "ChatBox"
):
super().__init__(control, parent)
class VoiceMessage(HumanMessage):
type = 'voice'
def __init__(
self,
control: uia.Control,
parent: "ChatBox"
):
super().__init__(control, parent)
def to_text(self):
"""语音转文字"""
if self.control.GetProgenyControl(8, 4):
return self.control.GetProgenyControl(8, 4).Name
voicecontrol = self.control.ButtonControl(Name='')
if not voicecontrol.Exists(0.5):
return WxResponse.failure('语音转文字失败')
self.right_click()
menu = CMenuWnd(self.parent)
menu.select('语音转文字')
text = ''
while True:
if not self.control.Exists(0):
return WxResponse.failure('消息已撤回')
text_control = self.control.GetProgenyControl(8, 4)
if text_control is not None:
if text_control.Name == text:
return text
text = text_control.Name
time.sleep(0.1)
class LinkMessage(HumanMessage):
type = 'link'
def __init__(
self,
control: uia.Control,
parent: "ChatBox"
):
super().__init__(control, parent)
def get_url(self) -> str:
self.click()
if webbrower := WeChatBrowser():
url = webbrower.get_url()
webbrower.close()
return url
else:
wxlog.debug(f'找不到浏览器窗口')
return None
class FileMessage(HumanMessage):
type = 'file'
def __init__(
self,
control: uia.Control,
parent: "ChatBox"
):
super().__init__(control, parent)
#self.filename = control.TextControl().Name
self.filename = control.GetProgenyControl(9, control_type='TextControl').Name
self.filesize = control.GetProgenyControl(10, control_type='TextControl').Name
def download(
self,
dir_path: Union[str, Path] = None,
force_click: bool = False,
timeout: int = 10
) -> Path:
"""下载文件"""
if dir_path is None:
dir_path = WxParam.DEFAULT_SAVE_PATH
filepath = get_file_dir(dir_path) / self.filename
t0 = time.time()
def open_file_menu():
while not (menu := CMenuWnd(self.parent)):
self.roll_into_view()
self.right_click()
return menu
if force_click:
self.click()
while True:
if time.time() - t0 > timeout:
return WxResponse.failure("文件下载超时")
try:
if self.control.TextControl(Name=self._lang('接收中')).Exists(0):
time.sleep(0.1)
continue
menu = open_file_menu()
if (option := self._lang('复制')) in menu.option_names:
menu.select(option)
temp_filepath = Path(ReadClipboardData().get('15')[0])
break
except:
time.sleep(0.1)
t0 = time.time()
while True:
if time.time() - t0 > 2:
return WxResponse.failure("文件下载超时")
try:
shutil.copyfile(temp_filepath, filepath)
SetClipboardText('')
return filepath
except:
time.sleep(0.01)
class OtherMessage(BaseMessage):
type = 'other'
def __init__(
self,
control: uia.Control,
parent: "ChatBox",
):
super().__init__(control, parent)
self.url = control.TextControl().Name

65
wxauto/param.py Normal file
View File

@@ -0,0 +1,65 @@
from typing import Literal
import os
PROJECT_NAME = 'wxauto'
class WxParam:
# 语言设置
LANGUAGE: Literal['cn', 'cn_t', 'en'] = 'cn'
# 是否启用日志文件
ENABLE_FILE_LOGGER: bool = True
# 下载文件/图片默认保存路径
DEFAULT_SAVE_PATH: str = os.path.join(os.getcwd(), 'wxauto文件下载')
# 是否启用消息哈希值用于辅助判断消息,开启后会稍微影响性能
MESSAGE_HASH: bool = False
# 头像到消息X偏移量用于消息定位点击消息等操作
DEFAULT_MESSAGE_XBIAS = 51
# 是否强制重新自动获取X偏移量如果设置为True则每次启动都会重新获取
FORCE_MESSAGE_XBIAS: bool = True
# 监听消息时间间隔,单位秒
LISTEN_INTERVAL: int = 1
# 搜索聊天对象超时时间
SEARCH_CHAT_TIMEOUT: int = 5
class WxResponse(dict):
def __init__(self, status: str, message: str, data: dict = None):
super().__init__(status=status, message=message, data=data)
def __str__(self):
return str(self.to_dict())
def __repr__(self):
return str(self.to_dict())
def to_dict(self):
return {
'status': self['status'],
'message': self['message'],
'data': self['data']
}
def __bool__(self):
return self.is_success
@property
def is_success(self):
return self['status'] == '成功'
@classmethod
def success(cls, message=None, data: dict = None):
return cls(status="成功", message=message, data=data)
@classmethod
def failure(cls, message: str, data: dict = None):
return cls(status="失败", message=message, data=data)
@classmethod
def error(cls, message: str, data: dict = None):
return cls(status="错误", message=message, data=data)

8
wxauto/ui/__init__.py Normal file
View File

@@ -0,0 +1,8 @@
from .base import BaseUIWnd, BaseUISubWnd
from . import (
chatbox,
component,
main,
navigationbox,
sessionbox
)

56
wxauto/ui/base.py Normal file
View File

@@ -0,0 +1,56 @@
from wxauto import uiautomation as uia
from wxauto.param import PROJECT_NAME
from wxauto.logger import wxlog
from abc import ABC, abstractmethod
import win32gui
from typing import Union
import time
class BaseUIWnd(ABC):
_ui_cls_name: str = None
_ui_name: str = None
control: uia.Control
@abstractmethod
def _lang(self, text: str):pass
def __repr__(self):
return f"<{PROJECT_NAME} - {self.__class__.__name__} at {hex(id(self))}>"
def __eq__(self, other):
return self.control == other.control
def __bool__(self):
return self.exists()
def _show(self):
if hasattr(self, 'HWND'):
win32gui.ShowWindow(self.HWND, 1)
win32gui.SetWindowPos(self.HWND, -1, 0, 0, 0, 0, 3)
win32gui.SetWindowPos(self.HWND, -2, 0, 0, 0, 0, 3)
self.control.SwitchToThisWindow()
def close(self):
try:
self.control.SendKeys('{Esc}')
except:
pass
def exists(self, wait=0):
try:
result = self.control.Exists(wait)
return result
except:
return False
class BaseUISubWnd(BaseUIWnd):
root: BaseUIWnd
parent: None
def _lang(self, text: str):
if getattr(self, 'parent'):
return self.parent._lang(text)
else:
return self.root._lang(text)

455
wxauto/ui/chatbox.py Normal file
View File

@@ -0,0 +1,455 @@
from .base import BaseUISubWnd
from wxauto.ui.component import (
CMenuWnd,
)
from wxauto.param import (
WxParam,
WxResponse,
)
from wxauto.languages import *
from wxauto.utils import (
SetClipboardText,
SetClipboardFiles,
GetAllWindowExs,
)
from wxauto.msgs import parse_msg
from wxauto import uiautomation as uia
from wxauto.logger import wxlog
from wxauto.uiautomation import Control
from wxauto.utils.tools import roll_into_view
import time
import os
USED_MSG_IDS = {}
class ChatBox(BaseUISubWnd):
def __init__(self, control: uia.Control, parent):
self.control: Control = control
self.root = parent
self.parent = parent # `wx` or `chat`
self.init()
def init(self):
self.msgbox = self.control.ListControl(Name=self._lang("消息"))
# if not self.msgbox.Exists(0):
# return
self.editbox = self.control.EditControl()
self.sendbtn = self.control.ButtonControl(Name=self._lang('发送'))
self.tools = self.control.PaneControl().ToolBarControl()
# self.id = self.msgbox.runtimeid
self._empty = False # 用于记录是否为完全没有聊天记录的窗口,因为这种窗口之前有不会触发新消息判断的问题
if (cid := self.id) and cid not in USED_MSG_IDS:
USED_MSG_IDS[self.id] = tuple((i.runtimeid for i in self.msgbox.GetChildren()))
if not USED_MSG_IDS[cid]:
self._empty = True
def _lang(self, text: str) -> str:
return WECHAT_CHAT_BOX.get(text, {WxParam.LANGUAGE: text}).get(WxParam.LANGUAGE)
def _update_used_msg_ids(self):
USED_MSG_IDS[self.id] = tuple((i.runtimeid for i in self.msgbox.GetChildren()))
def _open_chat_more_info(self):
for chatinfo_control, depth in uia.WalkControl(self.control):
if chatinfo_control.Name == self._lang('聊天信息'):
chatinfo_control.Click()
break
else:
return WxResponse.failure('未找到聊天信息按钮')
return ChatRoomDetailWnd(self)
def _activate_editbox(self):
if not self.editbox.HasKeyboardFocus:
self.editbox.MiddleClick()
@property
def who(self):
if hasattr(self, '_who'):
return self._who
self._who = self.editbox.Name
return self._who
@property
def id(self):
if self.msgbox.Exists(0):
return self.msgbox.runtimeid
return None
@property
def used_msg_ids(self):
return USED_MSG_IDS[self.id]
def get_info(self):
chat_info = {}
walk = uia.WalkControl(self.control)
for chat_name_control, depth in walk:
if isinstance(chat_name_control, uia.TextControl):
break
if (
not isinstance(chat_name_control, uia.TextControl)
or depth < 8
):
return {}
# chat_name_control = self.control.GetProgenyControl(11)
chat_name_control_list = chat_name_control.GetParentControl().GetChildren()
chat_name_control_count = len(chat_name_control_list)
if chat_name_control_count == 1:
if self.control.ButtonControl(Name='公众号主页', searchDepth=9).Exists(0):
chat_info['chat_type'] = 'official'
else:
chat_info['chat_type'] = 'friend'
chat_info['chat_name'] = chat_name_control.Name
elif chat_name_control_count >= 2:
try:
second_text = chat_name_control_list[1].Name
if second_text.startswith('@'):
chat_info['company'] = second_text
chat_info['chat_type'] = 'service'
chat_info['chat_name'] = chat_name_control.Name
else:
chat_info['group_member_count'] =\
int(second_text.replace('(', '').replace(')', ''))
chat_info['chat_type'] = 'group'
chat_info['chat_name'] =\
chat_name_control.Name.replace(second_text, '')
except:
chat_info['chat_type'] = 'friend'
chat_info['chat_name'] = chat_name_control.Name
ori_chat_name_control =\
chat_name_control.GetParentControl().\
GetParentControl().TextControl(searchDepth=1)
if ori_chat_name_control.Exists(0):
chat_info['chat_remark'] = chat_info['chat_name']
chat_info['chat_name'] = ori_chat_name_control.Name
self._info = chat_info
return chat_info
def input_at(self, at_list):
self._show()
if isinstance(at_list, str):
at_list = [at_list]
self._activate_editbox()
for friend in at_list:
self.editbox.SendKeys('@'+friend.replace(' ', ''))
atmenu = AtMenu(self)
atmenu.select(friend)
def clear_edit(self):
self._show()
self.editbox.Click()
self.editbox.SendKeys('{Ctrl}a', waitTime=0)
self.editbox.SendKeys('{DELETE}')
def send_text(self, content: str):
self._show()
t0 = time.time()
while True:
if time.time() - t0 > 10:
return WxResponse.failure(f'Timeout --> {self.who} - {content}')
SetClipboardText(content)
self._activate_editbox()
self.editbox.SendKeys('{Ctrl}v')
if self.editbox.GetValuePattern().Value.replace('', '').strip():
break
self.editbox.SendKeys('{Ctrl}v')
if self.editbox.GetValuePattern().Value.replace('', '').strip():
break
self.editbox.RightClick()
menu = CMenuWnd(self)
menu.select('粘贴')
if self.editbox.GetValuePattern().Value.replace('', '').strip():
break
t0 = time.time()
while self.editbox.GetValuePattern().Value:
if time.time() - t0 > 10:
return WxResponse.failure(f'Timeout --> {self.who} - {content}')
self._activate_editbox()
self.sendbtn.Click()
if not self.editbox.GetValuePattern().Value:
return WxResponse.success(f"success")
elif not self.editbox.GetValuePattern().Value.replace('', '').strip():
return self.send_text(content)
def send_msg(self, content: str, clear: bool=True, at=None):
if not content and not at:
return WxResponse.failure(f"参数 `content` 和 `at` 不能同时为空")
if clear:
self.clear_edit()
if at:
self.input_at(at)
return self.send_text(content)
def send_file(self, file_path):
self._show()
if isinstance(file_path, str):
file_path = [file_path]
file_path = [os.path.abspath(f) for f in file_path]
SetClipboardFiles(file_path)
self._activate_editbox()
self.editbox.SendKeys('{Ctrl}v')
self.sendbtn.Click()
if self.editbox.GetValuePattern().Value:
return WxResponse.fail("发送失败,请重试")
return WxResponse.success()
def load_more(self, interval=0.3):
self._show()
msg_len = len(self.msgbox.GetChildren())
loadmore = self.msgbox.GetChildren()[0]
loadmore_top = loadmore.BoundingRectangle.top
while True:
if len(self.msgbox.GetChildren()) > msg_len:
isload = True
break
else:
msg_len = len(self.msgbox.GetChildren())
self.msgbox.WheelUp(wheelTimes=10)
time.sleep(interval)
if self.msgbox.GetChildren()[0].BoundingRectangle.top == loadmore_top\
and len(self.msgbox.GetChildren()) == msg_len:
isload = False
break
else:
loadmore_top = self.msgbox.GetChildren()[0].BoundingRectangle.top
self.msgbox.WheelUp(wheelTimes=1, waitTime=0.1)
if isload:
return WxResponse.success()
else:
return WxResponse.failure("没有更多消息了")
def get_msgs(self):
if self.msgbox.Exists(0):
return [
parse_msg(msg_control, self)
for msg_control
in self.msgbox.GetChildren()
if msg_control.ControlTypeName == 'ListItemControl'
]
return []
def get_new_msgs(self):
if not self.msgbox.Exists(0):
return []
msg_controls = self.msgbox.GetChildren()
now_msg_ids = tuple((i.runtimeid for i in msg_controls))
if not now_msg_ids: # 当前没有消息id
return []
if self._empty and self.used_msg_ids:
self._empty = False
if not self._empty and (
(not self.used_msg_ids and now_msg_ids) # 没有使用过的消息id但当前有消息id
or now_msg_ids[-1] == self.used_msg_ids[-1] # 当前最后一条消息id和上次一样
or not set(now_msg_ids)&set(self.used_msg_ids) # 当前消息id和上次没有交集
):
# wxlog.debug('没有新消息')
return []
used_msg_ids_set = set(self.used_msg_ids)
last_one_msgid = max(
(x for x in now_msg_ids if x in used_msg_ids_set),
key=self.used_msg_ids.index, default=None
)
new1 = [x for x in now_msg_ids if x not in used_msg_ids_set]
new2 = now_msg_ids[now_msg_ids.index(last_one_msgid) + 1 :]\
if last_one_msgid is not None else []
new = [i for i in new1 if i in new2] if new2 else new1
USED_MSG_IDS[self.id] = tuple(self.used_msg_ids + tuple(new))[-100:]
new_controls = [i for i in msg_controls if i.runtimeid in new]
self.msgbox.MiddleClick()
return [
parse_msg(msg_control, self)
for msg_control
in new_controls
if msg_control.ControlTypeName == 'ListItemControl'
]
def get_msg_by_id(self, msg_id: str):
if not self.msgbox.Exists(0):
return []
msg_controls = self.msgbox.GetChildren()
if control_list := [i for i in msg_controls if i.runtimeid == msg_id]:
return parse_msg(control_list[0], self)
def _get_tail_after_nth_match(self, msgs, last_msg, n):
matches = [
i for i, msg in reversed(list(enumerate(msgs)))
if msg.content == last_msg
]
if len(matches) >= n:
wxlog.debug(f'匹配到基准消息:{last_msg}')
else:
split_last_msg = last_msg.split('')
nickname = split_last_msg[0]
content = ''.join(split_last_msg[1:])
matches = [
i for i, msg in reversed(list(enumerate(msgs)))
if msg.content == content
and msg.sender_remark == nickname
]
if len(matches) >= n:
wxlog.debug(f'匹配到基准消息:<{nickname}> {content}')
else:
wxlog.debug(f"未匹配到基准消息,以最后一条消息为基准:{msgs[-1].content}")
matches = [
i for i, msg in reversed(list(enumerate(msgs)))
if msg.attr in ('self', 'friend')
]
try:
index = matches[n - 1]
return msgs[index:]
except IndexError:
wxlog.debug(f"未匹配到第{n}条消息,返回空列表")
return []
def get_next_new_msgs(self, count=None, last_msg=None):
# 1. 消息列表不存在,则返回空列表
if not self.msgbox.Exists(0):
wxlog.debug('消息列表不存在,返回空列表')
return []
# 2. 判断是否有新消息按钮,有的话点一下
load_new_button = self.control.ButtonControl(RegexName=self._lang('re_新消息按钮'))
if load_new_button.Exists(0):
self._show()
wxlog.debug('检测到新消息按钮,点击加载新消息')
load_new_button.Click()
time.sleep(0.5)
msg_controls = self.msgbox.GetChildren()
USED_MSG_IDS[self.id] = tuple((i.runtimeid for i in msg_controls))
msgs = [
parse_msg(msg_control, self)
for msg_control
in msg_controls
if msg_control.ControlTypeName == 'ListItemControl'
]
# 3. 如果有“以下是新消息”标志,则直接返回该标志下的所有消息即可
index = next((
i for i, msg in enumerate(msgs)
if self._lang('以下为新消息') == msg.content
), None)
if index is not None:
wxlog.debug('获取以下是新消息下的所有消息')
return msgs[index:]
# 4. 根据会话列表传入的消息数量和最后一条新消息内容来判断新消息
if count and last_msg:
# index = next((
# i for i, msg in enumerate(msgs[::-1])
# if last_msg == msg.content
# ), None)
# if index is not None:
wxlog.debug(f'获取{count}条新消息,基准消息内容为:{last_msg}')
return self._get_tail_after_nth_match(msgs, last_msg, count)
def get_group_members(self):
self._show()
roominfoWnd = self._open_chat_more_info()
return roominfoWnd.get_group_members()
class ChatRoomDetailWnd(BaseUISubWnd):
_ui_cls_name: str = 'SessionChatRoomDetailWnd'
def __init__(self, parent):
self.parent = parent
self.root = parent.root
self.control = self.root.control.Control(ClassName=self._ui_cls_name, searchDepth=1)
def _lang(self, text: str) -> str:
return CHATROOM_DETAIL_WINDOW.get(text, {WxParam.LANGUAGE: text}).get(WxParam.LANGUAGE)
def _edit(self, key, value):
wxlog.debug(f'修改{key}为`{value}`')
btn = self.control.TextControl(Name=key).GetParentControl().ButtonControl(Name=key)
if btn.Exists(0):
roll_into_view(self.control, btn)
btn.Click()
else:
wxlog.debug(f'当前非群聊,无法修改{key}')
return WxResponse.failure(f'Not a group chat, cannot modify `{key}`')
while True:
edit_hwnd_list = [
i[0]
for i in GetAllWindowExs(self.control.NativeWindowHandle)
if i[1] == 'EditWnd'
]
if edit_hwnd_list:
edit_hwnd = edit_hwnd_list[0]
break
btn.Click()
edit_win32 = uia.Win32(edit_hwnd)
edit_win32.shortcut_select_all()
edit_win32.send_keys_shortcut('{DELETE}')
edit_win32.input(value)
edit_win32.send_keys_shortcut('{ENTER}')
return WxResponse.success()
def get_group_members(self, control=False):
"""获取群成员"""
more = self.control.ButtonControl(Name=self._lang('查看更多'), searchDepth=8)
if more.Exists(0.5):
more.Click()
members = [i for i in self.control.ListControl(Name=self._lang('聊天成员')).GetChildren()]
while members[-1].Name in [self._lang('添加'), self._lang('移出')]:
members = members[:-1]
if control:
return members
member_names = [i.Name for i in members]
self.close()
return member_names
class GroupMemberElement:
def __init__(self, control, parent) -> None:
self.control = control
self.parent = parent
self.root = self.parent.root
self.nickname = self.control.Name
def __repr__(self) -> str:
return f"<wxauto Group Member Element at {hex(id(self))}>"
class AtMenu(BaseUISubWnd):
_ui_cls_name = 'ChatContactMenu'
def __init__(self, parent):
self.root = parent.root
self.control = parent.parent.control.PaneControl(ClassName='ChatContactMenu')
# self.control.Exists(1)
def clear(self, friend):
if self.exists():
self.control.SendKeys('{ESC}')
for _ in range(len(friend)+1):
self.root.chatbox.editbox.SendKeys('{BACK}')
def select(self, friend):
friend_ = friend.replace(' ', '')
if self.exists():
ateles = self.control.ListControl().GetChildren()
if len(ateles) == 1:
ateles[0].Click()
else:
atele = self.control.ListItemControl(Name=friend)
if atele.Exists(0):
roll_into_view(self.control, atele)
atele.Click()
else:
self.clear(friend_)
else:
self.clear(friend_)

407
wxauto/ui/component.py Normal file
View File

@@ -0,0 +1,407 @@
from wxauto.utils import (
FindWindow,
SetClipboardText,
ReadClipboardData,
GetAllWindows,
GetWindowRect,
capture
)
from wxauto.param import (
WxParam,
WxResponse,
)
from wxauto.languages import *
from .base import (
BaseUISubWnd
)
from wxauto.logger import wxlog
from wxauto.uiautomation import (
ControlFromHandle,
)
from wxauto.utils.tools import (
get_file_dir,
roll_into_view,
)
from PIL import Image
from wxauto import uiautomation as uia
import traceback
import shutil
import time
class EditBox:
...
class SelectContactWnd(BaseUISubWnd):
"""选择联系人窗口"""
_ui_cls_name = 'SelectContactWnd'
def __init__(self, parent):
self.parent = parent
self.root = parent.root
hwnd = FindWindow(self._ui_cls_name, timeout=1)
if hwnd:
self.control = ControlFromHandle(hwnd)
else:
self.control = parent.root.control.PaneControl(ClassName=self._ui_cls_name, searchDepth=1)
self.editbox = self.control.EditControl()
def send(self, target):
if isinstance(target, str):
SetClipboardText(target)
while not self.editbox.HasKeyboardFocus:
self.editbox.Click()
time.sleep(0.1)
self.editbox.SendKeys('{Ctrl}a')
self.editbox.SendKeys('{Ctrl}v')
checkbox = self.control.ListControl().CheckBoxControl()
if checkbox.Exists(1):
checkbox.Click()
self.control.ButtonControl(Name='发送').Click()
return WxResponse.success()
else:
self.control.SendKeys('{Esc}')
wxlog.debug(f'未找到好友:{target}')
return WxResponse.failure(f'未找到好友:{target}')
elif isinstance(target, list):
n = 0
fail = []
multiselect = self.control.ButtonControl(Name='多选')
if multiselect.Exists(0):
multiselect.Click()
for i in target:
SetClipboardText(i)
while not self.editbox.HasKeyboardFocus:
self.editbox.Click()
time.sleep(0.1)
self.editbox.SendKeys('{Ctrl}a')
self.editbox.SendKeys('{Ctrl}v')
checkbox = self.control.ListControl().CheckBoxControl()
if checkbox.Exists(1):
checkbox.Click()
n += 1
else:
fail.append(i)
wxlog.debug(f"未找到转发对象:{i}")
if n > 0:
self.control.ButtonControl(RegexName='分别发送(\d+').Click()
if n == len(target):
return WxResponse.success()
else:
return WxResponse.success('存在未转发成功名单', data=fail)
else:
self.control.SendKeys('{Esc}')
wxlog.debug(f'所有好友均未未找到:{target}')
return WxResponse.failure(f'所有好友均未未找到:{target}')
class CMenuWnd(BaseUISubWnd):
_ui_cls_name = 'CMenuWnd'
def __init__(self, parent):
self.parent = parent
self.root = parent.root
if menulist := [i for i in GetAllWindows() if 'CMenuWnd' in i]:
self.control = uia.ControlFromHandle(menulist[0][0])
else:
self.control = self.root.control.MenuControl(ClassName=self._ui_cls_name)
def _lang(self, text: str) -> str:
return MENU_OPTIONS.get(text, {WxParam.LANGUAGE: text}).get(WxParam.LANGUAGE)
@property
def option_controls(self):
return self.control.ListControl().GetChildren()
@property
def option_names(self):
return [c.Name for c in self.option_controls]
def select(self, item):
if not self.exists(0):
return WxResponse.failure('菜单窗口不存在')
if isinstance(item, int):
self.option_controls[item].Click()
return WxResponse.success()
item = self._lang(item)
for c in self.option_controls:
if c.Name == item:
c.Click()
return WxResponse.success()
if self.exists(0):
self.close()
return WxResponse.failure(f'未找到选项:{item}')
def close(self):
try:
self.control.SendKeys('{ESC}')
except Exception as e:
pass
class NetErrInfoTipsBarWnd(BaseUISubWnd):
_ui_cls_name = 'NetErrInfoTipsBarWnd'
def __init__(self, parent):
self.control = parent.root.control.PaneControl(ClassName=self._ui_cls_name)
def __bool__(self):
return self.exists(0)
class WeChatImage(BaseUISubWnd):
_ui_cls_name = 'ImagePreviewWnd'
def __init__(self) -> None:
self.hwnd = FindWindow(classname=self._ui_cls_name)
if self.hwnd:
self.control = ControlFromHandle(self.hwnd)
self.type = 'image'
if self.control.PaneControl(ClassName='ImagePreviewLayerWnd').Exists(0):
self.type = 'video'
MainControl1 = [i for i in self.control.GetChildren() if not i.ClassName][0]
self.ToolsBox, self.PhotoBox = MainControl1.GetChildren()
# tools按钮
self.t_previous = self.ToolsBox.ButtonControl(Name=self._lang('上一张'))
self.t_next = self.ToolsBox.ButtonControl(Name=self._lang('下一张'))
self.t_zoom = self.ToolsBox.ButtonControl(Name=self._lang('放大'))
self.t_translate = self.ToolsBox.ButtonControl(Name=self._lang('翻译'))
self.t_ocr = self.ToolsBox.ButtonControl(Name=self._lang('提取文字'))
self.t_save = self.control.ButtonControl(Name=self._lang('另存为...'))
self.t_qrcode = self.ToolsBox.ButtonControl(Name=self._lang('识别图中二维码'))
def _lang(self, text: str) -> str:
return IMAGE_WINDOW.get(text, {WxParam.LANGUAGE: text}).get(WxParam.LANGUAGE)
def ocr(self, wait=10):
result = ''
ctrls = self.PhotoBox.GetChildren()
if len(ctrls) == 2:
self.t_ocr.Click()
t0 = time.time()
while time.time() - t0 < wait:
ctrls = self.PhotoBox.GetChildren()
if len(ctrls) == 3:
TranslateControl = ctrls[-1]
result = TranslateControl.TextControl().Name
if result:
return result
else:
self.t_ocr.Click()
time.sleep(0.1)
return result
def save(self, dir_path=None, timeout=10):
"""保存图片/视频
Args:
dir_path (str): 绝对路径,包括文件名和后缀,例如:"D:/Images/微信图片_xxxxxx.png"
timeout (int, optional): 保存超时时间默认10秒
Returns:
str: 文件保存路径即savepath
"""
if dir_path is None:
dir_path = WxParam.DEFAULT_SAVE_PATH
suffix = 'png' if self.type == 'image' else 'mp4'
filename = f"wxauto_{self.type}_{time.strftime('%Y%m%d%H%M%S')}.{suffix}"
filepath = get_file_dir(dir_path) / filename
t0 = time.time()
SetClipboardText('')
while True:
if time.time() - t0 > timeout:
if self.control.Exists(0):
self.control.SendKeys('{Esc}')
raise TimeoutError('下载超时')
try:
self.control.ButtonControl(Name=self._lang('更多')).Click()
menu = self.control.MenuControl(ClassName='CMenuWnd')
menu.MenuItemControl(Name=self._lang('复制')).Click()
clipboard_data = ReadClipboardData()
path = clipboard_data['15'][0]
wxlog.debug(f"读取到图片/视频路径:{path}")
break
except:
wxlog.debug(traceback.format_exc())
time.sleep(0.1)
shutil.copyfile(path, filepath)
SetClipboardText('')
if self.control.Exists(0):
wxlog.debug("关闭图片窗口")
self.control.SendKeys('{Esc}')
return filepath
class NewFriendElement:
def __init__(self, control, parent):
self.parent = parent
self.root = parent.root
self.control = control
self.name = self.control.Name
self.msg = self.control.GetFirstChildControl().PaneControl(SearchDepth=1).GetChildren()[-1].TextControl().Name
self.NewFriendsBox = self.root.chatbox.control.ListControl(Name='新的朋友').GetParentControl()
self.status = self.control.GetFirstChildControl().GetChildren()[-1]
self.acceptable = isinstance(self.status, uia.ButtonControl)
def __repr__(self) -> str:
return f"<wxauto New Friends Element at {hex(id(self))} ({self.name}: {self.msg})>"
def delete(self):
wxlog.info(f'删除好友请求: {self.name}')
roll_into_view(self.NewFriendsBox, self.control)
self.control.RightClick()
menu = CMenuWnd(self.root)
menu.select('删除')
def reply(self, text):
wxlog.debug(f'回复好友请求: {self.name}')
roll_into_view(self.NewFriendsBox, self.control)
self.control.Click()
self.root.ChatBox.ButtonControl(Name='回复').Click()
edit = self.root.ChatBox.EditControl()
edit.Click()
edit.SendKeys('{Ctrl}a')
SetClipboardText(text)
edit.SendKeys('{Ctrl}v')
time.sleep(0.1)
self.root.ChatBox.ButtonControl(Name='发送').Click()
dialog = self.root.UiaAPI.PaneControl(ClassName='WeUIDialog')
while edit.Exists(0):
if dialog.Exists(0):
systext = dialog.TextControl().Name
wxlog.debug(f'系统提示: {systext}')
dialog.SendKeys('{Esc}')
self.root.ChatBox.ButtonControl(Name='').Click()
return WxResponse.failure(msg=systext)
time.sleep(0.1)
self.root.ChatBox.ButtonControl(Name='').Click()
return WxResponse.success()
def accept(self, remark=None, tags=None, permission='朋友圈'):
"""接受好友请求
Args:
remark (str, optional): 备注名
tags (list, optional): 标签列表
permission (str, optional): 朋友圈权限, 可选值:'朋友圈', '仅聊天'
"""
if not self.acceptable:
wxlog.debug(f"当前好友状态无法接受好友请求:{self.name}")
return
wxlog.debug(f"接受好友请求:{self.name} 备注:{remark} 标签:{tags}")
self.root._show()
roll_into_view(self.NewFriendsBox, self.status)
self.status.Click()
NewFriendsWnd = self.root.control.WindowControl(ClassName='WeUIDialog')
tipscontrol = NewFriendsWnd.TextControl(Name="你的联系人较多,添加新的朋友时需选择权限")
permission_sns = NewFriendsWnd.CheckBoxControl(Name='聊天、朋友圈、微信运动等')
permission_chat = NewFriendsWnd.CheckBoxControl(Name='仅聊天')
if tipscontrol.Exists(0.5):
permission_sns = tipscontrol.GetParentControl().GetParentControl().TextControl(Name='朋友圈')
permission_chat = tipscontrol.GetParentControl().GetParentControl().TextControl(Name='仅聊天')
if remark:
remarkedit = NewFriendsWnd.TextControl(Name='备注名').GetParentControl().EditControl()
remarkedit.Click()
remarkedit.SendKeys('{Ctrl}a')
remarkedit.SendKeys(remark)
if tags:
tagedit = NewFriendsWnd.TextControl(Name='标签').GetParentControl().EditControl()
for tag in tags:
tagedit.Click()
tagedit.SendKeys(tag)
NewFriendsWnd.PaneControl(ClassName='DropdownWindow').TextControl().Click()
if permission == '朋友圈':
permission_sns.Click()
elif permission == '仅聊天':
permission_chat.Click()
NewFriendsWnd.ButtonControl(Name='确定').Click()
class WeChatLoginWnd(BaseUISubWnd):
_ui_cls_name = 'WeChatLoginWndForPC'
def __init__(self):
self.hwnd = FindWindow(classname=self._ui_cls_name)
if self.hwnd:
self.control = ControlFromHandle(self.hwnd)
def _lang(self, text: str) -> str:
return WECHAT_LOGINWND.get(text, {WxParam.LANGUAGE: text}).get(WxParam.LANGUAGE)
def clear_hint(self) -> bool:
self._show()
hint_dialog = self.control.PaneControl(Name=self._lang('提示'))
if hint_dialog.Exists(0):
dialog_button = self.control.ButtonControl(Name=self._lang('确定'))
dialog_button.Click()
return True
else:
return False
def login(self) -> bool:
self._show()
enter = self.control.ButtonControl(Name=self._lang('进入微信'))
if enter.Exists(0):
enter.Click()
return True
else:
return False
def get_qrcode(self) -> Image.Image:
self._show()
qrcode = self.control.ButtonControl(Name=self._lang('二维码'))
if qrcode.Exists(0):
window_rect = GetWindowRect(self.hwnd)
win_left, win_top, win_right, win_bottom = window_rect
bbox = win_left + 62, win_top + 88, win_left + 218, win_top + 245
return capture(self.hwnd, bbox)
else:
return None
class WeChatBrowser(BaseUISubWnd):
_ui_cls_name = 'Chrome_WidgetWin_0'
_ui_name = '微信'
def __init__(self):
self.hwnd = FindWindow(classname=self._ui_cls_name, name=self._ui_name)
if self.hwnd:
self.control = ControlFromHandle(self.hwnd)
def _lang(self, text: str) -> str:
return WECHAT_BROWSER.get(text, {WxParam.LANGUAGE: text}).get(WxParam.LANGUAGE)
def get_url(self) -> str:
self._show()
tab = self.control.TabItemControl()
if tab.Exists():
tab.RightClick()
copy_link_item = uia.MenuItemControl(Name=self._lang('复制链接'))
if copy_link_item.Exists():
copy_link_item.Click()
clipboard_data = ReadClipboardData()
url = (clipboard_data.get('13') or
clipboard_data.get('1') or
None)
SetClipboardText('')
return url
else:
wxlog.debug(f'找不到复制链接菜单项')
else:
wxlog.debug(f'找不到标签页')
def close(self):
close_button = self.control.ButtonControl(Name=self._lang('关闭'), foundIndex=1)
if close_button.Exists():
close_button.Click()
close_button = self.control.ButtonControl(Name=self._lang('关闭'), foundIndex=2)
if close_button.Exists():
close_button.Click()
close_button = self.control.ButtonControl(Name=self._lang('关闭'), foundIndex=3)
if close_button.Exists():
close_button.Click()

265
wxauto/ui/main.py Normal file
View File

@@ -0,0 +1,265 @@
from .base import (
BaseUIWnd,
BaseUISubWnd
)
from wxauto import uiautomation as uia
from wxauto.param import WxResponse
from .chatbox import ChatBox
from .sessionbox import SessionBox
from .navigationbox import NavigationBox
from wxauto.param import (
WxParam,
WxResponse,
PROJECT_NAME
)
from wxauto.languages import *
from wxauto.utils import (
GetAllWindows,
FindWindow,
)
from wxauto.logger import wxlog
from typing import (
Union,
List,
)
class WeChatSubWnd(BaseUISubWnd):
_ui_cls_name: str = 'ChatWnd'
chatbox: ChatBox = None
nickname: str = ''
def __init__(
self,
key: Union[str, int],
parent: 'WeChatMainWnd',
timeout: int = 3
):
self.root = self
self.parent = parent
if isinstance(key, str):
hwnd = FindWindow(classname=self._ui_cls_name, name=key, timeout=timeout)
else:
hwnd = key
self.control = uia.ControlFromHandle(hwnd)
if self.control is not None:
chatbox_control = self.control.PaneControl(ClassName='', searchDepth=1)
self.chatbox = ChatBox(chatbox_control, self)
self.nickname = self.control.Name
def __repr__(self):
return f'<{PROJECT_NAME} - {self.__class__.__name__} object("{self.nickname}")>'
@property
def pid(self):
if not hasattr(self, '_pid'):
self._pid = self.control.ProcessId
return self._pid
def _get_chatbox(
self,
nickname: str=None,
exact: bool=False
) -> ChatBox:
return self.chatbox
def chat_info(self):
return self.chatbox.get_info()
def load_more_message(self, interval=0.3) -> WxResponse:
return self.chatbox.load_more(interval)
def send_msg(
self,
msg: str,
who: str=None,
clear: bool=True,
at: Union[str, List[str]]=None,
exact: bool=False,
) -> WxResponse:
chatbox = self._get_chatbox(who, exact)
if not chatbox:
return WxResponse.failure(f'未找到会话: {who}')
return chatbox.send_msg(msg, clear, at)
def send_files(
self,
filepath,
who=None,
exact=False
) -> WxResponse:
chatbox = self._get_chatbox(who, exact)
if not chatbox:
return WxResponse.failure(f'未找到会话: {who}')
return chatbox.send_file(filepath)
def get_group_members(
self,
who: str=None,
exact: bool=False
) -> List[str]:
chatbox = self._get_chatbox(who, exact)
if not chatbox:
return WxResponse.failure(f'未找到会话: {who}')
return chatbox.get_group_members()
def get_msgs(self):
chatbox = self._get_chatbox()
if chatbox:
return chatbox.get_msgs()
return []
def get_new_msgs(self):
return self._get_chatbox().get_new_msgs()
def get_msg_by_id(self, msg_id: str):
return self._get_chatbox().get_msg_by_id(msg_id)
version_error_msg = """
错误:未找到可用的微信窗口
当前版本仅适用于3.9版本客户端如果您当前客户端版本为4.x请在官网下载3.9版本客户端
下载链接https://pc.weixin.qq.com
"""
class WeChatMainWnd(WeChatSubWnd):
_ui_cls_name: str = 'WeChatMainWndForPC'
_ui_name: str = '微信'
def __init__(self, hwnd: int = None):
self.root = self
self.parent = self
if hwnd:
self._setup_ui(hwnd)
else:
hwnd = FindWindow(classname=self._ui_cls_name)
if not hwnd:
raise Exception(version_error_msg)
self._setup_ui(hwnd)
print(f'初始化成功,获取到已登录窗口:{self.nickname}')
def _setup_ui(self, hwnd: int):
self.HWND = hwnd
self.control = uia.ControlFromHandle(hwnd)
MainControl1 = [i for i in self.control.GetChildren() if not i.ClassName][0]
MainControl2 = MainControl1.GetFirstChildControl()
navigation_control, sessionbox_control, chatbox_control = MainControl2.GetChildren()
self.navigation = NavigationBox(navigation_control, self)
self.sessionbox = SessionBox(sessionbox_control, self)
self.chatbox = ChatBox(chatbox_control, self)
self.nickname = self.navigation.my_icon.Name
self.NavigationBox = self.navigation.control
self.SessionBox = self.sessionbox.control
self.ChatBox = self.chatbox.control
def __repr__(self):
return f'<{PROJECT_NAME} - {self.__class__.__name__} object("{self.nickname}")>'
def _lang(self, text: str) -> str:
return WECHAT_MAIN.get(text, {WxParam.LANGUAGE: text}).get(WxParam.LANGUAGE)
def _get_chatbox(
self,
nickname: str=None,
exact: bool=False
) -> ChatBox:
if nickname and (chatbox := WeChatSubWnd(nickname, self, timeout=0)).control:
return chatbox.chatbox
else:
if nickname:
switch_result = self.sessionbox.switch_chat(keywords=nickname, exact=exact)
if not switch_result:
return None
if self.chatbox.msgbox.Exists(0.5):
return self.chatbox
def switch_chat(
self,
keywords: str,
exact: bool = False,
force: bool = False,
force_wait: Union[float, int] = 0.5
):
return self.sessionbox.switch_chat(keywords, exact, force, force_wait)
def get_all_sub_wnds(self):
sub_wxs = [i for i in GetAllWindows() if i[1] == WeChatSubWnd._ui_cls_name]
return [WeChatSubWnd(i[0], self) for i in sub_wxs]
def get_sub_wnd(self, who: str, timeout: int=0):
if hwnd := FindWindow(classname=WeChatSubWnd._ui_cls_name, name=who, timeout=timeout):
return WeChatSubWnd(hwnd, self)
def open_separate_window(self, keywords: str) -> WeChatSubWnd:
if subwin := self.get_sub_wnd(keywords):
wxlog.debug(f"{keywords} 获取到已存在的子窗口: {subwin}")
return subwin
self._show()
if nickname := self.sessionbox.switch_chat(keywords):
wxlog.debug(f"{keywords} 切换到聊天窗口: {nickname}")
if subwin := self.get_sub_wnd(nickname):
wxlog.debug(f"{nickname} 获取到已存在的子窗口: {subwin}")
return subwin
else:
keywords = nickname
if result := self.sessionbox.open_separate_window(keywords):
find_nickname = result['data'].get('nickname', keywords)
return WeChatSubWnd(find_nickname, self)
def _get_next_new_message(self, filter_mute: bool=False):
def get_new_message(session):
last_content = session.content
new_count = session.new_count
chat_name = session.name
session.click()
return self.chatbox.get_next_new_msgs(new_count, last_content)
def get_new_session(filter_mute):
sessions = self.sessionbox.get_session()
if sessions[0].name == self._lang('折叠的群聊'):
self.navigation.chat_icon.DoubleClick()
sessions = self.sessionbox.get_session()
new_sessions = [
i for i in sessions
if i.isnew
and i.name != self._lang('折叠的群聊')
]
if filter_mute:
new_sessions = [i for i in new_sessions if i.ismute == False]
return new_sessions
if new_msgs := self.chatbox.get_new_msgs():
wxlog.debug("获取当前页面新消息")
return new_msgs
elif new_sessions := get_new_session(filter_mute):
wxlog.debug("当前会话列表获取新消息")
return get_new_message(new_sessions[0])
else:
self.sessionbox.go_top()
if new_sessions := get_new_session(filter_mute):
wxlog.debug("当前会话列表获取新消息")
return get_new_message(new_sessions[0])
else:
self.navigation.chat_icon.DoubleClick()
if new_sessions := get_new_session(filter_mute):
wxlog.debug("翻页会话列表获取新消息")
return get_new_message(new_sessions[0])
else:
wxlog.debug("没有新消息")
return []
def get_next_new_message(self, filter_mute: bool=False):
if filter_mute and not self.navigation.has_new_message():
return {}
new_msgs = self._get_next_new_message(filter_mute)
if new_msgs:
chat_info = self.chatbox.get_info()
return {
'chat_name': chat_info['chat_name'],
'chat_type': chat_info['chat_type'],
'msg': new_msgs
}
else:
return {}

View File

@@ -0,0 +1,55 @@
from __future__ import annotations
from wxauto.param import (
WxParam,
)
from wxauto.languages import *
from wxauto.uiautomation import Control
class NavigationBox:
def __init__(self, control, parent):
self.control: Control = control
self.root = parent.root
self.parent = parent
self.top_control = control.GetTopLevelControl()
self.init()
def _lang(self, text: str) -> str:
return WECHAT_NAVIGATION_BOX.get(text, {WxParam.LANGUAGE: text}).get(WxParam.LANGUAGE)
def init(self):
self.my_icon = self.control.ButtonControl()
self.chat_icon = self.control.ButtonControl(Name=self._lang('聊天'))
self.contact_icon = self.control.ButtonControl(Name=self._lang('通讯录'))
self.favorites_icon = self.control.ButtonControl(Name=self._lang('收藏'))
self.files_icon = self.control.ButtonControl(Name=self._lang('聊天文件'))
self.moments_icon = self.control.ButtonControl(Name=self._lang('朋友圈'))
self.browser_icon = self.control.ButtonControl(Name=self._lang('搜一搜'))
self.video_icon = self.control.ButtonControl(Name=self._lang('视频号'))
self.stories_icon = self.control.ButtonControl(Name=self._lang('看一看'))
self.mini_program_icon = self.control.ButtonControl(Name=self._lang('小程序面板'))
self.phone_icon = self.control.ButtonControl(Name=self._lang('手机'))
self.settings_icon = self.control.ButtonControl(Name=self._lang('设置及其他'))
def switch_to_chat_page(self):
self.chat_icon.Click()
def switch_to_contact_page(self):
self.contact_icon.Click()
def switch_to_favorites_page(self):
self.favorites_icon.Click()
def switch_to_files_page(self):
self.files_icon.Click()
def switch_to_browser_page(self):
self.browser_icon.Click()
# 是否有新消息
def has_new_message(self):
from wxauto.utils.win32 import capture
rect = self.chat_icon.BoundingRectangle
bbox = rect.left, rect.top, rect.right, rect.bottom
img = capture(self.root.HWND, bbox)
return any(p[0] > p[1] and p[0] > p[2] for p in img.getdata())

265
wxauto/ui/sessionbox.py Normal file
View File

@@ -0,0 +1,265 @@
from __future__ import annotations
from wxauto.ui.component import (
CMenuWnd
)
from wxauto.param import (
WxParam,
WxResponse,
)
from wxauto.languages import *
from wxauto.utils import (
SetClipboardText,
)
from wxauto.logger import wxlog
from wxauto.uiautomation import Control
from wxauto.utils.tools import roll_into_view
from typing import (
List,
Union
)
import time
import re
class SessionBox:
def __init__(self, control, parent):
self.control: Control = control
self.root = parent.root
self.parent = parent
self.top_control = control.GetTopLevelControl()
self.init()
def _lang(self, text: str) -> str:
return WECHAT_SESSION_BOX.get(text, {WxParam.LANGUAGE: text}).get(WxParam.LANGUAGE)
def init(self):
self.searchbox = self.control.EditControl(Name=self._lang('搜索'))
self.session_list =\
self.control.ListControl(Name=self._lang('会话'), searchDepth=7)
self.archived_session_list =\
self.control.ListControl(Name=self._lang('折叠的群聊'), searchDepth=7)
def get_session(self) -> List[SessionElement]:
if self.session_list.Exists(0):
return [
SessionElement(i, self)
for i in self.session_list.GetChildren()
if i.Name != self._lang('折叠置顶聊天')
and not re.match(self._lang('re_置顶聊天'), i.Name)
]
elif self.archived_session_list.Exists(0):
return [SessionElement(i, self) for i in self.archived_session_list.GetChildren()]
else:
return []
def roll_up(self, n: int=5):
self.control.MiddleClick()
self.control.WheelUp(wheelTimes=n)
def roll_down(self, n: int=5):
self.control.MiddleClick()
self.control.WheelDown(wheelTimes=n)
def switch_chat(
self,
keywords: str,
exact: bool = False,
force: bool = False,
force_wait: Union[float, int] = 0.5
):
wxlog.debug(f"切换聊天窗口: {keywords}, {exact}, {force}, {force_wait}")
self.root._show()
sessions = self.get_session()
for session in sessions:
if (
keywords == session.name
and session.control.BoundingRectangle.height()
):
session.switch()
return keywords
self.searchbox.RightClick()
SetClipboardText(keywords)
menu = CMenuWnd(self)
menu.select('粘贴')
search_result = self.control.ListControl(RegexName='.*?IDS_FAV_SEARCH_RESULT.*?')
if force:
time.sleep(force_wait)
self.searchbox.SendKeys('{ENTER}')
return ''
t0 = time.time()
while time.time() -t0 < WxParam.SEARCH_CHAT_TIMEOUT:
results = []
search_result_items = search_result.GetChildren()
highlight_who = re.sub(r'(\s+)', r'</em>\1<em>', keywords)
for search_result_item in search_result_items:
item_name = search_result_item.Name
if (
search_result_item.ControlTypeName == 'PaneControl'
and search_result_item.TextControl(Name='聊天记录').Exists(0)
) or item_name == f'搜索 {keywords}':
break
elif (
search_result_item.ControlTypeName == 'ListItemControl'
and search_result_item.TextControl(Name=f"微信号: <em>{keywords}</em>").Exists(0)
):
wxlog.debug(f"{keywords} 匹配到微信号:{item_name}")
search_result_item.Click()
return item_name
elif (
search_result_item.ControlTypeName == 'ListItemControl'
and search_result_item.TextControl(Name=f"昵称: <em>{highlight_who}</em>").Exists(0)
):
wxlog.debug(f"{keywords} 匹配到昵称:{item_name}")
search_result_item.Click()
return item_name
elif (
search_result_item.ControlTypeName == 'ListItemControl'
and search_result_item.TextControl(Name=f"群聊名称: <em>{highlight_who}</em>").Exists(0)
):
wxlog.debug(f"{keywords} 匹配到群聊名称:{item_name}")
search_result_item.Click()
return item_name
elif (
search_result_item.ControlTypeName == 'ListItemControl'
and keywords == item_name
):
wxlog.debug(f"{keywords} 完整匹配")
search_result_item.Click()
return keywords
elif (
search_result_item.ControlTypeName == 'ListItemControl'
and keywords in item_name
):
results.append(search_result_item)
if exact:
wxlog.debug(f"{keywords} 未精准匹配返回None")
if search_result.Exists(0):
search_result.SendKeys('{Esc}')
return None
if results:
wxlog.debug(f"{keywords} 匹配到多个结果,返回第一个")
results[0].Click()
return results[0].Name
if search_result.Exists(0):
search_result.SendKeys('{Esc}')
def open_separate_window(self, name: str):
wxlog.debug(f"打开独立窗口: {name}")
sessions = self.get_session()
for session in sessions:
if session.name == name:
wxlog.debug(f"找到会话: {name}")
while session.control.BoundingRectangle.height():
try:
session.click()
session.double_click()
except:
pass
time.sleep(0.1)
else:
return WxResponse.success(data={'nickname': name})
wxlog.debug(f"未找到会话: {name}")
return WxResponse.failure('未找到会话')
def go_top(self):
wxlog.debug("回到会话列表顶部")
if self.archived_session_list.Exists(0):
self.control.ButtonControl(Name=self._lang('返回')).Click()
time.sleep(0.3)
first_session_name = self.session_list.GetChildren()[0].Name
while True:
self.control.WheelUp(wheelTimes=3)
time.sleep(0.1)
if self.session_list.GetChildren()[0].Name == first_session_name:
break
else:
first_session_name = self.session_list.GetChildren()[0].Name
class SessionElement:
def __init__(
self,
control: Control,
parent: SessionBox
):
self.root = parent.root
self.parent = parent
self.control = control
info_controls = [i for i in self.control.GetProgenyControl(3).GetChildren() if i.ControlTypeName=='TextControl']
self.name = info_controls[0].Name
self.time = info_controls[-1].Name
self.content = (
temp_control.Name
if (temp_control := control.GetProgenyControl(4, -1, control_type='TextControl'))
else None
)
self.ismute = (
True
if control.GetProgenyControl(4, 1, control_type='PaneControl')
else False
)
self.isnew = (new_tag_control := control.GetProgenyControl(2, 2)) is not None
self.new_count = 0
if self.isnew:
if new_tag_name := (new_tag_control.Name):
try:
self.new_count = int(new_tag_name)
self.ismute = False
except ValueError:
self.new_count = 999
else:
new_text = re.findall(self._lang('re_条数'), str(self.content))
if new_text:
try:
self.new_count = int(re.findall('\d+', new_text[0])[0])
except ValueError:
self.new_count = 999
self.content = self.content[len(new_text[0])+1:]
else:
self.new_count = 1
self.info = {
'name': self.name,
'time': self.time,
'content': self.content,
'isnew': self.isnew,
'new_count': self.new_count,
'ismute': self.ismute
}
def _lang(self, text: str) -> str:
return self.parent._lang(text)
def roll_into_view(self):
self.root._show()
roll_into_view(self.control.GetParentControl(), self.control)
def _click(self, right: bool=False, double: bool=False):
self.roll_into_view()
if right:
self.control.RightClick()
elif double:
self.control.DoubleClick()
else:
self.control.Click()
def click(self):
self._click()
def right_click(self):
self._click(right=True)
def double_click(self):
self._click(double=True)
def switch(self):
self.click()

8142
wxauto/uiautomation.py Normal file

File diff suppressed because it is too large Load Diff

2
wxauto/utils/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
from .win32 import *
from . import tools

102
wxauto/utils/tools.py Normal file
View File

@@ -0,0 +1,102 @@
from pathlib import Path
from datetime import datetime, timedelta
import time
import re
def get_file_dir(dir_path=None):
if dir_path is None:
dir_path = Path('.').absolute()
elif isinstance(dir_path, str):
dir_path = Path(dir_path)
dir_path.mkdir(parents=True, exist_ok=True)
return dir_path
def now_time(fmt='%Y%m%d%H%M%S%f'):
return datetime.now().strftime(fmt)
def parse_wechat_time(time_str):
"""
时间格式转换函数
Args:
time_str: 输入的时间字符串
Returns:
转换后的时间字符串
"""
time_str = time_str.replace('星期天', '星期日')
match = re.match(r'^(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$', time_str)
if match:
month, day, hour, minute, second = match.groups()
current_year = datetime.now().year
return datetime(current_year, int(month), int(day), int(hour), int(minute), int(second)).strftime('%Y-%m-%d %H:%M:%S')
match = re.match(r'^(\d{1,2}):(\d{1,2})$', time_str)
if match:
hour, minute = match.groups()
return datetime.now().strftime('%Y-%m-%d') + f' {hour}:{minute}:00'
match = re.match(r'^昨天 (\d{1,2}):(\d{1,2})$', time_str)
if match:
hour, minute = match.groups()
yesterday = datetime.now() - timedelta(days=1)
return yesterday.strftime('%Y-%m-%d') + f' {hour}:{minute}:00'
match = re.match(r'^星期([一二三四五六日]) (\d{1,2}):(\d{1,2})$', time_str)
if match:
weekday, hour, minute = match.groups()
weekday_num = ['', '', '', '', '', '', ''].index(weekday)
today_weekday = datetime.now().weekday()
delta_days = (today_weekday - weekday_num) % 7
target_day = datetime.now() - timedelta(days=delta_days)
return target_day.strftime('%Y-%m-%d') + f' {hour}:{minute}:00'
match = re.match(r'^(\d{4})年(\d{1,2})月(\d{1,2})日 (\d{1,2}):(\d{1,2})$', time_str)
if match:
year, month, day, hour, minute = match.groups()
return datetime(*[int(i) for i in [year, month, day, hour, minute]]).strftime('%Y-%m-%d %H:%M:%S')
match = re.match(r'^(\d{2})-(\d{2}) (上午|下午) (\d{1,2}):(\d{2})$', time_str)
if match:
month, day, period, hour, minute = match.groups()
current_year = datetime.now().year
hour = int(hour)
if period == '下午' and hour != 12:
hour += 12
elif period == '上午' and hour == 12:
hour = 0
return datetime(current_year, int(month), int(day), hour, int(minute)).strftime('%Y-%m-%d %H:%M:%S')
return time_str
def roll_into_view(win, ele, equal=False, bias=0):
while ele.BoundingRectangle.ycenter() < win.BoundingRectangle.top + bias or ele.BoundingRectangle.ycenter() >= win.BoundingRectangle.bottom - bias:
if ele.BoundingRectangle.ycenter() < win.BoundingRectangle.top + bias:
# 上滚动
while True:
if not ele.Exists(0) or not ele.BoundingRectangle.height():
return 'not exist'
win.WheelUp(wheelTimes=1)
time.sleep(0.1)
if equal:
if ele.BoundingRectangle.ycenter() >= win.BoundingRectangle.top + bias:
break
else:
if ele.BoundingRectangle.ycenter() > win.BoundingRectangle.top + bias:
break
elif ele.BoundingRectangle.ycenter() >= win.BoundingRectangle.bottom - bias:
# 下滚动
while True:
if not ele.Exists(0) or not ele.BoundingRectangle.height():
return 'not exist'
win.WheelDown(wheelTimes=1)
time.sleep(0.1)
if equal:
if ele.BoundingRectangle.ycenter() <= win.BoundingRectangle.bottom - bias:
break
else:
if ele.BoundingRectangle.ycenter() < win.BoundingRectangle.bottom - bias:
break
time.sleep(0.3)

302
wxauto/utils/win32.py Normal file
View File

@@ -0,0 +1,302 @@
import os
import time
import shutil
import win32ui
import win32gui
import win32api
import win32con
import win32process
import win32clipboard
import pyperclip
import ctypes
from PIL import Image
from wxauto import uiautomation as uia
def GetAllWindows():
"""
获取所有窗口的信息,返回一个列表,每个元素包含 (窗口句柄, 类名, 窗口标题)
"""
windows = []
def enum_windows_proc(hwnd, extra):
class_name = win32gui.GetClassName(hwnd) # 获取窗口类名
window_title = win32gui.GetWindowText(hwnd) # 获取窗口标题
windows.append((hwnd, class_name, window_title))
win32gui.EnumWindows(enum_windows_proc, None)
return windows
def GetCursorWindow():
x, y = win32api.GetCursorPos()
hwnd = win32gui.WindowFromPoint((x, y))
window_title = win32gui.GetWindowText(hwnd)
class_name = win32gui.GetClassName(hwnd)
return hwnd, window_title, class_name
def set_cursor_pos(x, y):
win32api.SetCursorPos((x, y))
def Click(rect):
x = (rect.left + rect.right) // 2
y = (rect.top + rect.bottom) // 2
set_cursor_pos(x, y)
win32api.mouse_event(win32con.MOUSEEVENTF_LEFTDOWN, x, y, 0, 0)
win32api.mouse_event(win32con.MOUSEEVENTF_LEFTUP, x, y, 0, 0)
def GetPathByHwnd(hwnd):
"""通过窗口句柄获取进程可执行文件路径 - 使用pywin32"""
try:
thread_id, process_id = win32process.GetWindowThreadProcessId(hwnd)
# 获取进程句柄
PROCESS_QUERY_INFORMATION = 0x0400
PROCESS_VM_READ = 0x0010
process_handle = win32api.OpenProcess(
PROCESS_QUERY_INFORMATION | PROCESS_VM_READ,
False,
process_id
)
# 获取可执行文件路径
exe_path = win32process.GetModuleFileNameEx(process_handle, 0)
# 关闭句柄
win32api.CloseHandle(process_handle)
return exe_path
except Exception as e:
print(f"Error: {e}")
return None
def GetVersionByPath(file_path):
try:
info = win32api.GetFileVersionInfo(file_path, '\\')
version = "{}.{}.{}.{}".format(win32api.HIWORD(info['FileVersionMS']),
win32api.LOWORD(info['FileVersionMS']),
win32api.HIWORD(info['FileVersionLS']),
win32api.LOWORD(info['FileVersionLS']))
except:
version = None
return version
def GetWindowRect(hwnd):
return win32gui.GetWindowRect(hwnd)
def capture(hwnd, bbox):
# 获取窗口的屏幕坐标
window_rect = win32gui.GetWindowRect(hwnd)
win_left, win_top, win_right, win_bottom = window_rect
win_width = win_right - win_left
win_height = win_bottom - win_top
# 获取窗口的设备上下文
hwndDC = win32gui.GetWindowDC(hwnd)
mfcDC = win32ui.CreateDCFromHandle(hwndDC)
saveDC = mfcDC.CreateCompatibleDC()
# 创建位图对象保存整个窗口截图
saveBitMap = win32ui.CreateBitmap()
saveBitMap.CreateCompatibleBitmap(mfcDC, win_width, win_height)
saveDC.SelectObject(saveBitMap)
# 使用PrintWindow捕获整个窗口包括被遮挡或最小化的窗口
result = ctypes.windll.user32.PrintWindow(hwnd, saveDC.GetSafeHdc(), 3)
# 转换为PIL图像
bmpinfo = saveBitMap.GetInfo()
bmpstr = saveBitMap.GetBitmapBits(True)
im = Image.frombuffer(
'RGB',
(bmpinfo['bmWidth'], bmpinfo['bmHeight']),
bmpstr, 'raw', 'BGRX', 0, 1)
# 释放资源
win32gui.DeleteObject(saveBitMap.GetHandle())
saveDC.DeleteDC()
mfcDC.DeleteDC()
win32gui.ReleaseDC(hwnd, hwndDC)
# 计算bbox相对于窗口左上角的坐标
bbox_left, bbox_top, bbox_right, bbox_bottom = bbox
# 转换为截图图像中的相对坐标
crop_left = bbox_left - win_left
crop_top = bbox_top - win_top
crop_right = bbox_right - win_left
crop_bottom = bbox_bottom - win_top
# 裁剪目标区域
cropped_im = im.crop((crop_left, crop_top, crop_right, crop_bottom))
return cropped_im
def GetText(HWND):
length = win32gui.SendMessage(HWND, win32con.WM_GETTEXTLENGTH)*2
buffer = win32gui.PyMakeBuffer(length)
win32api.SendMessage(HWND, win32con.WM_GETTEXT, length, buffer)
address, length_ = win32gui.PyGetBufferAddressAndLen(buffer[:-1])
text = win32gui.PyGetString(address, length_)[:int(length/2)]
buffer.release()
return text
def GetAllWindowExs(HWND):
if not HWND:
return
handles = []
win32gui.EnumChildWindows(
HWND, lambda hwnd, param: param.append([hwnd, win32gui.GetClassName(hwnd), GetText(hwnd)]), handles)
return handles
def FindWindow(classname=None, name=None, timeout=0) -> int:
t0 = time.time()
while True:
HWND = win32gui.FindWindow(classname, name)
if HWND:
break
if time.time() - t0 > timeout:
break
time.sleep(0.01)
return HWND
def FindTopLevelControl(classname=None, name=None, timeout=3):
hwnd = FindWindow(classname, name, timeout)
if hwnd:
return uia.ControlFromHandle(hwnd)
else:
return None
def FindWinEx(HWND, classname=None, name=None) -> list:
hwnds_classname = []
hwnds_name = []
def find_classname(hwnd, classname):
classname_ = win32gui.GetClassName(hwnd)
if classname_ == classname:
if hwnd not in hwnds_classname:
hwnds_classname.append(hwnd)
def find_name(hwnd, name):
name_ = GetText(hwnd)
if name in name_:
if hwnd not in hwnds_name:
hwnds_name.append(hwnd)
if classname:
win32gui.EnumChildWindows(HWND, find_classname, classname)
if name:
win32gui.EnumChildWindows(HWND, find_name, name)
if classname and name:
hwnds = [hwnd for hwnd in hwnds_classname if hwnd in hwnds_name]
else:
hwnds = hwnds_classname + hwnds_name
return hwnds
def ClipboardFormats(unit=0, *units):
units = list(units)
retry_count = 5
while retry_count > 0:
try:
win32clipboard.OpenClipboard()
try:
u = win32clipboard.EnumClipboardFormats(unit)
finally:
win32clipboard.CloseClipboard()
break
except Exception as e:
retry_count -= 1
units.append(u)
if u:
units = ClipboardFormats(u, *units)
return units
def ReadClipboardData():
Dict = {}
formats = ClipboardFormats()
for i in formats:
if i == 0:
continue
retry_count = 5
while retry_count > 0:
try:
win32clipboard.OpenClipboard()
try:
data = win32clipboard.GetClipboardData(i)
Dict[str(i)] = data
finally:
win32clipboard.CloseClipboard()
break
except Exception as e:
retry_count -= 1
return Dict
def SetClipboardText(text: str):
pyperclip.copy(text)
class DROPFILES(ctypes.Structure):
_fields_ = [
("pFiles", ctypes.c_uint),
("x", ctypes.c_long),
("y", ctypes.c_long),
("fNC", ctypes.c_int),
("fWide", ctypes.c_bool),
]
pDropFiles = DROPFILES()
pDropFiles.pFiles = ctypes.sizeof(DROPFILES)
pDropFiles.fWide = True
matedata = bytes(pDropFiles)
def SetClipboardFiles(paths):
for file in paths:
if not os.path.exists(file):
raise FileNotFoundError(f"file ({file}) not exists!")
files = ("\0".join(paths)).replace("/", "\\")
data = files.encode("U16")[2:]+b"\0\0"
t0 = time.time()
while True:
if time.time() - t0 > 10:
raise TimeoutError(f"设置剪贴板文件超时! --> {paths}")
try:
win32clipboard.OpenClipboard()
win32clipboard.EmptyClipboard()
win32clipboard.SetClipboardData(win32clipboard.CF_HDROP, matedata+data)
break
except:
pass
finally:
try:
win32clipboard.CloseClipboard()
except:
pass
def PasteFile(folder):
folder = os.path.realpath(folder)
if not os.path.exists(folder):
os.makedirs(folder)
t0 = time.time()
while True:
if time.time() - t0 > 10:
raise TimeoutError(f"读取剪贴板文件超时!")
try:
win32clipboard.OpenClipboard()
if win32clipboard.IsClipboardFormatAvailable(win32clipboard.CF_HDROP):
files = win32clipboard.GetClipboardData(win32clipboard.CF_HDROP)
for file in files:
filename = os.path.basename(file)
dest_file = os.path.join(folder, filename)
shutil.copy2(file, dest_file)
return True
else:
print("剪贴板中没有文件")
return False
except:
pass
finally:
win32clipboard.CloseClipboard()
def IsRedPixel(uicontrol):
rect = uicontrol.BoundingRectangle
hwnd = uicontrol.GetAncestorControl(lambda x,y:x.ClassName=='WeChatMainWndForPC').NativeWindowHandle
bbox = (rect.left, rect.top, rect.right, rect.bottom)
img = capture(hwnd, bbox)
return any(p[0] > p[1] and p[0] > p[2] for p in img.getdata())

386
wxauto/wx.py Normal file
View File

@@ -0,0 +1,386 @@
from .ui.main import (
WeChatMainWnd,
WeChatSubWnd
)
from .ui.component import (
WeChatLoginWnd
)
from .param import (
WxResponse,
WxParam,
PROJECT_NAME
)
from .logger import wxlog
from typing import (
Union,
List,
Dict,
Callable,
TYPE_CHECKING
)
from PIL import Image
from abc import ABC, abstractmethod
import threading
import traceback
import time
import sys
if TYPE_CHECKING:
from wxauto.msgs.base import Message
from wxauto.ui.sessionbox import SessionElement
class Listener(ABC):
def _listener_start(self):
wxlog.debug('开始监听')
self._listener_is_listening = True
self._listener_messages = {}
self._lock = threading.RLock()
self._listener_stop_event = threading.Event()
self._listener_thread = threading.Thread(target=self._listener_listen, daemon=True)
self._listener_thread.start()
def _listener_listen(self):
if not hasattr(self, 'listen') or not self.listen:
self.listen = {}
while not self._listener_stop_event.is_set():
try:
self._get_listen_messages()
except:
wxlog.debug(f'监听消息失败:{traceback.format_exc()}')
time.sleep(WxParam.LISTEN_INTERVAL)
def _safe_callback(self, callback, msg, chat):
try:
with self._lock:
callback(msg, chat)
except Exception as e:
wxlog.debug(f"监听消息回调发生错误:{traceback.format_exc()}")
def _listener_stop(self):
self._listener_is_listening = False
self._listener_stop_event.set()
self._listener_thread.join()
@abstractmethod
def _get_listen_messages(self):
...
class Chat:
"""微信聊天窗口实例"""
def __init__(self, core: WeChatSubWnd=None):
self.core = core
self.who = self.core.nickname
def __repr__(self):
return f'<{PROJECT_NAME} - {self.__class__.__name__} object("{self.core.nickname}")>'
@property
def chat_type(self):
return self.core.chatbox.get_info().get('chat_type', None)
def Show(self):
"""显示窗口"""
self.core._show()
def ChatInfo(self) -> Dict[str, str]:
"""获取聊天窗口信息
Returns:
dict: 聊天窗口信息
"""
return self.core.chatbox.get_info()
def SendMsg(
self,
msg: str,
who: str=None,
clear: bool=True,
at: Union[str, List[str]]=None,
exact: bool=False,
) -> WxResponse:
"""发送消息
Args:
msg (str): 消息内容
who (str, optional): 发送对象,不指定则发送给当前聊天对象,**当子窗口时,该参数无效**
clear (bool, optional): 发送后是否清空编辑框.
at (Union[str, List[str]], optional): @对象,不指定则不@任何人
exact (bool, optional): 搜索who好友时是否精确匹配默认False**当子窗口时,该参数无效**
Returns:
WxResponse: 是否发送成功
"""
return self.core.send_msg(msg, who, clear, at, exact)
def SendFiles(
self,
filepath,
who=None,
exact=False
) -> WxResponse:
"""向当前聊天窗口发送文件
Args:
filepath (str|list): 要复制文件的绝对路径
who (str): 发送对象,不指定则发送给当前聊天对象,**当子窗口时,该参数无效**
exact (bool, optional): 搜索who好友时是否精确匹配默认False**当子窗口时,该参数无效**
Returns:
WxResponse: 是否发送成功
"""
return self.core.send_files(filepath, who, exact)
def LoadMoreMessage(self, interval: float=0.3) -> WxResponse:
"""加载更多消息
Args:
interval (float, optional): 滚动间隔单位秒默认0.3
"""
return self.core.load_more_message(interval)
def GetAllMessage(self) -> List['Message']:
"""获取当前聊天窗口的所有消息
Returns:
List[Message]: 当前聊天窗口的所有消息
"""
return self.core.get_msgs()
def GetNewMessage(self) -> List['Message']:
"""获取当前聊天窗口的新消息
Returns:
List[Message]: 当前聊天窗口的新消息
"""
if not hasattr(self, '_last_chat'):
self._last_chat = self.ChatInfo().get('chat_name')
if (_last_chat := self.ChatInfo().get('chat_name')) != self._last_chat:
self._last_chat = _last_chat
self.core.chatbox._update_used_msg_ids()
return []
return self.core.get_new_msgs()
def GetMessageById(self, msg_id: str) -> 'Message':
"""根据消息id获取消息
Args:
msg_id (str): 消息id
Returns:
Message: 消息对象
"""
return self.core.get_msg_by_id(msg_id)
def GetGroupMembers(self) -> List[str]:
"""获取当前聊天群成员
Returns:
list: 当前聊天群成员列表
"""
return self.core.get_group_members()
def Close(self) -> None:
"""关闭微信窗口"""
self.core.close()
class WeChat(Chat, Listener):
"""微信主窗口实例"""
def __init__(
self,
debug: bool=False,
**kwargs
):
hwnd = None
if 'hwnd' in kwargs:
hwnd = kwargs['hwnd']
self.core = WeChatMainWnd(hwnd)
self.nickname = self.core.nickname
self.SessionBox = self.core.sessionbox
self.listen = {}
if debug:
wxlog.set_debug(True)
wxlog.debug('Debug mode is on')
self._listener_start()
self.Show()
def _get_listen_messages(self):
try:
sys.stdout.flush()
except:
pass
temp_listen = self.listen.copy()
for who in temp_listen:
chat, callback = temp_listen.get(who, (None, None))
try:
if chat is None or not chat.core.exists():
wxlog.debug(f"窗口 {who} 已关闭,移除监听")
self.RemoveListenChat(who, close_window=False)
continue
except:
continue
with self._lock:
msgs = chat.GetNewMessage()
for msg in msgs:
wxlog.debug(f"[{msg.attr} {msg.type}]获取到新消息:{who} - {msg.content}")
chat.Show()
self._safe_callback(callback, msg, chat)
def GetSession(self) -> List['SessionElement']:
"""获取当前会话列表
Returns:
List[SessionElement]: 当前会话列表
"""
return self.core.sessionbox.get_session()
def ChatWith(
self,
who: str,
exact: bool=False,
force: bool=False,
force_wait: Union[float, int] = 0.5
):
"""打开聊天窗口
Args:
who (str): 要聊天的对象
exact (bool, optional): 搜索who好友时是否精确匹配默认False
force (bool, optional): 不论是否匹配到都强制切换若启用则exact参数无效默认False
> 注force原理为输入搜索关键字后在等待`force_wait`秒后不判断结果直接回车,谨慎使用
force_wait (Union[float, int], optional): 强制切换时等待时间默认0.5秒
"""
return self.core.switch_chat(who, exact, force, force_wait)
def AddListenChat(
self,
nickname: str,
callback: Callable[['Message', str], None],
) -> WxResponse:
"""添加监听聊天将聊天窗口独立出去形成Chat对象子窗口用于监听
Args:
nickname (str): 要监听的聊天对象
callback (Callable[[Message, str], None]): 回调函数,参数为(Message对象, 聊天名称)
"""
if nickname in self.listen:
return WxResponse.failure('该聊天已监听')
subwin = self.core.open_separate_window(nickname)
if subwin is None:
return WxResponse.failure('找不到聊天窗口')
name = subwin.nickname
chat = Chat(subwin)
self.listen[name] = (chat, callback)
return chat
def StopListening(self, remove: bool = True) -> None:
"""停止监听"""
while self._listener_thread.is_alive():
self._listener_stop()
if remove:
listen = self.listen.copy()
for who in listen:
self.RemoveListenChat(who)
def StartListening(self) -> None:
if not self._listener_thread.is_alive():
self._listener_start()
def RemoveListenChat(
self,
nickname: str,
close_window: bool = True
) -> WxResponse:
"""移除监听聊天
Args:
nickname (str): 要移除的监听聊天对象
close_window (bool, optional): 是否关闭聊天窗口. Defaults to True.
Returns:
WxResponse: 执行结果
"""
if nickname not in self.listen:
return WxResponse.failure('未找到监听对象')
chat, _ = self.listen[nickname]
if close_window:
chat.Close()
del self.listen[nickname]
return WxResponse.success()
def GetNextNewMessage(self, filter_mute=False) -> Dict[str, List['Message']]:
"""获取下一个新消息
Args:
filter_mute (bool, optional): 是否过滤掉免打扰消息. Defaults to False.
Returns:
Dict[str, List['Message']]: 消息列表
"""
return self.core.get_next_new_message(filter_mute)
def SwitchToChat(self) -> None:
"""切换到聊天页面"""
self.core.navigation.chat_icon.Click()
def SwitchToContact(self) -> None:
"""切换到联系人页面"""
self.core.navigation.contact_icon.Click()
def GetSubWindow(self, nickname: str) -> 'Chat':
"""获取子窗口实例
Args:
nickname (str): 要获取的子窗口的昵称
Returns:
Chat: 子窗口实例
"""
if subwin := self.core.get_sub_wnd(nickname):
return Chat(subwin)
def GetAllSubWindow(self) -> List['Chat']:
"""获取所有子窗口实例
Returns:
List[Chat]: 所有子窗口实例
"""
return [Chat(subwin) for subwin in self.core.get_all_sub_wnds()]
def KeepRunning(self):
"""保持运行"""
while not self._listener_stop_event.is_set():
try:
time.sleep(1)
except KeyboardInterrupt:
wxlog.debug(f'wxauto("{self.nickname}") shutdown')
self.StopListening(True)
break
class WeChatLogin:
def ClearHint(self):
if loginWnd := WeChatLoginWnd() :
return loginWnd.clear_hint()
else:
return False
def Login(self):
"""登录"""
if loginWnd := WeChatLoginWnd() :
return loginWnd.login()
else:
return False
def GetQRCode(self) -> Image.Image:
"""获取二维码"""
if loginWnd := WeChatLoginWnd() :
return loginWnd.get_qrcode()
else:
return None

View File

@@ -0,0 +1,295 @@
# 微信群岗位信息提取工具 - 多群组版本使用说明
## 版本更新 v1.1
### 新功能
1. **支持多群组监听** - 可同时监听多个微信群
2. **UUID岗位ID** - 每个岗位使用唯一的UUID标识
3. **来源群组显示** - 界面显示岗位来自哪个群组
4. **打包时配置API** - API密钥在打包前配置更安全
## 配置说明
### 1. 配置文件 (config.json)
```json
{
"target_groups": [
"招聘信息群1",
"招聘信息群2",
"求职交流群"
],
"bailian_api_url": "https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation",
"api_key": "your-api-key-here",
"output_file": "jobs_data.json"
}
```
### 2. 配置步骤
#### 方式一:使用配置工具(推荐)
```bash
# 双击运行
配置API密钥.bat
# 按提示输入API密钥
```
#### 方式二:手动编辑
1. 打开 `config.json`
2. 修改 `target_groups` 数组,添加要监听的群组
3. 修改 `api_key` 为你的百炼API密钥
4. 保存文件
## 使用流程
### 开发/测试阶段
1. **配置API密钥**
```bash
# 运行配置工具
配置API密钥.bat
```
2. **配置目标群组**
- 编辑 `config.json`
- 在 `target_groups` 数组中添加群组名称
3. **运行程序**
```bash
# 直接运行Python版本
python job_extractor_gui.py
# 或使用启动工具
启动工具.bat → 选择 [1]
```
4. **在GUI中调整群组**
- 启动程序后可以在界面中修改群组列表
- 支持逗号、分号分隔
- 点击"保存群组配置"保存
### 打包部署阶段
1. **确认配置**
- 确保 `config.json` 中的 `api_key` 已正确配置
- 确认 `target_groups` 包含默认要监听的群组
2. **打包程序**
```bash
# 使用修复脚本打包
修复并重新打包.bat
# 或使用标准打包
build.bat
```
3. **分发使用**
- 将 `dist\微信岗位提取工具.exe` 分发给用户
- 用户可以在GUI中修改群组列表
- API密钥已内置无需用户配置
## 界面说明
```
┌─────────────────────────────────────────────────────┐
│ 配置 │
│ 目标群组: [招聘群1, 招聘群2, 求职群] [保存群组配置]│
│ (多个群组用逗号分隔) │
│ API密钥: sk-46cb053d...1c81a (已配置) │
├─────────────────────────────────────────────────────┤
│ [开始任务] [停止任务] [清空列表] [导出数据] │
├─────────────────────────────────────────────────────┤
│ 状态: 运行中 (3个群组) 已提取岗位: 15 │
├─────────────────────────────────────────────────────┤
│ 岗位列表 │
│ ┌─────────────────────────────────────────────────┐│
│ │序号│来源群组│岗位│公司│地点│薪资│联系│时间 │││
│ │ 1 │招聘群1│...│...│...│...│...│... │││
│ │ 2 │招聘群2│...│...│...│...│...│... │││
│ │ 3 │求职群 │...│...│...│...│...│... │││
│ └─────────────────────────────────────────────────┘│
├─────────────────────────────────────────────────────┤
│ 运行日志 │
│ [10:30:00] 正在连接微信... │
│ [10:30:01] ✓ 已连接微信 │
│ [10:30:05] [招聘群1] 收到消息 - 发送者: 张三 │
│ [10:30:06] [招聘群1] ✓ 提取到岗位信息 │
│ [10:30:10] [招聘群2] 收到消息 - 发送者: 李四 │
└─────────────────────────────────────────────────────┘
```
## 功能特点
### 1. 多群组监听
- 同时监听多个微信群
- 每个群组独立处理消息
- 自动标记消息来源
### 2. 群组配置
支持多种分隔符:
- 逗号:`群1, 群2, 群3`
- 中文逗号:`群1群2群3`
- 分号:`群1; 群2; 群3`
- 换行:每行一个群组
### 3. UUID标识
每个岗位都有唯一的UUID
```json
{
"_id": "550e8400-e29b-41d4-a716-446655440000",
"job_name": "Python开发",
"_metadata": {
"group_name": "招聘信息群1",
...
}
}
```
### 4. 来源追踪
- 界面显示岗位来源群组
- 详情中显示完整来源信息
- 日志中标记群组名称
## 数据格式
### 岗位数据结构
```json
{
"_id": "uuid-string",
"job_name": "岗位名称",
"job_description": "岗位描述",
"job_location": "工作地点",
"salary_min": 15000,
"salary_max": 25000,
"company_name": "公司名称",
"contact_person": "联系人",
"contact_info": "联系方式",
"_metadata": {
"source": "wechat_group",
"group_name": "招聘信息群1",
"sender": "HR小王",
"extract_time": "2026-02-11 14:30:00",
"original_message": "原始消息内容..."
}
}
```
## 常见问题
### Q: 如何添加新的群组?
A: 两种方式:
1. 在GUI界面的群组输入框中添加用逗号分隔
2. 编辑 `config.json`,在 `target_groups` 数组中添加
### Q: 某个群组监听失败怎么办?
A:
- 检查群组名称是否完全正确(区分大小写)
- 确保该群在微信会话列表中
- 查看日志了解具体错误信息
- 其他群组不受影响,会继续监听
### Q: 如何修改API密钥
A:
- 开发阶段:运行 `配置API密钥.bat` 或编辑 `config.json`
- 打包后:需要重新配置并打包
### Q: 可以监听多少个群组?
A: 理论上没有限制但建议不超过10个以保证性能
### Q: 如何区分不同群组的岗位?
A:
- 界面列表中有"来源群组"列
- 双击查看详情可看到完整来源信息
- 导出的JSON数据中包含 `group_name` 字段
### Q: UUID有什么用
A:
- 唯一标识每个岗位
- 便于数据去重
- 方便数据库存储和查询
- 支持数据同步和更新
## 最佳实践
### 1. 群组命名
建议使用清晰的群组名称:
- ✓ "北京招聘信息群"
- ✓ "Python开发求职群"
- ✗ "群聊" (太模糊)
### 2. 监听策略
- 优先监听活跃度高的群组
- 定期检查群组名称是否变更
- 及时移除不活跃的群组
### 3. 数据管理
- 定期导出数据备份
- 使用UUID进行数据去重
- 按来源群组分类整理
### 4. 性能优化
- 不要监听过多群组
- 定期清理历史数据
- 关闭不需要的群组监听
## 技术说明
### 多群组实现
```python
# 为每个群组创建独立的监听
for group_name in groups:
def make_callback(gname):
return lambda msg, chat: self.on_message(msg, chat, gname)
wx.AddListenChat(
nickname=group_name,
callback=make_callback(group_name)
)
```
### UUID生成
```python
import uuid
# 为每个岗位生成唯一ID
job_id = str(uuid.uuid4())
job_info["_id"] = job_id
```
## 更新日志
### v1.1 (2026-02-11)
- ✓ 支持多群组同时监听
- ✓ 使用UUID作为岗位ID
- ✓ 界面显示来源群组
- ✓ API密钥打包时配置
- ✓ 优化日志显示
### v1.0 (2026-02-11)
- 初始版本
- 单群组监听
- 基础功能实现
## 下一步计划
- [ ] 群组管理界面(添加/删除/启用/禁用)
- [ ] 按群组筛选岗位
- [ ] 群组统计信息
- [ ] 数据去重功能
- [ ] 导出Excel格式
- [ ] 数据库存储支持

111
岗位提取使用说明.md Normal file
View File

@@ -0,0 +1,111 @@
# 微信群岗位信息提取工具使用说明
## 功能说明
本工具可以自动监听指定微信群的消息使用阿里云百炼API智能提取招聘岗位信息并保存为结构化JSON数据。
## 提取的信息字段
- **job_name**: 工作名称
- **job_description**: 工作描述
- **job_location**: 工作地点
- **salary_min**: 月薪最低(元)
- **salary_max**: 月薪最高(元)
- **company_name**: 公司名称
- **contact_person**: 联系人
- **contact_info**: 联系方式
## 使用步骤
### 1. 安装依赖
```bash
pip install -e .
pip install requests
```
### 2. 配置文件
编辑 `config.json` 文件,配置以下参数:
```json
{
"target_group": "招聘信息群",
"bailian_api_url": "https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation",
"api_key": "sk-46cb053d75eb4ad88713917ba0f1c81a",
"check_interval": 5,
"output_file": "jobs_data.json"
}
```
参数说明:
- `target_group`: 要监听的微信群名称(必须完全匹配)
- `bailian_api_url`: 百炼API地址
- `api_key`: 百炼API密钥
- `check_interval`: 消息检查间隔(秒)
- `output_file`: 输出文件名
### 3. 运行程序
确保微信已登录并打开主窗口,然后运行:
```bash
python job_extractor.py
```
### 4. 停止监听
`Ctrl+C` 停止程序
## 输出文件格式
提取的岗位信息会保存到 `jobs_data.json` 文件中,格式示例:
```json
[
{
"job_name": "Python开发工程师",
"job_description": "负责后端开发熟悉Django/Flask框架",
"job_location": "北京市朝阳区",
"salary_min": 15000,
"salary_max": 25000,
"company_name": "某科技公司",
"contact_person": "张经理",
"contact_info": "13800138000",
"_metadata": {
"source": "wechat_group",
"group_name": "招聘信息群",
"sender": "HR小王",
"extract_time": "2026-02-11 10:30:00",
"original_message": "招聘Python开发..."
}
}
]
```
## 注意事项
1. 确保微信版本为 3.9.x
2. 微信必须保持登录状态
3. 群名称必须完全匹配(区分大小写)
4. API密钥请妥善保管不要泄露
5. 程序会持续运行直到手动停止
## 常见问题
### Q: 提示"找不到聊天窗口"
A: 请检查 `config.json` 中的 `target_group` 是否与微信群名称完全一致
### Q: API调用失败
A: 请检查:
- API密钥是否正确
- 网络连接是否正常
- API额度是否充足
### Q: 没有提取到岗位信息
A: 可能原因:
- 消息内容不包含招聘信息
- 消息格式不规范
- API识别失败
可以查看控制台输出的详细日志进行排查

96
快速修复指南.txt Normal file
View File

@@ -0,0 +1,96 @@
╔════════════════════════════════════════════════════════════╗
║ 微信岗位提取工具 - COM错误快速修复指南 ║
╚════════════════════════════════════════════════════════════╝
【问题】
打包后的exe运行时报错
[WinError -2147221008] 尚未调用 CoInitialize。
【原因】
wxauto使用Windows COM组件在多线程环境需要初始化。
【解决方案 - 三选一】
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
方案1一键修复推荐
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
双击运行:修复并重新打包.bat
脚本会自动:
✓ 安装pywin32
✓ 清理旧文件
✓ 重新打包
✓ 生成新的exe
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
方案2使用启动工具
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1. 双击:启动工具.bat
2. 选择:[6] 安装/更新依赖
3. 等待安装完成
4. 选择:[5] 打包成 EXE 文件
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
方案3手动修复
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
打开命令行,依次执行:
1. 安装依赖
pip install pywin32
2. 验证修复
python test_com_fix.py
3. 重新打包
pyinstaller build_exe.spec
4. 测试运行
dist\微信岗位提取工具.exe
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
【验证步骤】
1. 确保微信3.9已登录
2. 运行打包后的exe
3. 配置目标群组和API密钥
4. 点击"开始任务"
5. 查看是否能正常连接微信
如果能正常连接,说明修复成功!✓
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
【仍有问题?】
1. 查看详细文档:
- 打包问题修复说明.md
- GUI版本使用说明.md
2. 检查环境:
- Python版本3.9+
- 微信版本3.9.x
- 系统Windows 10/11
3. 常见问题:
- 杀毒软件拦截 → 添加白名单
- 权限不足 → 以管理员运行
- 缺少DLL → 安装VC++ Redistributable
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
【不想打包?】
直接运行Python版本
python job_extractor_gui.py
或使用启动工具:
双击:启动工具.bat → 选择 [1]
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
更新时间2026-02-11
版本v1.0

198
快速开始.md Normal file
View File

@@ -0,0 +1,198 @@
# 快速开始指南
## 5分钟上手微信群岗位提取工具
### 第一步:安装依赖(首次使用)
```bash
# 双击运行
启动工具.bat
# 选择 [6] 安装/更新依赖
```
或手动安装:
```bash
pip install -e .
pip install requests pywin32
```
### 第二步配置API密钥
```bash
# 双击运行
配置API密钥.bat
# 输入你的百炼API密钥
```
### 第三步:配置监听群组
编辑 `config.json`
```json
{
"target_groups": [
"你的招聘群1",
"你的招聘群2"
],
"api_key": "已配置的密钥"
}
```
### 第四步:运行程序
#### 方式A直接运行开发测试
```bash
# 双击运行
启动工具.bat
# 选择 [1] 启动 GUI 版本
```
#### 方式B打包成EXE分发部署
```bash
# 双击运行
修复并重新打包.bat
# 等待打包完成
# 运行 dist\微信岗位提取工具.exe
```
### 第五步:开始提取
1. 确保微信已登录
2. 在程序界面点击"开始任务"
3. 等待连接微信
4. 查看提取的岗位信息
## 界面操作
### 配置区域
- **目标群组**:输入要监听的群组,多个用逗号分隔
- **保存群组配置**:保存修改的群组列表
### 控制按钮
- **开始任务**:开始监听微信群
- **停止任务**:停止监听
- **清空列表**:清空所有岗位数据
- **导出数据**导出为JSON文件
### 岗位列表
- 显示所有提取的岗位
- 双击查看详细信息
- 包含来源群组、岗位、公司、薪资等
### 运行日志
- 显示程序运行状态
- 消息接收情况
- 提取结果
## 常见场景
### 场景1个人使用测试
```bash
1. 安装依赖
2. 配置API密钥
3. 编辑config.json添加群组
4. 运行python job_extractor_gui.py
5. 点击"开始任务"
```
### 场景2团队部署打包
```bash
1. 配置好config.json包含API密钥和默认群组
2. 运行:修复并重新打包.bat
3. 分发dist\微信岗位提取工具.exe
4. 用户可在界面修改群组列表
```
### 场景3多群组监听
```json
// config.json
{
"target_groups": [
"北京招聘群",
"上海招聘群",
"深圳招聘群",
"Python开发群",
"前端求职群"
]
}
```
## 验证清单
使用前请确认:
- [ ] Python 3.9+ 已安装
- [ ] 微信 3.9.x 已登录
- [ ] 依赖包已安装
- [ ] API密钥已配置
- [ ] 群组名称正确(完全匹配)
- [ ] 群组在微信会话列表中
## 故障排查
### 问题:连接微信失败
```
解决:
1. 确认微信已登录
2. 确认微信版本为3.9.x
3. 重启微信后再试
```
### 问题:添加监听失败
```
解决:
1. 检查群组名称是否正确
2. 确认群组在会话列表中
3. 尝试先在微信中打开该群
```
### 问题API调用失败
```
解决:
1. 检查API密钥是否正确
2. 确认网络连接正常
3. 检查API额度是否充足
```
### 问题打包后报COM错误
```
解决:
1. 运行:修复并重新打包.bat
2. 确保已安装pywin32
3. 查看:打包问题修复说明.md
```
## 下一步
- 查看 [多群组版本使用说明.md](多群组版本使用说明.md) 了解详细功能
- 查看 [打包问题修复说明.md](打包问题修复说明.md) 解决打包问题
- 查看 [项目说明.md](项目说明.md) 了解项目结构
## 获取帮助
遇到问题?查看这些文档:
1. **快速修复指南.txt** - 常见问题快速解决
2. **多群组版本使用说明.md** - 完整功能说明
3. **打包问题修复说明.md** - 打包相关问题
4. **GUI版本使用说明.md** - 界面操作说明
## 提示
- 💡 首次使用建议先测试单个群组
- 💡 群组名称必须完全匹配(区分大小写)
- 💡 API密钥打包后无法修改需重新打包
- 💡 建议定期导出数据备份
- 💡 监听的群组不要太多建议≤10个
---
开始使用吧!祝你提取顺利!🎉

159
打包问题修复说明.md Normal file
View File

@@ -0,0 +1,159 @@
# 打包问题修复说明
## 问题描述
打包后的exe运行时报错
```
[WinError -2147221008] 尚未调用 CoInitialize。
```
## 问题原因
这是因为wxauto使用了Windows的COM组件UIAutomation在多线程环境中需要显式初始化COM组件。打包后的exe环境与开发环境不同需要手动初始化。
## 解决方案
### 1. 安装pywin32
```bash
pip install pywin32
```
### 2. 代码修改
`job_extractor_gui.py` 中:
1. 导入pythoncom模块
```python
import pythoncom
```
2. 在线程函数中初始化COM
```python
def run_task(self):
# 初始化COM组件每个线程都需要
pythoncom.CoInitialize()
try:
# 原有代码...
pass
finally:
# 清理COM组件
pythoncom.CoUninitialize()
```
### 3. 更新打包配置
`build_exe.spec` 中添加pywin32相关模块
```python
hiddenimports=[
# ... 其他模块
'pythoncom',
'pywintypes',
'win32com',
'win32com.client',
'win32api',
'win32con',
'win32gui',
'win32process',
],
```
## 重新打包步骤
### 方法一:使用批处理脚本(推荐)
1. 双击运行 `build.bat`
2. 脚本会自动检查并安装pywin32
3. 等待打包完成
### 方法二:手动打包
```bash
# 1. 安装依赖
pip install pywin32 pyinstaller
# 2. 清理旧文件
rmdir /s /q build dist
# 3. 打包
pyinstaller build_exe.spec
# 4. 测试
dist\微信岗位提取工具.exe
```
## 验证修复
1. 运行打包后的exe
2. 配置目标群组和API密钥
3. 点击"开始任务"
4. 如果能正常连接微信并监听,说明修复成功
## 其他可能的问题
### 问题1缺少DLL文件
如果提示缺少某些DLL文件可能需要
- 安装 Visual C++ Redistributable
- 或使用 `--collect-all pywin32` 选项
### 问题2杀毒软件拦截
某些杀毒软件可能会拦截打包后的exe
- 添加到白名单
- 或临时关闭杀毒软件
### 问题3权限问题
如果提示权限不足:
- 以管理员身份运行
- 检查文件夹权限
## 测试清单
打包完成后,请测试以下功能:
- [ ] 程序能正常启动
- [ ] 配置能正常保存
- [ ] 能连接微信
- [ ] 能添加监听
- [ ] 能接收消息
- [ ] 能提取岗位信息
- [ ] 能显示在列表中
- [ ] 能查看详情
- [ ] 能导出数据
## 技术说明
### COM组件初始化
COMComponent Object Model是Windows的组件技术。使用COM组件的程序需要
1. **单线程公寓STA**:主线程自动初始化
2. **多线程公寓MTA**:每个线程需要手动初始化
wxauto使用UIAutomation基于COM在GUI程序的工作线程中需要
- 调用 `pythoncom.CoInitialize()` 初始化
- 调用 `pythoncom.CoUninitialize()` 清理
### PyInstaller打包
PyInstaller打包时需要注意
- 显式声明隐藏导入hiddenimports
- 包含数据文件datas
- 收集所有相关包collect-all
## 参考资料
- [PyWin32 文档](https://github.com/mhammond/pywin32)
- [PyInstaller 文档](https://pyinstaller.org/)
- [COM 初始化](https://docs.microsoft.com/en-us/windows/win32/api/combaseapi/nf-combaseapi-coinitialize)
## 更新日志
### 2026-02-11
- 修复COM组件初始化问题
- 添加pywin32依赖
- 更新打包配置
- 完善错误处理

266
项目说明.md Normal file
View File

@@ -0,0 +1,266 @@
# 微信群岗位信息提取工具
## 项目简介
这是一个基于wxauto和阿里云百炼API的智能招聘信息提取工具可以自动监听微信群消息提取结构化的岗位信息。
## 项目结构
```
.
├── wxauto/ # wxauto核心库
├── job_extractor.py # 命令行版本
├── job_extractor_gui.py # GUI版本推荐
├── test_api.py # API测试工具
├── view_jobs.py # 数据查看工具
├── config.json # 配置文件
├── jobs_data.json # 岗位数据(自动生成)
├── build_exe.py # 打包脚本Python
├── build_exe.spec # PyInstaller配置
├── build.bat # 打包脚本(批处理)
├── run_gui.bat # 快速启动脚本
├── requirements.txt # 依赖列表
├── 岗位提取使用说明.md # 命令行版使用说明
├── GUI版本使用说明.md # GUI版使用说明
└── 项目说明.md # 本文件
```
## 两个版本对比
### 命令行版本 (job_extractor.py)
优点:
- 轻量级,资源占用少
- 适合服务器部署
- 日志输出清晰
缺点:
- 需要命令行操作
- 无可视化界面
- 不够直观
### GUI版本 (job_extractor_gui.py) ⭐推荐
优点:
- 图形化界面,操作简单
- 实时显示岗位列表
- 可查看岗位详情
- 支持数据导出
- 可打包成exe独立运行
缺点:
- 资源占用稍多
- 打包后文件较大
## 快速开始
### 1. 安装依赖
```bash
# 安装wxauto
pip install -e .
# 安装其他依赖
pip install requests
# 如需打包exe
pip install pyinstaller
```
### 2. 配置
编辑 `config.json`
```json
{
"target_group": "招聘信息群",
"bailian_api_url": "https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation",
"api_key": "your-api-key-here",
"output_file": "jobs_data.json"
}
```
### 3. 运行
#### 方式一GUI版本推荐
```bash
# 直接运行
python job_extractor_gui.py
# 或双击
run_gui.bat
```
#### 方式二:命令行版本
```bash
python job_extractor.py
```
### 4. 打包成EXE可选
```bash
# 方式一:使用批处理脚本
双击 build.bat
# 方式二使用Python脚本
python build_exe.py
# 方式三直接使用PyInstaller
pyinstaller build_exe.spec
```
生成的exe文件在 `dist` 目录下。
## 功能说明
### 核心功能
1. **微信群监听**
- 自动连接微信
- 监听指定群组消息
- 实时处理新消息
2. **智能信息提取**
- 使用百炼API分析消息
- 提取结构化岗位信息
- 支持多种消息格式
3. **数据管理**
- 自动保存岗位数据
- JSON格式存储
- 支持数据导出
### 提取的信息
- 工作名称 (job_name)
- 工作描述 (job_description)
- 工作地点 (job_location)
- 月薪最低 (salary_min)
- 月薪最高 (salary_max)
- 公司名称 (company_name)
- 联系人 (contact_person)
- 联系方式 (contact_info)
### 元数据
每条岗位信息还包含:
- 来源群组
- 发送者
- 提取时间
- 原始消息
## 使用场景
1. **HR招聘**
- 自动收集招聘信息
- 整理岗位数据
- 分析市场行情
2. **求职者**
- 监控招聘群
- 及时获取岗位信息
- 筛选合适机会
3. **猎头公司**
- 批量收集岗位
- 建立岗位数据库
- 提高工作效率
## 技术栈
- **Python 3.9+**
- **wxauto** - 微信自动化
- **tkinter** - GUI界面
- **requests** - HTTP请求
- **阿里云百炼API** - 智能信息提取
- **PyInstaller** - 打包工具
## 系统要求
- 操作系统Windows 10/11
- 微信版本3.9.x
- Python版本3.9+
- 网络需要访问百炼API
## 注意事项
1. **合规使用**
- 仅用于学习和个人使用
- 遵守微信使用规范
- 不得用于商业用途
2. **隐私保护**
- 妥善保管API密钥
- 注意数据安全
- 不要泄露他人信息
3. **稳定性**
- 保持微信登录状态
- 确保网络连接稳定
- 定期备份数据
## 常见问题
### 打包相关
#### Q: 打包后运行报错 "尚未调用 CoInitialize"
A: 这是COM组件初始化问题已在新版本中修复。解决方法
1. 运行 `修复并重新打包.bat` 自动修复并重新打包
2. 或手动安装 `pip install pywin32` 后重新打包
3. 详见 [打包问题修复说明.md](打包问题修复说明.md)
#### Q: 如何验证修复是否成功
A: 运行测试脚本:
```bash
python test_com_fix.py
```
如果所有测试通过,说明修复成功。
详见各版本的使用说明文档:
- [命令行版使用说明](岗位提取使用说明.md)
- [GUI版使用说明](GUI版本使用说明.md)
## 测试工具
### 1. API测试
```bash
python test_api.py
```
测试百炼API连接和提取功能。
### 2. 数据查看
```bash
python view_jobs.py
```
查看已提取的岗位数据,包括统计信息。
## 开发计划
- [ ] 支持多群组同时监听
- [ ] 添加岗位筛选功能
- [ ] 支持导出Excel格式
- [ ] 添加数据统计图表
- [ ] 支持自定义提取字段
- [ ] 添加消息推送功能
## 更新日志
### v1.0 (2026-02-11)
- 初始版本发布
- 支持命令行和GUI两种模式
- 集成百炼API智能提取
- 支持打包成exe
## 许可证
本项目仅供学习交流使用,请勿用于商业用途。
## 免责声明
本工具基于UIAutomation技术开发仅用于技术交流学习。使用本工具产生的任何法律纠纷均与作者无关。请遵守相关法律法规和平台使用规范。