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
from pathlib import Path
from typing import Any, Dict, List, Optional
# 保证从项目根目录运行时可导入 worker 包
_ROOT = Path(__file__).resolve().parent
@@ -50,16 +51,23 @@ def _connect_local_chrome():
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。"""
from worker.bit_browser import BitBrowserAPI
from DrissionPage import ChromiumPage, ChromiumOptions
print("正在连接比特浏览器 API...")
bit_api = BitBrowserAPI(BIT_API_BASE)
bit_api = BitBrowserAPI(bit_api_base or BIT_API_BASE)
print("正在打开比特浏览器...")
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}")
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
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):
"""
推荐牛人流程:多岗位循环,所有岗位合计达到目标人数即停;不够则从第一个岗位再跑一轮直到够了。
@@ -256,36 +341,30 @@ def main(filters, position_names=None, greet_target=None):
time.sleep(1)
def main1():
if USE_LOCAL_CHROME:
def main1(
*,
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()
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')
# 示例:打开一个页面(可选)
page.get("https://www.zhipin.com/web/chat/recommend")
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)
payload = fetch_recommend_filters(page, timeout_sec=timeout_sec)
if echo:
print(payload["display_filters"])
return payload
if __name__ == "__main__":

View File

@@ -11,6 +11,8 @@ DEFAULTS = {
"server_url": "ws://8.137.99.82:9000/ws",
"worker_id": "worker-1",
"worker_name": "本机",
"sync_browser_id": "",
"sync_browser_name": "",
}

View File

@@ -11,6 +11,7 @@ import shutil
import subprocess
import sys
import threading
from typing import Optional
from PyQt5.QtWidgets import (
QApplication,
@@ -25,6 +26,7 @@ from PyQt5.QtWidgets import (
QPushButton,
QTextEdit,
QProgressBar,
QComboBox,
QMessageBox,
QFrame,
)
@@ -32,6 +34,7 @@ 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
from worker.bit_browser import BitBrowserAPI
# 日志缓冲
LOG_BATCH_CHARS = 2048
@@ -39,6 +42,41 @@ LOG_MAX_LINES = 2000
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:
"""在项目目录执行 git pull。"""
git = shutil.which("git")
@@ -81,11 +119,23 @@ def _get_worker_launcher() -> tuple[str, str, bool]:
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"""
launcher, cwd, use_worker_flag = _get_worker_launcher()
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
encoding = "gbk" if sys.platform == "win32" else "utf-8"
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"}
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:
return subprocess.Popen(
cmd, cwd=cwd, env=env,
@@ -159,6 +213,7 @@ class ClientGUI(QMainWindow):
self._build_ui()
self._log_worker = LogWorker(self._log_queue, self)
self._log_worker.batch_ready.connect(self._append_log_batch)
QTimer.singleShot(0, self._on_refresh_browsers)
def _build_ui(self):
central = QWidget()
@@ -185,6 +240,19 @@ class ClientGUI(QMainWindow):
self.entry_worker_name.setText(self.config.get("worker_name", ""))
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)
# 操作按钮
@@ -242,6 +310,9 @@ class ClientGUI(QMainWindow):
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()
browser = self._selected_browser()
self.config["sync_browser_id"] = browser.get("id", "")
self.config["sync_browser_name"] = browser.get("name", "")
save_config(self.config)
def _log(self, msg: str):
@@ -295,17 +366,94 @@ class ClientGUI(QMainWindow):
else:
QMessageBox.warning(self, "更新", "更新失败,请检查网络或 Git 配置")
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)
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):
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 "本机"
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._log("正在启动 Worker...\n")
proc = run_worker(server_url, worker_id, worker_name, self._log)
if sync_browser_name:
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:
self._set_status("就绪", "#22c55e")
return
@@ -367,6 +515,16 @@ class _FinishUpdateEvent(QEvent):
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():
app = QApplication(sys.argv)
w = ClientGUI()

View File

@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
"""
数据库初始化脚本:创建测试数据
用于测试新增的筛选和复聊功能
用于测试招聘回复与复聊功能
"""
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')
django.setup()
from server.models import FilterConfig, 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
from server.models import ChatScript
def create_chat_scripts():
@@ -106,21 +77,12 @@ def main():
print("=" * 60)
try:
# 创建筛选配置
config = create_filter_config()
# 创建话术
scripts = create_chat_scripts()
print("\n" + "=" * 60)
print("初始化完成!")
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)}")
for script in scripts:
print(f" - {script.position} / {script.get_script_type_display()}")

View File

@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
功能验证脚本:测试新增的筛选和消息过滤功能
功能验证脚本:测试消息过滤与联系方式提取功能
"""
import os
@@ -132,33 +132,6 @@ def test_contact_extraction():
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():
print("\n" + "=" * 60)
print("BOSS招聘自动化 - 功能验证")
@@ -168,7 +141,6 @@ def main():
test_time_parsing()
test_message_filtering()
test_contact_extraction()
test_education_filter()
print("\n" + "=" * 60)
print("所有测试完成!")

View File

@@ -1,52 +1,18 @@
# -*- coding: utf-8 -*-
"""
Filter APIs:
- CRUD for FilterConfig (legacy/manual configs)
- Recruit filter options (synced from site at worker startup)
"""
from rest_framework import status
from rest_framework.decorators import api_view
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 (
FilterConfigSerializer,
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:
data = RecruitFilterSnapshotSerializer(snapshot).data
return {
@@ -102,4 +68,3 @@ def recruit_filter_options(request):
}
)
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
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):
"""Per-account site filter snapshot fetched from zhipin recommend page."""

View File

@@ -5,7 +5,7 @@ DRF 序列化器。
from rest_framework import serializers
from server.models import (
BossAccount, TaskLog, FilterConfig, RecruitFilterSnapshot, ChatScript, ContactRecord, SystemConfig,
BossAccount, TaskLog, RecruitFilterSnapshot, ChatScript, ContactRecord, SystemConfig,
FollowUpConfig, FollowUpScript, FollowUpRecord
)
@@ -89,49 +89,6 @@ class LoginSerializer(serializers.Serializer):
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):

View File

@@ -35,10 +35,8 @@ urlpatterns = [
path("api/accounts/worker/<str:worker_id>", accounts.account_list_by_worker),
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/<int:pk>", filters.filter_detail),
# ─── 话术管理 ───
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-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("--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")
return parser.parse_args()
@@ -71,6 +73,8 @@ async def run(args):
bootstrap_result = bootstrap_recruit_filter_snapshot(
worker_id=args.worker_id,
bit_api_base=args.bit_api,
account_name=args.sync_browser_name,
browser_id=args.sync_browser_id,
logger=logger,
)
if bootstrap_result:
@@ -158,4 +162,3 @@ def main():
if __name__ == "__main__":
main()

View File

@@ -7,86 +7,28 @@ Recruit filter snapshot sync:
"""
from __future__ import annotations
import json
import importlib.util
import logging
from functools import lru_cache
from pathlib import Path
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"
RECOMMEND_URL = "https://www.zhipin.com/web/chat/recommend"
@lru_cache(maxsize=1)
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}")
def _packet_body(packet) -> dict:
if not packet:
return {}
response = getattr(packet, "response", None)
body = getattr(response, "body", None) if response is not None else None
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)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
main1 = getattr(module, "main1", None)
if not callable(main1):
raise RuntimeError(f"{SCRIPT_ONE_PATH} 中未找到可调用的 main1")
return main1
def fetch_recruit_filters_from_site(
@@ -99,30 +41,25 @@ def fetch_recruit_filters_from_site(
) -> Dict[str, Any]:
"""Fetch recruit filters from target site using one BitBrowser profile."""
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()
if resolved_browser_id.startswith("name:"):
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,
name=(account_name or "").strip() or None,
timeout_sec=timeout_sec,
echo=False,
)
browser = connect_browser(port=port)
tab = browser.latest_tab
if not isinstance(payload, dict):
raise RuntimeError("main1 未返回有效筛选数据")
tab.listen.start(FILTER_API)
tab.get(RECOMMEND_URL)
packet = tab.listen.wait(timeout=timeout_sec)
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)
groups = payload.get("groups", [])
flat_options = payload.get("flat_options", [])
raw_payload = payload.get("raw_payload", {})
log.info(
"Fetched recruit filters: account=%s groups=%d options=%d",
account_name,
@@ -132,7 +69,7 @@ def fetch_recruit_filters_from_site(
return {
"groups": groups,
"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,
bit_api_base: str,
account_name: str = "",
browser_id: str = "",
logger: Optional[logging.Logger] = None,
) -> Optional[Dict[str, Any]]:
"""
@@ -198,6 +137,23 @@ def bootstrap_recruit_filter_snapshot(
log = logger or logging.getLogger("worker.recruit_filter_sync")
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 = (
BossAccount.objects
.filter(worker_id=worker_id)
@@ -236,4 +192,3 @@ def bootstrap_recruit_filter_snapshot(
if last_error:
log.warning("Recruit filter bootstrap skipped: all accounts failed for worker=%s", worker_id)
return None

View File

@@ -13,7 +13,6 @@ import json
import random
import re
import time
from datetime import datetime, timedelta
from typing import Any, Callable, Coroutine, Dict, List, Optional
from common.protocol import TaskType
@@ -616,115 +615,9 @@ class BossReplyHandler(BaseTaskHandler):
return found[:3]
def _apply_filters(self, friend_list: list) -> list:
"""应用筛选条件过滤候选人列表。"""
try:
from server.models import FilterConfig
# 获取启用的筛选配置
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
"""旧筛选配置已下线,当前直接返回原始候选人列表。"""
self.logger.info("旧版筛选配置已移除reply 流程不再额外过滤候选人")
return friend_list
def _filter_my_messages(self, messages: list) -> list:
"""过滤掉自己发送的消息,只保留对方的消息。"""
@@ -958,4 +851,3 @@ class BossReplyHandler(BaseTaskHandler):
self.logger.error("等待回复失败: %s", e)
return result