375 lines
12 KiB
Python
Raw Normal View History

"""
语料采集 · Mac客户端
====================
在Mac上运行自动截图OCR+滚屏采集发送到服务端处理
用法:
# 实时采集模式(后台监控屏幕变化)
python3 mac-corpus-agent.py --token YOUR_TOKEN --mode auto
# 滚屏采集模式(采集历史对话,先手动滚到顶部)
python3 mac-corpus-agent.py --token YOUR_TOKEN --mode scroll
# 剪贴板采集模式(复制即采集)
python3 mac-corpus-agent.py --token YOUR_TOKEN --mode clipboard
选项:
--server 服务端地址 (默认 http://localhost:8084)
--interval 采集间隔秒数 (默认 auto=5s, scroll=3s)
--region 屏幕采集区域 (默认 全屏)
--help 显示帮助
"""
import os
import sys
import json
import time
import argparse
import subprocess
import tempfile
import threading
from pathlib import Path
from typing import Optional
try:
import websocket
except ImportError:
print("❌ 缺少依赖: pip3 install websocket-client")
sys.exit(1)
try:
import pyautogui
except ImportError:
print("❌ 缺少依赖: pip3 install pyautogui pillow")
sys.exit(1)
# ============================================================
# 配置
# ============================================================
VERSION = "1.0.0"
SERVER_URL = "http://localhost:8084"
RECONNECT_DELAY = 5 # 断线重连等待秒数
# 键盘快捷键(全局热键用,需管理员权限)
HOTKEY_STOP = "esc" # 停止采集
HOTKEY_PAUSE = "f6" # 暂停/继续
# ============================================================
# macOS工具函数
# ============================================================
def screenshot(region: Optional[tuple] = None) -> bytes:
"""截取屏幕指定区域返回PNG字节"""
if region:
img = pyautogui.screenshot(region=region)
else:
img = pyautogui.screenshot()
buf = tempfile.NamedTemporaryFile(suffix=".png", delete=False)
img.save(buf, format="PNG")
buf.close()
with open(buf.name, "rb") as f:
data = f.read()
os.unlink(buf.name)
return data
def ocr_text(image_path: str) -> str:
"""使用macOS原生Vision框架做OCR本地不上传"""
script = f'''
use framework "Vision"
use scripting additions
set theImage to (current application's NSImage's alloc()'s initWithContentsOfFile:"{image_path}")
set requestHandler to (current application's VNImageRequestHandler's alloc()'s initWithData:(theImage's TIFFRepresentation()) options:(missing value))
set textResult to ""
set theRequest to (current application's VNRecognizeTextRequest's alloc()'s initWithCompletionHandler:(lambda request, error
if error missing value then return
set observations to request's results()
repeat with obs in observations
set topCandidate to (obs's topCandidates:(1))
if topCandidate's count() > 0 then
set candidate to (topCandidate's objectAtIndex:(0))
set recognizedText to candidate's string() as text
set textResult to textResult & recognizedText & linefeed
end if
end repeat
end))
theRequest's setRecognitionLevel:(VNRequestTextRecognitionLevel1) -- Accurate
requestHandler's performRequests:({{theRequest}}) |error|:(missing value)
return textResult
'''
result = subprocess.run(
["osascript", "-e", script],
capture_output=True, text=True, timeout=30
)
return result.stdout.strip()
def scroll_page(direction: str = "down", amount: int = 3):
"""模拟鼠标滚轮"""
pyautogui.scroll(-amount if direction == "down" else amount)
def get_active_window_title() -> str:
"""获取当前活跃窗口标题"""
script = 'tell application "System Events" to get name of first application process whose frontmost is true'
result = subprocess.run(["osascript", "-e", script], capture_output=True, text=True)
return result.stdout.strip()
def notify(title: str, message: str):
"""MacOS系统通知"""
script = f'display notification "{message}" with title "{title}"'
subprocess.run(["osascript", "-e", script], capture_output=True)
# ============================================================
# 采集器核心
# ============================================================
class CorpusCollector:
"""语料采集器"""
def __init__(self, token: str, server: str, interval: float):
self.token = token
self.server = server
self.interval = interval
self.ws = None
self.running = False
self.paused = False
self.last_text = "" # 去重用
self.collected_count = 0
self.filtered_count = 0
def connect_ws(self) -> bool:
"""连接WebSocket"""
try:
ws_url = self.server.replace("http://", "ws://").replace("https://", "wss://")
ws_url = f"{ws_url}/ws/collect"
self.ws = websocket.create_connection(
f"{ws_url}?token={self.token}",
timeout=10
)
# 验证连接
self.ws.send(json.dumps({"type": "ping"}))
resp = json.loads(self.ws.recv())
if resp.get("type") == "pong":
print(f" ✅ WebSocket已连接")
return True
except Exception as e:
print(f" ❌ WebSocket连接失败: {e}")
return False
def send_text(self, text: str, source: str = "screen_capture"):
"""发送文本到服务器"""
if not text or len(text) < 10:
return
# 简单去重(连续相同内容跳过)
if text == self.last_text:
return
self.last_text = text
try:
if self.ws:
self.ws.send(json.dumps({
"type": "text",
"text": text,
"source": source
}))
resp = json.loads(self.ws.recv())
if resp.get("collected", 0) > 0:
self.collected_count += resp["collected"]
print(f" ✅ 采集 {resp['collected']} 条 | 总计: {self.collected_count}")
notify("语料采集", f"已采集 {resp['collected']} 条对话")
elif resp.get("valuable"):
pass # 有价值但未成对
else:
self.filtered_count += 1
except Exception as e:
print(f" ⚠️ 发送失败: {e}")
def process_screenshot(self):
"""截屏→OCR→发送"""
try:
# 截屏
img_data = screenshot()
# 存临时文件
tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False)
tmp.write(img_data)
tmp.close()
# OCR
text = ocr_text(tmp.name)
os.unlink(tmp.name)
if text.strip():
self.send_text(text.strip())
except Exception as e:
print(f" ⚠️ 截图处理失败: {e}")
# === 模式1: 实时采集 ===
def run_auto_mode(self):
"""实时模式:后台监控屏幕变化"""
print("\n🔴 实时采集模式启动中...")
print(f" 采集间隔: {self.interval}")
print(f" 按 ESC 停止, F6 暂停/继续")
print(f" 活跃窗口: {get_active_window_title()}")
self.running = True
while self.running:
if self.paused:
time.sleep(1)
continue
self.process_screenshot()
# 检查快捷键(每轮检查一次)
# Mac上需要更好的热键方案这里简化处理
for _ in range(int(self.interval * 2)):
if not self.running:
break
time.sleep(0.5)
self.cleanup()
# === 模式2: 滚屏采集 ===
def run_scroll_mode(self):
"""滚屏模式:自动向下滚动采集历史对话"""
print("\n📜 滚屏采集模式启动")
print(f" 采集间隔: {self.interval}")
print(f" 请确保已手动滚动到页面顶部!")
print(f" 5秒后开始...")
time.sleep(5)
self.running = True
scroll_count = 0
empty_rounds = 0
while self.running and empty_rounds < 10:
# 截图+OCR
self.process_screenshot()
# 滚动
scroll_page("down", 5)
scroll_count += 1
# 检测是否到底连续N次没有新内容
time.sleep(self.interval)
if scroll_count % 10 == 0:
print(f" 已滚动 {scroll_count} 次 | 采集: {self.collected_count} | 过滤: {self.filtered_count}")
print(f"\n✅ 滚屏采集完成")
print(f" 共滚动 {scroll_count}")
print(f" 采集: {self.collected_count}")
print(f" 过滤: {self.filtered_count}")
notify("语料采集完成", f"共采集 {self.collected_count} 条对话")
self.cleanup()
# === 模式3: 剪贴板采集 ===
def run_clipboard_mode(self):
"""剪贴板模式:监控剪贴板变化,自动采集"""
print("\n📋 剪贴板采集模式启动")
print(f" 监控间隔: {self.interval}")
print(f" 复制内容后自动采集...")
import subprocess
self.running = True
last_clip = ""
while self.running:
# 获取剪贴板内容
result = subprocess.run(
["pbpaste"],
capture_output=True, text=True
)
current = result.stdout.strip()
if current and current != last_clip:
print(f"\n 📋 检测到新内容 ({len(current)}字)")
self.send_text(current, "clipboard")
last_clip = current
time.sleep(self.interval)
self.cleanup()
def cleanup(self):
"""清理"""
if self.ws:
try:
self.ws.close()
except:
pass
# ============================================================
# 主入口
# ============================================================
def main():
parser = argparse.ArgumentParser(description="语料采集 Mac 客户端")
parser.add_argument("--token", required=True, help="Gitea Access Token")
parser.add_argument("--server", default=SERVER_URL, help=f"服务端地址 (默认 {SERVER_URL})")
parser.add_argument("--mode", choices=["auto", "scroll", "clipboard"], default="auto",
help="采集模式: auto=实时, scroll=滚屏, clipboard=剪贴板")
parser.add_argument("--interval", type=float, default=0,
help="采集间隔秒数 (默认 auto=5, scroll=3, clipboard=2)")
args = parser.parse_args()
# 默认间隔
if args.interval <= 0:
intervals = {"auto": 5.0, "scroll": 3.0, "clipboard": 2.0}
args.interval = intervals[args.mode]
print(f"\n{'='*50}")
print(f"🧠 语料采集 Mac 客户端 v{VERSION}")
print(f"{'='*50}")
print(f" 模式: {args.mode}")
print(f" 服务端: {args.server}")
print(f" 间隔: {args.interval}s")
print(f"{'='*50}\n")
collector = CorpusCollector(args.token, args.server, args.interval)
# 连接服务端
print("🔄 连接服务端...")
if not collector.connect_ws():
print(" ⚠️ WebSocket连接失败将使用HTTP回退")
print(" 请确保服务端已启动: python3 server.py")
print(f" 服务端地址: {args.server}")
try:
if args.mode == "auto":
collector.run_auto_mode()
elif args.mode == "scroll":
collector.run_scroll_mode()
elif args.mode == "clipboard":
collector.run_clipboard_mode()
except KeyboardInterrupt:
print("\n\n⏹️ 用户停止")
collector.cleanup()
print("\n👋 采集结束")
if __name__ == "__main__":
main()