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:
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
wxauto_logs
|
||||
wxauto文件下载
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
msgs.txt
|
||||
__pycache__/
|
||||
*.ipynb
|
||||
*.bat
|
||||
@AutomationLog.txt
|
||||
177
GUI版本使用说明.md
Normal file
177
GUI版本使用说明.md
Normal 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
201
LICENSE
Normal 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
123
README.md
Normal file
@@ -0,0 +1,123 @@
|
||||
[](https://plus.wxauto.org)
|
||||
|
||||
# wxautoV2版本
|
||||
|
||||
**文档**:
|
||||
[使用文档](https://docs.wxauto.org) |
|
||||
[云服务器wxauto部署指南](https://docs.wxauto.org/other/deploy)
|
||||
|
||||
| 环境 | 版本 |
|
||||
| :----: | :--: |
|
||||
| OS | [](https://www.microsoft.com/) |
|
||||
| 微信 | [](https://pan.baidu.com/s/1FvSw0Fk54GGvmQq8xSrNjA?pwd=vsmj) |
|
||||
| Python | [](https://www.python.org/)|
|
||||
|
||||
|
||||
|
||||
[](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)
|
||||
0
README_岗位提取工具.md
Normal file
0
README_岗位提取工具.md
Normal file
169
README_打包修复.md
Normal file
169
README_打包修复.md
Normal 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
72
auto_send_msg.py
Normal 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
80
build_exe.py
Normal 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
85
build_exe.spec
Normal 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
9
config.json
Normal 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
31
get_current_user.py
Normal 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
69
get_history.py
Normal 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
223
job_extractor.py
Normal 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
622
job_extractor_gui.py
Normal 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
19
jobs_data.json
Normal 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
24
pyproject.toml
Normal 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
89
receive_file_transfer.py
Normal 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
4
requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
requests>=2.31.0
|
||||
pillow>=10.0.0
|
||||
pyinstaller>=6.0.0
|
||||
pywin32>=306
|
||||
22
setup.cfg
Normal file
22
setup.cfg
Normal 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
141
test_api.py
Normal 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
114
test_com_fix.py
Normal 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
116
view_jobs.py
Normal 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
16
wxauto/__init__.py
Normal 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
5
wxauto/exceptions.py
Normal file
@@ -0,0 +1,5 @@
|
||||
class WxautoOCRError(Exception):
|
||||
...
|
||||
|
||||
class NetWorkError(Exception):
|
||||
...
|
||||
294
wxauto/languages.py
Normal file
294
wxauto/languages.py
Normal 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
128
wxauto/logger.py
Normal 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
75
wxauto/msgs/__init__.py
Normal 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
85
wxauto/msgs/attr.py
Normal 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
185
wxauto/msgs/base.py
Normal 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
73
wxauto/msgs/friend.py
Normal 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
133
wxauto/msgs/msg.py
Normal 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
67
wxauto/msgs/self.py
Normal 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
222
wxauto/msgs/type.py
Normal 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
65
wxauto/param.py
Normal 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
8
wxauto/ui/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from .base import BaseUIWnd, BaseUISubWnd
|
||||
from . import (
|
||||
chatbox,
|
||||
component,
|
||||
main,
|
||||
navigationbox,
|
||||
sessionbox
|
||||
)
|
||||
56
wxauto/ui/base.py
Normal file
56
wxauto/ui/base.py
Normal 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
455
wxauto/ui/chatbox.py
Normal 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
407
wxauto/ui/component.py
Normal 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
265
wxauto/ui/main.py
Normal 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 {}
|
||||
55
wxauto/ui/navigationbox.py
Normal file
55
wxauto/ui/navigationbox.py
Normal 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
265
wxauto/ui/sessionbox.py
Normal 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
8142
wxauto/uiautomation.py
Normal file
File diff suppressed because it is too large
Load Diff
2
wxauto/utils/__init__.py
Normal file
2
wxauto/utils/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .win32 import *
|
||||
from . import tools
|
||||
102
wxauto/utils/tools.py
Normal file
102
wxauto/utils/tools.py
Normal 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
302
wxauto/utils/win32.py
Normal 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
386
wxauto/wx.py
Normal 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
|
||||
|
||||
295
多群组版本使用说明.md
Normal file
295
多群组版本使用说明.md
Normal 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
111
岗位提取使用说明.md
Normal 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
96
快速修复指南.txt
Normal 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
198
快速开始.md
Normal 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
159
打包问题修复说明.md
Normal 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组件初始化
|
||||
|
||||
COM(Component 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
266
项目说明.md
Normal 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技术开发,仅用于技术交流学习。使用本工具产生的任何法律纠纷,均与作者无关。请遵守相关法律法规和平台使用规范。
|
||||
Reference in New Issue
Block a user