This commit is contained in:
27942
2026-03-06 10:05:49 +08:00
parent 908d53f73a
commit 59dd293f2d
12 changed files with 1817 additions and 957 deletions

View File

@@ -1,57 +1,52 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
共享消息协议定义。 Shared message protocol definitions for Server <-> Worker communication.
服务器与 Worker 之间通过 WebSocket 传递 JSON 消息,每条消息包含 type 字段标识消息类型。
""" """
from enum import Enum from enum import Enum
# ────────────────────────── 消息类型 ──────────────────────────
class MsgType(str, Enum): class MsgType(str, Enum):
"""WebSocket 消息类型枚举str 混入方便 JSON 序列化)。""" """WebSocket message types."""
# Worker Server # Worker -> Server
REGISTER = "register" # 注册:上报 worker 信息与浏览器列表 REGISTER = "register"
HEARTBEAT = "heartbeat" # 心跳 HEARTBEAT = "heartbeat"
BROWSER_LIST_UPDATE = "browser_list_update" # 浏览器列表变更 BROWSER_LIST_UPDATE = "browser_list_update"
TASK_PROGRESS = "task_progress" # 任务进度上报 TASK_PROGRESS = "task_progress"
TASK_RESULT = "task_result" # 任务最终结果 TASK_RESULT = "task_result"
TASK_STATUS_REPORT = "task_status_report" # 任务执行状态回报(响应服务端查询) TASK_STATUS_REPORT = "task_status_report"
# Server Worker # Server -> Worker
REGISTER_ACK = "register_ack" # 注册确认 REGISTER_ACK = "register_ack"
HEARTBEAT_ACK = "heartbeat_ack" # 心跳确认 HEARTBEAT_ACK = "heartbeat_ack"
TASK_ASSIGN = "task_assign" # 派发任务 TASK_ASSIGN = "task_assign"
TASK_CANCEL = "task_cancel" # 取消任务 TASK_CANCEL = "task_cancel"
TASK_STATUS_QUERY = "task_status_query" # 查询任务执行状态 TASK_STATUS_QUERY = "task_status_query"
# 双向 # Bidirectional
ERROR = "error" # 错误消息 ERROR = "error"
# ────────────────────────── 任务状态 ──────────────────────────
class TaskStatus(str, Enum): class TaskStatus(str, Enum):
"""任务生命周期状态。""" """Task lifecycle states."""
PENDING = "pending" # 已创建,等待派发
DISPATCHED = "dispatched" # 已派发给 Worker
RUNNING = "running" # Worker 正在执行
SUCCESS = "success" # 执行成功
FAILED = "failed" # 执行失败
CANCELLED = "cancelled" # 已取消
PENDING = "pending"
DISPATCHED = "dispatched"
RUNNING = "running"
SUCCESS = "success"
FAILED = "failed"
CANCELLED = "cancelled"
# ────────────────────────── 任务类型 ──────────────────────────
class TaskType(str, Enum): class TaskType(str, Enum):
"""可扩展的任务类型。新增任务在此追加即可。""" """Supported task types."""
BOSS_RECRUIT = "boss_recruit" # BOSS 直聘招聘流程
CHECK_LOGIN = "check_login" # 检测 BOSS 账号是否已登录
BOSS_RECRUIT = "boss_recruit" # New greeting flow (from 1.py main)
BOSS_REPLY = "boss_reply" # Old recruit flow, now reply flow
CHECK_LOGIN = "check_login"
# ────────────────────────── 辅助函数 ──────────────────────────
def make_msg(msg_type: MsgType, **payload) -> dict: def make_msg(msg_type: MsgType, **payload) -> dict:
"""构造一条标准 WebSocket JSON 消息。""" """Build a standard WebSocket JSON message."""
return {"type": msg_type.value, **payload} return {"type": msg_type.value, **payload}

View File

@@ -1,18 +1,18 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
筛选配置 API需要登录 Filter APIs:
- GET /api/filters -> 查询所有筛选配置 - CRUD for FilterConfig (legacy/manual configs)
- POST /api/filters -> 创建筛选配置 - Recruit filter options (synced from site at worker startup)
- GET /api/filters/{id} -> 查询单个配置
- PUT /api/filters/{id} -> 更新配置
- DELETE /api/filters/{id} -> 删除配置
""" """
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_success, api_error from server.core.response import api_error, api_success
from server.models import FilterConfig from server.models import BossAccount, FilterConfig, RecruitFilterSnapshot
from server.serializers import FilterConfigSerializer from server.serializers import (
FilterConfigSerializer,
RecruitFilterSnapshotSerializer,
)
@api_view(["GET", "POST"]) @api_view(["GET", "POST"])
@@ -45,3 +45,61 @@ def filter_detail(request, pk):
obj.delete() obj.delete()
return api_success(msg="筛选配置已删除") return api_success(msg="筛选配置已删除")
def _snapshot_payload(snapshot: RecruitFilterSnapshot) -> dict:
data = RecruitFilterSnapshotSerializer(snapshot).data
return {
"worker_id": data.get("worker_id", ""),
"account_name": data.get("account_name", ""),
"browser_id": data.get("browser_id", ""),
"groups": data.get("groups", []),
"flat_options": data.get("flat_options", []),
"synced_at": data.get("synced_at"),
}
@api_view(["GET"])
def recruit_filter_options(request):
"""
Read recruit filter options for dropdown.
Query by:
- account_id (preferred)
- or worker_id + account_name
"""
account_id = request.query_params.get("account_id")
worker_id = (request.query_params.get("worker_id") or "").strip()
account_name = (request.query_params.get("account_name") or "").strip()
snapshot = None
if account_id:
try:
account = BossAccount.objects.get(pk=int(account_id))
except (ValueError, BossAccount.DoesNotExist):
return api_error(status.HTTP_404_NOT_FOUND, "账号不存在")
snapshot = (
RecruitFilterSnapshot.objects
.filter(worker_id=account.worker_id, account_name=account.browser_name)
.first()
)
elif worker_id and account_name:
snapshot = (
RecruitFilterSnapshot.objects
.filter(worker_id=worker_id, account_name=account_name)
.first()
)
else:
snapshot = RecruitFilterSnapshot.objects.order_by("-synced_at").first()
if not snapshot:
return api_success(
{
"worker_id": worker_id,
"account_name": account_name,
"groups": [],
"flat_options": [],
"synced_at": None,
}
)
return api_success(_snapshot_payload(snapshot))

View File

@@ -0,0 +1,34 @@
# Generated by Codex on 2026-03-06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("server", "0006_filterconfig_position_keywords_city_salary_experience"),
]
operations = [
migrations.CreateModel(
name="RecruitFilterSnapshot",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("worker_id", models.CharField(db_index=True, default="", max_length=64, verbose_name="Worker ID")),
("account_name", models.CharField(db_index=True, default="", max_length=128, verbose_name="环境名称")),
("browser_id", models.CharField(blank=True, default="", max_length=128, verbose_name="浏览器 ID")),
("groups", models.JSONField(blank=True, default=list, verbose_name="筛选分组")),
("flat_options", models.JSONField(blank=True, default=list, verbose_name="扁平筛选项")),
("raw_payload", models.JSONField(blank=True, default=dict, verbose_name="原始筛选数据")),
("synced_at", models.DateTimeField(auto_now=True, verbose_name="同步时间")),
("created_at", models.DateTimeField(auto_now_add=True, verbose_name="创建时间")),
],
options={
"verbose_name": "招聘筛选快照",
"verbose_name_plural": "招聘筛选快照",
"db_table": "recruit_filter_snapshot",
"unique_together": {("worker_id", "account_name")},
},
),
]

View File

@@ -141,6 +141,28 @@ class FilterConfig(models.Model):
return self.name return self.name
class RecruitFilterSnapshot(models.Model):
"""Per-account site filter snapshot fetched from zhipin recommend page."""
worker_id = models.CharField(max_length=64, default="", db_index=True, verbose_name="Worker ID")
account_name = models.CharField(max_length=128, default="", db_index=True, verbose_name="环境名称")
browser_id = models.CharField(max_length=128, default="", blank=True, verbose_name="浏览器 ID")
groups = models.JSONField(default=list, blank=True, verbose_name="筛选分组")
flat_options = models.JSONField(default=list, blank=True, verbose_name="扁平筛选项")
raw_payload = models.JSONField(default=dict, blank=True, verbose_name="原始筛选数据")
synced_at = models.DateTimeField(auto_now=True, verbose_name="同步时间")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
class Meta:
db_table = "recruit_filter_snapshot"
unique_together = [("worker_id", "account_name")]
verbose_name = "招聘筛选快照"
verbose_name_plural = verbose_name
def __str__(self):
return f"{self.account_name}@{self.worker_id}"
class ChatScript(models.Model): class ChatScript(models.Model):
"""复聊话术表。""" """复聊话术表。"""
SCRIPT_TYPE_CHOICES = [ SCRIPT_TYPE_CHOICES = [

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, ChatScript, ContactRecord, SystemConfig, BossAccount, TaskLog, FilterConfig, RecruitFilterSnapshot, ChatScript, ContactRecord, SystemConfig,
FollowUpConfig, FollowUpScript, FollowUpRecord FollowUpConfig, FollowUpScript, FollowUpRecord
) )
@@ -134,6 +134,13 @@ class FilterConfigSerializer(serializers.ModelSerializer):
# ────────────────────────── 话术 ────────────────────────── # ────────────────────────── 话术 ──────────────────────────
class RecruitFilterSnapshotSerializer(serializers.ModelSerializer):
class Meta:
model = RecruitFilterSnapshot
fields = "__all__"
read_only_fields = ["id", "synced_at", "created_at"]
class ChatScriptSerializer(serializers.ModelSerializer): class ChatScriptSerializer(serializers.ModelSerializer):
script_type_display = serializers.CharField(source="get_script_type_display", read_only=True) script_type_display = serializers.CharField(source="get_script_type_display", read_only=True)

View File

@@ -37,6 +37,7 @@ urlpatterns = [
# ─── 筛选配置 ─── # ─── 筛选配置 ───
path("api/filters", filters.filter_list), path("api/filters", filters.filter_list),
path("api/filters/options", filters.recruit_filter_options),
path("api/filters/<int:pk>", filters.filter_detail), path("api/filters/<int:pk>", filters.filter_detail),
# ─── 话术管理 ─── # ─── 话术管理 ───

View File

@@ -1,7 +1,8 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Worker 启动入口。 Worker startup entrypoint.
启动方式: python -m worker.main [--server ws://IP:8000/ws] [--worker-id pc-a] [--worker-name 电脑A] Usage:
python -m worker.main [--server ws://IP:8000/ws] [--worker-id pc-a] [--worker-name name]
""" """
from __future__ import annotations from __future__ import annotations
@@ -9,7 +10,6 @@ import argparse
import asyncio import asyncio
import logging import logging
import os import os
import sys
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "server.settings") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "server.settings")
@@ -17,10 +17,11 @@ import django # noqa: E402
django.setup() # noqa: E402 django.setup() # noqa: E402
from tunnel.client import TunnelClient
from worker import config from worker import config
from worker.recruit_filter_sync import bootstrap_recruit_filter_snapshot
from worker.tasks.registry import register_all_handlers from worker.tasks.registry import register_all_handlers
from worker.ws_client import WorkerWSClient from worker.ws_client import WorkerWSClient
from tunnel.client import TunnelClient
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
@@ -33,38 +34,18 @@ logger = logging.getLogger("worker.main")
def parse_args(): def parse_args():
parser = argparse.ArgumentParser(description="Browser Control Worker Agent") parser = argparse.ArgumentParser(description="Browser Control Worker Agent")
parser.add_argument("--worker", action="store_true", help=argparse.SUPPRESS) parser.add_argument("--worker", action="store_true", help=argparse.SUPPRESS)
parser.add_argument( parser.add_argument("--server", default=config.SERVER_WS_URL, help=f"WebSocket server URL (default: {config.SERVER_WS_URL})")
"--server", parser.add_argument("--worker-id", default=config.WORKER_ID, help=f"Worker ID (default: {config.WORKER_ID})")
default=config.SERVER_WS_URL, parser.add_argument("--worker-name", default=config.WORKER_NAME, help=f"Worker name (default: {config.WORKER_NAME})")
help=f"中央服务器 WebSocket 地址 (默认: {config.SERVER_WS_URL})", parser.add_argument("--bit-api", default=config.BIT_API_BASE, help=f"BitBrowser local API URL (default: {config.BIT_API_BASE})")
) parser.add_argument("--no-tunnel", action="store_true", help="Disable tunnel client")
parser.add_argument(
"--worker-id",
default=config.WORKER_ID,
help=f"Worker ID (默认: {config.WORKER_ID})",
)
parser.add_argument(
"--worker-name",
default=config.WORKER_NAME,
help=f"Worker 名称 (默认: {config.WORKER_NAME})",
)
parser.add_argument(
"--bit-api",
default=config.BIT_API_BASE,
help=f"比特浏览器本地 API 地址 (默认: {config.BIT_API_BASE})",
)
parser.add_argument(
"--no-tunnel",
action="store_true",
help="禁用内网穿透隧道(不与隧道服务端连接)",
)
return parser.parse_args() return parser.parse_args()
def _local_port_from_bit_api(bit_api_base: str) -> int: def _local_port_from_bit_api(bit_api_base: str) -> int:
"""从 BIT_API_BASE (e.g. http://127.0.0.1:54345) 解析端口。"""
try: try:
from urllib.parse import urlparse from urllib.parse import urlparse
p = urlparse(bit_api_base if "://" in bit_api_base else "http://" + bit_api_base) p = urlparse(bit_api_base if "://" in bit_api_base else "http://" + bit_api_base)
return p.port or 54345 return p.port or 54345
except Exception: except Exception:
@@ -72,9 +53,9 @@ def _local_port_from_bit_api(bit_api_base: str) -> int:
def _extract_host_from_ws_url(ws_url: str) -> str: def _extract_host_from_ws_url(ws_url: str) -> str:
"""从 WebSocket URL (如 ws://8.137.99.82:9000/ws) 中提取 host。"""
try: try:
from urllib.parse import urlparse from urllib.parse import urlparse
p = urlparse(ws_url) p = urlparse(ws_url)
return p.hostname or "127.0.0.1" return p.hostname or "127.0.0.1"
except Exception: except Exception:
@@ -82,9 +63,25 @@ def _extract_host_from_ws_url(ws_url: str) -> str:
async def run(args): async def run(args):
# 注册所有任务处理器
register_all_handlers() register_all_handlers()
logger.info("已注册任务处理器") logger.info("Task handlers registered")
# Startup bootstrap: fetch site filters (main1 logic) and persist for frontend dropdown.
try:
bootstrap_result = bootstrap_recruit_filter_snapshot(
worker_id=args.worker_id,
bit_api_base=args.bit_api,
logger=logger,
)
if bootstrap_result:
logger.info(
"Recruit filters synced on startup: account=%s groups=%s options=%s",
bootstrap_result.get("account_name", ""),
bootstrap_result.get("groups", 0),
bootstrap_result.get("flat_options", 0),
)
except Exception as e:
logger.warning("Recruit filter startup sync failed: %s", e)
client = WorkerWSClient( client = WorkerWSClient(
server_url=args.server, server_url=args.server,
@@ -96,7 +93,6 @@ async def run(args):
tunnel_enabled = config.TUNNEL_ENABLED and not args.no_tunnel tunnel_enabled = config.TUNNEL_ENABLED and not args.no_tunnel
tunnel_client = None tunnel_client = None
if tunnel_enabled: if tunnel_enabled:
# 从命令行 --server 参数中提取云服务器 host而非配置文件默认值
tunnel_host = _extract_host_from_ws_url(args.server) tunnel_host = _extract_host_from_ws_url(args.server)
tunnel_client = TunnelClient( tunnel_client = TunnelClient(
server_host=tunnel_host, server_host=tunnel_host,
@@ -106,13 +102,17 @@ async def run(args):
local_port=_local_port_from_bit_api(args.bit_api), local_port=_local_port_from_bit_api(args.bit_api),
) )
logger.info( logger.info(
"隧道已启用: 暴露本地 %s -> %s (worker_id=%s)", "Tunnel enabled: expose local %s -> %s (worker_id=%s)",
_local_port_from_bit_api(args.bit_api), tunnel_host, args.worker_id, _local_port_from_bit_api(args.bit_api),
tunnel_host,
args.worker_id,
) )
logger.info( logger.info(
"Worker 启动: id=%s, name=%s, server=%s", "Worker startup: id=%s, name=%s, server=%s",
args.worker_id, args.worker_name, args.server, args.worker_id,
args.worker_name,
args.server,
) )
async def run_worker(): async def run_worker():
@@ -131,7 +131,7 @@ async def run(args):
else: else:
await worker_task await worker_task
except KeyboardInterrupt: except KeyboardInterrupt:
logger.info("收到中断信号,正在退出...") logger.info("Keyboard interrupt received, stopping...")
worker_task.cancel() worker_task.cancel()
if tunnel_task is not None: if tunnel_task is not None:
tunnel_task.cancel() tunnel_task.cancel()
@@ -153,8 +153,9 @@ def main():
try: try:
asyncio.run(run(args)) asyncio.run(run(args))
except KeyboardInterrupt: except KeyboardInterrupt:
logger.info("Worker 已退出") logger.info("Worker exited")
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@@ -0,0 +1,239 @@
# -*- coding: utf-8 -*-
"""
Recruit filter snapshot sync:
1) Open an existing browser profile from DB.
2) Fetch recommend filter options from zhipin.
3) Persist to DB for frontend dropdown usage.
"""
from __future__ import annotations
import json
import logging
from typing import Any, Dict, List, Optional
from worker.bit_browser import BitBrowserAPI
from worker.browser_control import connect_browser
FILTER_API = "wapi/zpblock/recommend/filters"
RECOMMEND_URL = "https://www.zhipin.com/web/chat/recommend"
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)
def fetch_recruit_filters_from_site(
*,
bit_api_base: str,
account_name: str,
browser_id: str = "",
timeout_sec: int = 30,
logger: Optional[logging.Logger] = None,
) -> 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)
resolved_browser_id = (browser_id or "").strip()
if resolved_browser_id.startswith("name:"):
resolved_browser_id = ""
_, port = bit_api.get_browser_for_drission(
browser_id=resolved_browser_id or None,
name=(account_name or "").strip() or None,
)
browser = connect_browser(port=port)
tab = browser.latest_tab
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)
log.info(
"Fetched recruit filters: account=%s groups=%d options=%d",
account_name,
len(groups),
len(flat_options),
)
return {
"groups": groups,
"flat_options": flat_options,
"raw_payload": {"filters": raw_filters},
}
def save_recruit_filter_snapshot(
*,
worker_id: str,
account_name: str,
browser_id: str = "",
groups: List[dict],
flat_options: List[str],
raw_payload: Dict[str, Any],
) -> None:
from server.models import RecruitFilterSnapshot
RecruitFilterSnapshot.objects.update_or_create(
worker_id=(worker_id or "").strip(),
account_name=(account_name or "").strip(),
defaults={
"browser_id": (browser_id or "").strip(),
"groups": groups or [],
"flat_options": flat_options or [],
"raw_payload": raw_payload or {},
},
)
def sync_recruit_filters_for_account(
*,
worker_id: str,
bit_api_base: str,
account_name: str,
browser_id: str = "",
logger: Optional[logging.Logger] = None,
) -> Dict[str, Any]:
"""Fetch and persist recruit filters for one account."""
payload = fetch_recruit_filters_from_site(
bit_api_base=bit_api_base,
account_name=account_name,
browser_id=browser_id,
logger=logger,
)
save_recruit_filter_snapshot(
worker_id=worker_id,
account_name=account_name,
browser_id=browser_id,
groups=payload["groups"],
flat_options=payload["flat_options"],
raw_payload=payload["raw_payload"],
)
return payload
def bootstrap_recruit_filter_snapshot(
*,
worker_id: str,
bit_api_base: str,
logger: Optional[logging.Logger] = None,
) -> Optional[Dict[str, Any]]:
"""
On worker startup, pick one existing DB account for this worker
and synchronize recruit filters.
"""
log = logger or logging.getLogger("worker.recruit_filter_sync")
from server.models import BossAccount
accounts = (
BossAccount.objects
.filter(worker_id=worker_id)
.exclude(browser_name="")
.order_by("-is_logged_in", "-updated_at")
)
if not accounts.exists():
log.info("Skip recruit filter bootstrap: no account found for worker=%s", worker_id)
return None
last_error = None
for account in accounts:
try:
payload = sync_recruit_filters_for_account(
worker_id=worker_id,
bit_api_base=bit_api_base,
account_name=account.browser_name,
browser_id=account.browser_id or "",
logger=log,
)
return {
"worker_id": worker_id,
"account_name": account.browser_name,
"groups": len(payload.get("groups", [])),
"flat_options": len(payload.get("flat_options", [])),
}
except Exception as e:
last_error = e
log.warning(
"Recruit filter bootstrap failed for account=%s worker=%s: %s",
account.browser_name,
worker_id,
e,
)
if last_error:
log.warning("Recruit filter bootstrap skipped: all accounts failed for worker=%s", worker_id)
return None

File diff suppressed because it is too large Load Diff

961
worker/tasks/boss_reply.py Normal file
View File

@@ -0,0 +1,961 @@
# -*- coding: utf-8 -*-
"""
BOSS 直聘招聘任务处理器。
招聘流程与本地脚本 boss_dp/自动化.py 的 main 保持一致:
1) 监听并获取 friendList
2) 逐个点开会话并监听历史消息
3) 若历史消息中未出现“手机号/微信号”,则发送询问并执行“换微信”
"""
from __future__ import annotations
import asyncio
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
from worker.bit_browser import BitBrowserAPI
from worker.browser_control import connect_browser
from worker.tasks.base import BaseTaskHandler, TaskCancelledError
CHAT_INDEX_URL = "https://www.zhipin.com/web/chat/index"
FRIEND_LIST_API = "wapi/zprelation/friend/getBossFriendListV2"
HISTORY_API = "wapi/zpchat/boss/historyMsg"
ASK_WECHAT_TEXT = "后续沟通会更及时,您方便留一下您的微信号吗?我这边加您。"
CONTACT_KEYWORDS = ("手机号", "微信号")
EXCHANGE_CONFIRM_XPATH = (
"x://span[contains(text(),'确定与对方交换微信吗?')]/../div[@class='btn-box']/"
"span[contains(@class,'boss-btn-primary')]"
)
EXCHANGE_CONFIRM_XPATH_ASCII = (
"x://span[contains(text(),'确定与对方交换微信吗?')]/../div[@class='btn-box']/"
"span[contains(@class,'boss-btn-primary')]"
)
class BossReplyHandler(BaseTaskHandler):
"""BOSS 直聘招聘自动化任务。"""
task_type = TaskType.BOSS_REPLY.value
async def execute(
self,
task_id: str,
params: Dict[str, Any],
progress_cb: Callable[[str, str], Coroutine],
) -> Any:
"""
执行 BOSS 招聘流程。
params:
- job_title: str 招聘岗位名称(用于结果展示)
- account_name: str 比特浏览器窗口名(用于打开浏览器)
- account_id: str 比特浏览器窗口 ID可选优先级高于 name
- bit_api_base: str 比特浏览器 API 地址(可选)
"""
job_title = params.get("job_title", "相关岗位")
account_name = params.get("account_name", "")
account_id = params.get("account_id", "")
bit_api_base = params.get("bit_api_base", "http://127.0.0.1:54345")
cancel_event = params.get("_cancel_event")
self.ensure_not_cancelled(cancel_event)
await progress_cb(task_id, "正在打开比特浏览器...")
result = await asyncio.get_event_loop().run_in_executor(
None,
self._run_sync,
task_id,
job_title,
account_name,
account_id,
bit_api_base,
progress_cb,
cancel_event,
)
return result
def _run_sync(
self,
task_id: str,
job_title: str,
account_name: str,
account_id: str,
bit_api_base: str,
progress_cb: Callable,
cancel_event,
) -> dict:
"""同步执行浏览器自动化(在线程池中运行)。"""
_ = (task_id, progress_cb)
self.ensure_not_cancelled(cancel_event)
bit_api = BitBrowserAPI(bit_api_base)
addr, port = bit_api.get_browser_for_drission(
browser_id=account_id or None,
name=account_name or None,
)
self.logger.info("已打开浏览器, CDP: %s (port=%d)", addr, port)
browser = connect_browser(port=port)
tab = browser.latest_tab
flow_result = self._recruit_flow_like_script(tab, job_title, cancel_event)
collected = flow_result["details"]
errors = flow_result["errors"]
wechat_set = {str(c.get("wechat", "")).strip() for c in collected if str(c.get("wechat", "")).strip()}
phone_set = {str(c.get("phone", "")).strip() for c in collected if str(c.get("phone", "")).strip()}
has_errors = bool(errors)
result = {
"job_title": job_title,
"total_processed": len(collected),
"wechat_collected": len(wechat_set),
"phone_collected": len(phone_set),
"details": collected,
"error_count": len(errors),
"errors": errors[:20],
"success": not has_errors,
}
if has_errors:
result["error"] = f"招聘流程出现 {len(errors)} 处错误"
return result
def _recruit_flow_like_script(self, tab, job_title: str, cancel_event) -> dict:
"""按 boss_dp/自动化.py 的 main 流程执行。"""
collected: List[dict] = []
errors: List[str] = []
self.ensure_not_cancelled(cancel_event)
try:
friend_list = self._open_chat_and_fetch_friend_list(tab)
except Exception as e:
err = f"获取 friendList 失败: {e}"
self.logger.error(err)
return {"details": collected, "errors": [err]}
if not friend_list:
return {"details": collected, "errors": ["未拿到 friendList"]}
# 应用筛选条件
friend_list = self._apply_filters(friend_list)
total = len(friend_list)
self.logger.info("friendList 筛选后=%d,本次处理=%d", len(friend_list), total)
for i, friend in enumerate(friend_list[:total], start=1):
try:
self.ensure_not_cancelled(cancel_event)
name = str(friend.get("name", "")).strip() or f"候选人{i}"
friend_job_name = str(friend.get("jobName", "")).strip()
friend_job_id = str(friend.get("jobId", "")).strip()
tab.listen.start(HISTORY_API)
if not self._open_friend_chat_like_script(tab, name):
errors.append(f"[{name}] 未找到联系人或点击失败")
continue
messages = self._wait_history_messages(tab)
self.ensure_not_cancelled(cancel_event)
# 过滤掉自己发送的消息
filtered_messages = self._filter_my_messages(messages)
has_contact_keyword = self._has_contact_keyword(filtered_messages)
contacts = self._extract_contacts(filtered_messages)
contact_written = bool(contacts.get("wechat") or contacts.get("phone"))
action_state = {
"asked_wechat": False,
"send_success": False,
"exchange_clicked": False,
"exchange_confirmed": False,
}
if not has_contact_keyword:
self.ensure_not_cancelled(cancel_event)
action_state = self._ask_and_exchange_wechat_like_script(tab)
# 先保存联系人记录(如果有的话)
temp_contact_id = None
if contacts.get("wechat") or contacts.get("phone"):
temp_contact_id = self._save_contact_record(name, friend_job_name, contacts, action_state)
# 发送后等待对方回复,进行复聊管理
if action_state["send_success"]:
self.ensure_not_cancelled(cancel_event)
reply_result = self._handle_follow_up_chat(tab, name, friend_job_name, temp_contact_id)
action_state.update(reply_result)
# 如果复聊中提取到了新的联系方式,更新联系人记录
if reply_result.get("extracted_contact_from_reply"):
panel_texts = self._collect_chat_panel_texts(tab)
new_contacts = self._extract_contacts(filtered_messages, extra_texts=panel_texts)
if new_contacts.get("wechat") or new_contacts.get("phone"):
contacts.update(new_contacts)
contact_written = True
panel_texts = self._collect_chat_panel_texts(tab)
contacts = self._extract_contacts(filtered_messages, extra_texts=panel_texts)
contact_written = bool(contacts["wechat"] or contacts["phone"])
if has_contact_keyword and not contact_written:
self.logger.warning(
"[%s] 历史消息含联系方式关键词,但未提取到有效联系方式,疑似识别失败",
name,
)
# 保存联系人记录到数据库获取contact_id用于复聊
contact_id = None
if contact_written:
contact_id = self._save_contact_record(name, friend_job_name, contacts, action_state)
collected.append(
{
"name": name,
"job": friend_job_name or job_title,
"job_id": friend_job_id,
"wechat": contacts["wechat"],
"phone": contacts["phone"],
"contact_written": contact_written,
"has_contact_keyword": has_contact_keyword,
**action_state,
}
)
except TaskCancelledError:
self.logger.info("任务执行中收到取消信号,提前结束")
break
except Exception as e:
err_msg = f"处理第 {i} 个会话出错: {e}"
self.logger.error(err_msg)
errors.append(err_msg)
finally:
if i < total:
self.ensure_not_cancelled(cancel_event)
self._sleep_between_sessions()
self.ensure_not_cancelled(cancel_event)
return {"details": collected, "errors": errors}
def _open_chat_and_fetch_friend_list(self, tab) -> list:
tab.listen.start(FRIEND_LIST_API)
tab.get(CHAT_INDEX_URL)
packet = tab.listen.wait()
body = self._packet_body(packet)
zp_data = body.get("zpData", {}) if isinstance(body, dict) else {}
friend_list = zp_data.get("friendList", []) if isinstance(zp_data, dict) else []
if isinstance(friend_list, list):
return friend_list
return []
@staticmethod
def _xpath_literal(value: str) -> str:
if "'" not in value:
return f"'{value}'"
if '"' not in value:
return f'"{value}"'
parts = value.split("'")
return "concat(" + ", \"'\", ".join(f"'{part}'" for part in parts) + ")"
def _open_friend_chat_like_script(self, tab, name: str) -> bool:
name_selector = f"x://span[text()={self._xpath_literal(name)}]"
ele = tab.ele(name_selector, timeout=2)
if not ele:
return False
try:
ele.run_js("this.scrollIntoView({block: 'center', behavior: 'auto'})")
except Exception:
pass
time.sleep(0.8)
try:
clickable = tab.ele(name_selector, timeout=2)
if not clickable:
return False
clickable.click(by_js=True)
return True
except Exception:
return False
def _wait_history_messages(self, tab) -> list:
packet = tab.listen.wait()
body = self._packet_body(packet)
zp_data = body.get("zpData", {}) if isinstance(body, dict) else {}
messages = zp_data.get("messages", []) if isinstance(zp_data, dict) else []
if isinstance(messages, list):
return messages
return []
@staticmethod
def _sleep_between_sessions() -> None:
"""会话间随机停顿,降低频繁切换带来的风控风险。"""
time.sleep(random.uniform(1.8, 4.2))
def _ask_and_exchange_wechat_like_script(self, tab) -> dict:
state = {
"asked_wechat": False,
"send_success": False,
"exchange_clicked": False,
"exchange_confirmed": False,
}
input_box = tab.ele('x://*[@id="boss-chat-editor-input"]', timeout=2)
if not input_box:
return state
try:
input_box.click(by_js=True)
except Exception:
pass
try:
input_box.clear()
except Exception:
pass
input_box.input(ASK_WECHAT_TEXT)
state["asked_wechat"] = True
time.sleep(random.randint(1, 3) + random.random())
state["send_success"] = self._send_with_confirm(
tab,
input_box=input_box,
message=ASK_WECHAT_TEXT,
)
time.sleep(random.randint(1, 5) + random.random())
state["exchange_clicked"] = self._click_change_wechat_like_script(tab)
if state["exchange_clicked"]:
time.sleep(random.randint(1, 2) + random.random())
state["exchange_confirmed"] = self._click_exchange_confirm_like_script(tab)
return state
def _click_send_like_script(self, tab, input_box=None) -> bool:
selectors = (
'x://div[text()="发送"]',
'x://span[text()="发送"]',
'x://button[normalize-space(.)="发送"]',
'x://*[@role="button" and normalize-space(.)="发送"]',
)
for _ in range(3):
for selector in selectors:
try:
btn = tab.ele(selector, timeout=1)
if not btn:
continue
try:
btn.click(by_js=True)
except Exception:
btn.click()
time.sleep(0.25)
return True
except Exception:
continue
time.sleep(0.25)
if input_box:
try:
input_box.input("\n")
time.sleep(0.25)
return True
except Exception:
pass
return False
def _send_with_confirm(self, tab, input_box, message: str, max_attempts: int = 2) -> bool:
"""发送后检查末条消息,避免因确认误判导致重复补发。"""
msg = (message or "").strip()
if not msg:
return False
for _ in range(max_attempts):
clicked = self._click_send_like_script(tab, input_box=input_box)
if not clicked:
continue
if self._confirm_last_sent_message(tab, msg):
return True
# 已点击发送但未在列表中确认时,不直接补发,先判断输入框是否已清空。
# 若已清空,通常表示消息已发出,只是 UI 刷新慢或识别未命中,避免重复发送。
if not self._editor_has_text(input_box, msg):
return True
time.sleep(0.35)
sent = self._confirm_last_sent_message(tab, msg)
if sent:
return True
self._clear_editor(input_box)
return False
@staticmethod
def _normalize_text(text: str) -> str:
return re.sub(r"\s+", "", text or "")
def _editor_has_text(self, input_box, expected: str = "") -> bool:
"""判断输入框是否仍残留指定文本。"""
expected_norm = self._normalize_text(expected)
current = ""
try:
current = input_box.run_js("return (this.innerText || this.textContent || this.value || '').trim();")
except Exception:
try:
current = input_box.text
except Exception:
current = ""
current_norm = self._normalize_text(str(current))
if not current_norm:
return False
if not expected_norm:
return True
return expected_norm in current_norm
def _clear_editor(self, input_box) -> None:
"""清空聊天输入框,避免残留预输入内容。"""
try:
input_box.click(by_js=True)
except Exception:
pass
try:
input_box.clear()
except Exception:
pass
try:
input_box.run_js(
"if (this.isContentEditable) { this.innerHTML=''; this.textContent=''; }"
)
except Exception:
pass
def _confirm_last_sent_message(self, tab, message: str) -> bool:
"""确认当前聊天窗口末条是否为刚发送内容。"""
msg = (message or "").strip()
if not msg:
return False
target = re.sub(r"\s+", "", msg)
try:
for _ in range(6):
items = tab.eles("css:.message-item", timeout=1)
if not items:
time.sleep(0.2)
continue
for e in reversed(items[-6:]):
text = (e.text or "").strip()
if not text:
continue
normalized = re.sub(r"\s+", "", text)
is_boss = False
try:
if e.ele("css:.item-boss", timeout=0):
is_boss = True
except Exception:
pass
if is_boss and target in normalized:
return True
time.sleep(0.2)
except Exception:
return False
return False
def _click_change_wechat_like_script(self, tab) -> bool:
try:
btn = tab.ele('x://span[text()="换微信"]', timeout=1)
if not btn:
return False
btn.click()
return True
except Exception:
return False
def _click_exchange_confirm_like_script(self, tab) -> bool:
try:
confirm = tab.ele(EXCHANGE_CONFIRM_XPATH, timeout=2)
if not confirm:
confirm = tab.ele(EXCHANGE_CONFIRM_XPATH_ASCII, timeout=2)
if not confirm:
return False
confirm.click(by_js=True)
return True
except Exception:
return False
@staticmethod
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)
if isinstance(parsed, dict):
return parsed
except Exception:
return {}
return {}
def _history_texts(self, messages: list) -> list[str]:
texts: list[str] = []
for msg in messages:
if not isinstance(msg, dict):
continue
body = msg.get("body")
text = ""
if isinstance(body, dict):
maybe_text = body.get("text")
if isinstance(maybe_text, str):
text = maybe_text
elif isinstance(msg.get("text"), str):
text = str(msg.get("text"))
text = text.strip()
if text:
texts.append(text)
return texts
def _collect_chat_panel_texts(self, tab, max_items: int = 80) -> list[str]:
"""从当前聊天面板读取可见消息文本,补充接口历史消息。"""
texts: list[str] = []
try:
items = tab.eles("css:.message-item", timeout=1)
except Exception:
return texts
if not items:
return texts
for e in items[-max_items:]:
try:
text = (e.text or "").strip()
except Exception:
text = ""
if text:
texts.append(text)
return texts
def _has_contact_keyword(self, messages: list) -> bool:
for text in self._history_texts(messages):
if any(k in text for k in CONTACT_KEYWORDS):
return True
return False
def _extract_contacts(self, messages: list, extra_texts: Optional[list[str]] = None) -> dict:
wechats: list[str] = []
phones: list[str] = []
all_texts = self._history_texts(messages)
if extra_texts:
all_texts.extend([str(t).strip() for t in extra_texts if str(t).strip()])
for text in all_texts:
wechats.extend(self._extract_wechat(text))
phones.extend(self._extract_phone(text))
wechats = list(dict.fromkeys(wechats))
phones = list(dict.fromkeys(phones))
return {
"wechat": wechats[0] if wechats else "",
"phone": phones[0] if phones else "",
}
@staticmethod
def _extract_wechat(text: str) -> list:
if not text or not text.strip():
return []
found = []
patterns = [
r"微信号[:\s]*([a-zA-Z0-9_\-]{6,20})",
r"微信[:\s]*([a-zA-Z0-9_\-]{6,20})",
r"wx[:\s]*([a-zA-Z0-9_\-]{6,20})",
r"wechat[:\s]*([a-zA-Z0-9_\-]{6,20})",
]
for pattern in patterns:
for match in re.finditer(pattern, text, re.IGNORECASE):
value = match.group(1).strip() if match.lastindex else match.group(0).strip()
if value and value not in found and len(value) >= 6:
found.append(value)
return found[:3]
@staticmethod
def _extract_phone(text: str) -> list:
if not text or not text.strip():
return []
found = []
raw_candidates = re.findall(r"1[3-9][\d\-\s]{9,15}", text)
for raw in raw_candidates:
digits = re.sub(r"\D", "", raw)
if len(digits) == 11 and digits.startswith("1") and digits not in found:
found.append(digits)
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
def _filter_my_messages(self, messages: list) -> list:
"""过滤掉自己发送的消息,只保留对方的消息。"""
filtered = []
for msg in messages:
if not isinstance(msg, dict):
continue
# from_id 为 0 表示是对方发送的消息
from_id = msg.get("fromId", 0)
if from_id == 0:
filtered.append(msg)
return filtered
def _handle_follow_up_chat(self, tab, name: str, job_name: str, contact_id: int = None) -> dict:
"""处理复聊管理,根据配置发送多轮话术。"""
result = {
"follow_up_attempted": False,
"got_reply": False,
"extracted_contact_from_reply": False,
}
if not contact_id:
return result
try:
from server.models import FollowUpConfig, FollowUpScript, FollowUpRecord
from django.utils import timezone
# 获取该岗位的复聊配置
config = FollowUpConfig.objects.filter(
position=job_name,
is_active=True
).first()
if not config:
# 尝试获取通用配置
config = FollowUpConfig.objects.filter(
position="通用",
is_active=True
).first()
if not config:
self.logger.info("[%s] 未找到复聊配置,跳过复聊", name)
return result
# 获取该联系人的复聊记录
last_record = FollowUpRecord.objects.filter(
contact_id=contact_id,
config_id=config.id
).order_by('-sent_at').first()
# 确定当前是第几天
if not last_record:
# 第一次复聊
day_number = 1
else:
# 计算距离上次发送的时间
hours_since_last = (timezone.now() - last_record.sent_at).total_seconds() / 3600
# 获取上次使用的话术的间隔时间
last_script = FollowUpScript.objects.filter(id=last_record.script_id).first()
if last_script and hours_since_last < last_script.interval_hours:
self.logger.info("[%s] 距离上次复聊不足 %d 小时,跳过", name, last_script.interval_hours)
return result
# 下一天
day_number = last_record.day_number + 1
# 获取该天的话术
script = FollowUpScript.objects.filter(
config_id=config.id,
day_number=day_number,
is_active=True
).order_by('order').first()
# 如果没有该天的话术,尝试获取"往后一直"的话术day_number=0
if not script:
script = FollowUpScript.objects.filter(
config_id=config.id,
day_number=0,
is_active=True
).order_by('order').first()
if not script:
self.logger.info("[%s] 未找到第 %d 天的复聊话术", name, day_number)
return result
# 发送话术
result["follow_up_attempted"] = True
send_success = self._send_message(tab, script.content)
if send_success:
# 记录发送
record = FollowUpRecord.objects.create(
contact_id=contact_id,
config_id=config.id,
script_id=script.id,
day_number=day_number,
content=script.content,
)
# 等待回复
reply_info = self._wait_for_reply(tab, script.content)
if reply_info["got_reply"]:
result["got_reply"] = True
result["extracted_contact_from_reply"] = reply_info["has_contact"]
# 更新记录
record.got_reply = True
record.reply_content = reply_info["reply_text"]
record.replied_at = timezone.now()
record.save()
self.logger.info("[%s] 第 %d 天复聊得到回复", name, day_number)
except Exception as e:
self.logger.error("复聊管理失败: %s", e)
return result
def _save_contact_record(self, name: str, job_name: str, contacts: dict, action_state: dict) -> int:
"""保存联系人记录到数据库返回contact_id。"""
try:
from server.models import ContactRecord
from django.utils import timezone
contact_value = contacts.get("wechat") or contacts.get("phone") or ""
if not contact_value:
return None
# 检查是否已存在
existing = ContactRecord.objects.filter(
name=name,
contact=contact_value
).first()
if existing:
# 更新现有记录
existing.wechat_exchanged = action_state.get("exchange_confirmed", False)
existing.reply_status = "已回复" if action_state.get("got_reply", False) else "未回复"
existing.save()
self.logger.info("更新联系人记录: %s - %s", name, contact_value)
return existing.id
else:
# 创建新记录
record = ContactRecord.objects.create(
name=name,
position=job_name,
contact=contact_value,
reply_status="已回复" if action_state.get("got_reply", False) else "未回复",
wechat_exchanged=action_state.get("exchange_confirmed", False),
contacted_at=timezone.now(),
notes=f"自动招聘获取 - 微信: {contacts.get('wechat', '')}, 手机: {contacts.get('phone', '')}"
)
self.logger.info("保存新联系人记录: %s - %s", name, contact_value)
return record.id
except Exception as e:
self.logger.error("保存联系人记录失败: %s", e)
return None
def _send_message(self, tab, message: str) -> bool:
"""发送消息的通用方法。"""
try:
input_box = tab.ele('x://*[@id="boss-chat-editor-input"]', timeout=2)
if not input_box:
return False
try:
input_box.click(by_js=True)
input_box.clear()
except Exception:
pass
input_box.input(message)
time.sleep(random.uniform(1, 2))
return self._send_with_confirm(tab, input_box=input_box, message=message)
except Exception as e:
self.logger.error("发送消息失败: %s", e)
return False
def _wait_for_reply(self, tab, sent_message: str, max_wait: int = 30) -> dict:
"""等待对方回复并提取信息。"""
result = {
"got_reply": False,
"has_contact": False,
"reply_text": "",
}
try:
check_interval = 3 # 每3秒检查一次
for _ in range(max_wait // check_interval):
time.sleep(check_interval)
# 重新获取聊天面板的消息
panel_texts = self._collect_chat_panel_texts(tab, max_items=10)
# 检查最后几条消息
for text in panel_texts[-5:]:
# 过滤掉我们发送的消息(包含发送的话术内容)
if sent_message in text:
continue
# 过滤掉包含"微信号"关键词但不是真实微信号的消息
if "微信号" in text and not self._extract_wechat(text):
continue
# 尝试提取联系方式
wechats = self._extract_wechat(text)
phones = self._extract_phone(text)
if wechats or phones:
result["got_reply"] = True
result["has_contact"] = True
result["reply_text"] = text
return result
# 即使没有联系方式,只要有新消息也算回复
if text and text not in [sent_message, ASK_WECHAT_TEXT]:
result["got_reply"] = True
result["reply_text"] = text
return result
except Exception as e:
self.logger.error("等待回复失败: %s", e)
return result

View File

@@ -1,7 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
任务处理器注册表。 Task handler registry for worker runtime.
Worker 启动时注册所有可用的 handler收到任务时按 task_type 查找对应 handler。
""" """
from __future__ import annotations from __future__ import annotations
@@ -12,35 +11,31 @@ from worker.tasks.base import BaseTaskHandler
logger = logging.getLogger("worker.tasks.registry") logger = logging.getLogger("worker.tasks.registry")
# task_type → handler 实例
_registry: Dict[str, BaseTaskHandler] = {} _registry: Dict[str, BaseTaskHandler] = {}
def register_handler(handler_cls: Type[BaseTaskHandler]) -> None: def register_handler(handler_cls: Type[BaseTaskHandler]) -> None:
"""注册一个任务处理器类(自动实例化)。"""
instance = handler_cls() instance = handler_cls()
if not instance.task_type: if not instance.task_type:
raise ValueError(f"{handler_cls.__name__} 未设置 task_type") raise ValueError(f"{handler_cls.__name__} missing task_type")
_registry[instance.task_type] = instance _registry[instance.task_type] = instance
logger.info("注册任务处理器: %s %s", instance.task_type, handler_cls.__name__) logger.info("Registered task handler: %s -> %s", instance.task_type, handler_cls.__name__)
def get_handler(task_type: str) -> Optional[BaseTaskHandler]: def get_handler(task_type: str) -> Optional[BaseTaskHandler]:
"""根据 task_type 获取对应的处理器实例。"""
return _registry.get(task_type) return _registry.get(task_type)
def list_handlers() -> list[str]: def list_handlers() -> list[str]:
"""列出所有已注册的 task_type。"""
return list(_registry.keys()) return list(_registry.keys())
def register_all_handlers() -> None: def register_all_handlers() -> None:
"""注册所有内置任务处理器。在此函数中 import 并注册。"""
from worker.tasks.boss_recruit import BossRecruitHandler from worker.tasks.boss_recruit import BossRecruitHandler
from worker.tasks.boss_reply import BossReplyHandler
from worker.tasks.check_login import CheckLoginHandler from worker.tasks.check_login import CheckLoginHandler
register_handler(BossRecruitHandler) register_handler(BossRecruitHandler)
register_handler(BossReplyHandler)
register_handler(CheckLoginHandler) register_handler(CheckLoginHandler)
# 未来扩展:在此处添加新的 handler
# from worker.tasks.xxx import XxxHandler
# register_handler(XxxHandler)

View File

@@ -215,6 +215,7 @@ class WorkerWSClient:
# 将 account_name 注入 params供 handler 使用) # 将 account_name 注入 params供 handler 使用)
if account_name: if account_name:
params.setdefault("account_name", account_name) params.setdefault("account_name", account_name)
params.setdefault("worker_id", self.worker_id)
params.setdefault("bit_api_base", self.bit_api.base_url) params.setdefault("bit_api_base", self.bit_api.base_url)
params["_cancel_event"] = cancel_event params["_cancel_event"] = cancel_event