feat: 微信自动化客服(wechatauto-replica) 干净历史导入 - AI 自动回复/语音收发/朋友圈发布
This commit is contained in:
@@ -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",
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user