diff --git a/1.py b/1.py index cf92d3a..343c7f9 100644 --- a/1.py +++ b/1.py @@ -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__": diff --git a/client_gui/config_store.py b/client_gui/config_store.py index 0a6f0eb..dbef055 100644 --- a/client_gui/config_store.py +++ b/client_gui/config_store.py @@ -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": "", } diff --git a/client_gui/main.py b/client_gui/main.py index 0c80096..11d8b3a 100644 --- a/client_gui/main.py +++ b/client_gui/main.py @@ -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() diff --git a/scripts/init_recruit_test_data.py b/scripts/init_recruit_test_data.py index 68e5a2b..b4f2187 100644 --- a/scripts/init_recruit_test_data.py +++ b/scripts/init_recruit_test_data.py @@ -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()}") diff --git a/scripts/test_recruit_features.py b/scripts/test_recruit_features.py index 5d0ba00..0e29bc5 100644 --- a/scripts/test_recruit_features.py +++ b/scripts/test_recruit_features.py @@ -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("所有测试完成!") diff --git a/server/api/filters.py b/server/api/filters.py index 9315845..71005d0 100644 --- a/server/api/filters.py +++ b/server/api/filters.py @@ -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)) - diff --git a/server/migrations/0008_delete_filterconfig.py b/server/migrations/0008_delete_filterconfig.py new file mode 100644 index 0000000..e784d82 --- /dev/null +++ b/server/migrations/0008_delete_filterconfig.py @@ -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", + ), + ] diff --git a/server/migrations/0009_task.py b/server/migrations/0009_task.py new file mode 100644 index 0000000..389fe1d --- /dev/null +++ b/server/migrations/0009_task.py @@ -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', + }, + ), + ] diff --git a/server/models.py b/server/models.py index 5f0015e..7342561 100644 --- a/server/models.py +++ b/server/models.py @@ -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.""" diff --git a/server/serializers.py b/server/serializers.py index 5496327..7a29c5a 100644 --- a/server/serializers.py +++ b/server/serializers.py @@ -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-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): diff --git a/server/urls.py b/server/urls.py index 8c2cdbe..85b3809 100644 --- a/server/urls.py +++ b/server/urls.py @@ -35,10 +35,8 @@ urlpatterns = [ path("api/accounts/worker/", accounts.account_list_by_worker), path("api/accounts/", accounts.account_detail), - # ─── 筛选配置 ─── - path("api/filters", filters.filter_list), + # ─── 招聘筛选快照 ─── path("api/filters/options", filters.recruit_filter_options), - path("api/filters/", filters.filter_detail), # ─── 话术管理 ─── path("api/scripts", scripts.script_list), diff --git a/worker/main.py b/worker/main.py index dde542a..d116c5e 100644 --- a/worker/main.py +++ b/worker/main.py @@ -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() - diff --git a/worker/recruit_filter_sync.py b/worker/recruit_filter_sync.py index eef5226..16e11b8 100644 --- a/worker/recruit_filter_sync.py +++ b/worker/recruit_filter_sync.py @@ -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 - diff --git a/worker/tasks/boss_reply.py b/worker/tasks/boss_reply.py index d7882c9..388f44a 100644 --- a/worker/tasks/boss_reply.py +++ b/worker/tasks/boss_reply.py @@ -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 -