feat: 微信自动化客服(wechatauto-replica) 干净历史导入 - AI 自动回复/语音收发/朋友圈发布

This commit is contained in:
2026-09-16 10:12:49 +08:00
commit c3b98f4c4e
69 changed files with 19828 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
from wechatauto.utils.win32 import (
GetAllWindows,
GetCursorWindow,
GetPathByHwnd,
FindWindow,
FindWinEx,
SetClipboardText,
SetClipboardFiles,
SetClipboardData,
ReadClipboardData,
PasteFile,
get_windows_by_pid,
GetText,
GetAllWindowExs,
)
from wechatauto.utils.lock import LockManager, uilock
__all__ = [
"GetAllWindows",
"GetCursorWindow",
"GetPathByHwnd",
"FindWindow",
"FindWinEx",
"SetClipboardText",
"SetClipboardFiles",
"SetClipboardData",
"ReadClipboardData",
"PasteFile",
"get_windows_by_pid",
"GetText",
"GetAllWindowExs",
"LockManager",
"uilock",
]
+127
View File
@@ -0,0 +1,127 @@
"""线程、进程与异步环境下的全局 UI 锁。"""
from __future__ import annotations
import asyncio
import functools
import inspect
import multiprocessing
import threading
from contextlib import asynccontextmanager, contextmanager
from typing import Any, Awaitable, Callable, TypeVar, overload
F = TypeVar("F", bound=Callable[..., Any])
AsyncReturn = TypeVar("AsyncReturn")
class LockManager:
"""提供跨线程/进程/异步的锁。
``process_lock``multiprocessing.Lock)不可重入:同一线程内嵌套
``acquire`` 会永久阻塞。因此用线程局部计数实现**同线程重入**——同一
线程重复获取时跳过进程锁(只需重入线程锁),保证
``@uilock`` 修饰的函数互相调用(如 ``Chat.ForwardVoiceMessage``
内部调用 ``VoiceMessage.forward_to``)不会死锁。
"""
process_lock = multiprocessing.Lock()
thread_lock = threading.RLock()
_async_lock: asyncio.Lock | None = None
_local = threading.local()
@classmethod
def _get_async_lock(cls) -> asyncio.Lock:
"""返回与当前事件循环绑定的 ``asyncio.Lock``。"""
loop = None
try:
loop = asyncio.get_running_loop()
except RuntimeError:
pass
lock = cls._async_lock
if lock is None or (loop and getattr(lock, "_loop", loop) is not loop):
lock = asyncio.Lock()
cls._async_lock = lock
return lock
@classmethod
@contextmanager
def acquire(cls):
"""同步环境下获取锁(同线程可重入)。"""
depth = getattr(cls._local, "depth", 0)
if depth > 0:
# 同线程嵌套:进程锁已被本线程持有,跳过它,只重入线程锁
with cls.thread_lock:
cls._local.depth = depth + 1
try:
yield
finally:
cls._local.depth = depth
return
with cls.process_lock:
with cls.thread_lock:
cls._local.depth = 1
try:
yield
finally:
cls._local.depth = 0
@classmethod
@asynccontextmanager
async def acquire_async(cls):
"""异步环境下获取锁(同线程可重入)。"""
depth = getattr(cls._local, "depth", 0)
if depth > 0:
async with cls._get_async_lock():
with cls.thread_lock:
cls._local.depth = depth + 1
try:
yield
finally:
cls._local.depth = depth
return
async with cls._get_async_lock():
with cls.process_lock:
with cls.thread_lock:
cls._local.depth = 1
try:
yield
finally:
cls._local.depth = 0
@overload
def uilock(func: Callable[..., Awaitable[AsyncReturn]]) -> Callable[..., Awaitable[AsyncReturn]]:
...
@overload
def uilock(func: F) -> F:
...
def uilock(func: F): # type: ignore[misc]
"""确保 UI 自动化操作串行执行的装饰器。"""
if inspect.iscoroutinefunction(func):
@functools.wraps(func)
async def async_wrapper(*args: Any, **kwargs: Any):
async with LockManager.acquire_async():
return await func(*args, **kwargs)
return async_wrapper
@functools.wraps(func)
def sync_wrapper(*args: Any, **kwargs: Any):
with LockManager.acquire():
return func(*args, **kwargs)
return sync_wrapper # type: ignore[return-value]
__all__ = ["LockManager", "uilock"]
+340
View File
@@ -0,0 +1,340 @@
from __future__ import annotations
from datetime import datetime, timedelta
from pathlib import Path
import math
import re
import shutil
import time
from PIL import Image
from wechatauto.uia import ControlFromHandle, GetUiClassNameWithTimeout, QT_INTERNAL_WIN_CLASS_PREFIX
from wechatauto.utils.win32 import GetAllWindows
def get_file_dir(dir_path=None) -> Path:
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 find_window_from_root(classname=None, name=None, pid: int = None, uiaclsname: str = None, timeout=1):
t0 = time.time()
while True:
wins = find_all_windows_from_root(classname, name, pid, uiaclsname)
if len(wins) > 0:
return wins[0]
if time.time() - t0 > timeout:
return None
def find_all_windows_from_root(classname: str = None, name: str = None, pid: int = None, uiaclsname: str = None):
"""从全部顶层窗口中找到满足条件的 UIA 控件。
Args:
classname: win32 窗口类名
name: win32 窗口标题
pid: 进程ID
uiaclsname: UIA 类名(如 mmui::MainWindow),优先于 win32 类名使用
"""
windows = GetAllWindows()
targets = []
for window in windows:
# 跳过 Qt 内部消息泵等隐藏窗口,其 UIA 服务可能阻塞调用
if window[1].startswith(QT_INTERNAL_WIN_CLASS_PREFIX):
continue
if (
(all((classname, name)) and classname == window[1] and name == window[2])
or (all((classname, not name)) and classname == window[1])
or (all((not classname, name)) and name == window[2])
or (all((not classname, not name)))
):
try:
if uiaclsname is not None:
if GetUiClassNameWithTimeout(window[0]) != uiaclsname:
continue
targets.append(ControlFromHandle(window[0]))
except Exception:
continue
if pid:
targets = [w for w in targets if w and w.ProcessId == pid]
if uiaclsname:
targets = [w for w in targets if w and w.ClassName == uiaclsname]
return targets
def now_time(fmt='%Y%m%d%H%M%S%f') -> str:
return datetime.now().strftime(fmt)
def parse_wechat_time(time_str: str) -> 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 is_valid_image(file_path) -> bool:
path = Path(file_path)
if not path.exists() or not path.is_file():
return False
try:
with Image.open(path) as img:
img.verify() # 只验证图像,不会完全解码
return True
except Exception:
return False
def delete_update_files():
"""清理微信更新缓存,避免更新弹窗干扰自动化操作"""
home = Path.home()
update_dir = home / 'AppData' / 'Roaming' / 'Tencent' / 'xwechat' / 'update'
if update_dir.exists():
for file in update_dir.iterdir():
try:
shutil.rmtree(file) if file.is_dir() else file.unlink()
except Exception:
pass
# ============================================================================================================================================
# 消息解析方法
# ============================================================================================================================================
def detect_message_direction(
image_path: str,
avatar_height_ratio: float = 0.8,
tolerance: int = 0,
) -> tuple[str, float]:
"""通过截图判断消息气泡的方向。
Args:
image_path: 消息截图路径。
avatar_height_ratio: 头像在截图中占据的高度比例。
tolerance: 像素颜色比较的容忍度。
Returns:
Tuple[str, float]: ``("left", distance)`` 或 ``("right", distance)``
``distance`` 表示从对应方向开始出现气泡的列索引,便于后续定位。
"""
img = Image.open(image_path)
if img.mode != 'RGB':
img = img.convert('RGB')
w, h = img.size
# 仅取中间 band 区域
band_h = int(h * avatar_height_ratio)
y0 = (h - band_h) // 2
y1 = y0 + band_h
pixels = img.load() # 获取像素访问对象
def is_uniform_column(x: int) -> bool:
base = pixels[x, y0] # 以 band 顶部像素作为参考
for y in range(y0, y1):
r, g, b = pixels[x, y]
if (abs(r - base[0]) > tolerance or
abs(g - base[1]) > tolerance or
abs(b - base[2]) > tolerance):
return False
return True
# 从左边扫描
left_idx = math.inf
for x in range(w):
if not is_uniform_column(x):
left_idx = x
break
# 从右边扫描
right_idx = math.inf
for offset, x in enumerate(range(w - 1, -1, -1)):
if not is_uniform_column(x):
right_idx = offset # 距右边界的列数
break
if left_idx == math.inf and right_idx == math.inf:
# 都没找到变化列,兜底
return 'right', math.inf
if left_idx <= right_idx:
return 'left', float(left_idx)
return 'right', float(right_idx)
def calculate_pixel_variance(region) -> float:
"""计算图像区域的像素变化程度"""
if region.size[0] == 0 or region.size[1] == 0:
return 0
# 获取所有像素值
pixels = list(region.getdata())
if not pixels:
return 0
# 分别计算R、G、B通道的方差
r_values = [p[0] for p in pixels]
g_values = [p[1] for p in pixels]
b_values = [p[2] for p in pixels]
r_variance = calculate_variance(r_values)
g_variance = calculate_variance(g_values)
b_variance = calculate_variance(b_values)
return r_variance + g_variance + b_variance
def calculate_variance(values) -> float:
"""计算数值列表的方差"""
if not values:
return 0
# 计算平均值
mean = sum(values) / len(values)
# 计算方差
variance = sum((x - mean) ** 2 for x in values) / len(values)
return variance
def calculate_color_diversity(region) -> float:
"""计算区域颜色多样性(备用方法)"""
pixels = list(region.getdata())
if not pixels:
return 0
# 统计不同颜色的数量
color_set = set(pixels)
unique_colors = len(color_set)
# 计算颜色多样性比例
diversity_ratio = unique_colors / len(pixels)
return diversity_ratio
def detect_message_direction_enhanced(
image_path: str,
avatar_width_ratio: float = 0.15,
avatar_height_ratio: float = 0.8,
) -> tuple[str, float]:
"""增强版检测,结合方差和颜色多样性"""
img = Image.open(image_path).convert('RGB')
width, height = img.size
avatar_width = int(width * avatar_width_ratio)
avatar_height = int(height * avatar_height_ratio)
avatar_start_y = (height - avatar_height) // 2
avatar_end_y = avatar_start_y + avatar_height
# 截取左右头像区域
left_box = (0, avatar_start_y, avatar_width, avatar_end_y)
right_box = (width - avatar_width, avatar_start_y, width, avatar_end_y)
left_region = img.crop(left_box)
right_region = img.crop(right_box)
# 计算方差和颜色多样性
left_variance = calculate_pixel_variance(left_region)
right_variance = calculate_pixel_variance(right_region)
left_diversity = calculate_color_diversity(left_region)
right_diversity = calculate_color_diversity(right_region)
# 综合评分(方差权重0.7,多样性权重0.3)
left_score = left_variance * 0.7 + left_diversity * 1000 * 0.3
right_score = right_variance * 0.7 + right_diversity * 1000 * 0.3
if left_score > right_score:
return 'left', float(left_score)
return 'right', float(right_score)
def batch_detect_messages(image_paths, method='basic', **kwargs):
"""批量检测多条消息的方向"""
results = []
detect_func = detect_message_direction if method == 'basic' else detect_message_direction_enhanced
for path in image_paths:
try:
result = detect_func(path, **kwargs)
if isinstance(result, tuple):
direction, distance = result
else:
direction, distance = result, None
sender = '对方' if direction == 'left' else '自己'
results.append({
'path': path,
'direction': direction,
'sender': sender,
'distance': distance,
})
except Exception as e:
results.append({
'path': path,
'direction': 'unknown',
'sender': '未知',
'error': str(e)
})
return results
+402
View File
@@ -0,0 +1,402 @@
import os
import time
import struct
import shutil
import traceback
import ctypes
from typing import List, Optional, Sequence, Tuple, Union
import win32ui
import win32gui
import win32api
import win32con
import win32process
import win32clipboard
import pyperclip
import psutil
from PIL import Image
def GetAllWindows(name=None, classname=None) -> List[Tuple[int, str, str]]:
"""获取所有顶层窗口的信息,返回 (窗口句柄, 类名, 窗口标题) 列表"""
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)
if name:
windows = [i for i in windows if i[-1] == name]
if classname:
windows = [i for i in windows if i[1] == classname]
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) -> Optional[str]:
try:
thread_id, process_id = win32process.GetWindowThreadProcessId(hwnd)
process = psutil.Process(process_id)
return process.exe()
except Exception:
return None
def GetVersionByPath(file_path) -> Optional[str]:
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 Exception:
version = None
return version
def capture(hwnd, bbox) -> Image.Image:
"""截取指定窗口的指定区域,返回 PIL 图像"""
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) -> str:
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) -> Optional[List[list]]:
if not HWND:
return None
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 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) -> List[int]:
units = list(units)
retry_count = 5
while retry_count > 0:
try:
win32clipboard.OpenClipboard()
try:
u = win32clipboard.EnumClipboardFormats(unit)
finally:
win32clipboard.CloseClipboard()
break
except Exception:
retry_count -= 1
units.append(u)
if u:
units = ClipboardFormats(u, *units)
return units
def ReadClipboardData() -> dict:
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:
retry_count -= 1
return Dict
def SetClipboardData(data_dict: dict) -> None:
try:
# 打开剪贴板
win32clipboard.OpenClipboard()
# 清空剪贴板
win32clipboard.EmptyClipboard()
# 遍历数据字典,设置各种格式的数据
for format_id, data in data_dict.items():
# 将字符串格式ID转换为整数
format_num = int(format_id)
if isinstance(data, str):
# 如果是字符串,使用Unicode格式
win32clipboard.SetClipboardData(format_num, data)
elif isinstance(data, bytes):
# 如果是字节数据,直接设置
win32clipboard.SetClipboardData(format_num, data)
except Exception as e:
print(f"设置剪贴板数据时出错: {e}")
finally:
# 关闭剪贴板
try:
win32clipboard.CloseClipboard()
except Exception:
pass
def SetClipboardText(text: str) -> None:
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),
]
def set_files_to_clipboard(file_paths: Union[str, Sequence[str]]) -> bool:
"""将文件路径列表设置到剪贴板的 CF_HDROP 格式"""
# 如果传入的是单个字符串,转换为列表
if isinstance(file_paths, str):
file_paths = [file_paths]
# 验证文件路径是否存在
valid_paths = []
for path in file_paths:
if os.path.exists(path):
# 转换为绝对路径
abs_path = os.path.abspath(path)
valid_paths.append(abs_path)
else:
raise ValueError(f"文件路径不存在: {path}")
if not valid_paths:
return False
try:
# 打开剪贴板
win32clipboard.OpenClipboard()
# 清空剪贴板
win32clipboard.EmptyClipboard()
# 计算偏移量(DROPFILES结构大小为20字节)
offset = 20
# 构建DROPFILES头部
dropfiles_header = struct.pack('<LLLLL',
offset, # pFiles偏移量
0, # pt.x
0, # pt.y
0, # fNC
1) # fWide (使用Unicode)
# 构建文件路径字符串(Unicode,以双null结尾)
file_list = []
for path in valid_paths:
# 转换为Unicode字节
file_list.append(path.encode('utf-16le'))
file_list.append(b'\x00\x00') # Unicode null终止符
# 添加额外的双null作为列表结束标记
file_list.append(b'\x00\x00')
# 合并所有数据
file_data = b''.join(file_list)
hdrop_data = dropfiles_header + file_data
# 设置到剪贴板
win32clipboard.SetClipboardData(win32con.CF_HDROP, hdrop_data)
return True
except Exception:
traceback.print_exc()
return False
finally:
# 关闭剪贴板
try:
win32clipboard.CloseClipboard()
except Exception:
pass
def SetClipboardFiles(paths) -> bool:
return set_files_to_clipboard(paths)
def PasteFile(folder) -> bool:
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 Exception:
pass
finally:
try:
win32clipboard.CloseClipboard()
except Exception:
pass
def enum_windows_by_pid(pid: int) -> List[int]:
window_list = []
def enum_callback(hwnd, lParam):
# 获取窗口的进程ID
_, process_id = win32process.GetWindowThreadProcessId(hwnd)
# 如果是目标进程,检查窗口是否可见
if process_id == pid:
if is_window_visible(hwnd):
window_list.append(hwnd)
return True
win32gui.EnumWindows(enum_callback, None)
return window_list
def is_window_visible(hwnd) -> bool:
# 检查窗口是否可见
style = win32gui.GetWindowLong(hwnd, win32con.GWL_STYLE)
# 检查窗口是否有 WS_VISIBLE 标志,且不是最小化的
if style & win32con.WS_VISIBLE and not win32gui.IsIconic(hwnd):
return True
return False
def get_windows_by_pid(pid: int) -> List[int]:
while True:
try:
windows = enum_windows_by_pid(pid)
return windows
except Exception:
time.sleep(0.1)