feat: Initialize wxauto WeChat automation project with job extraction tools

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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