haha
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -30,6 +30,9 @@ Desktop.ini
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# 客户端 GUI 本地配置
|
||||
client_config.json
|
||||
|
||||
# 日志
|
||||
*.log
|
||||
|
||||
|
||||
10
app.py
Normal file
10
app.py
Normal file
@@ -0,0 +1,10 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
中央服务器唯一入口。
|
||||
启动方式: python app.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
if __name__ == "__main__":
|
||||
from server.main import main
|
||||
main()
|
||||
63
build_client.spec
Normal file
63
build_client.spec
Normal file
@@ -0,0 +1,63 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
# 客户端 GUI 打包配置
|
||||
# 用法: 在项目根目录执行 pyinstaller build_client.spec
|
||||
|
||||
import os
|
||||
_project_root = os.path.dirname(os.path.abspath(SPECPATH))
|
||||
|
||||
block_cipher = None
|
||||
|
||||
a = Analysis(
|
||||
[os.path.join(_project_root, 'run_client.py')],
|
||||
pathex=[_project_root],
|
||||
binaries=[],
|
||||
datas=[],
|
||||
hiddenimports=[
|
||||
'client_gui',
|
||||
'client_gui.main',
|
||||
'client_gui.config_store',
|
||||
'PyQt5',
|
||||
'PyQt5.QtCore',
|
||||
'PyQt5.QtWidgets',
|
||||
'PyQt5.QtGui',
|
||||
],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
win_no_prefer_redirects=False,
|
||||
win_private_assemblies=False,
|
||||
cipher=block_cipher,
|
||||
noarchive=False,
|
||||
)
|
||||
|
||||
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
[],
|
||||
exclude_binaries=True,
|
||||
name='BOSS直聘Worker客户端',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
console=False, # 无控制台窗口
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
)
|
||||
|
||||
coll = COLLECT(
|
||||
exe,
|
||||
a.binaries,
|
||||
a.zipfiles,
|
||||
a.datas,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
name='BOSS直聘Worker客户端',
|
||||
)
|
||||
71
build_worker.spec
Normal file
71
build_worker.spec
Normal file
@@ -0,0 +1,71 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
# Worker 打包配置(供客户端 GUI 的「启动」按钮调用)
|
||||
# 用法: pyinstaller build_worker.spec
|
||||
# 将生成的 worker.exe 放入「BOSS直聘Worker客户端」输出目录
|
||||
|
||||
block_cipher = None
|
||||
|
||||
import os
|
||||
_project_root = os.path.dirname(os.path.abspath(SPECPATH))
|
||||
|
||||
a = Analysis(
|
||||
[os.path.join(_project_root, 'worker', 'main.py')],
|
||||
pathex=[_project_root],
|
||||
binaries=[],
|
||||
datas=[],
|
||||
hiddenimports=[
|
||||
'worker',
|
||||
'worker.ws_client',
|
||||
'worker.bit_browser',
|
||||
'worker.browser_control',
|
||||
'worker.tasks',
|
||||
'worker.tasks.registry',
|
||||
'worker.tasks.base',
|
||||
'worker.tasks.boss_recruit',
|
||||
'worker.tasks.check_login',
|
||||
'tunnel',
|
||||
'tunnel.client',
|
||||
'tunnel.protocol',
|
||||
'common',
|
||||
'common.protocol',
|
||||
],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
win_no_prefer_redirects=False,
|
||||
win_private_assemblies=False,
|
||||
cipher=block_cipher,
|
||||
noarchive=False,
|
||||
)
|
||||
|
||||
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
[],
|
||||
exclude_binaries=True,
|
||||
name='worker',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
console=True, # Worker 需要控制台输出
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
)
|
||||
|
||||
coll = COLLECT(
|
||||
exe,
|
||||
a.binaries,
|
||||
a.zipfiles,
|
||||
a.datas,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
name='worker',
|
||||
)
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 127 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.5 KiB |
2
client_gui/__init__.py
Normal file
2
client_gui/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""客户端 GUI 启动器(比特浏览器 + Worker)。"""
|
||||
51
client_gui/config_store.py
Normal file
51
client_gui/config_store.py
Normal file
@@ -0,0 +1,51 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""GUI 配置存储。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
# 默认配置(云端服务器公网 IP 写死)
|
||||
DEFAULTS = {
|
||||
"server_url": "ws://8.137.99.82:9000/ws",
|
||||
"worker_id": "worker-1",
|
||||
"worker_name": "本机",
|
||||
}
|
||||
|
||||
|
||||
def get_config_path() -> str:
|
||||
"""配置文件路径:项目根目录下的 client_config.json。"""
|
||||
root = get_project_root()
|
||||
return os.path.join(root, "client_config.json")
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
"""加载配置。若为旧版本地地址则迁移为云端地址。"""
|
||||
path = get_config_path()
|
||||
if os.path.isfile(path):
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
cfg = {**DEFAULTS, **data}
|
||||
# 旧版本地地址迁移为云端地址
|
||||
if cfg.get("server_url") == "ws://127.0.0.1:9000/ws":
|
||||
cfg["server_url"] = DEFAULTS["server_url"]
|
||||
return cfg
|
||||
except Exception:
|
||||
pass
|
||||
return DEFAULTS.copy()
|
||||
|
||||
|
||||
def save_config(data: dict) -> None:
|
||||
"""保存配置。"""
|
||||
path = get_config_path()
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def get_project_root() -> str:
|
||||
"""项目根目录。打包运行时为 exe 所在目录,否则为项目源码根目录。"""
|
||||
if getattr(sys, "frozen", False):
|
||||
return os.path.dirname(sys.executable)
|
||||
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
381
client_gui/main.py
Normal file
381
client_gui/main.py
Normal file
@@ -0,0 +1,381 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
客户端 GUI 主程序(PyQt5)。
|
||||
供线下有比特浏览器的电脑使用,非技术用户可通过界面完成「更新」和「启动」。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import queue
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
|
||||
from PyQt5.QtWidgets import (
|
||||
QApplication,
|
||||
QMainWindow,
|
||||
QWidget,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QTextEdit,
|
||||
QProgressBar,
|
||||
QMessageBox,
|
||||
QFrame,
|
||||
)
|
||||
from PyQt5.QtCore import Qt, QTimer, pyqtSignal, QObject, QEvent
|
||||
from PyQt5.QtGui import QFont, QTextCursor
|
||||
|
||||
from .config_store import load_config, save_config, get_project_root
|
||||
|
||||
# 日志缓冲
|
||||
LOG_BATCH_CHARS = 2048
|
||||
LOG_MAX_LINES = 2000
|
||||
LOG_DRAIN_INTERVAL_MS = 120
|
||||
|
||||
|
||||
def do_git_pull(project_root: str, log_callback) -> bool:
|
||||
"""在项目目录执行 git pull。"""
|
||||
git = shutil.which("git")
|
||||
if not git:
|
||||
log_callback("错误:未找到 git,请先安装 Git 或在已克隆的代码目录中手动更新。\n")
|
||||
return False
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[git, "pull"],
|
||||
cwd=project_root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
if proc.stdout:
|
||||
log_callback(proc.stdout)
|
||||
if proc.stderr:
|
||||
log_callback(proc.stderr)
|
||||
if proc.returncode == 0:
|
||||
log_callback("更新完成。\n")
|
||||
return True
|
||||
log_callback(f"更新失败,返回码: {proc.returncode}\n")
|
||||
return False
|
||||
except Exception as e:
|
||||
log_callback(f"更新出错: {e}\n")
|
||||
return False
|
||||
|
||||
|
||||
def _is_frozen() -> bool:
|
||||
return getattr(sys, "frozen", False)
|
||||
|
||||
|
||||
def _get_worker_launcher() -> tuple[str, str]:
|
||||
if _is_frozen():
|
||||
exe_dir = os.path.dirname(sys.executable)
|
||||
worker_exe = os.path.join(exe_dir, "worker.exe")
|
||||
if os.path.isfile(worker_exe):
|
||||
return worker_exe, exe_dir
|
||||
project_root = get_project_root()
|
||||
return sys.executable, project_root
|
||||
|
||||
|
||||
def run_worker(server_url: str, worker_id: str, worker_name: str, log_callback) -> subprocess.Popen | None:
|
||||
"""启动 Worker 子进程。"""
|
||||
launcher, cwd = _get_worker_launcher()
|
||||
is_python = launcher == sys.executable
|
||||
if is_python:
|
||||
cmd = [
|
||||
launcher, "-m", "worker.main",
|
||||
"--server", server_url,
|
||||
"--worker-id", worker_id,
|
||||
"--worker-name", worker_name,
|
||||
]
|
||||
env = {**os.environ, "PYTHONIOENCODING": "utf-8"}
|
||||
encoding = "utf-8"
|
||||
else:
|
||||
cmd = [launcher, "--server", server_url, "--worker-id", worker_id, "--worker-name", worker_name]
|
||||
env = os.environ
|
||||
# worker.exe 在 Windows 上可能用 GBK 输出
|
||||
encoding = "gbk" if sys.platform == "win32" else "utf-8"
|
||||
try:
|
||||
return subprocess.Popen(
|
||||
cmd, cwd=cwd, env=env,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
text=True, encoding=encoding, errors="replace", bufsize=1,
|
||||
)
|
||||
except Exception as e:
|
||||
log_callback(f"启动失败: {e}\n")
|
||||
return None
|
||||
|
||||
|
||||
def _reader_thread(proc: subprocess.Popen, log_queue: queue.Queue) -> None:
|
||||
"""后台线程:读取子进程 stdout,放入队列。避免主线程阻塞。"""
|
||||
try:
|
||||
for line in iter(proc.stdout.readline, ""):
|
||||
if line:
|
||||
log_queue.put(line)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class LogWorker(QObject):
|
||||
"""日志队列批量渲染,避免卡顿。"""
|
||||
batch_ready = pyqtSignal(str)
|
||||
|
||||
def __init__(self, log_queue: queue.Queue, parent=None):
|
||||
super().__init__(parent)
|
||||
self.log_queue = log_queue
|
||||
self.timer = QTimer(self)
|
||||
self.timer.timeout.connect(self._drain)
|
||||
self.timer.start(LOG_DRAIN_INTERVAL_MS)
|
||||
|
||||
def _drain(self):
|
||||
if self.log_queue.empty():
|
||||
return
|
||||
batch = []
|
||||
size = 0
|
||||
try:
|
||||
while size < LOG_BATCH_CHARS:
|
||||
batch.append(self.log_queue.get_nowait())
|
||||
size += len(batch[-1])
|
||||
except queue.Empty:
|
||||
pass
|
||||
if batch:
|
||||
self.batch_ready.emit("".join(batch))
|
||||
|
||||
|
||||
class ClientGUI(QMainWindow):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("BOSS 直聘 Worker 客户端")
|
||||
self.setMinimumSize(600, 460)
|
||||
self.resize(620, 500)
|
||||
|
||||
self.config = load_config()
|
||||
self.worker_proc: subprocess.Popen | None = None
|
||||
self._poll_timer: QTimer | None = None
|
||||
self._log_queue: queue.Queue[str] = queue.Queue()
|
||||
|
||||
self._build_ui()
|
||||
self._log_worker = LogWorker(self._log_queue, self)
|
||||
self._log_worker.batch_ready.connect(self._append_log_batch)
|
||||
|
||||
def _build_ui(self):
|
||||
central = QWidget()
|
||||
self.setCentralWidget(central)
|
||||
layout = QVBoxLayout(central)
|
||||
layout.setSpacing(8)
|
||||
|
||||
# 配置区
|
||||
cfg_group = QGroupBox("连接配置")
|
||||
cfg_layout = QGridLayout(cfg_group)
|
||||
cfg_layout.addWidget(QLabel("服务器地址:"), 0, 0)
|
||||
self.entry_server = QLineEdit()
|
||||
self.entry_server.setPlaceholderText("ws://8.137.99.82:9000/ws")
|
||||
self.entry_server.setText(self.config.get("server_url", ""))
|
||||
cfg_layout.addWidget(self.entry_server, 0, 1)
|
||||
|
||||
cfg_layout.addWidget(QLabel("Worker ID:"), 1, 0)
|
||||
self.entry_worker_id = QLineEdit()
|
||||
self.entry_worker_id.setText(self.config.get("worker_id", ""))
|
||||
cfg_layout.addWidget(self.entry_worker_id, 1, 1)
|
||||
|
||||
cfg_layout.addWidget(QLabel("Worker 名称:"), 2, 0)
|
||||
self.entry_worker_name = QLineEdit()
|
||||
self.entry_worker_name.setText(self.config.get("worker_name", ""))
|
||||
cfg_layout.addWidget(self.entry_worker_name, 2, 1)
|
||||
|
||||
layout.addWidget(cfg_group)
|
||||
|
||||
# 操作按钮
|
||||
btn_layout = QHBoxLayout()
|
||||
self.btn_update = QPushButton("更新代码")
|
||||
self.btn_update.setCursor(Qt.PointingHandCursor)
|
||||
self.btn_update.clicked.connect(self._on_update)
|
||||
btn_layout.addWidget(self.btn_update)
|
||||
|
||||
self.btn_start = QPushButton("启动")
|
||||
self.btn_start.setCursor(Qt.PointingHandCursor)
|
||||
self.btn_start.clicked.connect(self._on_start)
|
||||
btn_layout.addWidget(self.btn_start)
|
||||
|
||||
self.btn_stop = QPushButton("停止")
|
||||
self.btn_stop.setCursor(Qt.PointingHandCursor)
|
||||
self.btn_stop.clicked.connect(self._on_stop)
|
||||
self.btn_stop.setEnabled(False)
|
||||
btn_layout.addWidget(self.btn_stop)
|
||||
|
||||
btn_layout.addStretch()
|
||||
layout.addLayout(btn_layout)
|
||||
|
||||
# 进度条(更新时显示)
|
||||
self.progress = QProgressBar()
|
||||
self.progress.setRange(0, 0) # indeterminate
|
||||
self.progress.setVisible(False)
|
||||
layout.addWidget(self.progress)
|
||||
|
||||
# 日志区
|
||||
log_group = QGroupBox("运行日志")
|
||||
log_layout = QVBoxLayout(log_group)
|
||||
self.log_text = QTextEdit()
|
||||
self.log_text.setReadOnly(True)
|
||||
self.log_text.setFont(QFont("Consolas", 9))
|
||||
self.log_text.setStyleSheet(
|
||||
"QTextEdit { background-color: #f8fafc; color: #1e293b; border: 1px solid #e2e8f0; border-radius: 4px; }"
|
||||
)
|
||||
log_layout.addWidget(self.log_text)
|
||||
layout.addWidget(log_group, 1)
|
||||
|
||||
# 状态栏
|
||||
status_frame = QFrame()
|
||||
status_layout = QHBoxLayout(status_frame)
|
||||
status_layout.setContentsMargins(0, 4, 0, 0)
|
||||
self.status_indicator = QLabel("●")
|
||||
self.status_indicator.setStyleSheet("color: #22c55e; font-size: 12px;")
|
||||
status_layout.addWidget(self.status_indicator)
|
||||
self.status_label = QLabel("就绪")
|
||||
status_layout.addWidget(self.status_label)
|
||||
status_layout.addStretch()
|
||||
layout.addWidget(status_frame)
|
||||
|
||||
def _save_config(self):
|
||||
self.config["server_url"] = self.entry_server.text().strip()
|
||||
self.config["worker_id"] = self.entry_worker_id.text().strip()
|
||||
self.config["worker_name"] = self.entry_worker_name.text().strip()
|
||||
save_config(self.config)
|
||||
|
||||
def _log(self, msg: str):
|
||||
self._log_queue.put(msg)
|
||||
|
||||
def _append_log_batch(self, text: str):
|
||||
cursor = self.log_text.textCursor()
|
||||
cursor.movePosition(QTextCursor.End)
|
||||
self.log_text.setTextCursor(cursor)
|
||||
self.log_text.insertPlainText(text)
|
||||
# 限制行数
|
||||
block = self.log_text.document().firstBlock()
|
||||
count = 0
|
||||
while block.isValid():
|
||||
count += 1
|
||||
block = block.next()
|
||||
if count > LOG_MAX_LINES:
|
||||
cursor = self.log_text.textCursor()
|
||||
cursor.movePosition(QTextCursor.Start)
|
||||
for _ in range(count - LOG_MAX_LINES):
|
||||
cursor.movePosition(QTextCursor.Down, QTextCursor.KeepAnchor)
|
||||
cursor.removeSelectedText()
|
||||
self.log_text.verticalScrollBar().setValue(self.log_text.verticalScrollBar().maximum())
|
||||
|
||||
def _set_status(self, text: str, color: str = "#22c55e"):
|
||||
self.status_label.setText(text)
|
||||
self.status_indicator.setStyleSheet(f"color: {color}; font-size: 12px;")
|
||||
|
||||
def _on_update(self):
|
||||
self._save_config()
|
||||
self.btn_update.setEnabled(False)
|
||||
self._set_status("更新中…", "#f59e0b")
|
||||
self.progress.setVisible(True)
|
||||
self._log("正在更新代码(git pull)...\n")
|
||||
|
||||
def task():
|
||||
ok = do_git_pull(get_project_root(), self._log)
|
||||
QApplication.instance().postEvent(
|
||||
self, _FinishUpdateEvent(ok)
|
||||
)
|
||||
|
||||
threading.Thread(target=task, daemon=True).start()
|
||||
|
||||
def event(self, e):
|
||||
if isinstance(e, _FinishUpdateEvent):
|
||||
self.progress.setVisible(False)
|
||||
self.btn_update.setEnabled(True)
|
||||
self._set_status("更新完成" if e.ok else "就绪", "#22c55e" if e.ok else "#ef4444")
|
||||
if e.ok:
|
||||
QMessageBox.information(self, "更新", "代码更新成功")
|
||||
else:
|
||||
QMessageBox.warning(self, "更新", "更新失败,请检查网络或 Git 配置")
|
||||
return True
|
||||
return super().event(e)
|
||||
|
||||
def _on_start(self):
|
||||
self._save_config()
|
||||
server_url = self.entry_server.text().strip() or "ws://8.137.99.82:9000/ws"
|
||||
worker_id = self.entry_worker_id.text().strip() or "worker-1"
|
||||
worker_name = self.entry_worker_name.text().strip() or "本机"
|
||||
|
||||
self._set_status("启动中…", "#3b82f6")
|
||||
self._log("正在启动 Worker...\n")
|
||||
proc = run_worker(server_url, worker_id, worker_name, self._log)
|
||||
if proc is None:
|
||||
self._set_status("就绪", "#22c55e")
|
||||
return
|
||||
|
||||
self.worker_proc = proc
|
||||
self.btn_start.setEnabled(False)
|
||||
self.btn_stop.setEnabled(True)
|
||||
self._set_status("运行中", "#22c55e")
|
||||
|
||||
# 后台线程读取 stdout,避免主线程阻塞
|
||||
t = threading.Thread(target=_reader_thread, args=(proc, self._log_queue), daemon=True)
|
||||
t.start()
|
||||
|
||||
# 定时检查进程是否退出(非阻塞)
|
||||
self._exit_check_timer = QTimer(self)
|
||||
self._exit_check_timer.timeout.connect(self._check_worker_exit)
|
||||
self._exit_check_timer.start(300)
|
||||
|
||||
def _check_worker_exit(self):
|
||||
if self.worker_proc is None:
|
||||
return
|
||||
if self.worker_proc.poll() is not None:
|
||||
self._exit_check_timer.stop()
|
||||
self._exit_check_timer = None
|
||||
self._on_worker_exit()
|
||||
|
||||
def _on_worker_exit(self):
|
||||
self.worker_proc = None
|
||||
self.btn_start.setEnabled(True)
|
||||
self.btn_stop.setEnabled(False)
|
||||
self._set_status("已停止", "#6b7280")
|
||||
self._log("Worker 已退出。\n")
|
||||
|
||||
def _on_stop(self):
|
||||
if self.worker_proc:
|
||||
self._set_status("正在停止…", "#f59e0b")
|
||||
self.worker_proc.terminate()
|
||||
self._log("正在停止 Worker...\n")
|
||||
|
||||
def closeEvent(self, event):
|
||||
if self.worker_proc and self.worker_proc.poll() is None:
|
||||
reply = QMessageBox.question(
|
||||
self, "确认", "Worker 正在运行,确定要退出吗?",
|
||||
QMessageBox.Yes | QMessageBox.No, QMessageBox.No
|
||||
)
|
||||
if reply == QMessageBox.Yes:
|
||||
self.worker_proc.terminate()
|
||||
else:
|
||||
event.ignore()
|
||||
return
|
||||
event.accept()
|
||||
|
||||
|
||||
class _FinishUpdateEvent(QEvent):
|
||||
Type = QEvent.Type(QEvent.registerEventType())
|
||||
|
||||
def __init__(self, ok: bool):
|
||||
super().__init__(_FinishUpdateEvent.Type)
|
||||
self.ok = ok
|
||||
|
||||
|
||||
def main():
|
||||
app = QApplication(sys.argv)
|
||||
w = ClientGUI()
|
||||
w.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,279 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
根据背景图 + 拼图块图计算滑块目标偏移,并可选使用 DrissionPage 执行拖动。
|
||||
|
||||
默认读取当前项目根目录:
|
||||
- 下载 (1).png (背景图)
|
||||
- 下载.png (拼图块,RGBA 透明图)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import random
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
|
||||
def _load_images(bg_path: Path, piece_path: Path) -> tuple[np.ndarray, np.ndarray]:
|
||||
bg = np.array(Image.open(bg_path).convert("RGB"), dtype=np.int16)
|
||||
piece_rgba = np.array(Image.open(piece_path).convert("RGBA"), dtype=np.int16)
|
||||
return bg, piece_rgba
|
||||
|
||||
|
||||
def _piece_bbox(alpha: np.ndarray, threshold: int = 12) -> tuple[int, int, int, int, np.ndarray]:
|
||||
mask = alpha > threshold
|
||||
if not mask.any():
|
||||
raise ValueError("拼图块图片 alpha 全透明,无法匹配")
|
||||
|
||||
ys, xs = np.where(mask)
|
||||
y0, y1 = int(ys.min()), int(ys.max()) + 1
|
||||
x0, x1 = int(xs.min()), int(xs.max()) + 1
|
||||
return x0, y0, x1, y1, mask
|
||||
|
||||
|
||||
def calc_drag_distance(
|
||||
bg_path: Path,
|
||||
piece_path: Path,
|
||||
alpha_threshold: int = 12,
|
||||
) -> dict:
|
||||
"""返回最佳匹配位置与建议拖动距离。"""
|
||||
bg, piece_rgba = _load_images(bg_path, piece_path)
|
||||
|
||||
bh, bw = bg.shape[:2]
|
||||
ph, pw = piece_rgba.shape[:2]
|
||||
if ph != bh:
|
||||
raise ValueError(f"图片高度不一致:背景={bh}, 拼图块={ph}")
|
||||
|
||||
alpha = piece_rgba[:, :, 3]
|
||||
x0, y0, x1, y1, _ = _piece_bbox(alpha, threshold=alpha_threshold)
|
||||
|
||||
piece_crop = piece_rgba[y0:y1, x0:x1, :3]
|
||||
mask = alpha[y0:y1, x0:x1] > alpha_threshold
|
||||
ys, xs = np.where(mask)
|
||||
piece_pixels = piece_crop[ys, xs]
|
||||
|
||||
patch_h, patch_w = piece_crop.shape[:2]
|
||||
if patch_w > bw or patch_h > bh:
|
||||
raise ValueError("拼图块裁剪后尺寸超过背景图")
|
||||
|
||||
max_x = bw - patch_w
|
||||
best_x = 0
|
||||
best_score = float("inf")
|
||||
second_best = float("inf")
|
||||
|
||||
# 固定 y,按 x 扫描。评分越小越好。
|
||||
for x in range(max_x + 1):
|
||||
patch_pixels = bg[y0 + ys, x + xs]
|
||||
score = float(np.abs(patch_pixels - piece_pixels).mean())
|
||||
if score < best_score:
|
||||
second_best = best_score
|
||||
best_score = score
|
||||
best_x = x
|
||||
elif score < second_best:
|
||||
second_best = score
|
||||
|
||||
# 如果拼图初始从最左侧出发,建议拖动距离可用:best_x - x0
|
||||
drag_distance = best_x - x0
|
||||
confidence_ratio = (second_best / best_score) if best_score > 0 else float("inf")
|
||||
|
||||
return {
|
||||
"target_x": int(best_x),
|
||||
"piece_bbox_x0": int(x0),
|
||||
"piece_bbox_y0": int(y0),
|
||||
"piece_bbox_w": int(patch_w),
|
||||
"piece_bbox_h": int(patch_h),
|
||||
"drag_distance": int(drag_distance),
|
||||
"best_score": best_score,
|
||||
"second_best": second_best,
|
||||
"confidence_ratio": confidence_ratio,
|
||||
"bg_width": int(bw),
|
||||
"bg_height": int(bh),
|
||||
}
|
||||
|
||||
|
||||
def save_debug_overlay(bg_path: Path, out_path: Path, match: dict) -> None:
|
||||
img = Image.open(bg_path).convert("RGB")
|
||||
draw = ImageDraw.Draw(img)
|
||||
x = int(match["target_x"])
|
||||
y = int(match["piece_bbox_y0"])
|
||||
w = int(match["piece_bbox_w"])
|
||||
h = int(match["piece_bbox_h"])
|
||||
draw.rectangle([x, y, x + w, y + h], outline=(255, 0, 0), width=2)
|
||||
img.save(out_path)
|
||||
|
||||
|
||||
def build_human_track(distance: int) -> list[int]:
|
||||
"""生成拟人拖动轨迹(整数步长列表)。"""
|
||||
if distance == 0:
|
||||
return []
|
||||
|
||||
direction = 1 if distance > 0 else -1
|
||||
remaining = abs(int(round(distance)))
|
||||
|
||||
track: list[int] = []
|
||||
moved = 0
|
||||
velocity = 0.0
|
||||
interval = 0.02
|
||||
accelerate_until = remaining * 0.7
|
||||
|
||||
while moved < remaining:
|
||||
if moved < accelerate_until:
|
||||
acc = random.uniform(2.0, 4.0)
|
||||
else:
|
||||
acc = -random.uniform(3.0, 6.0)
|
||||
|
||||
step = max(1, int(velocity * interval + 0.5 * acc * interval * interval * 100))
|
||||
moved += step
|
||||
velocity = max(0.0, velocity + acc * interval)
|
||||
track.append(step)
|
||||
|
||||
overflow = moved - remaining
|
||||
if overflow > 0 and track:
|
||||
track[-1] -= overflow
|
||||
if track[-1] <= 0:
|
||||
track.pop()
|
||||
|
||||
# 小幅回拉,增加拟人性
|
||||
if remaining >= 30:
|
||||
back = random.randint(1, 3)
|
||||
track.extend([-back, back])
|
||||
|
||||
return [direction * s for s in track if s != 0]
|
||||
|
||||
|
||||
def drag_slider_with_dp(page, slider_selector: str, distance: int) -> None:
|
||||
slider = page.ele(slider_selector, timeout=8)
|
||||
if not slider:
|
||||
raise RuntimeError(f"未找到滑块元素:{slider_selector}")
|
||||
|
||||
# 若元素支持直接 drag,优先用内置实现
|
||||
if hasattr(slider, "drag"):
|
||||
try:
|
||||
slider.drag(offset_x=distance, offset_y=0, duration=0.8)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
actions = page.actions
|
||||
actions.move_to(slider, duration=0.2)
|
||||
|
||||
hold_ok = False
|
||||
for hold_call in (
|
||||
lambda: actions.hold(slider),
|
||||
lambda: actions.hold(),
|
||||
):
|
||||
try:
|
||||
hold_call()
|
||||
hold_ok = True
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not hold_ok:
|
||||
raise RuntimeError("无法按下滑块,请确认 DrissionPage 版本与页面状态")
|
||||
|
||||
for step in build_human_track(distance):
|
||||
dy = random.randint(-1, 1)
|
||||
moved = False
|
||||
for move_call in (
|
||||
lambda: actions.move(step, dy, duration=random.uniform(0.01, 0.04)),
|
||||
lambda: actions.move(offset_x=step, offset_y=dy, duration=random.uniform(0.01, 0.04)),
|
||||
lambda: actions.move(step, dy),
|
||||
lambda: actions.move(offset_x=step, offset_y=dy),
|
||||
):
|
||||
try:
|
||||
move_call()
|
||||
moved = True
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if not moved:
|
||||
raise RuntimeError("动作链 move 调用失败,请按当前 DrissionPage 版本调整 move 参数")
|
||||
|
||||
released = False
|
||||
for release_call in (
|
||||
lambda: actions.release(),
|
||||
lambda: actions.release(on_ele=None),
|
||||
):
|
||||
try:
|
||||
release_call()
|
||||
released = True
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not released:
|
||||
raise RuntimeError("动作链 release 调用失败")
|
||||
|
||||
|
||||
def maybe_run_dp_drag(args: argparse.Namespace, distance: int) -> None:
|
||||
if not args.url or not args.slider:
|
||||
return
|
||||
|
||||
from DrissionPage import ChromiumOptions, ChromiumPage
|
||||
|
||||
if args.port:
|
||||
co = ChromiumOptions().set_local_port(port=args.port)
|
||||
page = ChromiumPage(addr_or_opts=co)
|
||||
else:
|
||||
page = ChromiumPage()
|
||||
|
||||
page.get(args.url)
|
||||
if args.wait_after_open > 0:
|
||||
time.sleep(args.wait_after_open)
|
||||
|
||||
drag_slider_with_dp(page, args.slider, distance)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
root = Path(__file__).resolve().parent
|
||||
|
||||
parser = argparse.ArgumentParser(description="DP 滑块验证辅助脚本")
|
||||
parser.add_argument("--bg", type=Path, default=root / "下载 (1).png", help="背景图路径")
|
||||
parser.add_argument("--piece", type=Path, default=root / "下载.png", help="拼图块图路径")
|
||||
parser.add_argument("--alpha-threshold", type=int, default=12, help="透明阈值(0-255)")
|
||||
parser.add_argument("--debug-out", type=Path, default=root / "slider_match_debug.png", help="匹配结果标注图")
|
||||
|
||||
# 以下参数用于真实页面拖动(可选)
|
||||
parser.add_argument("--url", type=str, default="", help="验证码页面 URL")
|
||||
parser.add_argument("--slider", type=str, default="", help="滑块元素选择器")
|
||||
parser.add_argument("--port", type=int, default=0, help="连接已有浏览器的本地端口")
|
||||
parser.add_argument("--distance-adjust", type=int, default=0, help="对计算结果追加修正像素")
|
||||
parser.add_argument("--wait-after-open", type=float, default=1.0, help="打开页面后等待秒数")
|
||||
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
|
||||
if not args.bg.exists():
|
||||
raise FileNotFoundError(f"背景图不存在:{args.bg}")
|
||||
if not args.piece.exists():
|
||||
raise FileNotFoundError(f"拼图块图不存在:{args.piece}")
|
||||
|
||||
match = calc_drag_distance(args.bg, args.piece, alpha_threshold=args.alpha_threshold)
|
||||
save_debug_overlay(args.bg, args.debug_out, match)
|
||||
|
||||
distance = int(match["drag_distance"]) + int(args.distance_adjust)
|
||||
|
||||
print("匹配结果:")
|
||||
print(f" target_x={match['target_x']}")
|
||||
print(f" piece_bbox_x0={match['piece_bbox_x0']}")
|
||||
print(f" 建议拖动距离 drag_distance={match['drag_distance']}")
|
||||
print(f" 调整后拖动距离 distance={distance}")
|
||||
print(f" best_score={match['best_score']:.4f}, second_best={match['second_best']:.4f}")
|
||||
print(f" confidence_ratio={match['confidence_ratio']:.4f} (越大越好)")
|
||||
print(f" 标注图已输出:{args.debug_out}")
|
||||
|
||||
if args.url and args.slider:
|
||||
maybe_run_dp_drag(args, distance)
|
||||
print("DP 拖动已执行。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -7,6 +7,9 @@ pydantic>=2.0.0
|
||||
PyMySQL>=1.1.0
|
||||
asgiref>=3.8.0
|
||||
|
||||
# ─── 客户端 GUI ───
|
||||
PyQt5>=5.15.0
|
||||
|
||||
# ─── Worker 代理 (worker/) ───
|
||||
websockets>=14.0
|
||||
requests>=2.31.0
|
||||
|
||||
11
run_client.py
Normal file
11
run_client.py
Normal file
@@ -0,0 +1,11 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
客户端 GUI 启动入口。
|
||||
使用方式: python run_client.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from client_gui.main import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
549
tyyp_1html_dp.py
549
tyyp_1html_dp.py
@@ -1,549 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
站点流程自动化(DrissionPage):
|
||||
1) 打开 http://yscnb.com/tyyp/1.html
|
||||
2) 输入手机号
|
||||
3) 点击“获取验证码”
|
||||
4) 解析弹窗中的拼图验证码并执行拖动
|
||||
|
||||
说明:仅实现到“滑块拖动”步骤,不会自动填写短信验证码。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def _to_rgba_array(image_bytes: bytes) -> np.ndarray:
|
||||
return np.array(Image.open(BytesIO(image_bytes)).convert("RGBA"), dtype=np.int16)
|
||||
|
||||
|
||||
def _to_rgb_array(image_bytes: bytes) -> np.ndarray:
|
||||
return np.array(Image.open(BytesIO(image_bytes)).convert("RGB"), dtype=np.int16)
|
||||
|
||||
|
||||
def _piece_bbox(alpha: np.ndarray, threshold: int = 12) -> tuple[int, int, int, int]:
|
||||
mask = alpha > threshold
|
||||
if not mask.any():
|
||||
raise ValueError("拼图块 alpha 全透明,无法匹配")
|
||||
ys, xs = np.where(mask)
|
||||
return int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1
|
||||
|
||||
|
||||
def calc_drag_distance_from_bytes(bg_bytes: bytes, piece_bytes: bytes, alpha_threshold: int = 12) -> dict:
|
||||
"""计算拼图目标位移(基于背景图 + 拼图块图)。"""
|
||||
bg = _to_rgb_array(bg_bytes)
|
||||
piece_rgba = _to_rgba_array(piece_bytes)
|
||||
|
||||
bh, bw = bg.shape[:2]
|
||||
ph, _ = piece_rgba.shape[:2]
|
||||
if bh != ph:
|
||||
raise ValueError(f"背景与拼图块高度不一致: {bh} != {ph}")
|
||||
|
||||
alpha = piece_rgba[:, :, 3]
|
||||
x0, y0, x1, y1 = _piece_bbox(alpha, threshold=alpha_threshold)
|
||||
|
||||
piece_crop = piece_rgba[y0:y1, x0:x1, :3]
|
||||
mask = alpha[y0:y1, x0:x1] > alpha_threshold
|
||||
ys, xs = np.where(mask)
|
||||
piece_pixels = piece_crop[ys, xs]
|
||||
|
||||
patch_h, patch_w = piece_crop.shape[:2]
|
||||
if patch_w > bw or patch_h > bh:
|
||||
raise ValueError("拼图块裁剪尺寸超过背景图")
|
||||
|
||||
best_x = 0
|
||||
best_score = float("inf")
|
||||
second_best = float("inf")
|
||||
max_x = bw - patch_w
|
||||
|
||||
for x in range(max_x + 1):
|
||||
patch_pixels = bg[y0 + ys, x + xs]
|
||||
score = float(np.abs(patch_pixels - piece_pixels).mean())
|
||||
if score < best_score:
|
||||
second_best = best_score
|
||||
best_score = score
|
||||
best_x = x
|
||||
elif score < second_best:
|
||||
second_best = score
|
||||
|
||||
drag_distance = best_x - x0
|
||||
confidence_ratio = (second_best / best_score) if best_score > 0 else float("inf")
|
||||
|
||||
return {
|
||||
"target_x": int(best_x),
|
||||
"piece_bbox_x0": int(x0),
|
||||
"piece_bbox_y0": int(y0),
|
||||
"piece_bbox_w": int(patch_w),
|
||||
"piece_bbox_h": int(patch_h),
|
||||
"bg_width": int(bw),
|
||||
"bg_height": int(bh),
|
||||
"drag_distance": int(drag_distance),
|
||||
"best_score": best_score,
|
||||
"second_best": second_best,
|
||||
"confidence_ratio": confidence_ratio,
|
||||
}
|
||||
|
||||
|
||||
def parse_data_url(data_url: str) -> bytes:
|
||||
if not data_url.startswith("data:image"):
|
||||
raise ValueError("图片不是 data:image URL")
|
||||
_, data = data_url.split(",", 1)
|
||||
return base64.b64decode(data)
|
||||
|
||||
|
||||
def style_px(style_text: str, key: str, default: float) -> float:
|
||||
if not style_text:
|
||||
return default
|
||||
m = re.search(rf"{re.escape(key)}\s*:\s*([0-9.]+)px", style_text)
|
||||
if not m:
|
||||
return default
|
||||
return float(m.group(1))
|
||||
|
||||
|
||||
def click_safe(ele) -> None:
|
||||
try:
|
||||
ele.click()
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
ele.click(by_js=True)
|
||||
|
||||
|
||||
def _ease_out_quad(t: float) -> float:
|
||||
return t * (2 - t)
|
||||
|
||||
|
||||
def _ease_out_cubic(t: float) -> float:
|
||||
return 1 - (1 - t) ** 3
|
||||
|
||||
|
||||
def _ease_out_bounce(t: float) -> float:
|
||||
if t < 1 / 2.75:
|
||||
return 7.5625 * t * t
|
||||
elif t < 2 / 2.75:
|
||||
t -= 1.5 / 2.75
|
||||
return 7.5625 * t * t + 0.75
|
||||
elif t < 2.5 / 2.75:
|
||||
t -= 2.25 / 2.75
|
||||
return 7.5625 * t * t + 0.9375
|
||||
else:
|
||||
t -= 2.625 / 2.75
|
||||
return 7.5625 * t * t + 0.984375
|
||||
|
||||
|
||||
def build_human_track(distance: int, num_steps: int = 0) -> list[dict]:
|
||||
"""生成仿人轨迹列表,每项 {'dx': int, 'dy': int, 'dt': float(秒)}。
|
||||
|
||||
包含:加速-匀速-减速-过冲-回弹 五阶段。
|
||||
"""
|
||||
if distance == 0:
|
||||
return []
|
||||
|
||||
dist = abs(distance)
|
||||
sign = 1 if distance > 0 else -1
|
||||
|
||||
if num_steps <= 0:
|
||||
num_steps = max(12, int(dist * random.uniform(0.25, 0.4)))
|
||||
|
||||
overshoot = random.randint(max(2, int(dist * 0.03)), max(3, int(dist * 0.08)))
|
||||
total = dist + overshoot # 先超过,再回弹
|
||||
|
||||
easing = random.choice([_ease_out_quad, _ease_out_cubic])
|
||||
|
||||
# 正向阶段
|
||||
raw_positions: list[float] = []
|
||||
for i in range(1, num_steps + 1):
|
||||
t = i / num_steps
|
||||
raw_positions.append(easing(t) * total)
|
||||
|
||||
# 回弹阶段 (2~4 步)
|
||||
bounce_steps = random.randint(2, 4)
|
||||
for j in range(1, bounce_steps + 1):
|
||||
t = j / bounce_steps
|
||||
raw_positions.append(total - _ease_out_bounce(t) * overshoot)
|
||||
|
||||
track: list[dict] = []
|
||||
prev_x = 0.0
|
||||
for pos in raw_positions:
|
||||
dx = round(pos - prev_x)
|
||||
if dx == 0 and random.random() < 0.3:
|
||||
continue
|
||||
prev_x += dx
|
||||
dy = random.choice([-1, 0, 0, 0, 1])
|
||||
# 前半段快、后半段慢(加快整体速度)
|
||||
dt = random.uniform(0.005, 0.012) if prev_x < dist * 0.6 else random.uniform(0.008, 0.025)
|
||||
if random.random() < 0.03:
|
||||
dt += random.uniform(0.02, 0.06)
|
||||
track.append({"dx": sign * dx, "dy": dy, "dt": dt})
|
||||
|
||||
# 最终位置校正
|
||||
actual = sum(s["dx"] for s in track)
|
||||
diff = distance - actual
|
||||
if diff != 0:
|
||||
track.append({"dx": diff, "dy": 0, "dt": random.uniform(0.01, 0.03)})
|
||||
|
||||
return track
|
||||
|
||||
|
||||
def _dispatch_mouse(page, event_type: str, x: int, y: int, button: str = "left") -> None:
|
||||
"""通过 CDP Input.dispatchMouseEvent 发送鼠标事件。"""
|
||||
page.run_cdp(
|
||||
"Input.dispatchMouseEvent",
|
||||
type=event_type,
|
||||
x=x,
|
||||
y=y,
|
||||
button=button,
|
||||
clickCount=1 if event_type == "mousePressed" else 0,
|
||||
)
|
||||
|
||||
|
||||
def _get_element_center(page, ele) -> tuple[int, int]:
|
||||
"""获取元素在视口中的中心坐标。"""
|
||||
rect = page.run_js(
|
||||
"""const r = arguments[0].getBoundingClientRect();
|
||||
return {x: Math.round(r.x + r.width/2), y: Math.round(r.y + r.height/2)}""",
|
||||
ele,
|
||||
)
|
||||
if rect and isinstance(rect, dict):
|
||||
return int(rect["x"]), int(rect["y"])
|
||||
# 回退:通过元素属性
|
||||
loc = ele.rect.midpoint
|
||||
return int(loc[0]), int(loc[1])
|
||||
|
||||
|
||||
def drag_slider(page, slider_ele, distance: int) -> None:
|
||||
"""用 CDP 级鼠标事件完成拖拽,模拟真人操作轨迹。"""
|
||||
cx, cy = _get_element_center(page, slider_ele)
|
||||
|
||||
# 1. 鼠标移到滑块中心
|
||||
_dispatch_mouse(page, "mouseMoved", cx, cy)
|
||||
time.sleep(random.uniform(0.03, 0.08))
|
||||
|
||||
# 2. 按下
|
||||
_dispatch_mouse(page, "mousePressed", cx, cy)
|
||||
time.sleep(random.uniform(0.02, 0.06))
|
||||
|
||||
# 3. 沿轨迹移动
|
||||
cur_x, cur_y = cx, cy
|
||||
track = build_human_track(distance)
|
||||
for step in track:
|
||||
cur_x += step["dx"]
|
||||
cur_y += step["dy"]
|
||||
_dispatch_mouse(page, "mouseMoved", cur_x, cur_y)
|
||||
time.sleep(step["dt"])
|
||||
|
||||
# 4. 到达终点后短暂停留
|
||||
time.sleep(random.uniform(0.02, 0.06))
|
||||
|
||||
# 5. 释放
|
||||
_dispatch_mouse(page, "mouseReleased", cur_x, cur_y)
|
||||
|
||||
|
||||
def find_first(page, selectors: list[str], timeout: float = 5):
|
||||
for sel in selectors:
|
||||
try:
|
||||
ele = page.ele(sel, timeout=timeout)
|
||||
if ele:
|
||||
return ele
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def wait_for_data_src(img_ele, timeout: float = 6, interval: float = 0.12) -> str:
|
||||
"""轮询等待 img 元素的 src 变为有效 data:image URL(含非空 base64 数据)。"""
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
src = img_ele.attr("src") or ""
|
||||
if src.startswith("data:image"):
|
||||
_prefix, _, b64 = src.partition(",")
|
||||
if b64.strip():
|
||||
return src
|
||||
time.sleep(interval)
|
||||
raise RuntimeError(f"等待 data:image src 超时({timeout}s),当前 src 前80字符: {(img_ele.attr('src') or '')[:80]}")
|
||||
|
||||
|
||||
def save_debug(debug_dir: Path, bg_bytes: bytes, piece_bytes: bytes) -> tuple[Path, Path]:
|
||||
debug_dir.mkdir(parents=True, exist_ok=True)
|
||||
bg_path = debug_dir / "captcha_bg.png"
|
||||
piece_path = debug_dir / "captcha_piece.png"
|
||||
bg_path.write_bytes(bg_bytes)
|
||||
piece_path.write_bytes(piece_bytes)
|
||||
return bg_path, piece_path
|
||||
|
||||
|
||||
def run(args: argparse.Namespace) -> None:
|
||||
from DrissionPage import ChromiumOptions, ChromiumPage
|
||||
|
||||
t_start = time.perf_counter()
|
||||
|
||||
if args.port:
|
||||
co = ChromiumOptions().set_local_port(port=args.port)
|
||||
page = ChromiumPage(addr_or_opts=co)
|
||||
else:
|
||||
page = ChromiumPage()
|
||||
|
||||
page.get(args.url)
|
||||
time.sleep(args.wait_page)
|
||||
|
||||
# 首页先勾选协议 checkbox(id=color-input-red 的复选框),再点「立即订购」
|
||||
agree_checkbox = find_first(page, [
|
||||
"css:#color-input-red",
|
||||
"css:input[name='color-input-red']",
|
||||
'x://input[@id="color-input-red"]',
|
||||
"css:input.right-box[type='checkbox']",
|
||||
], timeout=5)
|
||||
if agree_checkbox:
|
||||
click_safe(agree_checkbox)
|
||||
print("已勾选协议复选框")
|
||||
time.sleep(0.4) # 勾选后等待
|
||||
|
||||
# 立即订购:点击 div.paybg 即为立即订购
|
||||
order_btn = None
|
||||
for attempt in range(4):
|
||||
order_btn = find_first(page, [
|
||||
"css:div.paybg",
|
||||
"css:.paybg",
|
||||
'x://button[contains(.,"立即订购")]',
|
||||
'x://a[contains(.,"立即订购")]',
|
||||
'x://span[contains(.,"立即订购")]',
|
||||
'x://div[contains(.,"立即订购")]',
|
||||
'x://*[contains(text(),"立即订购")]',
|
||||
'x://*[contains(.,"立即订购")]',
|
||||
"css:.btn-order",
|
||||
"css:.order-btn",
|
||||
"css:button.btn-primary",
|
||||
"css:button.btn",
|
||||
"css:a.btn",
|
||||
], timeout=1)
|
||||
if order_btn:
|
||||
break
|
||||
time.sleep(0.25)
|
||||
if order_btn:
|
||||
try:
|
||||
order_btn.run_js("this.scrollIntoView({block:'center'})")
|
||||
time.sleep(0.05)
|
||||
except Exception:
|
||||
pass
|
||||
click_safe(order_btn)
|
||||
print("已点击立即订购")
|
||||
time.sleep(0.4)
|
||||
else:
|
||||
# 兜底1:若页面有 jQuery,用 :contains 查找
|
||||
jq_clicked = False
|
||||
try:
|
||||
jq_clicked = page.run_js("""
|
||||
if (typeof $ !== 'undefined') {
|
||||
var el = $('button, a, span, div').filter(function(){ return $(this).text().indexOf('立即订购')>=0; }).first();
|
||||
if (el.length) { el[0].scrollIntoView({block:'center'}); el[0].click(); return true; }
|
||||
el = $('*').filter(function(){ return $(this).text().trim()==='立即订购'; }).first();
|
||||
if (el.length) { el[0].scrollIntoView({block:'center'}); el[0].click(); return true; }
|
||||
}
|
||||
return false;
|
||||
""")
|
||||
if jq_clicked:
|
||||
print("已通过 jQuery 点击立即订购")
|
||||
time.sleep(0.4)
|
||||
except Exception:
|
||||
pass
|
||||
if not jq_clicked:
|
||||
clicked = page.run_js("""
|
||||
var nodes = document.querySelectorAll('button, a, span, div, input[type=button], input[type=submit]');
|
||||
for (var i = 0; i < nodes.length; i++) {
|
||||
var t = (nodes[i].innerText || nodes[i].textContent || '').trim();
|
||||
if (t === '立即订购' || (t.indexOf('立即订购') >= 0 && t.length < 20)) {
|
||||
var el = nodes[i];
|
||||
if (el.offsetParent !== null || el.tagName === 'BODY') {
|
||||
el.scrollIntoView({block: 'center'});
|
||||
el.dispatchEvent(new MouseEvent('click', {bubbles: true, cancelable: true, view: window}));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (var i = 0; i < nodes.length; i++) {
|
||||
var t = (nodes[i].innerText || nodes[i].textContent || '').trim();
|
||||
if (t.indexOf('立即订购') >= 0) {
|
||||
nodes[i].scrollIntoView({block: 'center'});
|
||||
nodes[i].dispatchEvent(new MouseEvent('click', {bubbles: true, cancelable: true, view: window}));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
""")
|
||||
if clicked:
|
||||
print("已通过 JS 点击立即订购")
|
||||
time.sleep(0.4)
|
||||
elif not clicked:
|
||||
# 尝试在 iframe 内点击
|
||||
in_iframe = page.run_js("""
|
||||
var iframes = document.querySelectorAll('iframe');
|
||||
for (var i = 0; i < iframes.length; i++) {
|
||||
try {
|
||||
var doc = iframes[i].contentDocument || iframes[i].contentWindow.document;
|
||||
var all = doc.querySelectorAll('*');
|
||||
for (var j = 0; j < all.length; j++) {
|
||||
var t = (all[j].innerText || all[j].textContent || '').trim();
|
||||
if (t.indexOf('立即订购') >= 0) {
|
||||
all[j].scrollIntoView({block:'center'});
|
||||
all[j].dispatchEvent(new MouseEvent('click',{bubbles:true,cancelable:true,view:window}));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
return false;
|
||||
""")
|
||||
if in_iframe:
|
||||
print("已在 iframe 内点击立即订购")
|
||||
time.sleep(0.4)
|
||||
else:
|
||||
# 调试:输出包含「立即订购」的元素信息
|
||||
try:
|
||||
info = page.run_js("""
|
||||
var out=[], all=document.querySelectorAll('*');
|
||||
for(var i=0;i<all.length;i++){
|
||||
var t=(all[i].innerText||all[i].textContent||'').trim();
|
||||
if(t.indexOf('立即订购')>=0)
|
||||
out.push(all[i].tagName+(all[i].id?'#'+all[i].id:'')+(all[i].className?'.'+all[i].className.split(' ')[0]:''));
|
||||
}
|
||||
return out.slice(0,5).join(', ') || '无';
|
||||
""")
|
||||
print(f"调试: 包含「立即订购」的元素(前5个): {info}")
|
||||
except Exception:
|
||||
pass
|
||||
print("警告: 未找到「立即订购」按钮,尝试继续...")
|
||||
|
||||
phone_input = find_first(page, [
|
||||
'x://input[@placeholder="请输入手机号码"]',
|
||||
"css:input.inp-txt",
|
||||
], timeout=8)
|
||||
if not phone_input:
|
||||
raise RuntimeError("未找到手机号输入框")
|
||||
|
||||
phone_input.input(args.phone, clear=True)
|
||||
print(f"已输入手机号: {args.phone}")
|
||||
|
||||
if not args.skip_agree:
|
||||
agree = find_first(page, [
|
||||
"css:i.ico-checkbox",
|
||||
'x://i[contains(@class,"ico-checkbox")]',
|
||||
], timeout=2)
|
||||
if agree:
|
||||
try:
|
||||
click_safe(agree)
|
||||
print("已点击同意勾选")
|
||||
except Exception:
|
||||
print("同意勾选点击失败,继续执行")
|
||||
|
||||
send_btn = find_first(page, [
|
||||
"css:button.btn-code",
|
||||
'x://button[contains(text(),"获取验证码")]',
|
||||
], timeout=8)
|
||||
if not send_btn:
|
||||
raise RuntimeError("未找到“获取验证码”按钮")
|
||||
|
||||
click_safe(send_btn)
|
||||
print("已点击获取验证码,等待滑块弹窗")
|
||||
|
||||
# 等待验证码弹窗出现
|
||||
verify_box = find_first(page, [
|
||||
"css:.verifybox",
|
||||
"css:.verify-bar-area",
|
||||
], timeout=6)
|
||||
if not verify_box:
|
||||
raise RuntimeError("未检测到滑块验证码弹窗")
|
||||
|
||||
bg_img = find_first(page, ["css:.verify-img-panel img"], timeout=5)
|
||||
piece_img = find_first(page, ["css:.verify-sub-block img"], timeout=5)
|
||||
slider = find_first(page, ["css:.verify-move-block"], timeout=5)
|
||||
bar = find_first(page, ["css:.verify-bar-area"], timeout=5)
|
||||
|
||||
if not bg_img or not piece_img or not slider or not bar:
|
||||
raise RuntimeError("验证码关键元素缺失(背景图/拼图块/滑块)")
|
||||
|
||||
bg_src = wait_for_data_src(bg_img, timeout=10)
|
||||
piece_src = wait_for_data_src(piece_img, timeout=10)
|
||||
|
||||
bg_bytes = parse_data_url(bg_src)
|
||||
piece_bytes = parse_data_url(piece_src)
|
||||
|
||||
if len(bg_bytes) < 100 or len(piece_bytes) < 100:
|
||||
raise RuntimeError(f"验证码图片数据异常: bg={len(bg_bytes)}B, piece={len(piece_bytes)}B")
|
||||
|
||||
if args.debug_dir:
|
||||
bg_path, piece_path = save_debug(Path(args.debug_dir), bg_bytes, piece_bytes)
|
||||
print(f"已保存验证码图片: {bg_path} | {piece_path}")
|
||||
|
||||
match = calc_drag_distance_from_bytes(bg_bytes, piece_bytes, alpha_threshold=args.alpha_threshold)
|
||||
|
||||
# 用渲染宽度做像素→屏幕映射
|
||||
bg_display_w = page.run_js(
|
||||
"""const el = arguments[0]; const r = el.getBoundingClientRect(); return r.width;""",
|
||||
bg_img,
|
||||
)
|
||||
if not bg_display_w or bg_display_w <= 0:
|
||||
bg_display_w = match["bg_width"]
|
||||
scale = float(bg_display_w) / max(1, match["bg_width"])
|
||||
move_distance = int(round(match["drag_distance"] * scale)) + int(args.distance_adjust)
|
||||
|
||||
print(
|
||||
"滑块匹配结果: "
|
||||
f"bg_width={match['bg_width']}, bg_display_w={bg_display_w}, "
|
||||
f"target_x={match['target_x']}, drag_distance={match['drag_distance']}, "
|
||||
f"scale={scale:.4f}, move_distance={move_distance}, "
|
||||
f"confidence={match['confidence_ratio']:.4f}"
|
||||
)
|
||||
|
||||
drag_slider(page, slider, move_distance)
|
||||
time.sleep(args.wait_result)
|
||||
|
||||
elapsed = time.perf_counter() - t_start
|
||||
print(f"总耗时: {elapsed:.2f} 秒(程序开始 → 滑块滑动完成)")
|
||||
|
||||
# 尝试判断是否通过:遮罩是否消失
|
||||
still_visible = page.run_js(
|
||||
"""
|
||||
const m = document.querySelector('.mask');
|
||||
if (!m) return false;
|
||||
const s = window.getComputedStyle(m);
|
||||
return s.display !== 'none' && s.visibility !== 'hidden' && s.opacity !== '0';
|
||||
"""
|
||||
)
|
||||
if still_visible:
|
||||
print("拖动已执行,但验证码弹窗仍在,可能需要微调 --distance-adjust")
|
||||
else:
|
||||
print("滑块拖动完成,验证码弹窗已关闭(疑似验证通过)")
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
root = Path(__file__).resolve().parent
|
||||
p = argparse.ArgumentParser(description="天翼订购页 1.html 自动滑块脚本")
|
||||
p.add_argument("--url", default="http://yscnb.com/tyyp/1.html", help="目标页面 URL")
|
||||
p.add_argument("--phone", required=True, help="手机号")
|
||||
p.add_argument("--port", type=int, default=0, help="连接已有浏览器端口(可选)")
|
||||
p.add_argument("--skip-agree", action="store_true", help="跳过勾选“同意办理”")
|
||||
p.add_argument("--alpha-threshold", type=int, default=12, help="拼图 alpha 透明阈值")
|
||||
p.add_argument("--distance-adjust", type=int, default=0, help="拖动距离微调像素")
|
||||
p.add_argument("--wait-page", type=float, default=0.3, help="打开页面后等待秒数")
|
||||
p.add_argument("--wait-result", type=float, default=0.5, help="拖动后等待结果秒数")
|
||||
p.add_argument("--debug-dir", default=str(root / "captcha_debug"), help="验证码图片输出目录")
|
||||
return p
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = build_parser().parse_args()
|
||||
run(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -4,8 +4,8 @@ Worker 配置。通过命令行参数或环境变量设置。
|
||||
"""
|
||||
import os
|
||||
|
||||
# ─── 中央服务器 ───
|
||||
SERVER_WS_URL: str = os.getenv("SERVER_WS_URL", "ws://127.0.0.1:9000/ws")
|
||||
# ─── 中央服务器(云端公网 IP 写死) ───
|
||||
SERVER_WS_URL: str = os.getenv("SERVER_WS_URL", "ws://8.137.99.82:9000/ws")
|
||||
|
||||
# ─── Worker 标识 ───
|
||||
WORKER_ID: str = os.getenv("WORKER_ID", "worker-1")
|
||||
|
||||
BIN
下载 (1).png
BIN
下载 (1).png
Binary file not shown.
|
Before Width: | Height: | Size: 111 KiB |
56
部署说明.md
Normal file
56
部署说明.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# 部署说明
|
||||
|
||||
## 一、服务器部署
|
||||
|
||||
在服务器上,只需执行:
|
||||
|
||||
```bash
|
||||
python app.py
|
||||
```
|
||||
|
||||
即可启动中央服务器(Django + Channels + 隧道)。
|
||||
|
||||
---
|
||||
|
||||
## 二、客户端部署(线下有比特浏览器的电脑)
|
||||
|
||||
客户电脑可能不懂代码,需要提供 **GUI 程序**(PyQt5)完成「更新」和「启动」。
|
||||
|
||||
### 方式 A:源码运行(需安装 Python)
|
||||
|
||||
1. 将项目拷贝到客户电脑
|
||||
2. 安装依赖:`pip install -r requirements.txt`
|
||||
3. 运行 GUI:`python run_client.py`
|
||||
|
||||
### 方式 B:打包为 exe(推荐,无需 Python)
|
||||
|
||||
1. 安装 PyInstaller:`pip install pyinstaller`
|
||||
2. 在项目根目录执行打包:
|
||||
```bash
|
||||
# 打包客户端 GUI
|
||||
pyinstaller build_client.spec
|
||||
|
||||
# 打包 Worker(供 GUI 的「启动」按钮调用)
|
||||
pyinstaller build_worker.spec
|
||||
```
|
||||
3. 将生成的文件合并:
|
||||
- `dist/BOSS直聘Worker客户端/` 为 GUI 输出目录
|
||||
- 将 `dist/worker/worker.exe` 复制到 `dist/BOSS直聘Worker客户端/` 目录内
|
||||
4. 将整个 `BOSS直聘Worker客户端` 文件夹发给客户,客户双击 `BOSS直聘Worker客户端.exe` 即可使用
|
||||
|
||||
### GUI 功能说明
|
||||
|
||||
| 按钮 | 功能 |
|
||||
|------|------|
|
||||
| **更新代码** | 在项目目录执行 `git pull`,拉取最新自动化代码 |
|
||||
| **启动** | 启动 Worker,连接服务器并执行任务 |
|
||||
|
||||
配置项说明:
|
||||
|
||||
- **服务器地址**:中央服务器 WebSocket 地址,如 `ws://8.137.99.82:9000/ws`
|
||||
- **Worker ID**:本机 Worker 唯一标识
|
||||
- **Worker 名称**:便于识别的名称,如「电脑A」
|
||||
|
||||
> **说明**:
|
||||
> - 若使用「更新」功能,需在客户电脑安装 Git,并将 exe 放在项目(git 仓库)目录内。
|
||||
> - 若不使用「更新」,可只分发打包后的 exe,客户只需填写服务器地址等信息后点击「启动」即可。
|
||||
Reference in New Issue
Block a user