This commit is contained in:
ddrwode
2026-03-06 10:47:46 +08:00
parent 59dd293f2d
commit 7b351039f8
14 changed files with 385 additions and 427 deletions

139
1.py
View File

@@ -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__":

View File

@@ -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": "",
} }

View File

@@ -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()

View File

@@ -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()}")

View File

@@ -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("所有测试完成!")

View File

@@ -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))

View 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",
),
]

View 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',
},
),
]

View File

@@ -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."""

View File

@@ -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-dataQueryDict/多值列表)。"""
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):

View File

@@ -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),

View File

@@ -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()

View File

@@ -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

View File

@@ -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