""" 语料采集 · 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()