Guanghu Domestic Migration d1e47f4565
Some checks are pending
自动更新代码和重启 / update-and-restart (push) Waiting to run
CI检查 + 自动部署 / check (push) Waiting to run
CI检查 + 自动部署 / deploy (push) Blocked by required conditions
重启聊天服务 / restart (push) Waiting to run
chore: import sanitized domestic snapshot for REPO-002
Source snapshot: ca48d3ddf926d79aa138306164169baf764bb829
2026-07-17 15:54:41 +08:00

375 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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