任务描述

一个运行在本地电脑上的 Python 自动化脚本,用于监控远程服务器上的微信登录状态,并在微信掉线时自动完成重新登录的全流程。

系统架构

本地电脑(Python 脚本 + ADB)
    ↕ SSH/SFTP
远程服务器(微信 + 检测程序 + 常驻 AHK 脚本)
    ↕ USB
手机(微信确认登录)

核心流程

常规监控:

  1. 通过 HTTP 接口检测微信登录状态(检测程序提供)

  2. 接口不可用时,通过 SSH 执行 tasklist 检查微信和检测程序进程是否存活

  3. 检测程序挂了 → 认为微信掉线,触发重连
    重连流程:

  4. SSH 到服务器,通过 schtasks 启动微信进程(解决 SSH session 无法启动 GUI 程序的问题)

  5. 通过 SFTP 写信号文件,触发服务器上常驻的 AHK 脚本

  6. AHK 脚本发送 Enter 键点击微信登录按钮,然后截图 + 调用 Tesseract OCR 识别界面状态

  7. 根据 OCR 结果判断:

    • 等待手机确认 → 进入 ADB 流程

    • 需要扫码 → 记录日志并终止程序

    • 点击失败 → 重试(最多 3 次)

  8. ADB 唤醒手机屏幕、解锁、截图 + OCR 检测确认登录界面

  9. 检测到确认界面后点击登录按钮

  10. 启动检测程序,通过接口验证登录成功

技术难点与解决方案

难点

解决方案

SSH 无法启动 GUI 程序

使用 schtasks 创建临时计划任务

SSH session 无法操作桌面窗口

服务器上常驻 AHK 脚本 + 信号文件触发

SFTP 写文件与 AHK 删文件竞争

AHK 先执行完再删信号文件

微信绿色文字 OCR 识别率低

截图转灰度 + 用完整短语匹配

检测程序要求微信已登录才能启动

先通过 ADB+OCR 确认登录成功后再启动

ADB 截图 OCR 判断手机状态

本地 Python + pytesseract 识别

文件清单

本地:

  • wechat_monitor.py — 主监控脚本

  • test_adb_ocr.py — ADB OCR 测试脚本

  • upload_and_test.py — 上传 AHK 脚本到服务器
    服务器:

  • click_login.ahk — 常驻 AHK 脚本(信号监听 + 点击登录 + 截图 + OCR)

  • Tesseract OCR(含中文语言包)

依赖

本地 Python 环境:

  • paramiko(SSH/SFTP)

  • requests(HTTP 接口检测)

  • pytesseract + Pillow(手机端 OCR)

  • ADB 工具(手机控制)
    服务器:

  • AutoHotkey v2

  • Tesseract OCR + chi_sim 语言包

  • Windows OpenSSH 服务

运行前提

  1. 服务器上双击运行 click_login.ahk(常驻后台)

  2. 手机通过 USB 连接本地电脑,adb devices 能识别

  3. 本地运行 python wechat_monitor.py

知识点整理

一、SSH/SFTP 远程控制(paramiko)


1. paramiko 持久连接管理

import paramiko
class SSHManager:
    def __init__(self):
        self.client = None
    def connect(self):
        self.client = paramiko.SSHClient()
        self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
        self.client.connect(host, port=22, username=user, password=pwd, timeout=10)
    def is_connected(self):
        if self.client is None:
            return False
        transport = self.client.get_transport()
        return transport is not None and transport.is_active()
    def ensure_connected(self):
        if not self.is_connected():
            self.close()
            self.connect()
    def execute(self, command):
        self.ensure_connected()
        stdin, stdout, stderr = self.client.exec_command(command, timeout=20)
        out = stdout.read().decode("gbk", errors="ignore").strip()
        err = stderr.read().decode("gbk", errors="ignore").strip()
        return out, err
    def close(self):
        if self.client:
            try:
                self.client.close()
            except Exception:
                pass
            self.client = None

要点:

  • get_transport().is_active() 判断连接是否存活

  • 每次执行命令前 ensure_connected(),断了自动重连

  • Windows 服务器输出用 gbk 解码

  • exec_command 设置 timeout 防止命令卡死


2. SFTP 文件操作

# 写文件(比 echo > 更可靠)
ssh.ensure_connected()
sftp = ssh.client.open_sftp()
with sftp.open(remote_path, 'w') as f:
    f.write("content")
sftp.close()
# 读文件
sftp = ssh.client.open_sftp()
with sftp.open(remote_path, 'r') as f:
    content = f.read().decode('utf-8').strip()
sftp.close()
# 上传/下载
sftp.put(local_path, remote_path)
sftp.get(remote_path, local_path)
# 列目录
sftp.listdir_attr(remote_path)

要点:

  • SFTP 写文件比 echo > 重定向可靠(echo 通过 SSH 有时会失败)

  • 写完文件后句柄可能未立即释放,其他程序删除该文件会报"文件被占用"

  • 读文件时加 try/except FileNotFoundError 处理文件不存在的情况

  • 读文件加重试逻辑应对文件刚创建还没写完的情况


3. SSH Session 隔离(核心坑)

SSH 登录的 session(Session 0)与交互式桌面 session 完全隔离:

能做

不能做

执行命令行程序

启动 GUI 程序(看不到界面)

tasklist / taskkill

FindWindow / 操作窗口

文件读写

移动鼠标 / 模拟点击

schtasks 创建任务

PixelSearch / 截屏

网络操作

任何需要桌面访问的操作


4. 通过 SSH 启动 GUI 程序(schtasks 方案)

# 创建一次性计划任务并立即执行
# TN=TaskName TR=TashRun
ssh.execute(
    f'schtasks /Create /TN "TaskName" /TR "\'程序路径\'" '
    f'/SC ONCE /ST 00:00 /F /RL HIGHEST'
)
ssh.execute('schtasks /Run /TN "TaskName"')
ssh.execute('schtasks /Delete /TN "TaskName" /F')

要点:

  • /SC ONCE /ST 00:00 设置为一次性任务(时间已过不影响手动 Run)

  • /F 强制覆盖已存在的同名任务

  • /RL HIGHEST 最高权限运行

  • 任务在交互式桌面 session 执行,GUI 程序能正常显示

  • 执行后删除任务保持干净


5. 通过 SSH 操作桌面(信号文件方案)

问题: SSH 无法操作桌面窗口 解决: 在桌面 session 运行常驻脚本,SSH 通过文件通信触发操作

# Python 端:写信号文件触发
sftp = ssh.client.open_sftp()
with sftp.open(signal_file, 'w') as f:
    f.write("click")
sftp.close()

# 等待执行完成后读取结果
time.sleep(18)
sftp = ssh.client.open_sftp()
with sftp.open(result_file, 'r') as f:
    result = f.read().decode('utf-8').strip()
sftp.close()

要点:

  • 常驻脚本在交互式桌面运行(手动双击启动一次)

  • SSH 端只负责写信号文件和读结果文件

  • 信号文件应在操作完成后再删除(避免句柄竞争)

  • 等待时间要足够长,覆盖:信号检测间隔 + 操作执行时间


6. SSH 远程进程检测

# FI = Filter(过滤条件) 
# NH = 不显示表头(让输出更干净,方便脚本解析)
# 检查进程是否存在
out, _ = ssh.execute('tasklist /FI "IMAGENAME eq WeChat.exe" /NH')
is_running = "WeChat.exe" in out

# IM = Image Name(进程名)
# 杀进程
ssh.execute("taskkill /F /IM WeChat.exe")

7. SSH 认证问题

  • Windows OpenSSH 默认先尝试公钥认证,失败后尝试密码

  • 用户名注意大小写(administrator vs Administrator

  • AutoAddPolicy() 自动接受未知主机密钥(生产环境应改用 RejectPolicy + 已知主机文件)


8. 编码问题

  • Windows CMD 输出:GBK 编码 → .decode("gbk", errors="ignore")

  • 文件内容(Tesseract 输出等):UTF-8 → .decode("utf-8")

  • 写入文件时注意编码一致性


二、Windows 远程启动 GUI 程序

schtasks 方案

  • schtasks /Create /TN "名称" /TR "命令" /SC ONCE /ST 00:00 /F /RL HIGHEST

  • schtasks /Run /TN "名称" 立即执行

  • schtasks /Delete /TN "名称" /F 清理

  • 计划任务在交互式桌面 session 运行,能启动 GUI 程序

  • 但计划任务执行的程序也受 session 限制,不一定能操作桌面窗口(取决于用户配置)
    信号文件 + 常驻脚本方案

  • 在交互式桌面运行一个常驻脚本(AHK/bat)

  • 远程通过 SSH/SFTP 创建信号文件触发操作

  • 常驻脚本检测到信号文件后执行操作,完成后删除信号文件

  • 解决了 SSH session 隔离的所有问题


三、AutoHotkey v2


1. 脚本基础结构

#Requires AutoHotkey v2.0    ; 声明需要 v2 版本
#SingleInstance Force         ; 防止重复运行(新实例替换旧实例)
Persistent                    ; 脚本常驻不退出(没有热键/GUI时必须加)

A_IconTip — 设置托盘图标的鼠标悬停提示文字:

A_IconTip := "My Script - Running"

2. 变量与字符串

; 赋值
myVar := "hello"
myNum := 42

; 字符串拼接用 .(点号)
fullPath := "C:\folder\" . filename . ".txt"

; 多行拼接
content := "line1`n"
    . "line2`n"
    . "line3`n"

; 在函数内访问全局变量
DoSomething() {
    global RESULT_FILE, SCREENSHOT_PATH
    ; ...
}

注意: n 是换行符(AHK 中反引号 是转义字符,不是反斜杠)


3. 窗口操作

; 等待窗口出现(超时30秒,返回0表示超时)
if !WinWait("ahk_class WeChatLoginWndForPC", , 30) {
    ; 超时处理
}
; 检查窗口是否存在
if WinExist("ahk_class WeChatLoginWndForPC") {
    ; 存在
}
; 激活窗口(带到前台)
WinActivate "ahk_class WeChatLoginWndForPC"
; 获取窗口位置和大小
WinGetPos(&x, &y, &w, &h, "ahk_class WeChatLoginWndForPC")
; 置顶/取消置顶
WinSetAlwaysOnTop 1, "ahk_class WeChatLoginWndForPC"
WinSetAlwaysOnTop 0, "ahk_class WeChatLoginWndForPC"

窗口标识方式:

  • ahk_class ClassName — 按窗口类名匹配(最可靠)

  • ahk_exe process.exe — 按进程名匹配

  • "窗口标题" — 按标题匹配


4. 发送按键和点击(不依赖鼠标位置)

; 给指定窗口发送按键(窗口不需要在前台)
ControlSend("{Enter}", , "ahk_class WeChatLoginWndForPC");两个, ,占位符

; 给指定窗口发送鼠标点击(窗口内相对坐标)
SetControlDelay(-1)
ControlClick("x175 y400", "ahk_class WeChatLoginWndForPC", , , , "NA")

ControlSend 要点:

  • 第一个参数:按键({Enter}, {Tab}, {Space}, a, {Ctrl down}c{Ctrl up} 等)

  • 第二个参数:控件名(留空表示发给窗口本身)

  • 第三个参数:窗口标识

  • 不需要窗口在前台,不移动鼠标
    ControlClick 要点:

  • "NA" 参数表示不激活窗口(后台点击)

  • 坐标是窗口内的相对坐标

  • SetControlDelay(-1) 取消点击间延迟


5. 文件操作

; 检查文件是否存在
if FileExist("C:\path\to\file.txt") {
    ; 存在
}

; 删除文件
FileDelete("C:\path\to\file.txt")

; 写入文件(追加模式)
FileAppend("内容", "C:\path\to\file.txt")

; 读取文件(指定编码)
content := FileRead("C:\path\to\file.txt", "UTF-8")

; 字符串拼接路径
ocrFile := OCR_OUTPUT ".txt"   ; 结果: "C:\...\ocr_output.txt"

6. 运行外部程序

; 运行并等待完成(隐藏窗口)
RunWait('powershell -ExecutionPolicy Bypass -File "script.ps1"', , "Hide")

; 运行带引号的路径
RunWait('"C:\Program Files\app.exe" "参数"', , "Hide")

; 只运行不等待
Run("notepad.exe")

引号嵌套技巧:

  • AHK v2 字符串用单引号或双引号

  • 外层用单引号,内层用双引号(或反之)

  • 路径有空格必须加引号


7. 错误处理

try {
    FileDelete(SIGNAL_FILE)
} catch {
    ; 删除失败,忽略
}

注意: 不加 try/catch 时,AHK 遇到错误会弹对话框并暂停脚本执行。对于常驻脚本必须加错误处理。


8. 流程控制

; 无限循环
Loop {
    ; ...
    Sleep 2000   ; 毫秒
}

; 条件判断
if (ww > 0 and wh > 0) {
    ; ...
} else {
    ; ...
}

; 字符串包含检查
if InStr(ocrText, "关键词") {
    ; 找到了
}

; 多条件用 or
if InStr(text, "词1") or InStr(text, "词2") {
    ; 任一匹配
}

; 函数定义
DoClick() {
    global VAR1, VAR2   ; 声明要用的全局变量
    ; ...
    return              ; 提前返回
}

9. 像素搜索与找色(PixelSearch)

; 在指定区域搜索颜色(允许色差)
if PixelSearch(&Px, &Py, x1, y1, x2, y2, 0x07C160, 3) {
    ; 找到了,坐标在 Px, Py
} else {
    ; 没找到
}

限制:

  • 需要窗口在前台且可见

  • 通过 SSH/schtasks 执行时无法使用(看不到桌面)

  • 只有在交互式桌面 session 的常驻脚本里才能用


10. 调用 PowerShell 截图(动态生成脚本)

; 避免命令行引号嵌套问题:动态生成 .ps1 文件再调用
psScript := "C:\path\do_screenshot.ps1"
if FileExist(psScript)
    FileDelete(psScript)

scriptContent := "Add-Type -AssemblyName System.Drawing`n"
    . "$bmp = New-Object System.Drawing.Bitmap(" ww "," wh ")`n"
    . "$g = [System.Drawing.Graphics]::FromImage($bmp)`n"
    . "$g.CopyFromScreen(" wx "," wy ", 0, 0, ...)`n"
    . "..."

FileAppend(scriptContent, psScript)
RunWait('powershell -ExecutionPolicy Bypass -File "' psScript '"', , "Hide")

为什么不直接内联 PowerShell 命令:

  • AHK 字符串 + PowerShell 命令 + 引号嵌套极易出错

  • 动态生成 .ps1 文件再调用,引号问题完全消失


11. 信号文件模式(与外部程序通信)

; 主循环:检测信号文件 → 执行操作 → 删除信号文件
Loop {
    if FileExist(SIGNAL_FILE) {
        ; 清理旧结果
        if FileExist(RESULT_FILE)
            FileDelete(RESULT_FILE)

        ; 执行操作
        DoClick()

        ; 操作完成后再删信号文件(避免文件锁冲突)
        Sleep 1000
        try {
            FileDelete(SIGNAL_FILE)
        } catch {
        }
    }
    Sleep 2000
}

设计要点:

  • 信号文件在操作完成后再删除(不是检测到就删)

  • 避免与写入方的文件句柄竞争

  • 删除失败用 try/catch 忽略,不会导致重复触发(因为操作已执行完)

  • 结果通过另一个文件返回给调用方


12. 常见坑

问题

原因

解决

脚本启动后立刻退出

没有 Persistent 或热键

Persistent

弹错误对话框卡住

文件操作失败未捕获

try/catch

PixelSearch 找不到颜色

窗口被遮挡或不在前台

WinActivate + WinSetAlwaysOnTop

ControlSend 无效

窗口还没完全加载

Sleep 等待

字符串拼接报错

v2 语法:变量和字符串之间要有空格或用 .

"text" variable "text""text" . variable

内联 PowerShell 引号错误

多层引号嵌套

写到 .ps1 文件再调用

重复触发

信号文件删除失败

操作完成后再删 + try/catch

四、Windows 窗口类名识别

微信窗口类名

  • WeChatLoginWndForPC — 登录界面

  • WeChatMainWndForPC — 主界面(已登录)
    用途

  • 判断微信登录状态(不依赖接口)

  • 定位窗口进行截图

  • 注意:只有在交互式桌面 session 里才能通过 FindWindow/WinExist 找到


五、OCR(Tesseract)

安装

  • Windows 安装包:tesseract-ocr-w64-setup-*.exe

  • 中文语言包:chi_sim.traineddata 放到 tessdata/ 目录
    命令行调用

  • tesseract input.png output -l chi_sim

  • 输出文件自动加 .txt 后缀

  • 输出编码为 UTF-8
    Python 调用(pytesseract)

  • pytesseract.image_to_string(img, lang="chi_sim")

  • 配合 Pillow 的 Image.open().convert("L") 转灰度提高识别率
    识别技巧

  • 彩色文字(绿色)在白底上识别率低,转灰度后改善

  • 用完整短语匹配而非单个关键词,避免误匹配

  • 截图范围越精确,干扰越少,识别越准

  • ColorMatrix 灰度转换在 PowerShell 中可能导致全黑图片,直接用 Pillow 的 .convert("L") 更可靠


六、ADB 手机控制

1. 基础连接与设备管理

# 检查设备连接
adb devices

# 输出示例(已连接)
# List of devices attached
# nzojcyus8xamqsjr        device

# 获取屏幕分辨率
adb shell wm size
# 输出: Physical size: 1080x2400

2. 触摸操作

# 点击指定坐标
adb shell input tap 540 1530

# 滑动(从 x1,y1 滑到 x2,y2)
adb shell input swipe 540 2000 540 1000

# 长按(滑动距离为0,持续时间长)
adb shell input swipe 540 1530 540 1530 1000

坐标系:

  • X:从左到右递增,左上角为 0

  • Y:从上到下递增,左上角为 0

  • 坐标基于屏幕物理分辨率(如 1080x2400)

3. 按键事件

# 唤醒屏幕
adb shell input keyevent KEYCODE_WAKEUP

# 电源键
adb shell input keyevent KEYCODE_POWER

# 返回键
adb shell input keyevent KEYCODE_BACK

# Home键
adb shell input keyevent KEYCODE_HOME

# 回车
adb shell input keyevent KEYCODE_ENTER

4. 截图与文件传输

# 手机端截图保存
adb shell screencap -p /sdcard/screen.png

# 拉取到本地
adb pull /sdcard/screen.png D:\local\screen.png

# 推送文件到手机
adb push local_file.txt /sdcard/

5. 坐标定位方法

# 开启触摸坐标显示(屏幕顶部实时显示触摸坐标)
adb shell settings put system pointer_location 1

# 手指点击目标位置,记录屏幕顶部显示的 X, Y 值

# 关闭坐标显示
adb shell settings put system pointer_location 0

6. Python 中调用 ADB

import subprocess

# 唤醒屏幕
subprocess.run(["adb", "shell", "input", "keyevent", "KEYCODE_WAKEUP"], timeout=5)

# 上滑解锁
subprocess.run(["adb", "shell", "input", "swipe", "540", "2000", "540", "1000"], timeout=5)

# 点击坐标
subprocess.run(["adb", "shell", "input", "tap", "540", "1530"], timeout=5)

# 截图
subprocess.run(["adb", "shell", "screencap", "-p", "/sdcard/screen.png"], timeout=10)

# 拉取截图到本地
subprocess.run(["adb", "pull", "/sdcard/screen.png", "local_screenshot.png"], timeout=10)

要点:

  • 所有参数必须是字符串(坐标也要 str()

  • 设置 timeout 防止命令卡死

  • subprocess.run 会等待命令完成


7. ADB + OCR 识别手机界面

import subprocess
import os
from PIL import Image
import pytesseract

def adb_screenshot_and_ocr():
    """截图 + OCR 识别手机屏幕文字"""
    remote_path = "/sdcard/screen.png"
    local_path = "phone_screenshot.png"

    # 截图并拉到本地
    subprocess.run(["adb", "shell", "screencap", "-p", remote_path], timeout=10)
    subprocess.run(["adb", "pull", remote_path, local_path], timeout=10)

    if not os.path.exists(local_path):
        return ""

    # OCR 识别(转灰度提高识别率)
    img = Image.open(local_path).convert("L")
    text = pytesseract.image_to_string(img, lang="chi_sim")
    return text.strip()

OCR 技巧:

  • .convert("L") 转灰度,提高彩色文字识别率

  • lang="chi_sim" 指定中文简体

  • 用完整短语匹配而非单个关键词,避免误判


8. 基于 OCR 的状态检测与操作

def adb_check_phone_state():
    """根据OCR结果判断手机当前界面状态"""
    text = adb_screenshot_and_ocr()
    if not text:
        return "other"

    if "微信已登录" in text or "手机通知已关闭" in text:
        return "logged_in"
    elif "登录 Windows 微信" in text or "登录确认" in text or "同步最近的消息" in text:
        return "waiting_confirm"
    else:
        return "other"

设计模式:截图 → OCR → 关键词匹配 → 决定操作


9. 完整的 ADB 自动化流程

def adb_confirm_login():
    """唤醒手机 → 检测界面 → 点击确认"""
    # 1. 唤醒 + 解锁
    subprocess.run(["adb", "shell", "input", "keyevent", "KEYCODE_WAKEUP"], timeout=5)
    time.sleep(1)
    subprocess.run(["adb", "shell", "input", "swipe", "540", "2000", "540", "1000"], timeout=5)
    time.sleep(3)

    # 2. 轮询检测界面状态
    for attempt in range(5):
        state = adb_check_phone_state()

        if state == "waiting_confirm":
            # 3. 点击确认按钮
            subprocess.run(["adb", "shell", "input", "tap", "540", "1530"], timeout=5)
            time.sleep(3)
            return True
        elif state == "logged_in":
            return True
        else:
            time.sleep(3)  # 等待界面加载

    return False  # 超时

流程要点:

  • 先唤醒解锁,等几秒让界面加载

  • 轮询检测而非盲目点击(避免点错位置)

  • 设置最大重试次数防止死循环

  • 检测到已登录直接返回成功


10. 解锁屏幕的常见方式

# 无密码:上滑解锁
subprocess.run(["adb", "shell", "input", "swipe", "540", "2000", "540", "1000"], timeout=5)

# PIN 码解锁:上滑 + 输入PIN
subprocess.run(["adb", "shell", "input", "swipe", "540", "2000", "540", "1000"], timeout=5)
time.sleep(1)
subprocess.run(["adb", "shell", "input", "text", "1234"], timeout=5)
subprocess.run(["adb", "shell", "input", "keyevent", "KEYCODE_ENTER"], timeout=5)

# 检查屏幕是否亮着
# dumpsys 输出中 mScreenOn=true 或 Display Power: state=ON

11. 常见坑

问题

原因

解决

adb devices 显示 unauthorized

手机没授权 USB 调试

手机上点"允许 USB 调试"

点击坐标不准

分辨率不同或有虚拟导航栏

pointer_location 实际定位

截图拉取失败

/sdcard 路径权限问题

换路径如 /data/local/tmp/

OCR 识别率低

图片太大或有复杂背景

裁剪关键区域 + 转灰度

input tap 无反应

屏幕锁定或应用拦截了触摸

先唤醒解锁

命令超时

USB 连接不稳定

设置 timeout,失败重试

七、进程检测与管理

tasklist 检查进程

  • tasklist /FI "IMAGENAME eq WeChat.exe" /NH

  • 输出包含进程名则说明在运行
    taskkill 杀进程

  • taskkill /F /IM WeChat.exe 强制杀掉
    进程存活作为状态指标

  • 检测程序(pywxcode.exe)在微信未登录时会闪退

  • 检测程序活着 = 微信已登录

  • 检测程序挂了 = 微信可能掉线


八、文件竞争与时序问题

信号文件竞争

  • 写入方(SFTP)和读取/删除方(AHK)操作同一文件可能冲突

  • 解决:先执行操作,最后再删信号文件(此时写入方已释放句柄)
    文件系统延迟

  • Windows 上 del 删除文件后,文件系统可能有短暂延迟才完全释放

  • 删除后等 2-3 秒再创建同名文件更可靠
    读取时机

  • 文件刚创建可能还没写完,读取时加重试逻辑

  • 或者等足够长的时间再读取


九、编码问题

Windows CMD 输出

  • 中文 Windows 默认 GBK 编码

  • SSH 执行命令的输出用 .decode("gbk") 解码
    Tesseract 输出

  • 输出文件是 UTF-8 编码

  • AHK 读取时需指定 FileRead(path, "UTF-8")
    PowerShell 脚本

  • 含中文的 .ps1 文件传输时可能乱码

  • 用纯英文输出避免编码问题


十、远程桌面(RDP)相关

分辨率

  • 远程桌面连接时,服务器分辨率跟随客户端窗口大小。

  • 断开后分辨率可能变化,绝对坐标会失效。
    session

  • 远程桌面断开(不注销)后,交互式 session 仍然存在。

  • 常驻脚本继续运行,schtasks 任务也能在该 session 执行。
    鼠标操作

  • 远程桌面连着时,脚本和用户操作的是同一个鼠标光标。

  • 测试时不要手动移动鼠标,避免冲突。

完整代码

wechat_monitor.py

"""
微信登录状态监控脚本
功能:定期检查微信登录状态,掉线后自动重启微信并通过手机确认登录

流程:
1. 通过SSH窗口检测判断微信状态
2. 未登录/未运行 → 启动微信
3. ADB 控制手机确认登录
4. 轮询窗口检测等待登录成功
5. 登录成功后启动检测程序
6. 之后用接口做常规监控
"""

import requests
import time
import logging
import subprocess
import os
import paramiko

# 配置
CHECK_URL = "your_url"
CHECK_INTERVAL = 10  # 常规监控检查间隔(秒)

# SSH 配置
SSH_HOST = "your_ip"       # 服务器IP
SSH_PORT = 22                      # SSH端口
SSH_USER = "your_user_name"         # 用户名,根据实际修改
SSH_PASSWORD = "your_passward"     # 密码,根据实际修改

# 服务器上的路径/命令配置
WECHAT_EXE_PATH = r"C:\Users\Administrator\Desktop\app\WeChat.exe"
DETECTOR_EXE_PATH = r"C:\Users\Administrator\Desktop\WXService\pywxcode.exe"
DETECTOR_PROCESS_NAME = "pywxcode.exe"
AHK_EXE_PATH = r"C:\Program Files\AutoHotkey\v2\AutoHotkey64.exe"
CLICK_LOGIN_SCRIPT = r"C:\Users\Administrator\Desktop\click_login.ahk"

# 日志配置
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    handlers=[
        logging.StreamHandler(),
        logging.FileHandler("wechat_monitor.log", encoding="utf-8"),
    ],
)
logger = logging.getLogger(__name__)


class SSHManager:
    """持久SSH连接管理,断线自动重连"""

    def __init__(self):
        self.client = None

    def connect(self):
        self.client = paramiko.SSHClient()
        self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
        self.client.connect(SSH_HOST, port=SSH_PORT, username=SSH_USER, password=SSH_PASSWORD, timeout=10)
        logger.info("SSH连接已建立: %s@%s:%s", SSH_USER, SSH_HOST, SSH_PORT)

    def is_connected(self):
        if self.client is None:
            return False
        transport = self.client.get_transport()
        return transport is not None and transport.is_active()

    def ensure_connected(self):
        if not self.is_connected():
            logger.info("SSH连接不可用,正在重连...")
            self.close()
            self.connect()

    def execute(self, command):
        self.ensure_connected()
        logger.info("SSH执行命令: %s", command)
        stdin, stdout, stderr = self.client.exec_command(command, timeout=20)
        out = stdout.read().decode("gbk", errors="ignore").strip()
        err = stderr.read().decode("gbk", errors="ignore").strip()
        if out:
            logger.info("输出: %s", out)
        if err:
            logger.warning("错误输出: %s", err)
        return out, err

    def close(self):
        if self.client:
            try:
                self.client.close()
            except Exception:
                pass
            self.client = None


# 全局SSH管理器
ssh = SSHManager()


# ==================== 状态检测 ====================


def check_wechat_by_api():
    """
    通过检测程序接口判断微信状态(检测程序必须已运行)
    """
    try:
        resp = requests.get(CHECK_URL, timeout=5)
        data = resp.json()

        if "message" in data:
            logger.warning("[接口检测] 微信进程未启动: %s", data["message"])
            return "not_running"

        if data.get("logined") == 1:
            logger.info("[接口检测] 微信已登录, nickname=%s", data.get("nickname"))
            return "logged_in"

        logger.warning("[接口检测] 微信未登录, logined=%s", data.get("logined"))
        return "not_logged_in"

    except requests.exceptions.RequestException:
        return "error"
    except (ValueError, KeyError):
        return "error"


def check_wechat_by_process():
    """
    通过SSH检查进程存活状态判断微信是否掉线
    - WeChat.exe 不存在 → not_running
    - WeChat.exe 存在但 pywxcode.exe 不存在 → 检测程序挂了,可能掉线
    - 两个都在 → 可能正常(但无法确认登录状态)
    """
    try:
        out, _ = ssh.execute('tasklist /FI "IMAGENAME eq WeChat.exe" /NH')
        wechat_running = "WeChat.exe" in out

        if not wechat_running:
            logger.warning("[进程检测] 微信进程不存在")
            return "not_running"

        out2, _ = ssh.execute(f'tasklist /FI "IMAGENAME eq {DETECTOR_PROCESS_NAME}" /NH')
        detector_running = DETECTOR_PROCESS_NAME in out2

        if not detector_running:
            logger.warning("[进程检测] 微信在运行但检测程序不在,可能掉线")
            return "not_logged_in"

        logger.info("[进程检测] 微信和检测程序都在运行")
        return "logged_in"

    except Exception as e:
        logger.error("[进程检测] SSH执行失败: %s", e)
        return "error"


# ==================== 服务器操作 ====================


def start_wechat():
    """启动微信进程"""
    logger.info(">>> 启动微信...")
    try:
        ssh.execute("taskkill /F /IM WeChat.exe")
        ssh.execute(f"taskkill /F /IM {DETECTOR_PROCESS_NAME}")
        time.sleep(3)

        ssh.execute(
            f'schtasks /Create /TN "StartWeChat" /TR "\'{WECHAT_EXE_PATH}\'" '
            f'/SC ONCE /ST 00:00 /F /RL HIGHEST'
        )
        ssh.execute('schtasks /Run /TN "StartWeChat"')
        ssh.execute('schtasks /Delete /TN "StartWeChat" /F')
        logger.info("微信进程已启动")
        time.sleep(10)
        return True
    except Exception as e:
        logger.error("启动微信失败: %s", e)
        return False


def click_login_button():
    """
    通过SFTP写信号文件触发AHK点击登录按钮,并读取OCR检测结果
    返回值:
        "waiting_confirm" - 点击成功,等待手机确认
        "need_scan_qr" - 需要扫码,长时间未登录
        "retry" - 点击没成功,需要重试
        "already_logged_in" - 已经登录了
        "error" - 出错
    """
    logger.info(">>> 触发点击微信登录按钮...")
    try:
        signal_file = r"C:\Users\Administrator\Desktop\do_click.txt"
        result_file = r"C:\Users\Administrator\Desktop\click_result.txt"

        # 用SFTP写信号文件
        ssh.ensure_connected()
        sftp = ssh.client.open_sftp()
        with sftp.open(signal_file, 'w') as f:
            f.write("click")
        sftp.close()

        logger.info("信号文件已写入,等待AHK执行+OCR...")
        time.sleep(18)  # AHK需要:2s检测 + 1s等待 + 0.5s激活 + 3s点击后等待 + 截图+OCR

        # 读取结果文件
        ssh.ensure_connected()
        sftp = ssh.client.open_sftp()
        try:
            with sftp.open(result_file, 'r') as f:
                result = f.read().decode('utf-8').strip()
            sftp.close()
        except FileNotFoundError:
            sftp.close()
            logger.warning("结果文件不存在,AHK可能未执行")
            return "error"

        logger.info("AHK返回结果: %s", result)

        if "WAITING_PHONE_CONFIRM" in result:
            logger.info("点击成功,等待手机确认登录")
            return "waiting_confirm"
        elif "NEED_SCAN_QR" in result:
            logger.warning("需要扫码登录(长时间未登录)")
            return "need_scan_qr"
        elif "LOGIN_BUTTON_STILL_THERE" in result:
            logger.warning("登录按钮仍在,点击可能未成功")
            return "retry"
        elif "ALREADY_LOGGED_IN" in result:
            logger.info("微信已经登录")
            return "already_logged_in"
        else:
            logger.error("OCR识别失败,无法判断状态: %s", result)
            return "ocr_failed"

    except Exception as e:
        logger.error("触发点击失败: %s", e)
        return "error"


def start_detector():
    """启动检测程序(必须在微信登录成功后调用)"""
    logger.info(">>> 启动检测程序...")
    try:
        detector_dir = r"C:\Users\Administrator\Desktop\WXService"
        ssh.execute(
            f'schtasks /Create /TN "StartDetector" '
            f'/TR "cmd /c cd /d {detector_dir} && {DETECTOR_PROCESS_NAME}" '
            f'/SC ONCE /ST 00:00 /F /RL HIGHEST'
        )
        ssh.execute('schtasks /Run /TN "StartDetector"')
        ssh.execute('schtasks /Delete /TN "StartDetector" /F')
        logger.info("检测程序已启动")
        time.sleep(5)
        return True
    except Exception as e:
        logger.error("启动检测程序失败: %s", e)
        return False


# ==================== ADB 操作 ====================

# 手机确认登录按钮坐标(1080x2400 分辨率)
ADB_CONFIRM_X = 540
ADB_CONFIRM_Y = 1530
# 本地截图路径
ADB_SCREENSHOT_LOCAL = r"d:\Desktop\restart\phone_screenshot.png"
ADB_SCREENSHOT_REMOTE = "/sdcard/screen.png"
# 最大重试次数
ADB_MAX_RETRY = 3


def adb_screenshot_and_ocr():
    """ADB截图并OCR识别"""
    try:
        from PIL import Image
        import pytesseract

        subprocess.run(["adb", "shell", "screencap", "-p", ADB_SCREENSHOT_REMOTE], timeout=10)
        subprocess.run(["adb", "pull", ADB_SCREENSHOT_REMOTE, ADB_SCREENSHOT_LOCAL], timeout=10)

        if not os.path.exists(ADB_SCREENSHOT_LOCAL):
            return ""

        img = Image.open(ADB_SCREENSHOT_LOCAL).convert("L")
        text = pytesseract.image_to_string(img, lang="chi_sim")
        return text.strip()
    except Exception as e:
        logger.error("[ADB] 截图/OCR失败: %s", e)
        return ""


def adb_check_phone_state():
    """
    检测手机当前状态
    返回值:
        "waiting_confirm" - 确认登录界面
        "logged_in" - 已登录成功
        "other" - 其他界面
    """
    text = adb_screenshot_and_ocr()
    if not text:
        return "other"

    if "微信已登录" in text or "手机通知已关闭" in text:
        return "logged_in"
    elif "登录 Windows 微信" in text or "登录确认" in text or "同步最近的消息" in text:
        return "waiting_confirm"
    else:
        return "other"


def adb_confirm_login():
    """
    ADB 完整流程:
    1. 唤醒屏幕 + 解锁
    2. 等待确认登录界面出现
    3. 点击登录按钮
    4. 验证是否点击成功
    """
    logger.info("[ADB] 开始手机确认登录流程...")

    try:
        # 1. 唤醒屏幕
        subprocess.run(["adb", "shell", "input", "keyevent", "KEYCODE_WAKEUP"], timeout=5)
        time.sleep(1)
        # 解锁屏幕(上滑)
        subprocess.run(["adb", "shell", "input", "swipe", "540", "2000", "540", "1000"], timeout=5)
        time.sleep(3)

        # 2. 等待确认登录界面出现(重试几次)
        for attempt in range(ADB_MAX_RETRY):
            state = adb_check_phone_state()

            if state == "waiting_confirm":
                logger.info("[ADB] 检测到确认登录界面,点击登录按钮...")
                # 3. 点击确认登录
                subprocess.run(["adb", "shell", "input", "tap", str(ADB_CONFIRM_X), str(ADB_CONFIRM_Y)], timeout=5)
                time.sleep(3)
                logger.info("[ADB] 确认登录已点击")
                return True
            elif state == "logged_in":
                logger.info("[ADB] 手机显示已登录成功")
                return True
            else:
                logger.info("[ADB] 未检测到确认界面,等待重试 (%d/%d)...", attempt + 1, ADB_MAX_RETRY)
                time.sleep(3)

        logger.warning("[ADB] 等待确认界面超时")
        return False

    except Exception as e:
        logger.error("[ADB] 操作失败: %s", e)
        return False


# ==================== 主流程 ====================

def reconnect_flow():
    """
    完整重连流程:
    1. 启动微信
    2. 点击登录按钮 + OCR检测结果
    3. 根据结果决定下一步
    返回值:
        True - 重连成功
        False - 重连失败但可继续监控
        "exit" - 需要终止程序
    """
    MAX_CLICK_RETRY = 3

    # 1. 启动微信
    if not start_wechat():
        return False

    # 2. 点击登录按钮并检测结果
    for attempt in range(MAX_CLICK_RETRY):
        result = click_login_button()

        if result == "waiting_confirm":
            # 点击成功,进入ADB流程
            break
        elif result == "need_scan_qr":
            # 需要扫码,记录日志并终止程序
            logger.error("=" * 50)
            logger.error("需要扫码登录,自动登录流程无法继续")
            logger.error("可能原因:长时间未登录,微信要求重新扫码验证")
            logger.error("请手动扫码登录后重启监控脚本")
            logger.error("=" * 50)
            return "exit"
        elif result == "retry":
            logger.info("点击未成功,重试第%d/%d次...", attempt + 1, MAX_CLICK_RETRY)
            continue
        elif result == "already_logged_in":
            logger.info("微信已登录,跳过ADB确认")
            if not start_detector():
                return False
            return True
        elif result == "ocr_failed":
            logger.error("=" * 50)
            logger.error("OCR识别失败,无法判断微信状态,终止程序")
            logger.error("请检查服务器上Tesseract和AHK脚本是否正常")
            logger.error("=" * 50)
            return "exit"
        else:
            logger.warning("检测异常,重试第%d/%d次...", attempt + 1, MAX_CLICK_RETRY)
            continue
    else:
        # 达到最大重试次数
        logger.error("=" * 50)
        logger.error("点击登录按钮%d次均未成功,终止程序", MAX_CLICK_RETRY)
        logger.error("可能原因:AHK常驻脚本未运行,或微信界面异常")
        logger.error("请检查服务器状态后重启监控脚本")
        logger.error("=" * 50)
        return "exit"

    # 3. ADB 确认登录
    if not adb_confirm_login():
        # ADB没找到确认界面,可能是服务器端登录按钮没点成功,重新尝试点击
        logger.warning("ADB未检测到确认界面,重新尝试服务器端点击登录...")
        click_login_button()
        time.sleep(3)
        # 再试一次ADB
        if not adb_confirm_login():
            logger.error("ADB二次确认仍失败,重连流程终止")
            return False

    # 4. 启动检测程序(ADB已通过OCR确认登录状态,不需要额外等待)
    if not start_detector():
        return False

    # 5. 验证
    time.sleep(5)
    status = check_wechat_by_api()
    if status == "logged_in":
        logger.info(">>> 重连流程全部完成!微信已登录")
    else:
        logger.warning(">>> 重连流程完成,接口状态: %s", status)

    return True


def main():
    logger.info("=== 微信状态监控启动 ===")

    # 建立SSH连接
    try:
        ssh.connect()
    except Exception as e:
        logger.error("初始SSH连接失败: %s(后续会自动重连)", e)

    try:
        while True:
            # 先尝试接口检测(检测程序运行时)
            status = check_wechat_by_api()

            # 接口不可用时用进程检测
            if status == "error":
                status = check_wechat_by_process()

            if status == "logged_in":
                pass  # 正常,继续监控
            elif status in ("not_logged_in", "not_running"):
                logger.info("检测到掉线,启动重连流程...")
                result = reconnect_flow()
                if result == "exit":
                    logger.error("程序终止")
                    break
                # 重连后等待一段时间再检查,避免立刻又触发重连
                time.sleep(20)
            elif status == "error":
                logger.info("检测异常,等待下次重试...")

            time.sleep(CHECK_INTERVAL)
    finally:
        ssh.close()
        logger.info("=== 监控脚本退出,SSH连接已关闭 ===")


if __name__ == "__main__":
    main()

click_login.ahk

#Requires AutoHotkey v2.0
#SingleInstance Force
Persistent

; 常驻AHK脚本:监听信号文件,点击登录并OCR检测结果

SIGNAL_FILE := "C:\Users\Administrator\Desktop\do_click.txt"
RESULT_FILE := "C:\Users\Administrator\Desktop\click_result.txt"
SCREENSHOT_PATH := "C:\Users\Administrator\Desktop\wechat_screenshot.png"
TESSERACT_EXE := "C:\Program Files\Tesseract-OCR\tesseract.exe"
OCR_OUTPUT := "C:\Users\Administrator\Desktop\ocr_output"

A_IconTip := "WeChat Login Clicker - Waiting"

Loop {
    if FileExist(SIGNAL_FILE) {
        ; 清理旧结果文件
        if FileExist(RESULT_FILE)
            FileDelete(RESULT_FILE)
        if FileExist(SCREENSHOT_PATH)
            FileDelete(SCREENSHOT_PATH)
        if FileExist(OCR_OUTPUT ".txt")
            FileDelete(OCR_OUTPUT ".txt")

        ; 先执行点击流程
        DoClick()

        ; 执行完后再删信号文件(此时Python端已释放句柄)
        Sleep 1000
        try {
            FileDelete(SIGNAL_FILE)
        } catch {
        }
    }
    Sleep 2000
}

DoClick() {
    global RESULT_FILE, SCREENSHOT_PATH, TESSERACT_EXE, OCR_OUTPUT

    ; 等待微信登录窗口出现(最多等30秒)
    if !WinWait("ahk_class WeChatLoginWndForPC", , 30) {
        if WinExist("ahk_class WeChatMainWndForPC") {
            FileAppend("ALREADY_LOGGED_IN", RESULT_FILE)
        } else {
            FileAppend("ERROR:LOGIN_WINDOW_NOT_FOUND", RESULT_FILE)
        }
        return
    }

    Sleep 1000
    WinActivate "ahk_class WeChatLoginWndForPC"
    Sleep 500

    ; 发送Enter点击登录
    ControlSend("{Enter}", , "ahk_class WeChatLoginWndForPC")
    Sleep 10000

    ; 截图
    TakeScreenshot()
    Sleep 500

    ; 调用Tesseract OCR识别
    if !FileExist(SCREENSHOT_PATH) {
        FileAppend("ERROR:SCREENSHOT_FAILED", RESULT_FILE)
        return
    }

    RunWait('"' TESSERACT_EXE '" "' SCREENSHOT_PATH '" "' OCR_OUTPUT '" -l chi_sim', , "Hide")
    Sleep 500

    ; 读取OCR结果
    ocrFile := OCR_OUTPUT ".txt"
    if !FileExist(ocrFile) {
        FileAppend("ERROR:OCR_FAILED", RESULT_FILE)
        return
    }

    ocrText := FileRead(ocrFile, "UTF-8")

    ; 优先匹配点击成功状态
    if InStr(ocrText, "需在手机上完成登录") or InStr(ocrText, "在手机上完成登录") {
        FileAppend("WAITING_PHONE_CONFIRM", RESULT_FILE)
    } else if InStr(ocrText, "切换账号") {
        FileAppend("LOGIN_BUTTON_STILL_THERE", RESULT_FILE)
    } else if InStr(ocrText, "扫码登录") or InStr(ocrText, "仅传输文件") {
        FileAppend("NEED_SCAN_QR", RESULT_FILE)
    } else {
        FileAppend("UNKNOWN:" ocrText, RESULT_FILE)
    }
}

TakeScreenshot() {
    global SCREENSHOT_PATH

    wx := 0
    wy := 0
    ww := 0
    wh := 0

    if WinExist("ahk_class WeChatLoginWndForPC") {
        WinGetPos(&wx, &wy, &ww, &wh, "ahk_class WeChatLoginWndForPC")
    } else if WinExist("ahk_class WeChatMainWndForPC") {
        WinGetPos(&wx, &wy, &ww, &wh, "ahk_class WeChatMainWndForPC")
    }

    ; 生成截图PowerShell脚本(带灰度转换,提高OCR识别率)
    psScript := "C:\Users\Administrator\Desktop\do_screenshot.ps1"
    if FileExist(psScript)
        FileDelete(psScript)

    if (ww > 0 and wh > 0) {
        scriptContent := "Add-Type -AssemblyName System.Drawing`n"
            . "$bmp = New-Object System.Drawing.Bitmap(" ww "," wh ")`n"
            . "$g = [System.Drawing.Graphics]::FromImage($bmp)`n"
            . "$g.CopyFromScreen(" wx "," wy ", 0, 0, (New-Object System.Drawing.Size(" ww "," wh ")))`n"
            . "$g.Dispose()`n"
            . "$gray = New-Object System.Drawing.Bitmap(" ww "," wh ")`n"
            . "$g2 = [System.Drawing.Graphics]::FromImage($gray)`n"
            . "$cm = New-Object System.Drawing.Imaging.ColorMatrix`n"
            . "$cm.Matrix00 = 0.3; $cm.Matrix01 = 0.3; $cm.Matrix02 = 0.3`n"
            . "$cm.Matrix10 = 0.59; $cm.Matrix11 = 0.59; $cm.Matrix12 = 0.59`n"
            . "$cm.Matrix20 = 0.11; $cm.Matrix21 = 0.11; $cm.Matrix22 = 0.11`n"
            . "$cm.Matrix33 = 1; $cm.Matrix44 = 1`n"
            . "$attr = New-Object System.Drawing.Imaging.ImageAttributes`n"
            . "$attr.SetColorMatrix($cm)`n"
            . "$rect = New-Object System.Drawing.Rectangle(0, 0, " ww ", " wh ")`n"
            . "$g2.DrawImage($bmp, $rect, 0, 0, " ww ", " wh ", [System.Drawing.GraphicsUnit]::Pixel, $attr)`n"
            . "$g2.Dispose()`n"
            . "$gray.Save('" SCREENSHOT_PATH "')`n"
            . "$bmp.Dispose(); $gray.Dispose()`n"
    } else {
        scriptContent := "Add-Type -AssemblyName System.Windows.Forms`n"
            . "Add-Type -AssemblyName System.Drawing`n"
            . "$s = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds`n"
            . "$bmp = New-Object System.Drawing.Bitmap($s.Width, $s.Height)`n"
            . "$g = [System.Drawing.Graphics]::FromImage($bmp)`n"
            . "$g.CopyFromScreen(0, 0, 0, 0, (New-Object System.Drawing.Size($s.Width, $s.Height)))`n"
            . "$g.Dispose()`n"
            . "$gray = New-Object System.Drawing.Bitmap($s.Width, $s.Height)`n"
            . "$g2 = [System.Drawing.Graphics]::FromImage($gray)`n"
            . "$cm = New-Object System.Drawing.Imaging.ColorMatrix`n"
            . "$cm.Matrix00 = 0.3; $cm.Matrix01 = 0.3; $cm.Matrix02 = 0.3`n"
            . "$cm.Matrix10 = 0.59; $cm.Matrix11 = 0.59; $cm.Matrix12 = 0.59`n"
            . "$cm.Matrix20 = 0.11; $cm.Matrix21 = 0.11; $cm.Matrix22 = 0.11`n"
            . "$cm.Matrix33 = 1; $cm.Matrix44 = 1`n"
            . "$attr = New-Object System.Drawing.Imaging.ImageAttributes`n"
            . "$attr.SetColorMatrix($cm)`n"
            . "$rect = New-Object System.Drawing.Rectangle(0, 0, $s.Width, $s.Height)`n"
            . "$g2.DrawImage($bmp, $rect, 0, 0, $s.Width, $s.Height, [System.Drawing.GraphicsUnit]::Pixel, $attr)`n"
            . "$g2.Dispose()`n"
            . "$gray.Save('" SCREENSHOT_PATH "')`n"
            . "$bmp.Dispose(); $gray.Dispose()`n"
    }

    FileAppend(scriptContent, psScript)
    RunWait('powershell -ExecutionPolicy Bypass -File "' psScript '"', , "Hide")
}