haha
This commit is contained in:
139
1.py
139
1.py
@@ -11,6 +11,7 @@ import sys
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
# 保证从项目根目录运行时可导入 worker 包
|
# 保证从项目根目录运行时可导入 worker 包
|
||||||
_ROOT = Path(__file__).resolve().parent
|
_ROOT = Path(__file__).resolve().parent
|
||||||
@@ -50,16 +51,23 @@ def _connect_local_chrome():
|
|||||||
return page
|
return page
|
||||||
|
|
||||||
|
|
||||||
def _connect_bit_browser():
|
def _connect_bit_browser(
|
||||||
|
*,
|
||||||
|
bit_api_base: Optional[str] = None,
|
||||||
|
browser_name: Optional[str] = None,
|
||||||
|
browser_id: Optional[str] = None,
|
||||||
|
):
|
||||||
"""通过比特浏览器 API 打开并连接,返回 ChromiumPage。"""
|
"""通过比特浏览器 API 打开并连接,返回 ChromiumPage。"""
|
||||||
from worker.bit_browser import BitBrowserAPI
|
from worker.bit_browser import BitBrowserAPI
|
||||||
from DrissionPage import ChromiumPage, ChromiumOptions
|
from DrissionPage import ChromiumPage, ChromiumOptions
|
||||||
|
|
||||||
print("正在连接比特浏览器 API...")
|
print("正在连接比特浏览器 API...")
|
||||||
bit_api = BitBrowserAPI(BIT_API_BASE)
|
bit_api = BitBrowserAPI(bit_api_base or BIT_API_BASE)
|
||||||
print("正在打开比特浏览器...")
|
print("正在打开比特浏览器...")
|
||||||
cdp_addr, port, browser_id = bit_api.open_browser(
|
cdp_addr, port, browser_id = bit_api.open_browser(
|
||||||
browser_id=BROWSER_ID, name=BROWSER_NAME, remark=None
|
browser_id=browser_id if browser_id is not None else BROWSER_ID,
|
||||||
|
name=browser_name if browser_name is not None else BROWSER_NAME,
|
||||||
|
remark=None
|
||||||
)
|
)
|
||||||
print(f"已打开浏览器 ID={browser_id}, CDP 端口={port}")
|
print(f"已打开浏览器 ID={browser_id}, CDP 端口={port}")
|
||||||
co = ChromiumOptions().set_local_port(port=port)
|
co = ChromiumOptions().set_local_port(port=port)
|
||||||
@@ -166,6 +174,83 @@ def _greet_geek_list_skip_greeted(page, container, geek_list, greeted_keys):
|
|||||||
return n
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def _dedupe_filter_values(items: List[str]) -> List[str]:
|
||||||
|
seen = set()
|
||||||
|
result: List[str] = []
|
||||||
|
for item in items:
|
||||||
|
value = str(item or "").strip()
|
||||||
|
if not value or value in seen:
|
||||||
|
continue
|
||||||
|
seen.add(value)
|
||||||
|
result.append(value)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_recommend_filters(raw_filters: List[dict]) -> Dict[str, Any]:
|
||||||
|
groups: List[dict] = []
|
||||||
|
flat_options: List[str] = []
|
||||||
|
display_filters: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
for index, item in enumerate(raw_filters or []):
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
name = str(item.get("name", "")).strip()
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
options: List[str] = []
|
||||||
|
for option in item.get("options") or []:
|
||||||
|
if not isinstance(option, dict):
|
||||||
|
continue
|
||||||
|
option_name = str(option.get("name", "")).strip()
|
||||||
|
if option_name:
|
||||||
|
options.append(option_name)
|
||||||
|
options = _dedupe_filter_values(options)
|
||||||
|
|
||||||
|
group: Dict[str, Any] = {
|
||||||
|
"name": name,
|
||||||
|
"order": index,
|
||||||
|
"options": options,
|
||||||
|
}
|
||||||
|
|
||||||
|
start = item.get("start")
|
||||||
|
end = item.get("end")
|
||||||
|
try:
|
||||||
|
if start is not None and end is not None:
|
||||||
|
range_payload = {"start": int(start), "end": int(end)}
|
||||||
|
group["range"] = range_payload
|
||||||
|
display_filters[name] = range_payload
|
||||||
|
else:
|
||||||
|
display_filters[name] = options
|
||||||
|
except Exception:
|
||||||
|
display_filters[name] = options
|
||||||
|
|
||||||
|
groups.append(group)
|
||||||
|
flat_options.extend(options)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"groups": groups,
|
||||||
|
"flat_options": _dedupe_filter_values(flat_options),
|
||||||
|
"display_filters": display_filters,
|
||||||
|
"raw_payload": {"filters": raw_filters or []},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_recommend_filters(page, timeout_sec: int = 30) -> Dict[str, Any]:
|
||||||
|
"""抓取推荐页筛选项并返回结构化数据。"""
|
||||||
|
page.listen.start('wapi/zpblock/recommend/filters')
|
||||||
|
page.get("https://www.zhipin.com/web/chat/recommend")
|
||||||
|
res = page.listen.wait(timeout=timeout_sec)
|
||||||
|
body = getattr(getattr(res, "response", None), "body", None) or {}
|
||||||
|
zp_data = body.get("zpData", {}) if isinstance(body, dict) else {}
|
||||||
|
vip_filter = zp_data.get("vipFilter", {}) if isinstance(zp_data, dict) else {}
|
||||||
|
raw_filters = vip_filter.get("filters", []) if isinstance(vip_filter, dict) else []
|
||||||
|
if not isinstance(raw_filters, list):
|
||||||
|
raw_filters = []
|
||||||
|
return _parse_recommend_filters(raw_filters)
|
||||||
|
|
||||||
|
|
||||||
def main(filters, position_names=None, greet_target=None):
|
def main(filters, position_names=None, greet_target=None):
|
||||||
"""
|
"""
|
||||||
推荐牛人流程:多岗位循环,所有岗位合计达到目标人数即停;不够则从第一个岗位再跑一轮直到够了。
|
推荐牛人流程:多岗位循环,所有岗位合计达到目标人数即停;不够则从第一个岗位再跑一轮直到够了。
|
||||||
@@ -256,36 +341,30 @@ def main(filters, position_names=None, greet_target=None):
|
|||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
|
|
||||||
|
|
||||||
def main1():
|
def main1(
|
||||||
if USE_LOCAL_CHROME:
|
*,
|
||||||
|
use_local_chrome: Optional[bool] = None,
|
||||||
|
bit_api_base: Optional[str] = None,
|
||||||
|
browser_name: Optional[str] = None,
|
||||||
|
browser_id: Optional[str] = None,
|
||||||
|
timeout_sec: int = 30,
|
||||||
|
echo: bool = True,
|
||||||
|
):
|
||||||
|
use_local = USE_LOCAL_CHROME if use_local_chrome is None else use_local_chrome
|
||||||
|
|
||||||
|
if use_local:
|
||||||
page = _connect_local_chrome()
|
page = _connect_local_chrome()
|
||||||
else:
|
else:
|
||||||
page = _connect_bit_browser()
|
page = _connect_bit_browser(
|
||||||
|
bit_api_base=bit_api_base,
|
||||||
|
browser_name=browser_name,
|
||||||
|
browser_id=browser_id,
|
||||||
|
)
|
||||||
|
|
||||||
page.listen.start('wapi/zpblock/recommend/filters')
|
payload = fetch_recommend_filters(page, timeout_sec=timeout_sec)
|
||||||
|
if echo:
|
||||||
# 示例:打开一个页面(可选)
|
print(payload["display_filters"])
|
||||||
page.get("https://www.zhipin.com/web/chat/recommend")
|
return payload
|
||||||
res = page.listen.wait()
|
|
||||||
|
|
||||||
filters = {}
|
|
||||||
for i in res.response.body["zpData"]["vipFilter"]["filters"]:
|
|
||||||
print(i)
|
|
||||||
|
|
||||||
if i["name"] == "年龄":
|
|
||||||
print(i["start"])
|
|
||||||
print(i["end"])
|
|
||||||
|
|
||||||
filters[i["name"]] = range(int(i["start"]), int(i["end"]) + 1)
|
|
||||||
else:
|
|
||||||
datas = []
|
|
||||||
for i1 in i["options"]:
|
|
||||||
print(i1["name"])
|
|
||||||
datas.append(i1["name"])
|
|
||||||
|
|
||||||
filters[i["name"]] = datas
|
|
||||||
|
|
||||||
print(filters)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ DEFAULTS = {
|
|||||||
"server_url": "ws://8.137.99.82:9000/ws",
|
"server_url": "ws://8.137.99.82:9000/ws",
|
||||||
"worker_id": "worker-1",
|
"worker_id": "worker-1",
|
||||||
"worker_name": "本机",
|
"worker_name": "本机",
|
||||||
|
"sync_browser_id": "",
|
||||||
|
"sync_browser_name": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import shutil
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from PyQt5.QtWidgets import (
|
from PyQt5.QtWidgets import (
|
||||||
QApplication,
|
QApplication,
|
||||||
@@ -25,6 +26,7 @@ from PyQt5.QtWidgets import (
|
|||||||
QPushButton,
|
QPushButton,
|
||||||
QTextEdit,
|
QTextEdit,
|
||||||
QProgressBar,
|
QProgressBar,
|
||||||
|
QComboBox,
|
||||||
QMessageBox,
|
QMessageBox,
|
||||||
QFrame,
|
QFrame,
|
||||||
)
|
)
|
||||||
@@ -32,6 +34,7 @@ from PyQt5.QtCore import Qt, QTimer, pyqtSignal, QObject, QEvent
|
|||||||
from PyQt5.QtGui import QFont, QTextCursor
|
from PyQt5.QtGui import QFont, QTextCursor
|
||||||
|
|
||||||
from .config_store import load_config, save_config, get_project_root
|
from .config_store import load_config, save_config, get_project_root
|
||||||
|
from worker.bit_browser import BitBrowserAPI
|
||||||
|
|
||||||
# 日志缓冲
|
# 日志缓冲
|
||||||
LOG_BATCH_CHARS = 2048
|
LOG_BATCH_CHARS = 2048
|
||||||
@@ -39,6 +42,41 @@ LOG_MAX_LINES = 2000
|
|||||||
LOG_DRAIN_INTERVAL_MS = 120
|
LOG_DRAIN_INTERVAL_MS = 120
|
||||||
|
|
||||||
|
|
||||||
|
def _browser_online_flag(browser: dict) -> Optional[bool]:
|
||||||
|
for key in ("isRunning", "isOpen", "open", "is_open"):
|
||||||
|
if key in browser:
|
||||||
|
return bool(browser.get(key))
|
||||||
|
|
||||||
|
status = str(browser.get("status", "")).strip().lower()
|
||||||
|
if not status:
|
||||||
|
return None
|
||||||
|
if status in {"1", "true", "online", "running", "open", "已打开", "打开", "在线"}:
|
||||||
|
return True
|
||||||
|
if status in {"0", "false", "offline", "stopped", "closed", "已关闭", "关闭", "离线"}:
|
||||||
|
return False
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_browser_profiles() -> list[dict]:
|
||||||
|
"""从本地比特浏览器读取可选环境;若能识别在线状态则优先返回在线环境。"""
|
||||||
|
bit_api = BitBrowserAPI()
|
||||||
|
items = bit_api.list_browsers(page_size=200)
|
||||||
|
|
||||||
|
profiles = []
|
||||||
|
for item in items:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
profiles.append({
|
||||||
|
"id": str(item.get("id", "") or "").strip(),
|
||||||
|
"name": str(item.get("name", "") or "").strip(),
|
||||||
|
"remark": str(item.get("remark", "") or "").strip(),
|
||||||
|
"online": _browser_online_flag(item),
|
||||||
|
})
|
||||||
|
|
||||||
|
online_profiles = [item for item in profiles if item.get("online") is True]
|
||||||
|
return online_profiles or profiles
|
||||||
|
|
||||||
|
|
||||||
def do_git_pull(project_root: str, log_callback) -> bool:
|
def do_git_pull(project_root: str, log_callback) -> bool:
|
||||||
"""在项目目录执行 git pull。"""
|
"""在项目目录执行 git pull。"""
|
||||||
git = shutil.which("git")
|
git = shutil.which("git")
|
||||||
@@ -81,11 +119,23 @@ def _get_worker_launcher() -> tuple[str, str, bool]:
|
|||||||
return sys.executable, project_root, False
|
return sys.executable, project_root, False
|
||||||
|
|
||||||
|
|
||||||
def run_worker(server_url: str, worker_id: str, worker_name: str, log_callback) -> subprocess.Popen | None:
|
def run_worker(
|
||||||
|
server_url: str,
|
||||||
|
worker_id: str,
|
||||||
|
worker_name: str,
|
||||||
|
log_callback,
|
||||||
|
sync_browser_id: str = "",
|
||||||
|
sync_browser_name: str = "",
|
||||||
|
) -> subprocess.Popen | None:
|
||||||
"""启动 Worker 子进程(同一 exe 的 --worker 模式或 python -m worker.main)。"""
|
"""启动 Worker 子进程(同一 exe 的 --worker 模式或 python -m worker.main)。"""
|
||||||
launcher, cwd, use_worker_flag = _get_worker_launcher()
|
launcher, cwd, use_worker_flag = _get_worker_launcher()
|
||||||
if use_worker_flag:
|
if use_worker_flag:
|
||||||
cmd = [launcher, "--worker", "--server", server_url, "--worker-id", worker_id, "--worker-name", worker_name]
|
cmd = [
|
||||||
|
launcher, "--worker",
|
||||||
|
"--server", server_url,
|
||||||
|
"--worker-id", worker_id,
|
||||||
|
"--worker-name", worker_name,
|
||||||
|
]
|
||||||
env = os.environ
|
env = os.environ
|
||||||
encoding = "gbk" if sys.platform == "win32" else "utf-8"
|
encoding = "gbk" if sys.platform == "win32" else "utf-8"
|
||||||
else:
|
else:
|
||||||
@@ -97,6 +147,10 @@ def run_worker(server_url: str, worker_id: str, worker_name: str, log_callback)
|
|||||||
]
|
]
|
||||||
env = {**os.environ, "PYTHONIOENCODING": "utf-8"}
|
env = {**os.environ, "PYTHONIOENCODING": "utf-8"}
|
||||||
encoding = "utf-8"
|
encoding = "utf-8"
|
||||||
|
if sync_browser_id:
|
||||||
|
cmd.extend(["--sync-browser-id", sync_browser_id])
|
||||||
|
if sync_browser_name:
|
||||||
|
cmd.extend(["--sync-browser-name", sync_browser_name])
|
||||||
try:
|
try:
|
||||||
return subprocess.Popen(
|
return subprocess.Popen(
|
||||||
cmd, cwd=cwd, env=env,
|
cmd, cwd=cwd, env=env,
|
||||||
@@ -159,6 +213,7 @@ class ClientGUI(QMainWindow):
|
|||||||
self._build_ui()
|
self._build_ui()
|
||||||
self._log_worker = LogWorker(self._log_queue, self)
|
self._log_worker = LogWorker(self._log_queue, self)
|
||||||
self._log_worker.batch_ready.connect(self._append_log_batch)
|
self._log_worker.batch_ready.connect(self._append_log_batch)
|
||||||
|
QTimer.singleShot(0, self._on_refresh_browsers)
|
||||||
|
|
||||||
def _build_ui(self):
|
def _build_ui(self):
|
||||||
central = QWidget()
|
central = QWidget()
|
||||||
@@ -185,6 +240,19 @@ class ClientGUI(QMainWindow):
|
|||||||
self.entry_worker_name.setText(self.config.get("worker_name", ""))
|
self.entry_worker_name.setText(self.config.get("worker_name", ""))
|
||||||
cfg_layout.addWidget(self.entry_worker_name, 2, 1)
|
cfg_layout.addWidget(self.entry_worker_name, 2, 1)
|
||||||
|
|
||||||
|
cfg_layout.addWidget(QLabel("同步环境:"), 3, 0)
|
||||||
|
browser_layout = QHBoxLayout()
|
||||||
|
self.combo_sync_browser = QComboBox()
|
||||||
|
self.combo_sync_browser.setMinimumWidth(280)
|
||||||
|
browser_layout.addWidget(self.combo_sync_browser, 1)
|
||||||
|
|
||||||
|
self.btn_refresh_browsers = QPushButton("刷新环境")
|
||||||
|
self.btn_refresh_browsers.setCursor(Qt.PointingHandCursor)
|
||||||
|
self.btn_refresh_browsers.clicked.connect(lambda _checked=False: self._on_refresh_browsers(notify=True))
|
||||||
|
browser_layout.addWidget(self.btn_refresh_browsers)
|
||||||
|
cfg_layout.addLayout(browser_layout, 3, 1)
|
||||||
|
self._set_browser_placeholder("正在加载浏览器环境…")
|
||||||
|
|
||||||
layout.addWidget(cfg_group)
|
layout.addWidget(cfg_group)
|
||||||
|
|
||||||
# 操作按钮
|
# 操作按钮
|
||||||
@@ -242,6 +310,9 @@ class ClientGUI(QMainWindow):
|
|||||||
self.config["server_url"] = self.entry_server.text().strip()
|
self.config["server_url"] = self.entry_server.text().strip()
|
||||||
self.config["worker_id"] = self.entry_worker_id.text().strip()
|
self.config["worker_id"] = self.entry_worker_id.text().strip()
|
||||||
self.config["worker_name"] = self.entry_worker_name.text().strip()
|
self.config["worker_name"] = self.entry_worker_name.text().strip()
|
||||||
|
browser = self._selected_browser()
|
||||||
|
self.config["sync_browser_id"] = browser.get("id", "")
|
||||||
|
self.config["sync_browser_name"] = browser.get("name", "")
|
||||||
save_config(self.config)
|
save_config(self.config)
|
||||||
|
|
||||||
def _log(self, msg: str):
|
def _log(self, msg: str):
|
||||||
@@ -295,17 +366,94 @@ class ClientGUI(QMainWindow):
|
|||||||
else:
|
else:
|
||||||
QMessageBox.warning(self, "更新", "更新失败,请检查网络或 Git 配置")
|
QMessageBox.warning(self, "更新", "更新失败,请检查网络或 Git 配置")
|
||||||
return True
|
return True
|
||||||
|
if isinstance(e, _BrowserProfilesEvent):
|
||||||
|
self.btn_refresh_browsers.setEnabled(True)
|
||||||
|
self._apply_browser_profiles(e.profiles)
|
||||||
|
if e.error:
|
||||||
|
self._log(f"读取浏览器环境失败: {e.error}\n")
|
||||||
|
if e.notify:
|
||||||
|
QMessageBox.warning(self, "浏览器环境", e.error)
|
||||||
|
elif e.notify:
|
||||||
|
self._log(f"已加载 {len(e.profiles)} 个浏览器环境。\n")
|
||||||
|
return True
|
||||||
return super().event(e)
|
return super().event(e)
|
||||||
|
|
||||||
|
def _set_browser_placeholder(self, text: str):
|
||||||
|
self.combo_sync_browser.clear()
|
||||||
|
self.combo_sync_browser.addItem(text, None)
|
||||||
|
|
||||||
|
def _selected_browser(self) -> dict:
|
||||||
|
data = self.combo_sync_browser.currentData()
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
|
||||||
|
def _browser_label(self, browser: dict) -> str:
|
||||||
|
name = browser.get("name", "") or "(未命名环境)"
|
||||||
|
remark = browser.get("remark", "")
|
||||||
|
return f"{name} [{remark}]" if remark else name
|
||||||
|
|
||||||
|
def _apply_browser_profiles(self, profiles: list[dict]):
|
||||||
|
self.combo_sync_browser.clear()
|
||||||
|
if not profiles:
|
||||||
|
self._set_browser_placeholder("未找到可用浏览器环境")
|
||||||
|
return
|
||||||
|
|
||||||
|
for browser in profiles:
|
||||||
|
self.combo_sync_browser.addItem(self._browser_label(browser), browser)
|
||||||
|
|
||||||
|
saved_id = str(self.config.get("sync_browser_id", "") or "").strip()
|
||||||
|
saved_name = str(self.config.get("sync_browser_name", "") or "").strip()
|
||||||
|
target_index = 0
|
||||||
|
for index in range(self.combo_sync_browser.count()):
|
||||||
|
browser = self.combo_sync_browser.itemData(index)
|
||||||
|
if not isinstance(browser, dict):
|
||||||
|
continue
|
||||||
|
if saved_id and browser.get("id") == saved_id:
|
||||||
|
target_index = index
|
||||||
|
break
|
||||||
|
if not saved_id and saved_name and browser.get("name") == saved_name:
|
||||||
|
target_index = index
|
||||||
|
break
|
||||||
|
self.combo_sync_browser.setCurrentIndex(target_index)
|
||||||
|
|
||||||
|
def _on_refresh_browsers(self, notify: bool = False):
|
||||||
|
self.btn_refresh_browsers.setEnabled(False)
|
||||||
|
self._set_browser_placeholder("正在加载浏览器环境…")
|
||||||
|
if notify:
|
||||||
|
self._log("正在刷新浏览器环境列表...\n")
|
||||||
|
|
||||||
|
def task():
|
||||||
|
try:
|
||||||
|
profiles = fetch_browser_profiles()
|
||||||
|
error = ""
|
||||||
|
except Exception as err:
|
||||||
|
profiles = []
|
||||||
|
error = str(err)
|
||||||
|
QApplication.instance().postEvent(self, _BrowserProfilesEvent(profiles, error, notify))
|
||||||
|
|
||||||
|
threading.Thread(target=task, daemon=True).start()
|
||||||
|
|
||||||
def _on_start(self):
|
def _on_start(self):
|
||||||
self._save_config()
|
self._save_config()
|
||||||
server_url = self.entry_server.text().strip() or "ws://8.137.99.82:9000/ws"
|
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_id = self.entry_worker_id.text().strip() or "worker-1"
|
||||||
worker_name = self.entry_worker_name.text().strip() or "本机"
|
worker_name = self.entry_worker_name.text().strip() or "本机"
|
||||||
|
browser = self._selected_browser()
|
||||||
|
sync_browser_id = str(browser.get("id", "") or "").strip()
|
||||||
|
sync_browser_name = str(browser.get("name", "") or "").strip()
|
||||||
|
|
||||||
self._set_status("启动中…", "#3b82f6")
|
self._set_status("启动中…", "#3b82f6")
|
||||||
self._log("正在启动 Worker...\n")
|
if sync_browser_name:
|
||||||
proc = run_worker(server_url, worker_id, worker_name, self._log)
|
self._log(f"正在启动 Worker,并同步筛选环境:{sync_browser_name}\n")
|
||||||
|
else:
|
||||||
|
self._log("正在启动 Worker...\n")
|
||||||
|
proc = run_worker(
|
||||||
|
server_url,
|
||||||
|
worker_id,
|
||||||
|
worker_name,
|
||||||
|
self._log,
|
||||||
|
sync_browser_id=sync_browser_id,
|
||||||
|
sync_browser_name=sync_browser_name,
|
||||||
|
)
|
||||||
if proc is None:
|
if proc is None:
|
||||||
self._set_status("就绪", "#22c55e")
|
self._set_status("就绪", "#22c55e")
|
||||||
return
|
return
|
||||||
@@ -367,6 +515,16 @@ class _FinishUpdateEvent(QEvent):
|
|||||||
self.ok = ok
|
self.ok = ok
|
||||||
|
|
||||||
|
|
||||||
|
class _BrowserProfilesEvent(QEvent):
|
||||||
|
Type = QEvent.Type(QEvent.registerEventType())
|
||||||
|
|
||||||
|
def __init__(self, profiles: list[dict], error: str = "", notify: bool = False):
|
||||||
|
super().__init__(_BrowserProfilesEvent.Type)
|
||||||
|
self.profiles = profiles
|
||||||
|
self.error = error
|
||||||
|
self.notify = notify
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
w = ClientGUI()
|
w = ClientGUI()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""
|
"""
|
||||||
数据库初始化脚本:创建测试数据
|
数据库初始化脚本:创建测试数据
|
||||||
用于测试新增的筛选和复聊功能
|
用于测试招聘回复与复聊功能
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -13,36 +13,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|||||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'server.settings')
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'server.settings')
|
||||||
django.setup()
|
django.setup()
|
||||||
|
|
||||||
from server.models import FilterConfig, ChatScript
|
from server.models import ChatScript
|
||||||
|
|
||||||
|
|
||||||
def create_filter_config():
|
|
||||||
"""创建筛选配置示例"""
|
|
||||||
print("创建筛选配置...")
|
|
||||||
|
|
||||||
# 删除旧的测试配置
|
|
||||||
FilterConfig.objects.filter(name__contains="测试").delete()
|
|
||||||
|
|
||||||
# 创建新配置
|
|
||||||
config = FilterConfig.objects.create(
|
|
||||||
name="Python开发筛选配置",
|
|
||||||
age_min=22,
|
|
||||||
age_max=35,
|
|
||||||
gender="不限",
|
|
||||||
education="本科",
|
|
||||||
activity="3天内活跃",
|
|
||||||
positions=["Python开发", "后端开发", "全栈开发", "Django开发"],
|
|
||||||
greeting_min=5,
|
|
||||||
greeting_max=20,
|
|
||||||
rest_minutes=30,
|
|
||||||
collection_min=10,
|
|
||||||
collection_max=50,
|
|
||||||
message_interval=30,
|
|
||||||
is_active=True
|
|
||||||
)
|
|
||||||
print(f"✓ 创建筛选配置: {config.name} (ID: {config.id})")
|
|
||||||
|
|
||||||
return config
|
|
||||||
|
|
||||||
|
|
||||||
def create_chat_scripts():
|
def create_chat_scripts():
|
||||||
@@ -106,21 +77,12 @@ def main():
|
|||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 创建筛选配置
|
|
||||||
config = create_filter_config()
|
|
||||||
|
|
||||||
# 创建话术
|
# 创建话术
|
||||||
scripts = create_chat_scripts()
|
scripts = create_chat_scripts()
|
||||||
|
|
||||||
print("\n" + "=" * 60)
|
print("\n" + "=" * 60)
|
||||||
print("初始化完成!")
|
print("初始化完成!")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
print(f"\n筛选配置: {config.name}")
|
|
||||||
print(f" - 年龄: {config.age_min}-{config.age_max}岁")
|
|
||||||
print(f" - 学历: {config.education}及以上")
|
|
||||||
print(f" - 活跃度: {config.activity}")
|
|
||||||
print(f" - 期望职位: {', '.join(config.positions)}")
|
|
||||||
|
|
||||||
print(f"\n话术配置: 共 {len(scripts)} 条")
|
print(f"\n话术配置: 共 {len(scripts)} 条")
|
||||||
for script in scripts:
|
for script in scripts:
|
||||||
print(f" - {script.position} / {script.get_script_type_display()}")
|
print(f" - {script.position} / {script.get_script_type_display()}")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""
|
"""
|
||||||
功能验证脚本:测试新增的筛选和消息过滤功能
|
功能验证脚本:测试消息过滤与联系方式提取功能
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -132,33 +132,6 @@ def test_contact_extraction():
|
|||||||
print(f" [-] 未提取到联系方式")
|
print(f" [-] 未提取到联系方式")
|
||||||
|
|
||||||
|
|
||||||
# 测试学历筛选
|
|
||||||
def test_education_filter():
|
|
||||||
print("\n" + "=" * 60)
|
|
||||||
print("测试学历筛选功能")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
edu_levels = ["初中", "高中", "中专", "大专", "本科", "硕士", "博士"]
|
|
||||||
|
|
||||||
test_cases = [
|
|
||||||
("本科", "大专", False), # 要求本科,候选人大专,不通过
|
|
||||||
("本科", "本科", True), # 要求本科,候选人本科,通过
|
|
||||||
("本科", "硕士", True), # 要求本科,候选人硕士,通过
|
|
||||||
("大专", "本科", True), # 要求大专,候选人本科,通过
|
|
||||||
("硕士", "本科", False), # 要求硕士,候选人本科,不通过
|
|
||||||
]
|
|
||||||
|
|
||||||
for required, candidate, expected in test_cases:
|
|
||||||
candidate_level = next((i for i, edu in enumerate(edu_levels) if edu in candidate), -1)
|
|
||||||
required_level = next((i for i, edu in enumerate(edu_levels) if edu in required), -1)
|
|
||||||
|
|
||||||
result = candidate_level >= required_level if candidate_level != -1 and required_level != -1 else True
|
|
||||||
status = "[OK] 通过" if result == expected else "[FAIL] 失败"
|
|
||||||
|
|
||||||
print(f"\n要求: {required}, 候选人: {candidate}")
|
|
||||||
print(f" 期望: {'通过' if expected else '不通过'}, 实际: {'通过' if result else '不通过'} {status}")
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
print("\n" + "=" * 60)
|
print("\n" + "=" * 60)
|
||||||
print("BOSS招聘自动化 - 功能验证")
|
print("BOSS招聘自动化 - 功能验证")
|
||||||
@@ -168,7 +141,6 @@ def main():
|
|||||||
test_time_parsing()
|
test_time_parsing()
|
||||||
test_message_filtering()
|
test_message_filtering()
|
||||||
test_contact_extraction()
|
test_contact_extraction()
|
||||||
test_education_filter()
|
|
||||||
|
|
||||||
print("\n" + "=" * 60)
|
print("\n" + "=" * 60)
|
||||||
print("所有测试完成!")
|
print("所有测试完成!")
|
||||||
|
|||||||
@@ -1,52 +1,18 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""
|
"""
|
||||||
Filter APIs:
|
Filter APIs:
|
||||||
- CRUD for FilterConfig (legacy/manual configs)
|
|
||||||
- Recruit filter options (synced from site at worker startup)
|
- Recruit filter options (synced from site at worker startup)
|
||||||
"""
|
"""
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.decorators import api_view
|
from rest_framework.decorators import api_view
|
||||||
|
|
||||||
from server.core.response import api_error, api_success
|
from server.core.response import api_error, api_success
|
||||||
from server.models import BossAccount, FilterConfig, RecruitFilterSnapshot
|
from server.models import BossAccount, RecruitFilterSnapshot
|
||||||
from server.serializers import (
|
from server.serializers import (
|
||||||
FilterConfigSerializer,
|
|
||||||
RecruitFilterSnapshotSerializer,
|
RecruitFilterSnapshotSerializer,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@api_view(["GET", "POST"])
|
|
||||||
def filter_list(request):
|
|
||||||
if request.method == "GET":
|
|
||||||
qs = FilterConfig.objects.all().order_by("-updated_at")
|
|
||||||
return api_success(FilterConfigSerializer(qs, many=True).data)
|
|
||||||
|
|
||||||
ser = FilterConfigSerializer(data=request.data)
|
|
||||||
ser.is_valid(raise_exception=True)
|
|
||||||
ser.save()
|
|
||||||
return api_success(ser.data, http_status=status.HTTP_201_CREATED)
|
|
||||||
|
|
||||||
|
|
||||||
@api_view(["GET", "PUT", "DELETE"])
|
|
||||||
def filter_detail(request, pk):
|
|
||||||
try:
|
|
||||||
obj = FilterConfig.objects.get(pk=pk)
|
|
||||||
except FilterConfig.DoesNotExist:
|
|
||||||
return api_error(status.HTTP_404_NOT_FOUND, "筛选配置不存在")
|
|
||||||
|
|
||||||
if request.method == "GET":
|
|
||||||
return api_success(FilterConfigSerializer(obj).data)
|
|
||||||
|
|
||||||
if request.method == "PUT":
|
|
||||||
ser = FilterConfigSerializer(obj, data=request.data, partial=True)
|
|
||||||
ser.is_valid(raise_exception=True)
|
|
||||||
ser.save()
|
|
||||||
return api_success(ser.data)
|
|
||||||
|
|
||||||
obj.delete()
|
|
||||||
return api_success(msg="筛选配置已删除")
|
|
||||||
|
|
||||||
|
|
||||||
def _snapshot_payload(snapshot: RecruitFilterSnapshot) -> dict:
|
def _snapshot_payload(snapshot: RecruitFilterSnapshot) -> dict:
|
||||||
data = RecruitFilterSnapshotSerializer(snapshot).data
|
data = RecruitFilterSnapshotSerializer(snapshot).data
|
||||||
return {
|
return {
|
||||||
@@ -102,4 +68,3 @@ def recruit_filter_options(request):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
return api_success(_snapshot_payload(snapshot))
|
return api_success(_snapshot_payload(snapshot))
|
||||||
|
|
||||||
|
|||||||
16
server/migrations/0008_delete_filterconfig.py
Normal file
16
server/migrations/0008_delete_filterconfig.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Generated by Codex on 2026-03-06
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("server", "0007_recruitfiltersnapshot"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name="FilterConfig",
|
||||||
|
),
|
||||||
|
]
|
||||||
35
server/migrations/0009_task.py
Normal file
35
server/migrations/0009_task.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Generated by Django 6.0.2 on 2026-03-06 10:45
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('server', '0008_delete_filterconfig'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Task',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('task_id', models.CharField(max_length=32, unique=True, verbose_name='任务 ID')),
|
||||||
|
('task_type', models.CharField(max_length=64, verbose_name='任务类型')),
|
||||||
|
('worker_id', models.CharField(default='', max_length=64, verbose_name='执行的 Worker')),
|
||||||
|
('account_name', models.CharField(blank=True, default='', max_length=128, verbose_name='账号(环境名称)')),
|
||||||
|
('status', models.CharField(default='', max_length=32, verbose_name='当前状态')),
|
||||||
|
('params', models.JSONField(blank=True, null=True, verbose_name='任务参数')),
|
||||||
|
('progress', models.TextField(blank=True, null=True, verbose_name='进度信息')),
|
||||||
|
('result', models.JSONField(blank=True, null=True, verbose_name='任务结果')),
|
||||||
|
('error', models.TextField(blank=True, null=True, verbose_name='错误信息')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': '任务',
|
||||||
|
'verbose_name_plural': '任务',
|
||||||
|
'db_table': 'task',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -105,42 +105,6 @@ class AuthToken(models.Model):
|
|||||||
return self.username
|
return self.username
|
||||||
|
|
||||||
|
|
||||||
class FilterConfig(models.Model):
|
|
||||||
"""筛选条件配置表。"""
|
|
||||||
name = models.CharField(max_length=128, verbose_name="配置名称")
|
|
||||||
position_keywords = models.CharField(max_length=512, default="", blank=True, verbose_name="岗位关键词列表")
|
|
||||||
city = models.CharField(max_length=64, default="", blank=True, verbose_name="城市")
|
|
||||||
salary_min = models.CharField(max_length=32, default="", blank=True, verbose_name="最低薪资(K)")
|
|
||||||
salary_max = models.CharField(max_length=32, default="", blank=True, verbose_name="最高薪资(K)")
|
|
||||||
experience = models.CharField(max_length=64, default="", blank=True, verbose_name="工作经验")
|
|
||||||
education = models.CharField(max_length=32, default="不限", verbose_name="学历要求")
|
|
||||||
is_active = models.BooleanField(default=True, verbose_name="是否启用")
|
|
||||||
# 以下为兼容旧版保留字段
|
|
||||||
age_min = models.IntegerField(default=18, verbose_name="最小年龄")
|
|
||||||
age_max = models.IntegerField(default=60, verbose_name="最大年龄")
|
|
||||||
gender = models.CharField(max_length=32, default="不限", verbose_name="性别")
|
|
||||||
activity = models.CharField(max_length=32, default="不限", verbose_name="活跃度")
|
|
||||||
positions = models.JSONField(default=list, blank=True, verbose_name="期望岗位列表")
|
|
||||||
greeting_min = models.IntegerField(default=5, verbose_name="打招呼最少条数/天")
|
|
||||||
greeting_max = models.IntegerField(default=20, verbose_name="打招呼最多条数/天")
|
|
||||||
rest_minutes = models.IntegerField(default=30, verbose_name="每轮休息分钟")
|
|
||||||
collection_min = models.IntegerField(default=10, verbose_name="收藏最少个数/天")
|
|
||||||
collection_max = models.IntegerField(default=50, verbose_name="收藏最多个数/天")
|
|
||||||
message_interval = models.IntegerField(default=30, verbose_name="打招呼间隔秒")
|
|
||||||
min_amount = models.IntegerField(null=True, blank=True, verbose_name="最小金额")
|
|
||||||
max_amount = models.IntegerField(null=True, blank=True, verbose_name="最大金额")
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
|
|
||||||
updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间")
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
db_table = "filter_config"
|
|
||||||
verbose_name = "筛选配置"
|
|
||||||
verbose_name_plural = verbose_name
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
|
|
||||||
class RecruitFilterSnapshot(models.Model):
|
class RecruitFilterSnapshot(models.Model):
|
||||||
"""Per-account site filter snapshot fetched from zhipin recommend page."""
|
"""Per-account site filter snapshot fetched from zhipin recommend page."""
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ DRF 序列化器。
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from server.models import (
|
from server.models import (
|
||||||
BossAccount, TaskLog, FilterConfig, RecruitFilterSnapshot, ChatScript, ContactRecord, SystemConfig,
|
BossAccount, TaskLog, RecruitFilterSnapshot, ChatScript, ContactRecord, SystemConfig,
|
||||||
FollowUpConfig, FollowUpScript, FollowUpRecord
|
FollowUpConfig, FollowUpScript, FollowUpRecord
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -89,49 +89,6 @@ class LoginSerializer(serializers.Serializer):
|
|||||||
password = serializers.CharField(max_length=128)
|
password = serializers.CharField(max_length=128)
|
||||||
|
|
||||||
|
|
||||||
# ────────────────────────── 筛选配置 ──────────────────────────
|
|
||||||
|
|
||||||
# 请求中可能出现的“金额”字段别名,统一映射为 min_amount / max_amount,保证请求与响应字段名一致
|
|
||||||
FILTER_AMOUNT_ALIASES = {
|
|
||||||
"minAmount": "min_amount",
|
|
||||||
"maxAmount": "max_amount",
|
|
||||||
"最小金额": "min_amount",
|
|
||||||
"最大金额": "max_amount",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class FilterConfigSerializer(serializers.ModelSerializer):
|
|
||||||
"""筛选配置:列表/详情返回 name, position_keywords, city, salary_min, salary_max, experience, education, is_active 等。"""
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = FilterConfig
|
|
||||||
fields = "__all__"
|
|
||||||
read_only_fields = ["id", "created_at", "updated_at"]
|
|
||||||
|
|
||||||
def to_internal_value(self, data):
|
|
||||||
"""请求中兼容 minAmount/maxAmount 等别名;支持 multipart/form-data(QueryDict/多值列表)。"""
|
|
||||||
if data is None:
|
|
||||||
data = {}
|
|
||||||
else:
|
|
||||||
data = dict(data)
|
|
||||||
# multipart 解析后字段值可能是 list(如 ["测试配置"]),需展平为标量
|
|
||||||
for key in list(data.keys()):
|
|
||||||
val = data[key]
|
|
||||||
if isinstance(val, (list, tuple)):
|
|
||||||
data[key] = val[0] if len(val) > 0 else ""
|
|
||||||
for alias, canonical in FILTER_AMOUNT_ALIASES.items():
|
|
||||||
if alias in data and canonical not in data:
|
|
||||||
data[canonical] = data.pop(alias)
|
|
||||||
# 请求里 is_active 可能是字符串 "true"/"false" 或单元素列表
|
|
||||||
raw = data.get("is_active")
|
|
||||||
if raw is not None:
|
|
||||||
if isinstance(raw, (list, tuple)):
|
|
||||||
raw = raw[0] if raw else ""
|
|
||||||
if isinstance(raw, str):
|
|
||||||
data["is_active"] = raw.lower() in ("true", "1", "yes", "是")
|
|
||||||
return super().to_internal_value(data)
|
|
||||||
|
|
||||||
|
|
||||||
# ────────────────────────── 话术 ──────────────────────────
|
# ────────────────────────── 话术 ──────────────────────────
|
||||||
|
|
||||||
class RecruitFilterSnapshotSerializer(serializers.ModelSerializer):
|
class RecruitFilterSnapshotSerializer(serializers.ModelSerializer):
|
||||||
|
|||||||
@@ -35,10 +35,8 @@ urlpatterns = [
|
|||||||
path("api/accounts/worker/<str:worker_id>", accounts.account_list_by_worker),
|
path("api/accounts/worker/<str:worker_id>", accounts.account_list_by_worker),
|
||||||
path("api/accounts/<int:account_id>", accounts.account_detail),
|
path("api/accounts/<int:account_id>", accounts.account_detail),
|
||||||
|
|
||||||
# ─── 筛选配置 ───
|
# ─── 招聘筛选快照 ───
|
||||||
path("api/filters", filters.filter_list),
|
|
||||||
path("api/filters/options", filters.recruit_filter_options),
|
path("api/filters/options", filters.recruit_filter_options),
|
||||||
path("api/filters/<int:pk>", filters.filter_detail),
|
|
||||||
|
|
||||||
# ─── 话术管理 ───
|
# ─── 话术管理 ───
|
||||||
path("api/scripts", scripts.script_list),
|
path("api/scripts", scripts.script_list),
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ def parse_args():
|
|||||||
parser.add_argument("--worker-id", default=config.WORKER_ID, help=f"Worker ID (default: {config.WORKER_ID})")
|
parser.add_argument("--worker-id", default=config.WORKER_ID, help=f"Worker ID (default: {config.WORKER_ID})")
|
||||||
parser.add_argument("--worker-name", default=config.WORKER_NAME, help=f"Worker name (default: {config.WORKER_NAME})")
|
parser.add_argument("--worker-name", default=config.WORKER_NAME, help=f"Worker name (default: {config.WORKER_NAME})")
|
||||||
parser.add_argument("--bit-api", default=config.BIT_API_BASE, help=f"BitBrowser local API URL (default: {config.BIT_API_BASE})")
|
parser.add_argument("--bit-api", default=config.BIT_API_BASE, help=f"BitBrowser local API URL (default: {config.BIT_API_BASE})")
|
||||||
|
parser.add_argument("--sync-browser-id", default="", help="Browser ID used for startup recruit filter sync")
|
||||||
|
parser.add_argument("--sync-browser-name", default="", help="Browser name used for startup recruit filter sync")
|
||||||
parser.add_argument("--no-tunnel", action="store_true", help="Disable tunnel client")
|
parser.add_argument("--no-tunnel", action="store_true", help="Disable tunnel client")
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
|
|
||||||
@@ -71,6 +73,8 @@ async def run(args):
|
|||||||
bootstrap_result = bootstrap_recruit_filter_snapshot(
|
bootstrap_result = bootstrap_recruit_filter_snapshot(
|
||||||
worker_id=args.worker_id,
|
worker_id=args.worker_id,
|
||||||
bit_api_base=args.bit_api,
|
bit_api_base=args.bit_api,
|
||||||
|
account_name=args.sync_browser_name,
|
||||||
|
browser_id=args.sync_browser_id,
|
||||||
logger=logger,
|
logger=logger,
|
||||||
)
|
)
|
||||||
if bootstrap_result:
|
if bootstrap_result:
|
||||||
@@ -158,4 +162,3 @@ def main():
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|
||||||
|
|||||||
@@ -7,86 +7,28 @@ Recruit filter snapshot sync:
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import importlib.util
|
||||||
import logging
|
import logging
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from worker.bit_browser import BitBrowserAPI
|
|
||||||
from worker.browser_control import connect_browser
|
SCRIPT_ONE_PATH = Path(__file__).resolve().parent.parent / "1.py"
|
||||||
|
|
||||||
|
|
||||||
FILTER_API = "wapi/zpblock/recommend/filters"
|
@lru_cache(maxsize=1)
|
||||||
RECOMMEND_URL = "https://www.zhipin.com/web/chat/recommend"
|
def _load_main1():
|
||||||
|
spec = importlib.util.spec_from_file_location("boss_dp_script_one", SCRIPT_ONE_PATH)
|
||||||
|
if spec is None or spec.loader is None:
|
||||||
|
raise RuntimeError(f"无法加载脚本: {SCRIPT_ONE_PATH}")
|
||||||
|
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
def _packet_body(packet) -> dict:
|
spec.loader.exec_module(module)
|
||||||
if not packet:
|
main1 = getattr(module, "main1", None)
|
||||||
return {}
|
if not callable(main1):
|
||||||
response = getattr(packet, "response", None)
|
raise RuntimeError(f"{SCRIPT_ONE_PATH} 中未找到可调用的 main1")
|
||||||
body = getattr(response, "body", None) if response is not None else None
|
return main1
|
||||||
if isinstance(body, dict):
|
|
||||||
return body
|
|
||||||
if isinstance(body, str):
|
|
||||||
try:
|
|
||||||
parsed = json.loads(body)
|
|
||||||
return parsed if isinstance(parsed, dict) else {}
|
|
||||||
except Exception:
|
|
||||||
return {}
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def _dedupe(items: List[str]) -> List[str]:
|
|
||||||
seen = set()
|
|
||||||
result: List[str] = []
|
|
||||||
for item in items:
|
|
||||||
value = str(item or "").strip()
|
|
||||||
if not value or value in seen:
|
|
||||||
continue
|
|
||||||
seen.add(value)
|
|
||||||
result.append(value)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_groups(raw_filters: List[dict]) -> tuple[list[dict], list[str]]:
|
|
||||||
groups: List[dict] = []
|
|
||||||
flat_options: List[str] = []
|
|
||||||
|
|
||||||
for idx, item in enumerate(raw_filters or []):
|
|
||||||
if not isinstance(item, dict):
|
|
||||||
continue
|
|
||||||
name = str(item.get("name", "")).strip()
|
|
||||||
if not name:
|
|
||||||
continue
|
|
||||||
|
|
||||||
options: List[str] = []
|
|
||||||
raw_options = item.get("options")
|
|
||||||
if isinstance(raw_options, list):
|
|
||||||
for opt in raw_options:
|
|
||||||
if not isinstance(opt, dict):
|
|
||||||
continue
|
|
||||||
opt_name = str(opt.get("name", "")).strip()
|
|
||||||
if opt_name:
|
|
||||||
options.append(opt_name)
|
|
||||||
options = _dedupe(options)
|
|
||||||
|
|
||||||
group: Dict[str, Any] = {
|
|
||||||
"name": name,
|
|
||||||
"order": idx,
|
|
||||||
"options": options,
|
|
||||||
}
|
|
||||||
|
|
||||||
start = item.get("start")
|
|
||||||
end = item.get("end")
|
|
||||||
try:
|
|
||||||
if start is not None and end is not None:
|
|
||||||
group["range"] = {"start": int(start), "end": int(end)}
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
groups.append(group)
|
|
||||||
flat_options.extend(options)
|
|
||||||
|
|
||||||
return groups, _dedupe(flat_options)
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_recruit_filters_from_site(
|
def fetch_recruit_filters_from_site(
|
||||||
@@ -99,30 +41,25 @@ def fetch_recruit_filters_from_site(
|
|||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Fetch recruit filters from target site using one BitBrowser profile."""
|
"""Fetch recruit filters from target site using one BitBrowser profile."""
|
||||||
log = logger or logging.getLogger("worker.recruit_filter_sync")
|
log = logger or logging.getLogger("worker.recruit_filter_sync")
|
||||||
bit_api = BitBrowserAPI(bit_api_base)
|
main1 = _load_main1()
|
||||||
|
|
||||||
resolved_browser_id = (browser_id or "").strip()
|
resolved_browser_id = (browser_id or "").strip()
|
||||||
if resolved_browser_id.startswith("name:"):
|
if resolved_browser_id.startswith("name:"):
|
||||||
resolved_browser_id = ""
|
resolved_browser_id = ""
|
||||||
|
|
||||||
_, port = bit_api.get_browser_for_drission(
|
payload = main1(
|
||||||
|
use_local_chrome=False,
|
||||||
|
bit_api_base=bit_api_base,
|
||||||
|
browser_name=(account_name or "").strip() or None,
|
||||||
browser_id=resolved_browser_id or None,
|
browser_id=resolved_browser_id or None,
|
||||||
name=(account_name or "").strip() or None,
|
timeout_sec=timeout_sec,
|
||||||
|
echo=False,
|
||||||
)
|
)
|
||||||
browser = connect_browser(port=port)
|
if not isinstance(payload, dict):
|
||||||
tab = browser.latest_tab
|
raise RuntimeError("main1 未返回有效筛选数据")
|
||||||
|
|
||||||
tab.listen.start(FILTER_API)
|
groups = payload.get("groups", [])
|
||||||
tab.get(RECOMMEND_URL)
|
flat_options = payload.get("flat_options", [])
|
||||||
packet = tab.listen.wait(timeout=timeout_sec)
|
raw_payload = payload.get("raw_payload", {})
|
||||||
body = _packet_body(packet)
|
|
||||||
zp_data = body.get("zpData", {}) if isinstance(body, dict) else {}
|
|
||||||
vip_filter = zp_data.get("vipFilter", {}) if isinstance(zp_data, dict) else {}
|
|
||||||
raw_filters = vip_filter.get("filters", []) if isinstance(vip_filter, dict) else []
|
|
||||||
if not isinstance(raw_filters, list):
|
|
||||||
raw_filters = []
|
|
||||||
|
|
||||||
groups, flat_options = _parse_groups(raw_filters)
|
|
||||||
log.info(
|
log.info(
|
||||||
"Fetched recruit filters: account=%s groups=%d options=%d",
|
"Fetched recruit filters: account=%s groups=%d options=%d",
|
||||||
account_name,
|
account_name,
|
||||||
@@ -132,7 +69,7 @@ def fetch_recruit_filters_from_site(
|
|||||||
return {
|
return {
|
||||||
"groups": groups,
|
"groups": groups,
|
||||||
"flat_options": flat_options,
|
"flat_options": flat_options,
|
||||||
"raw_payload": {"filters": raw_filters},
|
"raw_payload": raw_payload if isinstance(raw_payload, dict) else {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -189,6 +126,8 @@ def bootstrap_recruit_filter_snapshot(
|
|||||||
*,
|
*,
|
||||||
worker_id: str,
|
worker_id: str,
|
||||||
bit_api_base: str,
|
bit_api_base: str,
|
||||||
|
account_name: str = "",
|
||||||
|
browser_id: str = "",
|
||||||
logger: Optional[logging.Logger] = None,
|
logger: Optional[logging.Logger] = None,
|
||||||
) -> Optional[Dict[str, Any]]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
@@ -198,6 +137,23 @@ def bootstrap_recruit_filter_snapshot(
|
|||||||
log = logger or logging.getLogger("worker.recruit_filter_sync")
|
log = logger or logging.getLogger("worker.recruit_filter_sync")
|
||||||
from server.models import BossAccount
|
from server.models import BossAccount
|
||||||
|
|
||||||
|
selected_account_name = (account_name or "").strip()
|
||||||
|
selected_browser_id = (browser_id or "").strip()
|
||||||
|
if selected_account_name:
|
||||||
|
payload = sync_recruit_filters_for_account(
|
||||||
|
worker_id=worker_id,
|
||||||
|
bit_api_base=bit_api_base,
|
||||||
|
account_name=selected_account_name,
|
||||||
|
browser_id=selected_browser_id,
|
||||||
|
logger=log,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"worker_id": worker_id,
|
||||||
|
"account_name": selected_account_name,
|
||||||
|
"groups": len(payload.get("groups", [])),
|
||||||
|
"flat_options": len(payload.get("flat_options", [])),
|
||||||
|
}
|
||||||
|
|
||||||
accounts = (
|
accounts = (
|
||||||
BossAccount.objects
|
BossAccount.objects
|
||||||
.filter(worker_id=worker_id)
|
.filter(worker_id=worker_id)
|
||||||
@@ -236,4 +192,3 @@ def bootstrap_recruit_filter_snapshot(
|
|||||||
if last_error:
|
if last_error:
|
||||||
log.warning("Recruit filter bootstrap skipped: all accounts failed for worker=%s", worker_id)
|
log.warning("Recruit filter bootstrap skipped: all accounts failed for worker=%s", worker_id)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import json
|
|||||||
import random
|
import random
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from typing import Any, Callable, Coroutine, Dict, List, Optional
|
from typing import Any, Callable, Coroutine, Dict, List, Optional
|
||||||
|
|
||||||
from common.protocol import TaskType
|
from common.protocol import TaskType
|
||||||
@@ -616,115 +615,9 @@ class BossReplyHandler(BaseTaskHandler):
|
|||||||
return found[:3]
|
return found[:3]
|
||||||
|
|
||||||
def _apply_filters(self, friend_list: list) -> list:
|
def _apply_filters(self, friend_list: list) -> list:
|
||||||
"""应用筛选条件过滤候选人列表。"""
|
"""旧筛选配置已下线,当前直接返回原始候选人列表。"""
|
||||||
try:
|
self.logger.info("旧版筛选配置已移除,reply 流程不再额外过滤候选人")
|
||||||
from server.models import FilterConfig
|
return friend_list
|
||||||
|
|
||||||
# 获取启用的筛选配置
|
|
||||||
filter_config = FilterConfig.objects.filter(is_active=True).first()
|
|
||||||
if not filter_config:
|
|
||||||
self.logger.info("未找到启用的筛选配置,跳过筛选")
|
|
||||||
return friend_list
|
|
||||||
|
|
||||||
filtered = []
|
|
||||||
for friend in friend_list:
|
|
||||||
# 筛选活跃度(最后上线时间)
|
|
||||||
last_time = friend.get("lastTime", "")
|
|
||||||
if not self._check_activity(last_time, filter_config.activity):
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 从简历信息中获取年龄、学历、期望职位
|
|
||||||
resume = friend.get("resume", {}) or {}
|
|
||||||
|
|
||||||
# 筛选年龄
|
|
||||||
age = resume.get("age")
|
|
||||||
if age and not (filter_config.age_min <= int(age) <= filter_config.age_max):
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 筛选学历
|
|
||||||
education = resume.get("education", "")
|
|
||||||
if filter_config.education != "不限" and education:
|
|
||||||
if not self._check_education(education, filter_config.education):
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 筛选期望职位
|
|
||||||
job_name = friend.get("jobName", "")
|
|
||||||
if filter_config.positions and job_name:
|
|
||||||
if not any(pos in job_name for pos in filter_config.positions):
|
|
||||||
continue
|
|
||||||
|
|
||||||
filtered.append(friend)
|
|
||||||
|
|
||||||
self.logger.info("筛选前: %d 人,筛选后: %d 人", len(friend_list), len(filtered))
|
|
||||||
return filtered
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error("应用筛选条件失败: %s,返回原列表", e)
|
|
||||||
return friend_list
|
|
||||||
|
|
||||||
def _check_activity(self, last_time: str, activity_filter: str) -> bool:
|
|
||||||
"""检查活跃度是否符合要求。"""
|
|
||||||
if activity_filter == "不限":
|
|
||||||
return True
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 解析时间字符串
|
|
||||||
now = datetime.now()
|
|
||||||
|
|
||||||
if "昨天" in last_time:
|
|
||||||
last_active = now - timedelta(days=1)
|
|
||||||
elif "今天" in last_time or "刚刚" in last_time:
|
|
||||||
last_active = now
|
|
||||||
elif "月" in last_time and "日" in last_time:
|
|
||||||
# 格式如 "03月03日"
|
|
||||||
match = re.search(r"(\d+)月(\d+)日", last_time)
|
|
||||||
if match:
|
|
||||||
month = int(match.group(1))
|
|
||||||
day = int(match.group(2))
|
|
||||||
year = now.year
|
|
||||||
# 如果月份大于当前月份,说明是去年的
|
|
||||||
if month > now.month:
|
|
||||||
year -= 1
|
|
||||||
last_active = datetime(year, month, day)
|
|
||||||
else:
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
return True
|
|
||||||
|
|
||||||
# 计算天数差
|
|
||||||
days_diff = (now - last_active).days
|
|
||||||
|
|
||||||
# 根据筛选条件判断
|
|
||||||
if activity_filter == "今天活跃":
|
|
||||||
return days_diff == 0
|
|
||||||
elif activity_filter == "3天内活跃":
|
|
||||||
return days_diff <= 3
|
|
||||||
elif activity_filter == "本周活跃":
|
|
||||||
return days_diff <= 7
|
|
||||||
elif activity_filter == "本月活跃":
|
|
||||||
return days_diff <= 30
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.warning("解析活跃度时间失败: %s, last_time=%s", e, last_time)
|
|
||||||
return True
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _check_education(candidate_edu: str, required_edu: str) -> bool:
|
|
||||||
"""检查学历是否符合要求。"""
|
|
||||||
edu_levels = ["初中", "高中", "中专", "大专", "本科", "硕士", "博士"]
|
|
||||||
|
|
||||||
try:
|
|
||||||
candidate_level = next((i for i, edu in enumerate(edu_levels) if edu in candidate_edu), -1)
|
|
||||||
required_level = next((i for i, edu in enumerate(edu_levels) if edu in required_edu), -1)
|
|
||||||
|
|
||||||
if candidate_level == -1 or required_level == -1:
|
|
||||||
return True
|
|
||||||
|
|
||||||
return candidate_level >= required_level
|
|
||||||
except Exception:
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _filter_my_messages(self, messages: list) -> list:
|
def _filter_my_messages(self, messages: list) -> list:
|
||||||
"""过滤掉自己发送的消息,只保留对方的消息。"""
|
"""过滤掉自己发送的消息,只保留对方的消息。"""
|
||||||
@@ -958,4 +851,3 @@ class BossReplyHandler(BaseTaskHandler):
|
|||||||
self.logger.error("等待回复失败: %s", e)
|
self.logger.error("等待回复失败: %s", e)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user