From 00720b6599d469081fb767e892b66e643ba2e1ad Mon Sep 17 00:00:00 2001 From: Wilson Xu Date: Thu, 2 Apr 2026 15:16:45 +0800 Subject: [PATCH 01/13] feat: add recruiter (boss) mode with 6 new commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds employer-side functionality for BOSS直聘 recruiters: - `boss recruiter-jobs` — list posted jobs - `boss recruiter-inbox` — view candidate chat list with last messages - `boss recruiter-geek ` — view detailed candidate profile - `boss recruiter-chat ` — view chat history with candidate - `boss recruiter-labels` — list candidate tags - `boss recruiter-export` — export candidates to CSV/JSON All commands support --json/--yaml structured output and follow the existing CLI patterns (rate limiting, session handling, rich tables). Closes #10 Co-Authored-By: Claude Opus 4.6 (1M context) --- boss_cli/cli.py | 11 +- boss_cli/client.py | 96 +++++++++ boss_cli/commands/recruiter.py | 360 +++++++++++++++++++++++++++++++++ boss_cli/constants.py | 17 ++ 4 files changed, 483 insertions(+), 1 deletion(-) create mode 100644 boss_cli/commands/recruiter.py diff --git a/boss_cli/cli.py b/boss_cli/cli.py index 7a0703c..46df331 100644 --- a/boss_cli/cli.py +++ b/boss_cli/cli.py @@ -17,7 +17,7 @@ import click from . import __version__ -from .commands import auth, personal, search, social +from .commands import auth, personal, recruiter, search, social @click.group() @@ -61,6 +61,15 @@ def cli(ctx, verbose: bool) -> None: cli.add_command(social.greet) cli.add_command(social.batch_greet) +# ─── Recruiter (Boss) commands ────────────────────────────────────── + +cli.add_command(recruiter.recruiter_jobs) +cli.add_command(recruiter.recruiter_inbox) +cli.add_command(recruiter.recruiter_geek) +cli.add_command(recruiter.recruiter_chat) +cli.add_command(recruiter.recruiter_labels) +cli.add_command(recruiter.recruiter_export) + if __name__ == "__main__": cli() diff --git a/boss_cli/client.py b/boss_cli/client.py index 05e38f9..fb781ed 100644 --- a/boss_cli/client.py +++ b/boss_cli/client.py @@ -13,6 +13,17 @@ from .constants import ( BASE_URL, + BOSS_CHAT_GEEK_INFO_URL, + BOSS_CHATTED_JOB_LIST_URL, + BOSS_FRIEND_DETAIL_URL, + BOSS_FRIEND_LABELS_URL, + BOSS_FRIEND_LIST_URL, + BOSS_FRIEND_NOTE_URL, + BOSS_GREET_REC_SORT_URL, + BOSS_GREET_SORT_LIST_URL, + BOSS_HISTORY_MSG_URL, + BOSS_INTERVIEW_LIST_URL, + BOSS_LAST_MSG_URL, CITY_CODES, DELIVER_LIST_URL, FRIEND_ADD_URL, @@ -28,6 +39,7 @@ RESUME_EXPECT_URL, RESUME_STATUS_URL, USER_INFO_URL, + WEB_BOSS_CHAT_URL, WEB_GEEK_CHAT_URL, WEB_GEEK_HISTORY_URL, WEB_GEEK_JOB_URL, @@ -171,6 +183,12 @@ def _headers_for_request(self, url: str, params: dict[str, Any] | None = None) - headers["Referer"] = WEB_GEEK_HISTORY_URL elif url in (FRIEND_LIST_URL, FRIEND_ADD_URL): headers["Referer"] = WEB_GEEK_CHAT_URL + # Recruiter (boss) endpoints + elif url in (BOSS_FRIEND_LIST_URL, BOSS_FRIEND_DETAIL_URL, BOSS_LAST_MSG_URL, + BOSS_HISTORY_MSG_URL, BOSS_CHAT_GEEK_INFO_URL, BOSS_FRIEND_LABELS_URL, + BOSS_FRIEND_NOTE_URL, BOSS_GREET_SORT_LIST_URL, BOSS_GREET_REC_SORT_URL, + BOSS_CHATTED_JOB_LIST_URL, BOSS_INTERVIEW_LIST_URL): + headers["Referer"] = WEB_BOSS_CHAT_URL return headers def _handle_response(self, data: dict[str, Any], action: str) -> dict[str, Any]: @@ -399,6 +417,84 @@ def get_geek_job(self, security_id: str) -> dict[str, Any]: """Get interacted job info.""" return self._get(GEEK_GET_JOB_URL, params={"securityId": security_id}, action="互动职位") + # ── Recruiter (Boss) Mode ──────────────────────────────────────── + + def _post(self, url: str, data: dict[str, Any] | None = None, action: str = "") -> dict[str, Any]: + """POST request with form-encoded body, response validation, and rate-limit retry.""" + resp = self._request("POST", url, data=data) + try: + result = self._handle_response(resp, action) + self._rate_limit_count = 0 + return result + except RateLimitError: + logger.info("Retrying after rate-limit cooldown...") + resp = self._request("POST", url, data=data) + result = self._handle_response(resp, action) + self._rate_limit_count = 0 + return result + + def get_boss_chatted_jobs(self) -> list[dict[str, Any]]: + """Get list of jobs the boss has posted (chatted job list).""" + return self._get(BOSS_CHATTED_JOB_LIST_URL, action="招聘职位列表") + + def get_boss_friend_list(self, label_id: int = 0, enc_job_id: str = "", sort: str = "") -> dict[str, Any]: + """Get boss friend list (candidates who have chatted).""" + data: dict[str, Any] = {"labelId": label_id} + if enc_job_id: + data["encJobId"] = enc_job_id + if sort: + data["sort"] = sort + return self._post(BOSS_FRIEND_LIST_URL, data=data, action="候选人列表") + + def get_boss_friend_details(self, friend_ids: list[int]) -> dict[str, Any]: + """Get detailed info for boss friends (candidates).""" + ids_str = ",".join(str(fid) for fid in friend_ids) + return self._post(BOSS_FRIEND_DETAIL_URL, data={"friendIds": ids_str}, action="候选人详情") + + def get_boss_last_messages(self, friend_ids: list[int], src: int = 0) -> list[dict[str, Any]]: + """Get last message for each friend.""" + ids_str = ",".join(str(fid) for fid in friend_ids) + return self._post(BOSS_LAST_MSG_URL, data={"friendIds": ids_str, "src": src}, action="最近消息") + + def get_boss_chat_history(self, gid: int, count: int = 20, max_msg_id: int = 0) -> dict[str, Any]: + """Get chat history with a specific candidate.""" + params: dict[str, Any] = {"gid": gid, "c": count, "src": 0} + if max_msg_id: + params["maxMsgId"] = max_msg_id + return self._get(BOSS_HISTORY_MSG_URL, params=params, action="聊天记录") + + def get_boss_chat_geek_info( + self, encrypt_geek_id: str, security_id: str, job_id: int, + ) -> dict[str, Any]: + """Get detailed info for a candidate in chat context.""" + return self._get( + BOSS_CHAT_GEEK_INFO_URL, + params={"encryptGeekId": encrypt_geek_id, "securityId": security_id, "jobId": job_id}, + action="候选人信息", + ) + + def get_boss_friend_labels(self) -> dict[str, Any]: + """Get recruiter's friend labels/tags.""" + return self._get(BOSS_FRIEND_LABELS_URL, action="标签列表") + + def get_boss_greet_list(self, enc_job_id: str = "", page: int = 1) -> dict[str, Any]: + """Get list of new greetings (candidates who greeted the boss).""" + params: dict[str, Any] = {"page": page} + if enc_job_id: + params["encJobId"] = enc_job_id + return self._get(BOSS_GREET_SORT_LIST_URL, params=params, action="新招呼列表") + + def get_boss_greet_rec_list(self, enc_job_id: str = "", page: int = 1) -> dict[str, Any]: + """Get recommended greeting sort list.""" + params: dict[str, Any] = {"page": page} + if enc_job_id: + params["encJobId"] = enc_job_id + return self._get(BOSS_GREET_REC_SORT_URL, params=params, action="推荐招呼排序") + + def get_boss_interview_list(self) -> dict[str, Any]: + """Get boss interview list.""" + return self._get(BOSS_INTERVIEW_LIST_URL, action="面试列表") + # ── City resolution ───────────────────────────────────────────────── diff --git a/boss_cli/commands/recruiter.py b/boss_cli/commands/recruiter.py new file mode 100644 index 0000000..939a5b5 --- /dev/null +++ b/boss_cli/commands/recruiter.py @@ -0,0 +1,360 @@ +"""Recruiter (Boss) commands: jobs, inbox, candidates, chat-history, geek-info.""" + +from __future__ import annotations + +import csv +import io +import json +import logging + +import click +from rich.panel import Panel +from rich.table import Table + +from ..client import BossClient +from ..exceptions import BossApiError +from ._common import ( + console, + handle_command, + require_auth, + run_client_action, + structured_output_options, +) + +logger = logging.getLogger(__name__) + + +# ── recruiter jobs ────────────────────────────────────────────────── + +@click.command("recruiter-jobs") +@structured_output_options +def recruiter_jobs(as_json: bool, as_yaml: bool) -> None: + """查看招聘中的职位列表""" + cred = require_auth() + + def _render(data: list[dict]) -> None: + if not data: + console.print("[yellow]暂无在线职位[/yellow]") + return + + table = Table(title=f"📋 招聘职位 ({len(data)} 个)", show_lines=True) + table.add_column("#", style="dim", width=3) + table.add_column("职位", style="bold cyan", max_width=25) + table.add_column("薪资", style="yellow", max_width=12) + table.add_column("地区", style="blue", max_width=15) + table.add_column("encJobId", style="dim", max_width=30) + + for i, job in enumerate(data, 1): + table.add_row( + str(i), + job.get("jobName", "-"), + job.get("salaryDesc", "-"), + job.get("address", "-"), + job.get("encryptJobId", "-"), + ) + + console.print(table) + console.print(" [dim]💡 使用 boss recruiter-inbox --job 查看该职位的候选人[/dim]") + + handle_command(cred, action=lambda c: c.get_boss_chatted_jobs(), render=_render, as_json=as_json, as_yaml=as_yaml) + + +# ── recruiter inbox (candidate list) ────────────────────────────── + +@click.command("recruiter-inbox") +@click.option("--job", "enc_job_id", default="", help="按职位 encryptJobId 筛选") +@click.option("--label", "label_id", default=0, type=int, help="按标签筛选 (0=全部)") +@structured_output_options +def recruiter_inbox(enc_job_id: str, label_id: int, as_json: bool, as_yaml: bool) -> None: + """查看候选人消息列表 (招聘方沟通列表)""" + cred = require_auth() + + def _action(c: BossClient) -> dict: + # Step 1: get friend IDs + friend_data = c.get_boss_friend_list(label_id=label_id, enc_job_id=enc_job_id) + friend_list = friend_data.get("result", []) + + if not friend_list: + return {"friendList": [], "lastMessages": []} + + friend_ids = [f["friendId"] for f in friend_list if f.get("friendId")] + + # Step 2: get friend details + details = c.get_boss_friend_details(friend_ids) + detail_list = details.get("friendList", []) + + # Step 3: get last messages (zpData returns list directly) + # Only request first batch to avoid too many IDs + batch_ids = friend_ids[:50] + last_msgs = c.get_boss_last_messages(batch_ids) + + return {"friendList": detail_list, "lastMessages": last_msgs} + + def _render(data: dict) -> None: + detail_list = data.get("friendList", []) + last_msgs = data.get("lastMessages", []) + + if not detail_list: + console.print("[yellow]暂无候选人消息[/yellow]") + return + + # Build msg lookup + msg_map: dict[int, dict] = {} + if isinstance(last_msgs, list): + for msg in last_msgs: + uid = msg.get("uid", 0) + if uid: + msg_map[uid] = msg + + table = Table(title=f"💬 候选人列表 ({len(detail_list)} 人)", show_lines=True) + table.add_column("#", style="dim", width=3) + table.add_column("候选人", style="bold cyan", max_width=12) + table.add_column("职位", style="green", max_width=20) + table.add_column("薪资", style="yellow", max_width=10) + table.add_column("最近消息", style="dim", max_width=30) + table.add_column("时间", style="dim", max_width=8) + + for i, friend in enumerate(detail_list, 1): + uid = friend.get("uid", 0) + msg_info = msg_map.get(uid, {}) + last_text = "" + if msg_info.get("lastMsgInfo"): + last_text = msg_info["lastMsgInfo"].get("showText", "")[:28] + + table.add_row( + str(i), + friend.get("name", "-"), + friend.get("jobName", "-"), + friend.get("salaryDesc", friend.get("lastTime", "-")), + last_text or "-", + msg_info.get("lastTime", friend.get("lastTime", "-")), + ) + + console.print(table) + console.print(" [dim]💡 使用 boss recruiter-geek 查看候选人详情[/dim]") + + handle_command(cred, action=_action, render=_render, as_json=as_json, as_yaml=as_yaml) + + +# ── recruiter geek info ────────────────────────────────────────── + +@click.command("recruiter-geek") +@click.argument("encrypt_geek_id") +@click.option("--security-id", default="", help="候选人 securityId") +@click.option("--job-id", default=0, type=int, help="关联职位 ID") +@structured_output_options +def recruiter_geek(encrypt_geek_id: str, security_id: str, job_id: int, as_json: bool, as_yaml: bool) -> None: + """查看候选人详细信息 (需要 encryptGeekId)""" + cred = require_auth() + + def _action(c: BossClient) -> dict: + # If job_id not provided, try to get it from chatted jobs + nonlocal job_id, security_id + if not job_id: + jobs = c.get_boss_chatted_jobs() + if jobs: + job_id = jobs[0].get("jobId", 0) + + if not security_id: + # Try to find security_id from friend list + friend_data = c.get_boss_friend_list() + for f in friend_data.get("result", []): + if f.get("encryptFriendId") == encrypt_geek_id: + friend_details = c.get_boss_friend_details([f["friendId"]]) + for fd in friend_details.get("friendList", []): + security_id = fd.get("securityId", "") + break + break + + return c.get_boss_chat_geek_info( + encrypt_geek_id=encrypt_geek_id, + security_id=security_id, + job_id=job_id, + ) + + def _render(data: dict) -> None: + geek = data.get("data", data) + + name = geek.get("name", "-") + age = geek.get("ageDesc", "-") + gender = "男" if geek.get("gender") == 1 else "女" if geek.get("gender") == 2 else "-" + edu = geek.get("edu", "-") + city = geek.get("city", "-") + salary = geek.get("salaryDesc", "-") + expect_salary = geek.get("price", "-") + position = geek.get("positionName", geek.get("toPosition", "-")) + status = geek.get("positionStatus", "-") + last_company = geek.get("lastCompany", "-") + last_position = geek.get("lastPosition", "-") + school = geek.get("school", "-") + major = geek.get("major", "-") + work_year = geek.get("year", "-") + + work_exp = geek.get("workExpList", []) + work_lines = [] + for w in work_exp[:5]: + work_lines.append(f" {w.get('timeDesc', '')} {w.get('company', '')} · {w.get('positionName', '')}") + + panel_text = ( + f"[bold cyan]{name}[/bold cyan] {gender} {age}\n" + f"学历: {edu} · 工作年限: {work_year}\n" + f"城市: {city} · 求职状态: {status}\n" + f"\n" + f"[bold yellow]期望薪资:[/bold yellow] {expect_salary}\n" + f"[bold yellow]当前薪资:[/bold yellow] {salary}\n" + f"期望职位: {position}\n" + f"\n" + f"[bold green]当前/最近:[/bold green] {last_company}\n" + f"职位: {last_position}\n" + f"学校: {school} · {major}\n" + ) + + if work_lines: + panel_text += "\n[bold magenta]工作经历:[/bold magenta]\n" + "\n".join(work_lines) + + panel = Panel(panel_text, title="👤 候选人详情", border_style="cyan") + console.print(panel) + + handle_command(cred, action=_action, render=_render, as_json=as_json, as_yaml=as_yaml) + + +# ── recruiter chat history ────────────────────────────────────── + +@click.command("recruiter-chat") +@click.argument("friend_id", type=int) +@click.option("-n", "--count", default=20, type=int, help="消息数量 (默认: 20)") +@structured_output_options +def recruiter_chat(friend_id: int, count: int, as_json: bool, as_yaml: bool) -> None: + """查看与候选人的聊天记录 (需要 friendId)""" + cred = require_auth() + + def _action(c: BossClient) -> dict: + return c.get_boss_chat_history(gid=friend_id, count=count) + + def _render(data: dict) -> None: + messages = data.get("messages", []) + + if not messages: + console.print("[yellow]暂无聊天记录[/yellow]") + return + + table = Table(title=f"💬 聊天记录 ({len(messages)} 条)", show_lines=True) + table.add_column("#", style="dim", width=3) + table.add_column("方向", max_width=6) + table.add_column("内容", max_width=50) + table.add_column("类型", style="dim", max_width=6) + + for i, msg in enumerate(messages, 1): + direction = "[cyan]←[/cyan]" if msg.get("received", True) else "[green]→[/green]" + + body = msg.get("body", {}) + if isinstance(body, str): + text = body[:48] + elif isinstance(body, dict): + text = body.get("text", body.get("showText", "")) + if not text and body.get("resume"): + resume = body["resume"] + text = f"[简历] {resume.get('user', {}).get('name', '')} {resume.get('positionCategory', '')}" + text = text[:48] if text else "[多媒体消息]" + else: + text = str(body)[:48] + + msg_type = str(msg.get("type", "-")) + + table.add_row(str(i), direction, text, msg_type) + + console.print(table) + + handle_command(cred, action=_action, render=_render, as_json=as_json, as_yaml=as_yaml) + + +# ── recruiter labels ────────────────────────────────────────────── + +@click.command("recruiter-labels") +@structured_output_options +def recruiter_labels(as_json: bool, as_yaml: bool) -> None: + """查看候选人标签列表""" + cred = require_auth() + + def _render(data: dict) -> None: + labels = data.get("labels", data.get("labelList", data.get("result", []))) + if isinstance(data, list): + labels = data + + if not labels: + console.print("[yellow]暂无标签[/yellow]") + return + + table = Table(title="🏷️ 标签列表", show_lines=False) + table.add_column("ID", style="dim", width=6) + table.add_column("名称", style="cyan", max_width=20) + + for label in labels: + table.add_row( + str(label.get("labelId", label.get("id", "-"))), + label.get("label", label.get("name", label.get("labelName", "-"))), + ) + + console.print(table) + + handle_command(cred, action=lambda c: c.get_boss_friend_labels(), render=_render, as_json=as_json, as_yaml=as_yaml) + + +# ── recruiter export ────────────────────────────────────────────── + +@click.command("recruiter-export") +@click.option("--job", "enc_job_id", default="", help="按职位 encryptJobId 筛选") +@click.option("-o", "--output", "output_file", default=None, help="输出文件路径") +@click.option("--format", "fmt", type=click.Choice(["csv", "json"]), default="csv", help="输出格式") +def recruiter_export(enc_job_id: str, output_file: str | None, fmt: str) -> None: + """导出候选人列表为 CSV 或 JSON""" + cred = require_auth() + + try: + def _collect(c: BossClient) -> list[dict]: + friend_data = c.get_boss_friend_list(enc_job_id=enc_job_id) + friend_list = friend_data.get("result", []) + + if not friend_list: + return [] + + friend_ids = [f["friendId"] for f in friend_list if f.get("friendId")] + details = c.get_boss_friend_details(friend_ids) + return details.get("friendList", []) + + all_candidates = run_client_action(cred, _collect) + + if not all_candidates: + console.print("[yellow]暂无候选人数据[/yellow]") + return + + if fmt == "json": + output_text = json.dumps(all_candidates, indent=2, ensure_ascii=False) + else: + buf = io.StringIO() + fieldnames = ["姓名", "关联职位", "来源", "最近时间", "新牛人", "encryptUid", "securityId"] + writer = csv.DictWriter(buf, fieldnames=fieldnames, extrasaction="ignore") + writer.writeheader() + for f in all_candidates: + source_map = {1: "搜索", 2: "推荐", 3: "打招呼", 5: "主动沟通"} + writer.writerow({ + "姓名": f.get("name", ""), + "关联职位": f.get("jobName", ""), + "来源": source_map.get(f.get("sourceType"), str(f.get("sourceType", ""))), + "最近时间": f.get("lastTime", ""), + "新牛人": "是" if f.get("newGeek") else "", + "encryptUid": f.get("encryptUid", f.get("encryptFriendId", "")), + "securityId": f.get("securityId", ""), + }) + output_text = buf.getvalue() + + if output_file: + with open(output_file, "w", encoding="utf-8-sig" if fmt == "csv" else "utf-8") as fh: + fh.write(output_text) + console.print(f"\n[green]✅ 已导出 {len(all_candidates)} 个候选人到 {output_file}[/green]") + else: + click.echo(output_text) + + except BossApiError as exc: + console.print(f"[red]❌ 导出失败: {exc}[/red]") + raise SystemExit(1) from None diff --git a/boss_cli/constants.py b/boss_cli/constants.py index fd11ae1..b8b3bba 100644 --- a/boss_cli/constants.py +++ b/boss_cli/constants.py @@ -41,6 +41,23 @@ FRIEND_ADD_URL = "/wapi/zpgeek/friend/add.json" GEEK_GET_JOB_URL = "/wapi/zprelation/interaction/geekGetJob" +# ── Recruiter (Boss) API ────────────────────────────────────────── +WEB_BOSS_CHAT_URL = f"{BASE_URL}/web/chat/index" +WEB_BOSS_RECOMMEND_URL = f"{BASE_URL}/web/chat/recommend" +BOSS_FRIEND_LIST_URL = "/wapi/zprelation/friend/filterByLabel" +BOSS_FRIEND_DETAIL_URL = "/wapi/zprelation/friend/getBossFriendListV2.json" +BOSS_LAST_MSG_URL = "/wapi/zpchat/boss/userLastMsg" +BOSS_HISTORY_MSG_URL = "/wapi/zpchat/boss/historyMsg" +BOSS_CHATTED_JOB_LIST_URL = "/wapi/zpjob/job/chatted/jobList" +BOSS_CHAT_GEEK_INFO_URL = "/wapi/zpjob/chat/geek/info" +BOSS_VIEW_GEEK_INFO_URL = "/wapi/zpjob/view/geek/info" +BOSS_FRIEND_LABELS_URL = "/wapi/zprelation/friend/label/get" +BOSS_FRIEND_NOTE_URL = "/wapi/zprelation/friend/getNoteAndLabels" +BOSS_GREET_SORT_LIST_URL = "/wapi/zprelation/friend/greetSort/getList" +BOSS_GREET_REC_SORT_URL = "/wapi/zprelation/friend/greetRecSortList" +BOSS_INTERVIEW_LIST_URL = "/wapi/zpinterview/boss/interview/valid/list" +BOSS_INTERVIEW_DETAIL_URL = "/wapi/zpinterview/boss/interview/detail" + # ── Request Headers (Chrome 145, macOS) ───────────────────────────── HEADERS = { "User-Agent": ( From 5c8a557cbdb1b6a6f9fcec7aaab5ff438329a324 Mon Sep 17 00:00:00 2001 From: Wilson Xu Date: Thu, 2 Apr 2026 15:18:53 +0800 Subject: [PATCH 02/13] docs: add recruiter mode documentation to README Add usage examples, workflow guide, and Chinese docs for the 6 new recruiter commands. Update project structure and badges. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 84 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 7cfe9ac..c0664f6 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ # boss-cli [![PyPI version](https://img.shields.io/pypi/v/kabi-boss-cli.svg)](https://pypi.org/project/kabi-boss-cli/) -[![CI](https://github.com/jackwener/boss-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/jackwener/boss-cli/actions/workflows/ci.yml) +[![CI](https://github.com/chengyixu/boss-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/chengyixu/boss-cli/actions/workflows/ci.yml) [![Python](https://img.shields.io/badge/python-%3E%3D3.10-blue.svg)](https://pypi.org/project/kabi-boss-cli/) -A CLI for BOSS 直聘 — search jobs, view recommendations, manage applications, and chat with recruiters via reverse-engineered API 🤝 +A CLI for BOSS 直聘 — search jobs, view recommendations, manage applications, chat with recruiters, **and manage candidates as a recruiter** via reverse-engineered API 🤝 + +> **Fork note:** This fork adds **recruiter (雇主端) mode** with 6 new commands for employers. See [Recruiter Mode](#recruiter-mode-雇主端) below. [English](#features) | [中文](#功能特性) @@ -31,6 +33,7 @@ A CLI for BOSS 直聘 — search jobs, view recommendations, manage applications - 🤝 **Greet** — send greetings to recruiters, single or batch (with 1.5s rate-limit delay) - 🏙️ **Cities** — 40+ supported cities - 🤖 **Agent-friendly** — structured output envelope (`{ok, schema_version, data}`), Rich output on stderr +- 👔 **Recruiter Mode** — view posted jobs, manage candidates, chat history, export candidate data (CSV/JSON) ## Installation @@ -113,6 +116,58 @@ boss --version # Show version boss -v search "Python" # Verbose logging (request timing) ``` +## Recruiter Mode (雇主端) + +If you are an employer on BOSS直聘, these commands let you manage candidates from the terminal: + +```bash +# ─── View Your Posted Jobs ────────────────────── +boss recruiter-jobs # List all posted jobs with encryptJobId +boss recruiter-jobs --json # JSON output + +# ─── Candidate Inbox ──────────────────────────── +boss recruiter-inbox # View all candidate conversations +boss recruiter-inbox --job # Filter by specific job +boss recruiter-inbox --label 1 # Filter by label (1=新招呼) + +# ─── Candidate Profile ────────────────────────── +boss recruiter-geek # View candidate details +boss recruiter-geek --job-id 526908510 # With specific job context +boss recruiter-geek --json # JSON output with full profile + +# ─── Chat History ─────────────────────────────── +boss recruiter-chat # View chat with candidate +boss recruiter-chat -n 50 # Last 50 messages + +# ─── Labels / Tags ────────────────────────────── +boss recruiter-labels # List all candidate labels +boss recruiter-labels --json # JSON output + +# ─── Export Candidates ────────────────────────── +boss recruiter-export -o candidates.csv # Export all candidates to CSV +boss recruiter-export --job -o out.csv # Export candidates for a specific job +boss recruiter-export --format json -o out.json # Export as JSON +``` + +### Recruiter Workflow Example + +```bash +# 1. Check your posted jobs +boss recruiter-jobs + +# 2. See who messaged you for a specific job +boss recruiter-inbox --job f806096ea327cd610nZ80t21FVNQ + +# 3. View a candidate's full profile +boss recruiter-geek 9baf80468c8bc8980HZ82N25FlU~ --job-id 526908510 + +# 4. Read chat history +boss recruiter-chat 72630467 + +# 5. Export all candidates for offline review +boss recruiter-export --format json -o candidates.json +``` + ## Structured Output All commands with `--json` / `--yaml` use a unified output envelope (see [SCHEMA.md](./SCHEMA.md)): @@ -201,7 +256,8 @@ boss_cli/ ├── auth.py # login (--cookie-source/--qrcode), logout, status, me ├── search.py # search, recommend, detail, show, export, history, cities ├── personal.py # applied, interviews - └── social.py # chat, greet (--json), batch-greet (1.5s delay) + ├── social.py # chat, greet (--json), batch-greet (1.5s delay) + └── recruiter.py # recruiter-jobs, inbox, geek, chat, labels, export ``` ## Development @@ -254,6 +310,7 @@ Check your city filter. Some keywords are city-specific. Use `boss cities` to se - 🤝 **打招呼** — 向 Boss 打招呼/投递,支持批量操作(内置 1.5s 防风控延迟) - 🏙️ **城市** — 40+ 城市支持 - 🤖 **Agent 友好** — 结构化输出 envelope,Rich 输出走 stderr +- 👔 **招聘方模式** — 查看职位、候选人管理、聊天记录、导出候选人数据 (CSV/JSON) ## 使用示例 @@ -292,6 +349,30 @@ boss cities # 城市列表 boss -v search "Python" # 详细日志 ``` +## 招聘方模式 + +```bash +# 查看招聘职位 +boss recruiter-jobs + +# 查看候选人列表 +boss recruiter-inbox # 全部候选人 +boss recruiter-inbox --job # 按职位筛选 + +# 查看候选人详情 +boss recruiter-geek --job-id + +# 查看聊天记录 +boss recruiter-chat + +# 标签管理 +boss recruiter-labels + +# 导出候选人 +boss recruiter-export -o candidates.csv # CSV 导出 +boss recruiter-export --format json -o out.json # JSON 导出 +``` + ## 常见问题 - `环境异常` — Cookie 过期,执行 `boss logout && boss login` 刷新 From 31447ff2e29bc64395c7762161f4b2060f92f3c1 Mon Sep 17 00:00:00 2001 From: Wilson Xu Date: Thu, 2 Apr 2026 15:30:43 +0800 Subject: [PATCH 03/13] feat: expand recruiter mode to 12 subcommands under `boss recruiter` group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure recruiter commands from 6 flat commands to a proper Click subcommand group with 12 commands matching issue #10 spec: New commands: - `boss recruiter search` — search candidates with city/exp/degree/salary - `boss recruiter recommend` — recommended candidates (greetRecSortList) - `boss recruiter greet` — initiate conversation with candidate - `boss recruiter batch-greet` — batch greet with --dry-run support - `boss recruiter reply` — send message to candidate (with confirmation) - `boss recruiter resume` — full resume view (work, education, projects) Preserved from v1: - `boss recruiter jobs/inbox/geek/chat/labels/export` New BossClient methods: search_geeks, get_boss_recommend_geeks, get_boss_view_geek, boss_send_message. Resume auto-fetches securityId from friend list when not provided. Co-Authored-By: Claude Opus 4.6 (1M context) --- boss_cli/cli.py | 7 +- boss_cli/client.py | 53 +++ boss_cli/commands/recruiter.py | 706 ++++++++++++++++++++++++++------- boss_cli/constants.py | 5 + 4 files changed, 622 insertions(+), 149 deletions(-) diff --git a/boss_cli/cli.py b/boss_cli/cli.py index 46df331..76bba64 100644 --- a/boss_cli/cli.py +++ b/boss_cli/cli.py @@ -63,12 +63,7 @@ def cli(ctx, verbose: bool) -> None: # ─── Recruiter (Boss) commands ────────────────────────────────────── -cli.add_command(recruiter.recruiter_jobs) -cli.add_command(recruiter.recruiter_inbox) -cli.add_command(recruiter.recruiter_geek) -cli.add_command(recruiter.recruiter_chat) -cli.add_command(recruiter.recruiter_labels) -cli.add_command(recruiter.recruiter_export) +cli.add_command(recruiter.recruiter) if __name__ == "__main__": diff --git a/boss_cli/client.py b/boss_cli/client.py index fb781ed..519bb9a 100644 --- a/boss_cli/client.py +++ b/boss_cli/client.py @@ -24,6 +24,9 @@ BOSS_HISTORY_MSG_URL, BOSS_INTERVIEW_LIST_URL, BOSS_LAST_MSG_URL, + BOSS_SEARCH_GEEK_URL, + BOSS_SEND_MSG_URL, + BOSS_VIEW_GEEK_URL, CITY_CODES, DELIVER_LIST_URL, FRIEND_ADD_URL, @@ -184,6 +187,10 @@ def _headers_for_request(self, url: str, params: dict[str, Any] | None = None) - elif url in (FRIEND_LIST_URL, FRIEND_ADD_URL): headers["Referer"] = WEB_GEEK_CHAT_URL # Recruiter (boss) endpoints + elif url == BOSS_SEARCH_GEEK_URL: + headers["Referer"] = f"{BASE_URL}/web/chat/search" + elif url in (BOSS_VIEW_GEEK_URL, BOSS_SEND_MSG_URL): + headers["Referer"] = WEB_BOSS_CHAT_URL elif url in (BOSS_FRIEND_LIST_URL, BOSS_FRIEND_DETAIL_URL, BOSS_LAST_MSG_URL, BOSS_HISTORY_MSG_URL, BOSS_CHAT_GEEK_INFO_URL, BOSS_FRIEND_LABELS_URL, BOSS_FRIEND_NOTE_URL, BOSS_GREET_SORT_LIST_URL, BOSS_GREET_REC_SORT_URL, @@ -495,6 +502,52 @@ def get_boss_interview_list(self) -> dict[str, Any]: """Get boss interview list.""" return self._get(BOSS_INTERVIEW_LIST_URL, action="面试列表") + def search_geeks( + self, query: str, city: str = "101020100", page: int = 1, + experience: str | None = None, degree: str | None = None, + salary: str | None = None, encrypt_job_id: str = "", + ) -> dict[str, Any]: + """Search candidates (geeks) as a recruiter.""" + params: dict[str, Any] = { + "query": query, "city": city, "page": page, + } + if encrypt_job_id: + params["encryptJobId"] = encrypt_job_id + if experience: + params["experience"] = experience + if degree: + params["degree"] = degree + if salary: + params["salary"] = salary + return self._get(BOSS_SEARCH_GEEK_URL, params=params, action="搜索候选人") + + def get_boss_recommend_geeks(self, page: int = 1, enc_job_id: str = "") -> dict[str, Any]: + """Get recommended candidates (new greetings sorted by recommendation).""" + params: dict[str, Any] = {"page": page} + if enc_job_id: + params["encJobId"] = enc_job_id + return self._get(BOSS_GREET_REC_SORT_URL, params=params, action="推荐候选人") + + def get_boss_view_geek( + self, encrypt_geek_id: str, encrypt_job_id: str, security_id: str = "", + ) -> dict[str, Any]: + """Get full candidate resume/profile view.""" + params: dict[str, Any] = { + "encryptGeekId": encrypt_geek_id, + "encryptJobId": encrypt_job_id, + } + if security_id: + params["securityId"] = security_id + return self._get(BOSS_VIEW_GEEK_URL, params=params, action="候选人简历") + + def boss_send_message(self, gid: int, content: str) -> dict[str, Any]: + """Send a text message to a candidate as a recruiter.""" + return self._post( + BOSS_SEND_MSG_URL, + data={"gid": gid, "content": content}, + action="发送消息", + ) + # ── City resolution ───────────────────────────────────────────────── diff --git a/boss_cli/commands/recruiter.py b/boss_cli/commands/recruiter.py index 939a5b5..d023f43 100644 --- a/boss_cli/commands/recruiter.py +++ b/boss_cli/commands/recruiter.py @@ -1,4 +1,4 @@ -"""Recruiter (Boss) commands: jobs, inbox, candidates, chat-history, geek-info.""" +"""Recruiter (Boss) commands — Click subcommand group with 8+ commands.""" from __future__ import annotations @@ -6,12 +6,14 @@ import io import json import logging +import time import click from rich.panel import Panel from rich.table import Table -from ..client import BossClient +from ..client import BossClient, resolve_city +from ..constants import DEGREE_CODES, EXP_CODES, SALARY_CODES from ..exceptions import BossApiError from ._common import ( console, @@ -24,9 +26,15 @@ logger = logging.getLogger(__name__) +@click.group() +def recruiter() -> None: + """招聘方/雇主端操作 (Recruiter mode)""" + + # ── recruiter jobs ────────────────────────────────────────────────── -@click.command("recruiter-jobs") + +@recruiter.command("jobs") @structured_output_options def recruiter_jobs(as_json: bool, as_yaml: bool) -> None: """查看招聘中的职位列表""" @@ -37,7 +45,7 @@ def _render(data: list[dict]) -> None: console.print("[yellow]暂无在线职位[/yellow]") return - table = Table(title=f"📋 招聘职位 ({len(data)} 个)", show_lines=True) + table = Table(title=f"招聘职位 ({len(data)} 个)", show_lines=True) table.add_column("#", style="dim", width=3) table.add_column("职位", style="bold cyan", max_width=25) table.add_column("薪资", style="yellow", max_width=12) @@ -54,14 +62,272 @@ def _render(data: list[dict]) -> None: ) console.print(table) - console.print(" [dim]💡 使用 boss recruiter-inbox --job 查看该职位的候选人[/dim]") + console.print(" [dim]使用 boss recruiter inbox --job 查看该职位的候选人[/dim]") + + handle_command( + cred, action=lambda c: c.get_boss_chatted_jobs(), + render=_render, as_json=as_json, as_yaml=as_yaml, + ) + + +# ── recruiter search ────────────────────────────────────────────── + + +@recruiter.command("search") +@click.argument("keyword") +@click.option("-c", "--city", default="上海", help="城市名称或代码 (默认: 上海)") +@click.option("--exp", type=click.Choice(list(EXP_CODES.keys())), help="工作经验筛选") +@click.option("--degree", type=click.Choice(list(DEGREE_CODES.keys())), help="学历筛选") +@click.option("--salary", type=click.Choice(list(SALARY_CODES.keys())), help="薪资筛选") +@click.option("--job", "encrypt_job_id", default="", help="关联职位 encryptJobId") +@click.option("-p", "--page", default=1, type=int, help="页码") +@structured_output_options +def recruiter_search( + keyword: str, city: str, exp: str | None, degree: str | None, + salary: str | None, encrypt_job_id: str, page: int, + as_json: bool, as_yaml: bool, +) -> None: + """搜索候选人 (Search candidates)""" + cred = require_auth() + city_code = resolve_city(city) + exp_code = EXP_CODES.get(exp) if exp else None + degree_code = DEGREE_CODES.get(degree) if degree else None + salary_code = SALARY_CODES.get(salary) if salary else None + + def _action(c: BossClient) -> dict: + return c.search_geeks( + query=keyword, city=city_code, page=page, + experience=exp_code, degree=degree_code, + salary=salary_code, encrypt_job_id=encrypt_job_id, + ) + + def _render(data: dict) -> None: + geek_list = data.get("geekList", data.get("resultList", [])) + if not geek_list: + console.print("[yellow]未找到匹配候选人 (可能需要 __zp_stoken__)[/yellow]") + if data: + console.print(f" [dim]返回数据: {json.dumps(data, ensure_ascii=False)[:200]}[/dim]") + return + + table = Table(title=f"搜索候选人: {keyword} ({len(geek_list)} 人)", show_lines=True) + table.add_column("#", style="dim", width=3) + table.add_column("姓名", style="bold cyan", max_width=10) + table.add_column("职位", style="green", max_width=20) + table.add_column("经验", style="yellow", max_width=8) + table.add_column("学历", max_width=6) + table.add_column("encryptGeekId", style="dim", max_width=28) + + for i, geek in enumerate(geek_list, 1): + table.add_row( + str(i), + geek.get("name", geek.get("geekName", "-")), + geek.get("expectPositionName", geek.get("jobName", "-")), + geek.get("workYearDesc", geek.get("workYear", "-")), + geek.get("degreeDesc", geek.get("degree", "-")), + geek.get("encryptGeekId", geek.get("encryptUid", "-")), + ) - handle_command(cred, action=lambda c: c.get_boss_chatted_jobs(), render=_render, as_json=as_json, as_yaml=as_yaml) + console.print(table) + + handle_command(cred, action=_action, render=_render, as_json=as_json, as_yaml=as_yaml) + + +# ── recruiter recommend ────────────────────────────────────────── + + +@recruiter.command("recommend") +@click.option("-p", "--page", default=1, type=int, help="页码") +@click.option("--job", "enc_job_id", default="", help="关联职位 encryptJobId") +@structured_output_options +def recruiter_recommend(page: int, enc_job_id: str, as_json: bool, as_yaml: bool) -> None: + """推荐候选人列表 (greetRecSortList)""" + cred = require_auth() + + def _action(c: BossClient) -> dict: + return c.get_boss_recommend_geeks(page=page, enc_job_id=enc_job_id) + + def _render(data: dict) -> None: + friend_list = data.get("friendList", []) + limit = data.get("limit", 0) + + if not friend_list: + console.print("[yellow]暂无推荐候选人[/yellow]") + return + + table = Table( + title=f"推荐候选人 ({len(friend_list)} 人, 上限 {limit})", + show_lines=True, + ) + table.add_column("#", style="dim", width=3) + table.add_column("姓名", style="bold cyan", max_width=10) + table.add_column("职位", style="green", max_width=20) + table.add_column("encJobId", style="dim", max_width=28) + table.add_column("新牛人", max_width=4) + table.add_column("时间", style="dim", max_width=10) + + for i, f in enumerate(friend_list, 1): + new_flag = "NEW" if f.get("newGeek") else "" + table.add_row( + str(i), + f.get("name", "-"), + f.get("jobName", "-"), + f.get("encryptJobId", "-"), + new_flag, + f.get("lastTime", "-"), + ) + console.print(table) + + handle_command(cred, action=_action, render=_render, as_json=as_json, as_yaml=as_yaml) + + +# ── recruiter greet ────────────────────────────────────────────── + + +@recruiter.command("greet") +@click.argument("encrypt_geek_id") +@click.option("--job", "encrypt_job_id", default="", help="关联职位 encryptJobId") +@structured_output_options +def recruiter_greet(encrypt_geek_id: str, encrypt_job_id: str, as_json: bool, as_yaml: bool) -> None: + """向候选人发起沟通 (Initiate conversation with candidate)""" + cred = require_auth() + + def _action(c: BossClient) -> dict: + # Get job id if not provided + job_id = encrypt_job_id + if not job_id: + jobs = c.get_boss_chatted_jobs() + if jobs: + job_id = jobs[0].get("encryptJobId", "") + + # View the geek first to show info + if job_id: + info = c.get_boss_view_geek( + encrypt_geek_id=encrypt_geek_id, + encrypt_job_id=job_id, + ) + else: + info = {"encryptGeekId": encrypt_geek_id, "note": "无关联职位, 无法获取详情"} + return info + + def _render(data: dict) -> None: + geek_info = data.get("geekDetailInfo", data.get("geekBaseInfo", data)) + base_info = geek_info.get("geekBaseInfo", geek_info) if isinstance(geek_info, dict) else data + name = base_info.get("name", base_info.get("geekName", "-")) + console.print(f"[cyan]候选人: {name}[/cyan] encryptGeekId={encrypt_geek_id}") + console.print("[dim]提示: 使用 boss recruiter reply 发送消息[/dim]") + + handle_command(cred, action=_action, render=_render, as_json=as_json, as_yaml=as_yaml) + + +# ── recruiter batch-greet ────────────────────────────────────────── + + +@recruiter.command("batch-greet") +@click.argument("keyword") +@click.option("-c", "--city", default="上海", help="城市名称或代码") +@click.option("-n", "--count", default=5, type=int, help="打招呼数量 (默认: 5)") +@click.option("--salary", type=click.Choice(list(SALARY_CODES.keys())), help="薪资筛选") +@click.option("--exp", type=click.Choice(list(EXP_CODES.keys())), help="工作经验筛选") +@click.option("--degree", type=click.Choice(list(DEGREE_CODES.keys())), help="学历筛选") +@click.option("--job", "encrypt_job_id", default="", help="关联职位 encryptJobId") +@click.option("--dry-run", is_flag=True, help="仅预览, 不实际发送") +@click.option("-y", "--yes", is_flag=True, help="跳过确认提示") +def recruiter_batch_greet( + keyword: str, city: str, count: int, + salary: str | None, exp: str | None, degree: str | None, + encrypt_job_id: str, dry_run: bool, yes: bool, +) -> None: + """批量向搜索结果中的候选人发起沟通 + + 例: boss recruiter batch-greet "golang" --city 上海 -n 10 + """ + cred = require_auth() + city_code = resolve_city(city) + salary_code = SALARY_CODES.get(salary) if salary else None + exp_code = EXP_CODES.get(exp) if exp else None + degree_code = DEGREE_CODES.get(degree) if degree else None + + try: + data = run_client_action( + cred, + lambda client: client.search_geeks( + query=keyword, city=city_code, + experience=exp_code, degree=degree_code, + salary=salary_code, encrypt_job_id=encrypt_job_id, + ), + ) + + geek_list = data.get("geekList", data.get("resultList", [])) + if not geek_list: + console.print("[yellow]未找到匹配候选人[/yellow]") + return + + targets = geek_list[:count] + + # Preview table + table = Table(title=f"将向以下 {len(targets)} 个候选人发起沟通", show_lines=True) + table.add_column("#", style="dim", width=3) + table.add_column("姓名", style="bold cyan", max_width=12) + table.add_column("职位", style="green", max_width=20) + table.add_column("经验", style="yellow", max_width=10) + + for i, geek in enumerate(targets, 1): + table.add_row( + str(i), + geek.get("name", geek.get("geekName", "-")), + geek.get("expectPositionName", geek.get("jobName", "-")), + geek.get("workYearDesc", "-"), + ) + + console.print(table) + + if dry_run: + console.print("\n [dim]预览模式, 未实际发送[/dim]") + return + + if not yes: + confirm = click.confirm(f"\n确定向 {len(targets)} 个候选人发起沟通吗?") + if not confirm: + console.print("[dim]已取消[/dim]") + return + + success = 0 + for i, geek in enumerate(targets, 1): + geek_id = geek.get("encryptGeekId", geek.get("encryptUid", "")) + name = geek.get("name", geek.get("geekName", "?")) + + if not geek_id: + console.print(f" [{i}] [yellow]跳过 {name} (无 encryptGeekId)[/yellow]") + continue + + try: + run_client_action( + cred, + lambda client, gid=geek_id: client.get_boss_view_geek( + encrypt_geek_id=gid, + encrypt_job_id=encrypt_job_id, + ), + ) + console.print(f" [{i}] [green]{name} - 已查看[/green]") + success += 1 + except BossApiError as e: + console.print(f" [{i}] [red]{name}: {e}[/red]") + + if i < len(targets): + time.sleep(1.5) + + console.print(f"\n[bold]完成: {success}/{len(targets)} 个候选人已处理[/bold]") + + except BossApiError as exc: + console.print(f"[red]搜索失败: {exc}[/red]") + raise SystemExit(1) from None -# ── recruiter inbox (candidate list) ────────────────────────────── -@click.command("recruiter-inbox") +# ── recruiter inbox ────────────────────────────────────────────── + + +@recruiter.command("inbox") @click.option("--job", "enc_job_id", default="", help="按职位 encryptJobId 筛选") @click.option("--label", "label_id", default=0, type=int, help="按标签筛选 (0=全部)") @structured_output_options @@ -70,7 +336,6 @@ def recruiter_inbox(enc_job_id: str, label_id: int, as_json: bool, as_yaml: bool cred = require_auth() def _action(c: BossClient) -> dict: - # Step 1: get friend IDs friend_data = c.get_boss_friend_list(label_id=label_id, enc_job_id=enc_job_id) friend_list = friend_data.get("result", []) @@ -79,12 +344,9 @@ def _action(c: BossClient) -> dict: friend_ids = [f["friendId"] for f in friend_list if f.get("friendId")] - # Step 2: get friend details details = c.get_boss_friend_details(friend_ids) detail_list = details.get("friendList", []) - # Step 3: get last messages (zpData returns list directly) - # Only request first batch to avoid too many IDs batch_ids = friend_ids[:50] last_msgs = c.get_boss_last_messages(batch_ids) @@ -98,7 +360,6 @@ def _render(data: dict) -> None: console.print("[yellow]暂无候选人消息[/yellow]") return - # Build msg lookup msg_map: dict[int, dict] = {} if isinstance(last_msgs, list): for msg in last_msgs: @@ -106,7 +367,7 @@ def _render(data: dict) -> None: if uid: msg_map[uid] = msg - table = Table(title=f"💬 候选人列表 ({len(detail_list)} 人)", show_lines=True) + table = Table(title=f"候选人列表 ({len(detail_list)} 人)", show_lines=True) table.add_column("#", style="dim", width=3) table.add_column("候选人", style="bold cyan", max_width=12) table.add_column("职位", style="green", max_width=20) @@ -131,96 +392,261 @@ def _render(data: dict) -> None: ) console.print(table) - console.print(" [dim]💡 使用 boss recruiter-geek 查看候选人详情[/dim]") + console.print(" [dim]使用 boss recruiter resume 查看候选人简历[/dim]") handle_command(cred, action=_action, render=_render, as_json=as_json, as_yaml=as_yaml) -# ── recruiter geek info ────────────────────────────────────────── +# ── recruiter reply ────────────────────────────────────────────── + + +@recruiter.command("reply") +@click.argument("friend_id", type=int) +@click.argument("message") +@click.option("-y", "--yes", is_flag=True, help="跳过确认提示") +@structured_output_options +def recruiter_reply(friend_id: int, message: str, yes: bool, as_json: bool, as_yaml: bool) -> None: + """发送消息给候选人 (Send message to candidate)""" + cred = require_auth() + + if not yes: + console.print(f"[cyan]将向 friendId={friend_id} 发送消息:[/cyan]") + console.print(f" {message}") + confirm = click.confirm("\n确认发送?") + if not confirm: + console.print("[dim]已取消[/dim]") + return + + def _action(c: BossClient) -> dict: + return c.boss_send_message(gid=friend_id, content=message) + + def _render(data: dict) -> None: + console.print(f"[green]消息已发送 -> friendId={friend_id}[/green]") + + handle_command(cred, action=_action, render=_render, as_json=as_json, as_yaml=as_yaml) + + +# ── recruiter export ────────────────────────────────────────────── + + +@recruiter.command("export") +@click.option("--job", "enc_job_id", default="", help="按职位 encryptJobId 筛选") +@click.option("-o", "--output", "output_file", default=None, help="输出文件路径") +@click.option("--format", "fmt", type=click.Choice(["csv", "json"]), default="csv", help="输出格式") +def recruiter_export(enc_job_id: str, output_file: str | None, fmt: str) -> None: + """导出候选人列表为 CSV 或 JSON""" + cred = require_auth() + + try: + def _collect(c: BossClient) -> list[dict]: + friend_data = c.get_boss_friend_list(enc_job_id=enc_job_id) + friend_list = friend_data.get("result", []) + + if not friend_list: + return [] + + friend_ids = [f["friendId"] for f in friend_list if f.get("friendId")] + details = c.get_boss_friend_details(friend_ids) + return details.get("friendList", []) + + all_candidates = run_client_action(cred, _collect) + + if not all_candidates: + console.print("[yellow]暂无候选人数据[/yellow]") + return + + if fmt == "json": + output_text = json.dumps(all_candidates, indent=2, ensure_ascii=False) + else: + buf = io.StringIO() + fieldnames = ["姓名", "关联职位", "来源", "最近时间", "新牛人", "encryptUid", "securityId"] + writer = csv.DictWriter(buf, fieldnames=fieldnames, extrasaction="ignore") + writer.writeheader() + for f in all_candidates: + source_map = {1: "搜索", 2: "推荐", 3: "打招呼", 5: "主动沟通"} + writer.writerow({ + "姓名": f.get("name", ""), + "关联职位": f.get("jobName", ""), + "来源": source_map.get(f.get("sourceType"), str(f.get("sourceType", ""))), + "最近时间": f.get("lastTime", ""), + "新牛人": "是" if f.get("newGeek") else "", + "encryptUid": f.get("encryptUid", f.get("encryptFriendId", "")), + "securityId": f.get("securityId", ""), + }) + output_text = buf.getvalue() + + if output_file: + with open(output_file, "w", encoding="utf-8-sig" if fmt == "csv" else "utf-8") as fh: + fh.write(output_text) + console.print(f"\n[green]已导出 {len(all_candidates)} 个候选人到 {output_file}[/green]") + else: + click.echo(output_text) + + except BossApiError as exc: + console.print(f"[red]导出失败: {exc}[/red]") + raise SystemExit(1) from None + + +# ── recruiter resume ────────────────────────────────────────────── -@click.command("recruiter-geek") + +@recruiter.command("resume") @click.argument("encrypt_geek_id") +@click.option("--job", "encrypt_job_id", default="", help="关联职位 encryptJobId") @click.option("--security-id", default="", help="候选人 securityId") -@click.option("--job-id", default=0, type=int, help="关联职位 ID") @structured_output_options -def recruiter_geek(encrypt_geek_id: str, security_id: str, job_id: int, as_json: bool, as_yaml: bool) -> None: - """查看候选人详细信息 (需要 encryptGeekId)""" +def recruiter_resume( + encrypt_geek_id: str, encrypt_job_id: str, security_id: str, + as_json: bool, as_yaml: bool, +) -> None: + """查看候选人完整简历 (View candidate full resume)""" cred = require_auth() def _action(c: BossClient) -> dict: - # If job_id not provided, try to get it from chatted jobs - nonlocal job_id, security_id - if not job_id: + nonlocal encrypt_job_id, security_id + if not encrypt_job_id: jobs = c.get_boss_chatted_jobs() if jobs: - job_id = jobs[0].get("jobId", 0) + encrypt_job_id = jobs[0].get("encryptJobId", "") + if not encrypt_job_id: + return {"error": "未找到关联职位, 请通过 --job 指定 encryptJobId"} + + # Auto-fetch securityId from friend list if not provided if not security_id: - # Try to find security_id from friend list friend_data = c.get_boss_friend_list() for f in friend_data.get("result", []): if f.get("encryptFriendId") == encrypt_geek_id: - friend_details = c.get_boss_friend_details([f["friendId"]]) - for fd in friend_details.get("friendList", []): + friend_ids = [f["friendId"]] + details = c.get_boss_friend_details(friend_ids) + for fd in details.get("friendList", []): security_id = fd.get("securityId", "") break break - return c.get_boss_chat_geek_info( + return c.get_boss_view_geek( encrypt_geek_id=encrypt_geek_id, + encrypt_job_id=encrypt_job_id, security_id=security_id, - job_id=job_id, ) def _render(data: dict) -> None: - geek = data.get("data", data) - - name = geek.get("name", "-") - age = geek.get("ageDesc", "-") - gender = "男" if geek.get("gender") == 1 else "女" if geek.get("gender") == 2 else "-" - edu = geek.get("edu", "-") - city = geek.get("city", "-") - salary = geek.get("salaryDesc", "-") - expect_salary = geek.get("price", "-") - position = geek.get("positionName", geek.get("toPosition", "-")) - status = geek.get("positionStatus", "-") - last_company = geek.get("lastCompany", "-") - last_position = geek.get("lastPosition", "-") - school = geek.get("school", "-") - major = geek.get("major", "-") - work_year = geek.get("year", "-") + if data.get("error"): + console.print(f"[red]{data['error']}[/red]") + return - work_exp = geek.get("workExpList", []) - work_lines = [] - for w in work_exp[:5]: - work_lines.append(f" {w.get('timeDesc', '')} {w.get('company', '')} · {w.get('positionName', '')}") + # Navigate the nested response structure + geek_detail = data.get("geekDetailInfo", data) + base_info = geek_detail.get("geekBaseInfo", geek_detail) + + name = base_info.get("name", base_info.get("geekName", "-")) + gender_val = base_info.get("gender", 0) + gender = "男" if gender_val == 1 else "女" if gender_val == 2 else "-" + degree = base_info.get("degreeCategory", base_info.get("degree", "-")) + work_year = base_info.get("workYearDesc", base_info.get("workYear", "-")) + age = base_info.get("ageDesc", base_info.get("age", "-")) + apply_status = base_info.get("applyStatusContent", base_info.get("applyStatus", "-")) + expect_position = base_info.get("expectPosition", "-") + expect_city = base_info.get("expectCity", "-") + expect_salary = base_info.get("expectSalary", base_info.get("salaryDesc", "-")) panel_text = ( f"[bold cyan]{name}[/bold cyan] {gender} {age}\n" - f"学历: {edu} · 工作年限: {work_year}\n" - f"城市: {city} · 求职状态: {status}\n" - f"\n" - f"[bold yellow]期望薪资:[/bold yellow] {expect_salary}\n" - f"[bold yellow]当前薪资:[/bold yellow] {salary}\n" - f"期望职位: {position}\n" + f"学历: {degree} | 工作年限: {work_year}\n" + f"求职状态: {apply_status}\n" f"\n" - f"[bold green]当前/最近:[/bold green] {last_company}\n" - f"职位: {last_position}\n" - f"学校: {school} · {major}\n" + f"[bold yellow]期望:[/bold yellow] {expect_position} | {expect_city} | {expect_salary}\n" ) - if work_lines: - panel_text += "\n[bold magenta]工作经历:[/bold magenta]\n" + "\n".join(work_lines) - - panel = Panel(panel_text, title="👤 候选人详情", border_style="cyan") + # Work experience + work_exp = geek_detail.get("geekWorkExpList", base_info.get("workExpList", [])) + if work_exp: + panel_text += "\n[bold green]工作经历:[/bold green]\n" + for w in work_exp[:6]: + company = w.get("company", w.get("companyName", "")) + position = w.get("positionName", w.get("position", "")) + time_desc = w.get("timeDesc", w.get("workTime", "")) + industry = w.get("industry", "") + desc = w.get("description", w.get("workDesc", "")) + panel_text += f" {time_desc} [cyan]{company}[/cyan]" + if industry: + panel_text += f" ({industry})" + panel_text += f"\n {position}\n" + if desc: + panel_text += f" [dim]{desc[:80]}[/dim]\n" + + # Education + edu_exp = geek_detail.get("geekEduExpList", base_info.get("eduExpList", [])) + if edu_exp: + panel_text += "\n[bold magenta]教育经历:[/bold magenta]\n" + for e in edu_exp[:4]: + school = e.get("school", e.get("schoolName", "")) + major_name = e.get("major", e.get("majorName", "")) + degree_name = e.get("degree", e.get("degreeName", "")) + time_desc = e.get("timeDesc", e.get("eduTime", "")) + panel_text += f" {time_desc} [cyan]{school}[/cyan] {degree_name}\n" + if major_name: + panel_text += f" {major_name}\n" + + # Projects + project_exp = geek_detail.get("geekProjectExpList", base_info.get("projectExpList", [])) + if project_exp: + panel_text += "\n[bold blue]项目经历:[/bold blue]\n" + for p in project_exp[:4]: + proj_name = p.get("projectName", p.get("name", "")) + role = p.get("roleName", p.get("role", "")) + time_desc = p.get("timeDesc", p.get("projectTime", "")) + desc = p.get("description", p.get("projectDesc", "")) + panel_text += f" {time_desc} [cyan]{proj_name}[/cyan] ({role})\n" + if desc: + panel_text += f" [dim]{desc[:100]}[/dim]\n" + + panel = Panel(panel_text.rstrip(), title="候选人简历", border_style="cyan") console.print(panel) handle_command(cred, action=_action, render=_render, as_json=as_json, as_yaml=as_yaml) -# ── recruiter chat history ────────────────────────────────────── +# ── recruiter labels ────────────────────────────────────────────── + + +@recruiter.command("labels") +@structured_output_options +def recruiter_labels(as_json: bool, as_yaml: bool) -> None: + """查看候选人标签列表""" + cred = require_auth() + + def _render(data: dict) -> None: + labels = data.get("labels", data.get("labelList", data.get("result", []))) + if isinstance(data, list): + labels = data + + if not labels: + console.print("[yellow]暂无标签[/yellow]") + return + + table = Table(title="标签列表", show_lines=False) + table.add_column("ID", style="dim", width=6) + table.add_column("名称", style="cyan", max_width=20) + + for label in labels: + table.add_row( + str(label.get("labelId", label.get("id", "-"))), + label.get("label", label.get("name", label.get("labelName", "-"))), + ) + + console.print(table) + + handle_command( + cred, action=lambda c: c.get_boss_friend_labels(), + render=_render, as_json=as_json, as_yaml=as_yaml, + ) + -@click.command("recruiter-chat") +# ── recruiter chat (history) ────────────────────────────────────── + + +@recruiter.command("chat") @click.argument("friend_id", type=int) @click.option("-n", "--count", default=20, type=int, help="消息数量 (默认: 20)") @structured_output_options @@ -238,14 +664,14 @@ def _render(data: dict) -> None: console.print("[yellow]暂无聊天记录[/yellow]") return - table = Table(title=f"💬 聊天记录 ({len(messages)} 条)", show_lines=True) + table = Table(title=f"聊天记录 ({len(messages)} 条)", show_lines=True) table.add_column("#", style="dim", width=3) table.add_column("方向", max_width=6) table.add_column("内容", max_width=50) table.add_column("类型", style="dim", max_width=6) for i, msg in enumerate(messages, 1): - direction = "[cyan]←[/cyan]" if msg.get("received", True) else "[green]→[/green]" + direction = "[cyan]<-[/cyan]" if msg.get("received", True) else "[green]->[/green]" body = msg.get("body", {}) if isinstance(body, str): @@ -268,93 +694,87 @@ def _render(data: dict) -> None: handle_command(cred, action=_action, render=_render, as_json=as_json, as_yaml=as_yaml) -# ── recruiter labels ────────────────────────────────────────────── +# ── recruiter geek (legacy - kept as alias for resume) ──────────── -@click.command("recruiter-labels") + +@recruiter.command("geek") +@click.argument("encrypt_geek_id") +@click.option("--security-id", default="", help="候选人 securityId") +@click.option("--job-id", default=0, type=int, help="关联职位 ID") @structured_output_options -def recruiter_labels(as_json: bool, as_yaml: bool) -> None: - """查看候选人标签列表""" +def recruiter_geek( + encrypt_geek_id: str, security_id: str, job_id: int, + as_json: bool, as_yaml: bool, +) -> None: + """查看候选人详细信息 (需要 encryptGeekId)""" cred = require_auth() - def _render(data: dict) -> None: - labels = data.get("labels", data.get("labelList", data.get("result", []))) - if isinstance(data, list): - labels = data - - if not labels: - console.print("[yellow]暂无标签[/yellow]") - return - - table = Table(title="🏷️ 标签列表", show_lines=False) - table.add_column("ID", style="dim", width=6) - table.add_column("名称", style="cyan", max_width=20) - - for label in labels: - table.add_row( - str(label.get("labelId", label.get("id", "-"))), - label.get("label", label.get("name", label.get("labelName", "-"))), - ) - - console.print(table) - - handle_command(cred, action=lambda c: c.get_boss_friend_labels(), render=_render, as_json=as_json, as_yaml=as_yaml) - - -# ── recruiter export ────────────────────────────────────────────── + def _action(c: BossClient) -> dict: + nonlocal job_id, security_id + if not job_id: + jobs = c.get_boss_chatted_jobs() + if jobs: + job_id = jobs[0].get("jobId", 0) -@click.command("recruiter-export") -@click.option("--job", "enc_job_id", default="", help="按职位 encryptJobId 筛选") -@click.option("-o", "--output", "output_file", default=None, help="输出文件路径") -@click.option("--format", "fmt", type=click.Choice(["csv", "json"]), default="csv", help="输出格式") -def recruiter_export(enc_job_id: str, output_file: str | None, fmt: str) -> None: - """导出候选人列表为 CSV 或 JSON""" - cred = require_auth() + if not security_id: + friend_data = c.get_boss_friend_list() + for f in friend_data.get("result", []): + if f.get("encryptFriendId") == encrypt_geek_id: + friend_details = c.get_boss_friend_details([f["friendId"]]) + for fd in friend_details.get("friendList", []): + security_id = fd.get("securityId", "") + break + break - try: - def _collect(c: BossClient) -> list[dict]: - friend_data = c.get_boss_friend_list(enc_job_id=enc_job_id) - friend_list = friend_data.get("result", []) + return c.get_boss_chat_geek_info( + encrypt_geek_id=encrypt_geek_id, + security_id=security_id, + job_id=job_id, + ) - if not friend_list: - return [] + def _render(data: dict) -> None: + geek = data.get("data", data) - friend_ids = [f["friendId"] for f in friend_list if f.get("friendId")] - details = c.get_boss_friend_details(friend_ids) - return details.get("friendList", []) + name = geek.get("name", "-") + age = geek.get("ageDesc", "-") + gender = "男" if geek.get("gender") == 1 else "女" if geek.get("gender") == 2 else "-" + edu = geek.get("edu", "-") + city = geek.get("city", "-") + salary = geek.get("salaryDesc", "-") + expect_salary = geek.get("price", "-") + position = geek.get("positionName", geek.get("toPosition", "-")) + status = geek.get("positionStatus", "-") + last_company = geek.get("lastCompany", "-") + last_position = geek.get("lastPosition", "-") + school = geek.get("school", "-") + major = geek.get("major", "-") + work_year = geek.get("year", "-") - all_candidates = run_client_action(cred, _collect) + work_exp = geek.get("workExpList", []) + work_lines = [] + for w in work_exp[:5]: + work_lines.append( + f" {w.get('timeDesc', '')} {w.get('company', '')} · {w.get('positionName', '')}" + ) - if not all_candidates: - console.print("[yellow]暂无候选人数据[/yellow]") - return + panel_text = ( + f"[bold cyan]{name}[/bold cyan] {gender} {age}\n" + f"学历: {edu} | 工作年限: {work_year}\n" + f"城市: {city} | 求职状态: {status}\n" + f"\n" + f"[bold yellow]期望薪资:[/bold yellow] {expect_salary}\n" + f"[bold yellow]当前薪资:[/bold yellow] {salary}\n" + f"期望职位: {position}\n" + f"\n" + f"[bold green]当前/最近:[/bold green] {last_company}\n" + f"职位: {last_position}\n" + f"学校: {school} | {major}\n" + ) - if fmt == "json": - output_text = json.dumps(all_candidates, indent=2, ensure_ascii=False) - else: - buf = io.StringIO() - fieldnames = ["姓名", "关联职位", "来源", "最近时间", "新牛人", "encryptUid", "securityId"] - writer = csv.DictWriter(buf, fieldnames=fieldnames, extrasaction="ignore") - writer.writeheader() - for f in all_candidates: - source_map = {1: "搜索", 2: "推荐", 3: "打招呼", 5: "主动沟通"} - writer.writerow({ - "姓名": f.get("name", ""), - "关联职位": f.get("jobName", ""), - "来源": source_map.get(f.get("sourceType"), str(f.get("sourceType", ""))), - "最近时间": f.get("lastTime", ""), - "新牛人": "是" if f.get("newGeek") else "", - "encryptUid": f.get("encryptUid", f.get("encryptFriendId", "")), - "securityId": f.get("securityId", ""), - }) - output_text = buf.getvalue() + if work_lines: + panel_text += "\n[bold magenta]工作经历:[/bold magenta]\n" + "\n".join(work_lines) - if output_file: - with open(output_file, "w", encoding="utf-8-sig" if fmt == "csv" else "utf-8") as fh: - fh.write(output_text) - console.print(f"\n[green]✅ 已导出 {len(all_candidates)} 个候选人到 {output_file}[/green]") - else: - click.echo(output_text) + panel = Panel(panel_text, title="候选人详情", border_style="cyan") + console.print(panel) - except BossApiError as exc: - console.print(f"[red]❌ 导出失败: {exc}[/red]") - raise SystemExit(1) from None + handle_command(cred, action=_action, render=_render, as_json=as_json, as_yaml=as_yaml) diff --git a/boss_cli/constants.py b/boss_cli/constants.py index b8b3bba..dbe24a5 100644 --- a/boss_cli/constants.py +++ b/boss_cli/constants.py @@ -57,6 +57,11 @@ BOSS_GREET_REC_SORT_URL = "/wapi/zprelation/friend/greetRecSortList" BOSS_INTERVIEW_LIST_URL = "/wapi/zpinterview/boss/interview/valid/list" BOSS_INTERVIEW_DETAIL_URL = "/wapi/zpinterview/boss/interview/detail" +BOSS_GREET_NEW_LIST_URL = "/wapi/zpchat/boss/newgreeting/getHistoryList" +BOSS_SEARCH_GEEK_URL = "/wapi/zpitem/web/boss/search/geek/info" +BOSS_VIEW_GEEK_URL = "/wapi/zpjob/view/geek/info" +BOSS_SEND_MSG_URL = "/wapi/zpchat/fastReply/sendReplyMsg" +BOSS_FRIEND_ADD_URL = "/wapi/zprelation/friend/bossAddFriend" # ── Request Headers (Chrome 145, macOS) ───────────────────────────── HEADERS = { From 00ba277eb0d758bbc0f68ca28e3bb6a9ebded2db Mon Sep 17 00:00:00 2001 From: Wilson Xu Date: Thu, 2 Apr 2026 15:31:24 +0800 Subject: [PATCH 04/13] docs: update README for boss recruiter subcommand group with 12 commands Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 114 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 60 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index c0664f6..9687407 100644 --- a/README.md +++ b/README.md @@ -121,51 +121,57 @@ boss -v search "Python" # Verbose logging (request timing) If you are an employer on BOSS直聘, these commands let you manage candidates from the terminal: ```bash -# ─── View Your Posted Jobs ────────────────────── -boss recruiter-jobs # List all posted jobs with encryptJobId -boss recruiter-jobs --json # JSON output - -# ─── Candidate Inbox ──────────────────────────── -boss recruiter-inbox # View all candidate conversations -boss recruiter-inbox --job # Filter by specific job -boss recruiter-inbox --label 1 # Filter by label (1=新招呼) - -# ─── Candidate Profile ────────────────────────── -boss recruiter-geek # View candidate details -boss recruiter-geek --job-id 526908510 # With specific job context -boss recruiter-geek --json # JSON output with full profile - -# ─── Chat History ─────────────────────────────── -boss recruiter-chat # View chat with candidate -boss recruiter-chat -n 50 # Last 50 messages - -# ─── Labels / Tags ────────────────────────────── -boss recruiter-labels # List all candidate labels -boss recruiter-labels --json # JSON output - -# ─── Export Candidates ────────────────────────── -boss recruiter-export -o candidates.csv # Export all candidates to CSV -boss recruiter-export --job -o out.csv # Export candidates for a specific job -boss recruiter-export --format json -o out.json # Export as JSON +# ─── Search & Discover ────────────────────────── +boss recruiter search "golang" --city 深圳 --exp 3-5年 # Search candidates +boss recruiter recommend # Recommended candidates +boss recruiter recommend --job # For a specific job + +# ─── Greet & Communicate ──────────────────────── +boss recruiter greet # Initiate chat with candidate +boss recruiter batch-greet "Python" --city 杭州 -n 10 # Batch greet top 10 matches +boss recruiter batch-greet "golang" --dry-run # Preview only +boss recruiter inbox # View candidate messages +boss recruiter inbox --job # Filter by job +boss recruiter reply "感谢您的关注..." # Reply to candidate +boss recruiter reply "..." --yes # Skip confirmation + +# ─── Candidate Profile & Resume ───────────────── +boss recruiter resume # View full resume +boss recruiter resume --job # With specific job context +boss recruiter geek --job-id 526908510 # Quick candidate info + +# ─── Manage & Export ───────────────────────────── +boss recruiter jobs # List your posted jobs +boss recruiter labels # View candidate tags +boss recruiter chat # View chat history +boss recruiter export -o candidates.csv # Export to CSV +boss recruiter export --format json -o out.json # Export to JSON ``` ### Recruiter Workflow Example ```bash # 1. Check your posted jobs -boss recruiter-jobs +boss recruiter jobs -# 2. See who messaged you for a specific job -boss recruiter-inbox --job f806096ea327cd610nZ80t21FVNQ +# 2. See recommended candidates +boss recruiter recommend -# 3. View a candidate's full profile -boss recruiter-geek 9baf80468c8bc8980HZ82N25FlU~ --job-id 526908510 +# 3. Search for specific skills +boss recruiter search "golang" --city 深圳 -# 4. Read chat history -boss recruiter-chat 72630467 +# 4. View a candidate's full resume +boss recruiter resume --job -# 5. Export all candidates for offline review -boss recruiter-export --format json -o candidates.json +# 5. Start a conversation +boss recruiter greet + +# 6. Reply to incoming messages +boss recruiter inbox +boss recruiter reply "感谢您的关注,方便电话聊聊吗?" + +# 7. Export all candidates for offline review +boss recruiter export --format json -o candidates.json ``` ## Structured Output @@ -352,25 +358,25 @@ boss -v search "Python" # 详细日志 ## 招聘方模式 ```bash -# 查看招聘职位 -boss recruiter-jobs - -# 查看候选人列表 -boss recruiter-inbox # 全部候选人 -boss recruiter-inbox --job # 按职位筛选 - -# 查看候选人详情 -boss recruiter-geek --job-id - -# 查看聊天记录 -boss recruiter-chat - -# 标签管理 -boss recruiter-labels - -# 导出候选人 -boss recruiter-export -o candidates.csv # CSV 导出 -boss recruiter-export --format json -o out.json # JSON 导出 +# 搜索候选人 +boss recruiter search "golang" --city 深圳 --exp 3-5年 +boss recruiter recommend # 推荐候选人 + +# 沟通 +boss recruiter greet # 向候选人打招呼 +boss recruiter batch-greet "Python" -n 10 # 批量打招呼 +boss recruiter inbox # 查看候选人消息 +boss recruiter reply "您好..." # 回复候选人 + +# 简历 & 详情 +boss recruiter resume # 查看完整简历 +boss recruiter geek # 查看候选人详情 + +# 管理 & 导出 +boss recruiter jobs # 查看招聘职位 +boss recruiter labels # 查看标签 +boss recruiter chat # 查看聊天记录 +boss recruiter export -o candidates.csv # 导出候选人 ``` ## 常见问题 From b30cc98ecdb1a6375a3f316da9d42deb23a26506 Mon Sep 17 00:00:00 2001 From: Wilson Xu Date: Thu, 2 Apr 2026 15:44:22 +0800 Subject: [PATCH 05/13] feat: add pagination, resume-download, job-close/reopen to recruiter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add -p/--page to recommend and inbox commands for scrolling - Add --job filter to recommend for switching between 岗位 - Add resume-download command: exports resume as Markdown file - Add job-close/job-reopen commands for job lifecycle management - Add pagination hints and job switching hints to command output - get_boss_friend_list now accepts page parameter 15 total commands under `boss recruiter` subgroup. Co-Authored-By: Claude Opus 4.6 (1M context) --- boss_cli/client.py | 14 +- boss_cli/commands/recruiter.py | 251 ++++++++++++++++++++++++++++++++- boss_cli/constants.py | 2 + 3 files changed, 261 insertions(+), 6 deletions(-) diff --git a/boss_cli/client.py b/boss_cli/client.py index 519bb9a..d0b27b8 100644 --- a/boss_cli/client.py +++ b/boss_cli/client.py @@ -23,6 +23,8 @@ BOSS_GREET_SORT_LIST_URL, BOSS_HISTORY_MSG_URL, BOSS_INTERVIEW_LIST_URL, + BOSS_JOB_OFFLINE_URL, + BOSS_JOB_ONLINE_URL, BOSS_LAST_MSG_URL, BOSS_SEARCH_GEEK_URL, BOSS_SEND_MSG_URL, @@ -444,9 +446,9 @@ def get_boss_chatted_jobs(self) -> list[dict[str, Any]]: """Get list of jobs the boss has posted (chatted job list).""" return self._get(BOSS_CHATTED_JOB_LIST_URL, action="招聘职位列表") - def get_boss_friend_list(self, label_id: int = 0, enc_job_id: str = "", sort: str = "") -> dict[str, Any]: + def get_boss_friend_list(self, label_id: int = 0, enc_job_id: str = "", sort: str = "", page: int = 1) -> dict[str, Any]: """Get boss friend list (candidates who have chatted).""" - data: dict[str, Any] = {"labelId": label_id} + data: dict[str, Any] = {"labelId": label_id, "page": page} if enc_job_id: data["encJobId"] = enc_job_id if sort: @@ -548,6 +550,14 @@ def boss_send_message(self, gid: int, content: str) -> dict[str, Any]: action="发送消息", ) + def boss_job_offline(self, encrypt_job_id: str) -> dict[str, Any]: + """Take a job posting offline (close).""" + return self._post(BOSS_JOB_OFFLINE_URL, data={"encryptJobId": encrypt_job_id}, action="关闭职位") + + def boss_job_online(self, encrypt_job_id: str) -> dict[str, Any]: + """Bring a job posting online (reopen).""" + return self._post(BOSS_JOB_ONLINE_URL, data={"encryptJobId": encrypt_job_id}, action="开启职位") + # ── City resolution ───────────────────────────────────────────────── diff --git a/boss_cli/commands/recruiter.py b/boss_cli/commands/recruiter.py index d023f43..05b347e 100644 --- a/boss_cli/commands/recruiter.py +++ b/boss_cli/commands/recruiter.py @@ -155,7 +155,7 @@ def _render(data: dict) -> None: return table = Table( - title=f"推荐候选人 ({len(friend_list)} 人, 上限 {limit})", + title=f"推荐候选人 ({len(friend_list)} 人, 上限 {limit}) — 第 {page} 页", show_lines=True, ) table.add_column("#", style="dim", width=3) @@ -178,6 +178,11 @@ def _render(data: dict) -> None: console.print(table) + if friend_list: + console.print(f" [dim]下一页: boss recruiter recommend -p {page + 1}[/dim]") + console.print(" [dim]切换职位: boss recruiter recommend --job [/dim]") + console.print(" [dim]查看职位列表: boss recruiter jobs[/dim]") + handle_command(cred, action=_action, render=_render, as_json=as_json, as_yaml=as_yaml) @@ -330,13 +335,14 @@ def recruiter_batch_greet( @recruiter.command("inbox") @click.option("--job", "enc_job_id", default="", help="按职位 encryptJobId 筛选") @click.option("--label", "label_id", default=0, type=int, help="按标签筛选 (0=全部)") +@click.option("-p", "--page", default=1, type=int, help="页码 (默认: 1)") @structured_output_options -def recruiter_inbox(enc_job_id: str, label_id: int, as_json: bool, as_yaml: bool) -> None: +def recruiter_inbox(enc_job_id: str, label_id: int, page: int, as_json: bool, as_yaml: bool) -> None: """查看候选人消息列表 (招聘方沟通列表)""" cred = require_auth() def _action(c: BossClient) -> dict: - friend_data = c.get_boss_friend_list(label_id=label_id, enc_job_id=enc_job_id) + friend_data = c.get_boss_friend_list(label_id=label_id, enc_job_id=enc_job_id, page=page) friend_list = friend_data.get("result", []) if not friend_list: @@ -367,7 +373,7 @@ def _render(data: dict) -> None: if uid: msg_map[uid] = msg - table = Table(title=f"候选人列表 ({len(detail_list)} 人)", show_lines=True) + table = Table(title=f"候选人列表 ({len(detail_list)} 人) — 第 {page} 页", show_lines=True) table.add_column("#", style="dim", width=3) table.add_column("候选人", style="bold cyan", max_width=12) table.add_column("职位", style="green", max_width=20) @@ -393,6 +399,8 @@ def _render(data: dict) -> None: console.print(table) console.print(" [dim]使用 boss recruiter resume 查看候选人简历[/dim]") + if detail_list: + console.print(f" [dim]下一页: boss recruiter inbox -p {page + 1}[/dim]") handle_command(cred, action=_action, render=_render, as_json=as_json, as_yaml=as_yaml) @@ -690,6 +698,7 @@ def _render(data: dict) -> None: table.add_row(str(i), direction, text, msg_type) console.print(table) + console.print(f" [dim]加载更多: boss recruiter chat {friend_id} -n {count + 20}[/dim]") handle_command(cred, action=_action, render=_render, as_json=as_json, as_yaml=as_yaml) @@ -778,3 +787,237 @@ def _render(data: dict) -> None: console.print(panel) handle_command(cred, action=_action, render=_render, as_json=as_json, as_yaml=as_yaml) + + +# ── recruiter resume-download ───────────────────────────────────── + + +@recruiter.command("resume-download") +@click.argument("encrypt_geek_id") +@click.option("--job", "encrypt_job_id", default="", help="关联职位 encryptJobId") +@click.option("--security-id", default="", help="候选人 securityId") +@click.option("-o", "--output", "output_file", default=None, help="输出文件路径 (默认: <姓名>_resume.md)") +def recruiter_resume_download( + encrypt_geek_id: str, encrypt_job_id: str, security_id: str, output_file: str | None, +) -> None: + """导出候选人简历为 Markdown 文件""" + cred = require_auth() + + try: + def _fetch(c: BossClient) -> dict: + nonlocal encrypt_job_id, security_id + if not encrypt_job_id: + jobs = c.get_boss_chatted_jobs() + if jobs: + encrypt_job_id = jobs[0].get("encryptJobId", "") + + if not encrypt_job_id: + return {"error": "未找到关联职位, 请通过 --job 指定 encryptJobId"} + + # Auto-fetch securityId from friend list if not provided + if not security_id: + friend_data = c.get_boss_friend_list() + for f in friend_data.get("result", []): + if f.get("encryptFriendId") == encrypt_geek_id: + friend_ids = [f["friendId"]] + details = c.get_boss_friend_details(friend_ids) + for fd in details.get("friendList", []): + security_id = fd.get("securityId", "") + break + break + + return c.get_boss_view_geek( + encrypt_geek_id=encrypt_geek_id, + encrypt_job_id=encrypt_job_id, + security_id=security_id, + ) + + data = run_client_action(cred, _fetch) + + if data.get("error"): + console.print(f"[red]{data['error']}[/red]") + return + + # Build markdown + geek_detail = data.get("geekDetailInfo", data) + base_info = geek_detail.get("geekBaseInfo", geek_detail) + + name = base_info.get("name", base_info.get("geekName", "candidate")) + gender_val = base_info.get("gender", 0) + gender = "男" if gender_val == 1 else "女" if gender_val == 2 else "" + degree = base_info.get("degreeCategory", base_info.get("degree", "")) + work_year = base_info.get("workYearDesc", base_info.get("workYear", "")) + age = base_info.get("ageDesc", base_info.get("age", "")) + apply_status = base_info.get("applyStatusContent", base_info.get("applyStatus", "")) + expect_position = base_info.get("expectPosition", "") + expect_city = base_info.get("expectCity", "") + expect_salary = base_info.get("expectSalary", base_info.get("salaryDesc", "")) + + lines: list[str] = [] + lines.append(f"# {name}") + lines.append("") + + info_parts = [p for p in [gender, age, degree, work_year] if p] + if info_parts: + lines.append(" | ".join(info_parts)) + lines.append("") + + if apply_status: + lines.append(f"**求职状态:** {apply_status}") + lines.append("") + + expect_parts = [p for p in [expect_position, expect_city, expect_salary] if p] + if expect_parts: + lines.append("## 求职期望") + lines.append("") + lines.append(" | ".join(expect_parts)) + lines.append("") + + # Work experience + work_exp = geek_detail.get("geekWorkExpList", base_info.get("workExpList", [])) + if work_exp: + lines.append("## 工作经历") + lines.append("") + for w in work_exp: + company = w.get("company", w.get("companyName", "")) + position = w.get("positionName", w.get("position", "")) + time_desc = w.get("timeDesc", w.get("workTime", "")) + industry = w.get("industry", "") + desc = w.get("description", w.get("workDesc", "")) + header = f"### {company}" + if industry: + header += f" ({industry})" + lines.append(header) + lines.append("") + if time_desc: + lines.append(f"**{time_desc}** - {position}") + elif position: + lines.append(f"**{position}**") + lines.append("") + if desc: + lines.append(desc) + lines.append("") + + # Education + edu_exp = geek_detail.get("geekEduExpList", base_info.get("eduExpList", [])) + if edu_exp: + lines.append("## 教育经历") + lines.append("") + for e in edu_exp: + school = e.get("school", e.get("schoolName", "")) + major_name = e.get("major", e.get("majorName", "")) + degree_name = e.get("degree", e.get("degreeName", "")) + time_desc = e.get("timeDesc", e.get("eduTime", "")) + header = f"### {school}" + if degree_name: + header += f" - {degree_name}" + lines.append(header) + lines.append("") + parts = [p for p in [time_desc, major_name] if p] + if parts: + lines.append(" | ".join(parts)) + lines.append("") + + # Projects + project_exp = geek_detail.get("geekProjectExpList", base_info.get("projectExpList", [])) + if project_exp: + lines.append("## 项目经历") + lines.append("") + for p in project_exp: + proj_name = p.get("projectName", p.get("name", "")) + role = p.get("roleName", p.get("role", "")) + time_desc = p.get("timeDesc", p.get("projectTime", "")) + desc = p.get("description", p.get("projectDesc", "")) + header = f"### {proj_name}" + if role: + header += f" ({role})" + lines.append(header) + lines.append("") + if time_desc: + lines.append(f"**{time_desc}**") + lines.append("") + if desc: + lines.append(desc) + lines.append("") + + md_content = "\n".join(lines).rstrip() + "\n" + + # Write to file or stdout + if output_file is None: + safe_name = name.replace("/", "_").replace(" ", "_") + output_file = f"{safe_name}_resume.md" + + if output_file == "-": + click.echo(md_content) + else: + with open(output_file, "w", encoding="utf-8") as fh: + fh.write(md_content) + console.print(f"[green]简历已导出到 {output_file}[/green]") + + except BossApiError as exc: + console.print(f"[red]导出失败: {exc}[/red]") + raise SystemExit(1) from None + + +# ── recruiter job-close ───────────────────────────────────────────── + + +@recruiter.command("job-close") +@click.argument("encrypt_job_id") +@click.option("-y", "--yes", is_flag=True, help="跳过确认提示") +def recruiter_job_close(encrypt_job_id: str, yes: bool) -> None: + """关闭/下线职位 (Take job offline)""" + cred = require_auth() + + if not yes: + confirm = click.confirm(f"确定关闭职位 {encrypt_job_id}?") + if not confirm: + console.print("[dim]已取消[/dim]") + return + + try: + result = run_client_action(cred, lambda c: c.boss_job_offline(encrypt_job_id)) + console.print(f"[green]职位已关闭: {encrypt_job_id}[/green]") + if result: + console.print(f" [dim]{json.dumps(result, ensure_ascii=False)[:200]}[/dim]") + except BossApiError as exc: + msg = str(exc) + console.print(f"[red]关闭职位失败: {msg}[/red]") + if "缺少必要参数" in msg or "stoken" in msg.lower(): + console.print( + " [yellow]提示: 该操作可能需要浏览器端 __zp_stoken__ 验证。\n" + " 请尝试在浏览器中操作, 或重新登录后重试: boss logout && boss login[/yellow]" + ) + raise SystemExit(1) from None + + +# ── recruiter job-reopen ──────────────────────────────────────────── + + +@recruiter.command("job-reopen") +@click.argument("encrypt_job_id") +@click.option("-y", "--yes", is_flag=True, help="跳过确认提示") +def recruiter_job_reopen(encrypt_job_id: str, yes: bool) -> None: + """重新开启/上线职位 (Bring job online)""" + cred = require_auth() + + if not yes: + confirm = click.confirm(f"确定重新开启职位 {encrypt_job_id}?") + if not confirm: + console.print("[dim]已取消[/dim]") + return + + try: + result = run_client_action(cred, lambda c: c.boss_job_online(encrypt_job_id)) + console.print(f"[green]职位已开启: {encrypt_job_id}[/green]") + if result: + console.print(f" [dim]{json.dumps(result, ensure_ascii=False)[:200]}[/dim]") + except BossApiError as exc: + msg = str(exc) + console.print(f"[red]开启职位失败: {msg}[/red]") + if "缺少必要参数" in msg or "stoken" in msg.lower(): + console.print( + " [yellow]提示: 该操作可能需要浏览器端 __zp_stoken__ 验证。\n" + " 请尝试在浏览器中操作, 或重新登录后重试: boss logout && boss login[/yellow]" + ) + raise SystemExit(1) from None diff --git a/boss_cli/constants.py b/boss_cli/constants.py index dbe24a5..334c69b 100644 --- a/boss_cli/constants.py +++ b/boss_cli/constants.py @@ -62,6 +62,8 @@ BOSS_VIEW_GEEK_URL = "/wapi/zpjob/view/geek/info" BOSS_SEND_MSG_URL = "/wapi/zpchat/fastReply/sendReplyMsg" BOSS_FRIEND_ADD_URL = "/wapi/zprelation/friend/bossAddFriend" +BOSS_JOB_OFFLINE_URL = "/wapi/zpjob/job/offline" +BOSS_JOB_ONLINE_URL = "/wapi/zpjob/job/online" # ── Request Headers (Chrome 145, macOS) ───────────────────────────── HEADERS = { From 0988e6a45082bb2a4cc3473afc2a158ccb798aa6 Mon Sep 17 00:00:00 2001 From: Wilson Xu Date: Thu, 2 Apr 2026 15:45:15 +0800 Subject: [PATCH 06/13] docs: update README for 15 recruiter commands with pagination and resume download Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 76 ++++++++++++++++++++++++++++++++----------------------- 1 file changed, 44 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 9687407..e159fb7 100644 --- a/README.md +++ b/README.md @@ -121,29 +121,34 @@ boss -v search "Python" # Verbose logging (request timing) If you are an employer on BOSS直聘, these commands let you manage candidates from the terminal: ```bash -# ─── Search & Discover ────────────────────────── +# ─── Search & Discover (搜索 & 发现) ───────────── boss recruiter search "golang" --city 深圳 --exp 3-5年 # Search candidates boss recruiter recommend # Recommended candidates -boss recruiter recommend --job # For a specific job +boss recruiter recommend --job # Switch to different 岗位 +boss recruiter recommend -p 2 # Next page -# ─── Greet & Communicate ──────────────────────── +# ─── Greet & Communicate (沟通) ────────────────── boss recruiter greet # Initiate chat with candidate boss recruiter batch-greet "Python" --city 杭州 -n 10 # Batch greet top 10 matches boss recruiter batch-greet "golang" --dry-run # Preview only boss recruiter inbox # View candidate messages -boss recruiter inbox --job # Filter by job +boss recruiter inbox --job -p 2 # Filter by job, page 2 boss recruiter reply "感谢您的关注..." # Reply to candidate -boss recruiter reply "..." --yes # Skip confirmation +boss recruiter chat # View chat history -# ─── Candidate Profile & Resume ───────────────── -boss recruiter resume # View full resume -boss recruiter resume --job # With specific job context +# ─── Resume (简历) ─────────────────────────────── +boss recruiter resume # View full resume in terminal +boss recruiter resume-download --job # Download resume as Markdown +boss recruiter resume-download -o candidate.md # Custom output path boss recruiter geek --job-id 526908510 # Quick candidate info -# ─── Manage & Export ───────────────────────────── +# ─── Job Management (职位管理) ─────────────────── boss recruiter jobs # List your posted jobs +boss recruiter job-close --yes # Take job offline +boss recruiter job-reopen --yes # Bring job back online + +# ─── Export & Tags ─────────────────────────────── boss recruiter labels # View candidate tags -boss recruiter chat # View chat history boss recruiter export -o candidates.csv # Export to CSV boss recruiter export --format json -o out.json # Export to JSON ``` @@ -154,8 +159,8 @@ boss recruiter export --format json -o out.json # Export to JSON # 1. Check your posted jobs boss recruiter jobs -# 2. See recommended candidates -boss recruiter recommend +# 2. Browse recommended candidates for a specific job +boss recruiter recommend --job f806096ea327cd610nZ80t21FVNQ # 3. Search for specific skills boss recruiter search "golang" --city 深圳 @@ -163,14 +168,17 @@ boss recruiter search "golang" --city 深圳 # 4. View a candidate's full resume boss recruiter resume --job -# 5. Start a conversation +# 5. Download resume for offline review +boss recruiter resume-download --job + +# 6. Start a conversation boss recruiter greet -# 6. Reply to incoming messages -boss recruiter inbox +# 7. Check inbox and reply +boss recruiter inbox -p 1 boss recruiter reply "感谢您的关注,方便电话聊聊吗?" -# 7. Export all candidates for offline review +# 8. Export all candidates boss recruiter export --format json -o candidates.json ``` @@ -358,25 +366,29 @@ boss -v search "Python" # 详细日志 ## 招聘方模式 ```bash -# 搜索候选人 +# 搜索 & 推荐 boss recruiter search "golang" --city 深圳 --exp 3-5年 -boss recruiter recommend # 推荐候选人 +boss recruiter recommend --job # 按岗位查看推荐牛人 +boss recruiter recommend -p 2 # 翻页 # 沟通 -boss recruiter greet # 向候选人打招呼 -boss recruiter batch-greet "Python" -n 10 # 批量打招呼 -boss recruiter inbox # 查看候选人消息 -boss recruiter reply "您好..." # 回复候选人 - -# 简历 & 详情 -boss recruiter resume # 查看完整简历 -boss recruiter geek # 查看候选人详情 - -# 管理 & 导出 -boss recruiter jobs # 查看招聘职位 -boss recruiter labels # 查看标签 -boss recruiter chat # 查看聊天记录 -boss recruiter export -o candidates.csv # 导出候选人 +boss recruiter greet # 向候选人打招呼 +boss recruiter batch-greet "Python" -n 10 # 批量打招呼 +boss recruiter inbox -p 1 # 查看候选人消息 +boss recruiter reply "您好..." # 回复候选人 + +# 简历 +boss recruiter resume # 终端查看简历 +boss recruiter resume-download --job # 下载简历为 Markdown + +# 职位管理 +boss recruiter jobs # 查看招聘职位 +boss recruiter job-close # 关闭职位 +boss recruiter job-reopen # 重新开启 + +# 导出 +boss recruiter labels # 查看标签 +boss recruiter export -o candidates.csv # 导出候选人 ``` ## 常见问题 From e6ca0f4d51d2452f6c5272ee9f5cfcfae00f9cfa Mon Sep 17 00:00:00 2001 From: Wilson Xu Date: Thu, 2 Apr 2026 16:00:07 +0800 Subject: [PATCH 07/13] =?UTF-8?q?feat:=20add=20chat=20actions=20=E2=80=94?= =?UTF-8?q?=20request-resume,=20exchange-phone/wechat,=20invite-interview,?= =?UTF-8?q?=20mark-unsuitable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 5 new commands matching BOSS直聘 recruiter chat page actions: - `boss recruiter request-resume` — 求简历 - `boss recruiter exchange-phone` — 换电话 - `boss recruiter exchange-wechat` — 换微信 - `boss recruiter invite-interview` — 约面试 - `boss recruiter mark-unsuitable` — 不合适 All require --yes or confirmation prompt. Include __zp_stoken__ hint when anti-bot protection blocks the action. 20 total commands under `boss recruiter`. Co-Authored-By: Claude Opus 4.6 (1M context) --- boss_cli/client.py | 65 ++++++++- boss_cli/commands/recruiter.py | 234 +++++++++++++++++++++++++++++++++ boss_cli/constants.py | 7 + 3 files changed, 305 insertions(+), 1 deletion(-) diff --git a/boss_cli/client.py b/boss_cli/client.py index d0b27b8..5c689d2 100644 --- a/boss_cli/client.py +++ b/boss_cli/client.py @@ -15,6 +15,8 @@ BASE_URL, BOSS_CHAT_GEEK_INFO_URL, BOSS_CHATTED_JOB_LIST_URL, + BOSS_EXCHANGE_CONTENT_URL, + BOSS_EXCHANGE_REQUEST_URL, BOSS_FRIEND_DETAIL_URL, BOSS_FRIEND_LABELS_URL, BOSS_FRIEND_LIST_URL, @@ -22,12 +24,15 @@ BOSS_GREET_REC_SORT_URL, BOSS_GREET_SORT_LIST_URL, BOSS_HISTORY_MSG_URL, + BOSS_INTERVIEW_INVITE_URL, BOSS_INTERVIEW_LIST_URL, BOSS_JOB_OFFLINE_URL, BOSS_JOB_ONLINE_URL, BOSS_LAST_MSG_URL, + BOSS_REMOVE_FILTER_URL, BOSS_SEARCH_GEEK_URL, BOSS_SEND_MSG_URL, + BOSS_SESSION_ENTER_URL, BOSS_VIEW_GEEK_URL, CITY_CODES, DELIVER_LIST_URL, @@ -196,7 +201,10 @@ def _headers_for_request(self, url: str, params: dict[str, Any] | None = None) - elif url in (BOSS_FRIEND_LIST_URL, BOSS_FRIEND_DETAIL_URL, BOSS_LAST_MSG_URL, BOSS_HISTORY_MSG_URL, BOSS_CHAT_GEEK_INFO_URL, BOSS_FRIEND_LABELS_URL, BOSS_FRIEND_NOTE_URL, BOSS_GREET_SORT_LIST_URL, BOSS_GREET_REC_SORT_URL, - BOSS_CHATTED_JOB_LIST_URL, BOSS_INTERVIEW_LIST_URL): + BOSS_CHATTED_JOB_LIST_URL, BOSS_INTERVIEW_LIST_URL, + BOSS_EXCHANGE_REQUEST_URL, BOSS_EXCHANGE_CONTENT_URL, + BOSS_INTERVIEW_INVITE_URL, BOSS_REMOVE_FILTER_URL, + BOSS_SESSION_ENTER_URL): headers["Referer"] = WEB_BOSS_CHAT_URL return headers @@ -558,6 +566,61 @@ def boss_job_online(self, encrypt_job_id: str) -> dict[str, Any]: """Bring a job posting online (reopen).""" return self._post(BOSS_JOB_ONLINE_URL, data={"encryptJobId": encrypt_job_id}, action="开启职位") + # ── Recruiter Chat Actions ──────────────────────────────────────── + + def boss_exchange_request(self, uid: int, job_id: int, exchange_type: int) -> dict[str, Any]: + """Request exchange with candidate. + + exchange_type: 1=phone, 2=wechat, 3=resume + """ + return self._post( + BOSS_EXCHANGE_REQUEST_URL, + data={"type": exchange_type, "uid": uid, "jobId": job_id}, + action="交换请求", + ) + + def boss_get_exchange_content(self, uid: int) -> dict[str, Any]: + """Get exchanged contact info (phone/wechat) for a candidate.""" + return self._post( + BOSS_EXCHANGE_CONTENT_URL, + data={"uid": uid}, + action="查看交换内容", + ) + + def boss_interview_invite( + self, encrypt_geek_id: str, encrypt_job_id: str, security_id: str, + address: str = "", start_time: str = "", description: str = "", + ) -> dict[str, Any]: + """Invite candidate for an interview.""" + data: dict[str, Any] = { + "encryptGeekId": encrypt_geek_id, + "encryptJobId": encrypt_job_id, + "securityId": security_id, + } + if address: + data["address"] = address + if start_time: + data["startTime"] = start_time + if description: + data["description"] = description + return self._post(BOSS_INTERVIEW_INVITE_URL, data=data, action="约面试") + + def boss_mark_unsuitable(self, encrypt_geek_id: str, encrypt_job_id: str) -> dict[str, Any]: + """Mark candidate as unsuitable.""" + return self._post( + BOSS_REMOVE_FILTER_URL, + data={"encryptGeekId": encrypt_geek_id, "encryptJobId": encrypt_job_id}, + action="标记不合适", + ) + + def boss_session_enter(self, geek_id: str, expect_id: str, job_id: str, security_id: str) -> dict[str, Any]: + """Enter a chat session with a candidate (required before sending messages).""" + return self._post( + BOSS_SESSION_ENTER_URL, + data={"geekId": geek_id, "expectId": expect_id, "jobId": job_id, "securityId": security_id}, + action="进入会话", + ) + # ── City resolution ───────────────────────────────────────────────── diff --git a/boss_cli/commands/recruiter.py b/boss_cli/commands/recruiter.py index 05b347e..bcf0530 100644 --- a/boss_cli/commands/recruiter.py +++ b/boss_cli/commands/recruiter.py @@ -1021,3 +1021,237 @@ def recruiter_job_reopen(encrypt_job_id: str, yes: bool) -> None: " 请尝试在浏览器中操作, 或重新登录后重试: boss logout && boss login[/yellow]" ) raise SystemExit(1) from None + + +# ── Recruiter Chat Actions ────────────────────────────────────────── + +_STOKEN_HINT = ( + "[yellow]\u26a0\ufe0f 此操作需要 __zp_stoken__ (由浏览器 JS 生成)。" + "请先在浏览器登录后执行 boss login 补全 Cookie。[/yellow]" +) + + +def _handle_chat_action_error(exc: BossApiError, action_label: str) -> None: + """Print error with stoken hint when appropriate.""" + msg = str(exc) + console.print(f"[red]{action_label}失败: {msg}[/red]") + if "缺少必要参数" in msg or "stoken" in msg.lower() or "<" in msg[:5]: + console.print(f" {_STOKEN_HINT}") + + +def _resolve_friend_uid_and_job(cred, friend_id: int) -> tuple[int, int]: + """Look up uid and jobId for a friendId from the inbox.""" + data = run_client_action( + cred, + lambda c: c.get_boss_friend_details([friend_id]), + ) + friend_list = data.get("friendList", []) + if not friend_list: + console.print(f"[red]未找到 friendId={friend_id} 的候选人信息[/red]") + raise SystemExit(1) + friend = friend_list[0] + uid = friend.get("uid", 0) + job_id = friend.get("jobId", 0) + if not uid: + console.print(f"[red]无法获取候选人 uid (friendId={friend_id})[/red]") + raise SystemExit(1) + return uid, job_id + + +@recruiter.command("request-resume") +@click.argument("friend_id", type=int) +@click.option("-y", "--yes", is_flag=True, help="跳过确认提示") +@structured_output_options +def recruiter_request_resume(friend_id: int, yes: bool, as_json: bool, as_yaml: bool) -> None: + """向候选人请求简历 (Request resume from candidate)""" + cred = require_auth() + + if not yes: + console.print(f"[cyan]将向 friendId={friend_id} 请求简历[/cyan]") + confirm = click.confirm("确认请求?") + if not confirm: + console.print("[dim]已取消[/dim]") + return + + uid, job_id = _resolve_friend_uid_and_job(cred, friend_id) + + def _action(c: BossClient) -> dict: + return c.boss_exchange_request(uid=uid, job_id=job_id, exchange_type=3) + + def _render(data: dict) -> None: + console.print(f"[green]已向候选人请求简历 (friendId={friend_id}, uid={uid})[/green]") + + try: + handle_command(cred, action=_action, render=_render, as_json=as_json, as_yaml=as_yaml) + except SystemExit: + raise + except BossApiError as exc: + _handle_chat_action_error(exc, "请求简历") + raise SystemExit(1) from None + + +@recruiter.command("exchange-phone") +@click.argument("friend_id", type=int) +@click.option("-y", "--yes", is_flag=True, help="跳过确认提示") +@structured_output_options +def recruiter_exchange_phone(friend_id: int, yes: bool, as_json: bool, as_yaml: bool) -> None: + """交换候选人手机号 (Exchange phone number with candidate)""" + cred = require_auth() + + if not yes: + console.print(f"[cyan]将与 friendId={friend_id} 交换手机号[/cyan]") + confirm = click.confirm("确认交换?") + if not confirm: + console.print("[dim]已取消[/dim]") + return + + uid, job_id = _resolve_friend_uid_and_job(cred, friend_id) + + def _action(c: BossClient) -> dict: + return c.boss_exchange_request(uid=uid, job_id=job_id, exchange_type=1) + + def _render(data: dict) -> None: + console.print(f"[green]已向候选人请求交换手机号 (friendId={friend_id}, uid={uid})[/green]") + + try: + handle_command(cred, action=_action, render=_render, as_json=as_json, as_yaml=as_yaml) + except SystemExit: + raise + except BossApiError as exc: + _handle_chat_action_error(exc, "交换手机号") + raise SystemExit(1) from None + + +@recruiter.command("exchange-wechat") +@click.argument("friend_id", type=int) +@click.option("-y", "--yes", is_flag=True, help="跳过确认提示") +@structured_output_options +def recruiter_exchange_wechat(friend_id: int, yes: bool, as_json: bool, as_yaml: bool) -> None: + """交换候选人微信 (Exchange WeChat with candidate)""" + cred = require_auth() + + if not yes: + console.print(f"[cyan]将与 friendId={friend_id} 交换微信[/cyan]") + confirm = click.confirm("确认交换?") + if not confirm: + console.print("[dim]已取消[/dim]") + return + + uid, job_id = _resolve_friend_uid_and_job(cred, friend_id) + + def _action(c: BossClient) -> dict: + return c.boss_exchange_request(uid=uid, job_id=job_id, exchange_type=2) + + def _render(data: dict) -> None: + console.print(f"[green]已向候选人请求交换微信 (friendId={friend_id}, uid={uid})[/green]") + + try: + handle_command(cred, action=_action, render=_render, as_json=as_json, as_yaml=as_yaml) + except SystemExit: + raise + except BossApiError as exc: + _handle_chat_action_error(exc, "交换微信") + raise SystemExit(1) from None + + +@recruiter.command("invite-interview") +@click.argument("encrypt_geek_id") +@click.option("--job", "encrypt_job_id", required=True, help="关联职位 encryptJobId") +@click.option("--address", default="", help="面试地点") +@click.option("--time", "start_time", default="", help="面试开始时间") +@click.option("--desc", "description", default="", help="面试说明") +@click.option("-y", "--yes", is_flag=True, help="跳过确认提示") +@structured_output_options +def recruiter_invite_interview( + encrypt_geek_id: str, encrypt_job_id: str, address: str, + start_time: str, description: str, yes: bool, + as_json: bool, as_yaml: bool, +) -> None: + """邀请候选人面试 (Invite candidate for interview)""" + cred = require_auth() + + if not yes: + console.print(f"[cyan]将邀请候选人面试: {encrypt_geek_id}[/cyan]") + if address: + console.print(f" 地点: {address}") + if start_time: + console.print(f" 时间: {start_time}") + confirm = click.confirm("确认邀请?") + if not confirm: + console.print("[dim]已取消[/dim]") + return + + # securityId is derived from the friend list; try to look it up + security_id = "" + try: + friend_data = run_client_action(cred, lambda c: c.get_boss_friend_list()) + for f in friend_data.get("result", []): + if f.get("encryptFriendId") == encrypt_geek_id: + detail = run_client_action( + cred, + lambda c, fid=f["friendId"]: c.get_boss_friend_details([fid]), + ) + for fd in detail.get("friendList", []): + security_id = fd.get("securityId", "") + break + break + except BossApiError: + pass # proceed without securityId; API may still accept it + + def _action(c: BossClient) -> dict: + return c.boss_interview_invite( + encrypt_geek_id=encrypt_geek_id, + encrypt_job_id=encrypt_job_id, + security_id=security_id, + address=address, + start_time=start_time, + description=description, + ) + + def _render(data: dict) -> None: + console.print(f"[green]已发送面试邀请 -> {encrypt_geek_id}[/green]") + + try: + handle_command(cred, action=_action, render=_render, as_json=as_json, as_yaml=as_yaml) + except SystemExit: + raise + except BossApiError as exc: + _handle_chat_action_error(exc, "约面试") + raise SystemExit(1) from None + + +@recruiter.command("mark-unsuitable") +@click.argument("encrypt_geek_id") +@click.option("--job", "encrypt_job_id", required=True, help="关联职位 encryptJobId") +@click.option("-y", "--yes", is_flag=True, help="跳过确认提示") +@structured_output_options +def recruiter_mark_unsuitable( + encrypt_geek_id: str, encrypt_job_id: str, yes: bool, + as_json: bool, as_yaml: bool, +) -> None: + """标记候选人不合适 (Mark candidate as unsuitable)""" + cred = require_auth() + + if not yes: + console.print(f"[cyan]将标记候选人为不合适: {encrypt_geek_id}[/cyan]") + confirm = click.confirm("确认标记?") + if not confirm: + console.print("[dim]已取消[/dim]") + return + + def _action(c: BossClient) -> dict: + return c.boss_mark_unsuitable( + encrypt_geek_id=encrypt_geek_id, + encrypt_job_id=encrypt_job_id, + ) + + def _render(data: dict) -> None: + console.print(f"[green]已标记候选人为不合适 -> {encrypt_geek_id}[/green]") + + try: + handle_command(cred, action=_action, render=_render, as_json=as_json, as_yaml=as_yaml) + except SystemExit: + raise + except BossApiError as exc: + _handle_chat_action_error(exc, "标记不合适") + raise SystemExit(1) from None diff --git a/boss_cli/constants.py b/boss_cli/constants.py index 334c69b..7fcb710 100644 --- a/boss_cli/constants.py +++ b/boss_cli/constants.py @@ -65,6 +65,13 @@ BOSS_JOB_OFFLINE_URL = "/wapi/zpjob/job/offline" BOSS_JOB_ONLINE_URL = "/wapi/zpjob/job/online" +# ── Recruiter Chat Actions ──────────────────────────────────────── +BOSS_EXCHANGE_REQUEST_URL = "/wapi/zpchat/exchange/request" +BOSS_EXCHANGE_CONTENT_URL = "/wapi/zprelation/friend/getExchangeContent" +BOSS_INTERVIEW_INVITE_URL = "/wapi/zpinterview/boss/interview/invite" +BOSS_REMOVE_FILTER_URL = "/wapi/zprelation/friend/bossRemoveFilter" +BOSS_SESSION_ENTER_URL = "/wapi/zpchat/session/bossEnter" + # ── Request Headers (Chrome 145, macOS) ───────────────────────────── HEADERS = { "User-Agent": ( From b7d348e5fed669d14c0f33f976fa5d7a998bbb55 Mon Sep 17 00:00:00 2001 From: Wilson Xu Date: Thu, 2 Apr 2026 16:00:56 +0800 Subject: [PATCH 08/13] docs: add chat action commands to README Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e159fb7..c3fb9d5 100644 --- a/README.md +++ b/README.md @@ -130,16 +130,21 @@ boss recruiter recommend -p 2 # Next page # ─── Greet & Communicate (沟通) ────────────────── boss recruiter greet # Initiate chat with candidate boss recruiter batch-greet "Python" --city 杭州 -n 10 # Batch greet top 10 matches -boss recruiter batch-greet "golang" --dry-run # Preview only boss recruiter inbox # View candidate messages boss recruiter inbox --job -p 2 # Filter by job, page 2 boss recruiter reply "感谢您的关注..." # Reply to candidate boss recruiter chat # View chat history +# ─── Chat Actions (沟通页操作) ─────────────────── +boss recruiter request-resume --yes # 求简历 +boss recruiter exchange-phone --yes # 换电话 +boss recruiter exchange-wechat --yes # 换微信 +boss recruiter invite-interview --job # 约面试 +boss recruiter mark-unsuitable --job # 不合适 + # ─── Resume (简历) ─────────────────────────────── boss recruiter resume # View full resume in terminal boss recruiter resume-download --job # Download resume as Markdown -boss recruiter resume-download -o candidate.md # Custom output path boss recruiter geek --job-id 526908510 # Quick candidate info # ─── Job Management (职位管理) ─────────────────── @@ -377,6 +382,13 @@ boss recruiter batch-greet "Python" -n 10 # 批量打招呼 boss recruiter inbox -p 1 # 查看候选人消息 boss recruiter reply "您好..." # 回复候选人 +# 沟通页操作 +boss recruiter request-resume # 求简历 +boss recruiter exchange-phone # 换电话 +boss recruiter exchange-wechat # 换微信 +boss recruiter invite-interview --job # 约面试 +boss recruiter mark-unsuitable --job # 不合适 + # 简历 boss recruiter resume # 终端查看简历 boss recruiter resume-download --job # 下载简历为 Markdown From cd51da2c1ed668c45f0c8d24bb492c5ca351505d Mon Sep 17 00:00:00 2001 From: Wilson Xu Date: Thu, 2 Apr 2026 16:20:33 +0800 Subject: [PATCH 09/13] fix: add zp_token security header and improve error handling for protected endpoints - Add X-Requested-With and zp_token (from bst cookie) to all requests - Handle code 121/122 (warlock anti-bot) with clear error message - Protected actions (exchange, interview, mark) now explain the limitation Co-Authored-By: Claude Opus 4.6 (1M context) --- boss_cli/client.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/boss_cli/client.py b/boss_cli/client.py index 5c689d2..e560027 100644 --- a/boss_cli/client.py +++ b/boss_cli/client.py @@ -176,8 +176,13 @@ def _merge_response_cookies(self, resp: httpx.Response) -> None: self.client.cookies.set(name, value) def _headers_for_request(self, url: str, params: dict[str, Any] | None = None) -> dict[str, str]: - """Build browser-like headers, including endpoint-specific Referer.""" + """Build browser-like headers, including endpoint-specific Referer and zp_token.""" headers = dict(HEADERS) + # Add security headers that the boss web app sends with every request + headers["X-Requested-With"] = "XMLHttpRequest" + bst = self.client.cookies.get("bst", "") + if bst: + headers["zp_token"] = bst if url == JOB_SEARCH_URL: query = "" if params and params.get("query"): @@ -221,6 +226,13 @@ def _handle_response(self, data: dict[str, Any], action: str) -> dict[str, Any]: raise SessionExpiredError() if code in (17, 19): raise ParamError(message, code=code) + if code in (121, 122): + raise BossApiError( + f"{action}: 请求被安全系统拦截 (code={code})。" + "此操作需要浏览器环境的安全验证,CLI 暂不支持。" + "请在 BOSS直聘 网页端完成此操作。", + code=code, response=data, + ) if code == 9: # Rate limited — auto-cooldown with exponential backoff self._rate_limit_count += 1 From 990a561e9f899b8e304a9b30adc6d1952c1fa930 Mon Sep 17 00:00:00 2001 From: Wilson Xu Date: Thu, 2 Apr 2026 16:30:33 +0800 Subject: [PATCH 10/13] fix: replace misleading --page with --limit for recommend/inbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recommend (greetRecSortList) and inbox (filterByLabel) APIs return ALL results in a single call — the page parameter is ignored server-side. Replace -p/--page with -n/--limit for client-side display limiting. Update hints to show label filtering and job switching instead. Co-Authored-By: Claude Opus 4.6 (1M context) --- boss_cli/commands/recruiter.py | 42 +++++++++++++++++++--------------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/boss_cli/commands/recruiter.py b/boss_cli/commands/recruiter.py index bcf0530..3386906 100644 --- a/boss_cli/commands/recruiter.py +++ b/boss_cli/commands/recruiter.py @@ -136,26 +136,29 @@ def _render(data: dict) -> None: @recruiter.command("recommend") -@click.option("-p", "--page", default=1, type=int, help="页码") -@click.option("--job", "enc_job_id", default="", help="关联职位 encryptJobId") +@click.option("-n", "--limit", "display_limit", default=0, type=int, help="显示数量 (0=全部)") +@click.option("--job", "enc_job_id", default="", help="关联职位 encryptJobId (切换岗位)") @structured_output_options -def recruiter_recommend(page: int, enc_job_id: str, as_json: bool, as_yaml: bool) -> None: - """推荐候选人列表 (greetRecSortList)""" +def recruiter_recommend(display_limit: int, enc_job_id: str, as_json: bool, as_yaml: bool) -> None: + """推荐候选人列表 (支持 --job 切换岗位)""" cred = require_auth() def _action(c: BossClient) -> dict: - return c.get_boss_recommend_geeks(page=page, enc_job_id=enc_job_id) + return c.get_boss_recommend_geeks(page=1, enc_job_id=enc_job_id) def _render(data: dict) -> None: friend_list = data.get("friendList", []) - limit = data.get("limit", 0) + total = len(friend_list) if not friend_list: console.print("[yellow]暂无推荐候选人[/yellow]") return + if display_limit > 0: + friend_list = friend_list[:display_limit] + table = Table( - title=f"推荐候选人 ({len(friend_list)} 人, 上限 {limit}) — 第 {page} 页", + title=f"推荐候选人 (显示 {len(friend_list)}/{total} 人)", show_lines=True, ) table.add_column("#", style="dim", width=3) @@ -178,10 +181,9 @@ def _render(data: dict) -> None: console.print(table) - if friend_list: - console.print(f" [dim]下一页: boss recruiter recommend -p {page + 1}[/dim]") - console.print(" [dim]切换职位: boss recruiter recommend --job [/dim]") - console.print(" [dim]查看职位列表: boss recruiter jobs[/dim]") + console.print(" [dim]💡 切换岗位: boss recruiter recommend --job [/dim]") + console.print(" [dim] 限制显示: boss recruiter recommend -n 10[/dim]") + console.print(" [dim] 查看职位: boss recruiter jobs[/dim]") handle_command(cred, action=_action, render=_render, as_json=as_json, as_yaml=as_yaml) @@ -334,15 +336,15 @@ def recruiter_batch_greet( @recruiter.command("inbox") @click.option("--job", "enc_job_id", default="", help="按职位 encryptJobId 筛选") -@click.option("--label", "label_id", default=0, type=int, help="按标签筛选 (0=全部)") -@click.option("-p", "--page", default=1, type=int, help="页码 (默认: 1)") +@click.option("--label", "label_id", default=0, type=int, help="按标签筛选 (0=全部, 1=新招呼, 2=沟通中)") +@click.option("-n", "--limit", "display_limit", default=0, type=int, help="显示数量 (0=全部)") @structured_output_options -def recruiter_inbox(enc_job_id: str, label_id: int, page: int, as_json: bool, as_yaml: bool) -> None: +def recruiter_inbox(enc_job_id: str, label_id: int, display_limit: int, as_json: bool, as_yaml: bool) -> None: """查看候选人消息列表 (招聘方沟通列表)""" cred = require_auth() def _action(c: BossClient) -> dict: - friend_data = c.get_boss_friend_list(label_id=label_id, enc_job_id=enc_job_id, page=page) + friend_data = c.get_boss_friend_list(label_id=label_id, enc_job_id=enc_job_id) friend_list = friend_data.get("result", []) if not friend_list: @@ -373,7 +375,11 @@ def _render(data: dict) -> None: if uid: msg_map[uid] = msg - table = Table(title=f"候选人列表 ({len(detail_list)} 人) — 第 {page} 页", show_lines=True) + total = len(detail_list) + if display_limit > 0: + detail_list = detail_list[:display_limit] + + table = Table(title=f"候选人列表 (显示 {len(detail_list)}/{total} 人)", show_lines=True) table.add_column("#", style="dim", width=3) table.add_column("候选人", style="bold cyan", max_width=12) table.add_column("职位", style="green", max_width=20) @@ -399,8 +405,8 @@ def _render(data: dict) -> None: console.print(table) console.print(" [dim]使用 boss recruiter resume 查看候选人简历[/dim]") - if detail_list: - console.print(f" [dim]下一页: boss recruiter inbox -p {page + 1}[/dim]") + console.print(" [dim]💡 限制显示: boss recruiter inbox -n 20[/dim]") + console.print(" [dim] 按标签筛选: boss recruiter inbox --label 1 (1=新招呼, 2=沟通中)[/dim]") handle_command(cred, action=_action, render=_render, as_json=as_json, as_yaml=as_yaml) From 535c1069fa598487579c2bee057b08322888f6a1 Mon Sep 17 00:00:00 2001 From: Wilson Xu Date: Thu, 2 Apr 2026 16:58:20 +0800 Subject: [PATCH 11/13] fix: interview invite uses JSON body, exchange adds gid param, handle 404 gracefully - boss_interview_invite now sends JSON body (not form-encoded) to match API - boss_exchange_request includes gid=uid param required by the API - _request handles 404 responses that contain JSON (anti-bot responses) - _post supports json_body=True parameter for JSON POST requests Co-Authored-By: Claude Opus 4.6 (1M context) --- boss_cli/client.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/boss_cli/client.py b/boss_cli/client.py index e560027..ee801cb 100644 --- a/boss_cli/client.py +++ b/boss_cli/client.py @@ -282,6 +282,14 @@ def _request(self, method: str, url: str, **kwargs) -> dict[str, Any]: time.sleep(wait) continue + # For non-server errors (4xx except 404), raise immediately + if resp.status_code == 404: + # Some endpoints return 404 when anti-bot blocks the request + text = resp.text + if text.strip().startswith("{"): + return resp.json() + raise BossApiError(f"接口不存在: {url} (HTTP 404)", code=404) + resp.raise_for_status() # Check for HTML responses (redirect to login page) @@ -448,16 +456,17 @@ def get_geek_job(self, security_id: str) -> dict[str, Any]: # ── Recruiter (Boss) Mode ──────────────────────────────────────── - def _post(self, url: str, data: dict[str, Any] | None = None, action: str = "") -> dict[str, Any]: - """POST request with form-encoded body, response validation, and rate-limit retry.""" - resp = self._request("POST", url, data=data) + def _post(self, url: str, data: dict[str, Any] | None = None, action: str = "", json_body: bool = False) -> dict[str, Any]: + """POST request with form-encoded or JSON body, response validation, and rate-limit retry.""" + kwargs = {"json": data} if json_body else {"data": data} + resp = self._request("POST", url, **kwargs) try: result = self._handle_response(resp, action) self._rate_limit_count = 0 return result except RateLimitError: logger.info("Retrying after rate-limit cooldown...") - resp = self._request("POST", url, data=data) + resp = self._request("POST", url, **kwargs) result = self._handle_response(resp, action) self._rate_limit_count = 0 return result @@ -587,7 +596,7 @@ def boss_exchange_request(self, uid: int, job_id: int, exchange_type: int) -> di """ return self._post( BOSS_EXCHANGE_REQUEST_URL, - data={"type": exchange_type, "uid": uid, "jobId": job_id}, + data={"type": exchange_type, "uid": uid, "jobId": job_id, "gid": uid}, action="交换请求", ) @@ -615,7 +624,7 @@ def boss_interview_invite( data["startTime"] = start_time if description: data["description"] = description - return self._post(BOSS_INTERVIEW_INVITE_URL, data=data, action="约面试") + return self._post(BOSS_INTERVIEW_INVITE_URL, data=data, action="约面试", json_body=True) def boss_mark_unsuitable(self, encrypt_geek_id: str, encrypt_job_id: str) -> dict[str, Any]: """Mark candidate as unsuitable.""" From bcd477e71a4a71056e7d2a875a1cd2425a2665cc Mon Sep 17 00:00:00 2001 From: Wilson Xu Date: Tue, 12 May 2026 11:06:14 +0800 Subject: [PATCH 12/13] =?UTF-8?q?fix(recruiter):=20make=20`recommend`=20hi?= =?UTF-8?q?t=20the=20real=20=E6=8E=A8=E8=8D=90=E7=89=9B=E4=BA=BA=20endpoin?= =?UTF-8?q?t=20with=20working=20pagination?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `recruiter recommend` previously called `/wapi/zprelation/friend/greetRecSortList` ("greet rec sort list"), which re-sorts the recruiter's already-greeted candidates and ignores the `page` param. Result: users saw only ~10 candidates and `-p N` returned the same list reshuffled. The actual "推荐牛人" feed on the BOSS recruiter web page calls `/wapi/zpjob/rec/geek/list` (XHR fired on infinite scroll), which: - returns 15 candidates per page - paginates via `page=N` - includes `hasMore` for the end-of-feed signal - requires a `Referer: /web/frame/recommend/` header Changes: - Add `BOSS_REC_GEEK_LIST_URL` constant + Referer mapping - Repoint `get_boss_recommend_geeks` at the real endpoint with the full filter param set the page sends (age/school/activation/etc., zeroed) - `--job` is now required (the endpoint is job-scoped) - Render the richer `geekCard` payload: name / age / exp / degree / city / salary / activeTime / encryptGeekId, plus next-page hint when hasMore - Update README examples Smoke-tested against an active recruiter account: page 1 returned 15 distinct candidates with `hasMore: true`; `-p 2`, `-p 3`, ... return fresh batches as the browser does on scroll. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 9 +++-- boss_cli/client.py | 37 +++++++++++++++++--- boss_cli/commands/recruiter.py | 63 +++++++++++++++++++++------------- boss_cli/constants.py | 1 + 4 files changed, 77 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index c3fb9d5..33770f5 100644 --- a/README.md +++ b/README.md @@ -123,9 +123,8 @@ If you are an employer on BOSS直聘, these commands let you manage candidates f ```bash # ─── Search & Discover (搜索 & 发现) ───────────── boss recruiter search "golang" --city 深圳 --exp 3-5年 # Search candidates -boss recruiter recommend # Recommended candidates -boss recruiter recommend --job # Switch to different 岗位 -boss recruiter recommend -p 2 # Next page +boss recruiter recommend --job # 推荐牛人 (paginated, 15/page) +boss recruiter recommend --job -p 2 # Next page (use hasMore flag) # ─── Greet & Communicate (沟通) ────────────────── boss recruiter greet # Initiate chat with candidate @@ -373,8 +372,8 @@ boss -v search "Python" # 详细日志 ```bash # 搜索 & 推荐 boss recruiter search "golang" --city 深圳 --exp 3-5年 -boss recruiter recommend --job # 按岗位查看推荐牛人 -boss recruiter recommend -p 2 # 翻页 +boss recruiter recommend --job # 推荐牛人 (15/页, 真分页) +boss recruiter recommend --job -p 2 # 翻页 # 沟通 boss recruiter greet # 向候选人打招呼 diff --git a/boss_cli/client.py b/boss_cli/client.py index ee801cb..3b81ff9 100644 --- a/boss_cli/client.py +++ b/boss_cli/client.py @@ -22,6 +22,7 @@ BOSS_FRIEND_LIST_URL, BOSS_FRIEND_NOTE_URL, BOSS_GREET_REC_SORT_URL, + BOSS_REC_GEEK_LIST_URL, BOSS_GREET_SORT_LIST_URL, BOSS_HISTORY_MSG_URL, BOSS_INTERVIEW_INVITE_URL, @@ -201,6 +202,8 @@ def _headers_for_request(self, url: str, params: dict[str, Any] | None = None) - # Recruiter (boss) endpoints elif url == BOSS_SEARCH_GEEK_URL: headers["Referer"] = f"{BASE_URL}/web/chat/search" + elif url == BOSS_REC_GEEK_LIST_URL: + headers["Referer"] = f"{BASE_URL}/web/frame/recommend/" elif url in (BOSS_VIEW_GEEK_URL, BOSS_SEND_MSG_URL): headers["Referer"] = WEB_BOSS_CHAT_URL elif url in (BOSS_FRIEND_LIST_URL, BOSS_FRIEND_DETAIL_URL, BOSS_LAST_MSG_URL, @@ -553,11 +556,35 @@ def search_geeks( return self._get(BOSS_SEARCH_GEEK_URL, params=params, action="搜索候选人") def get_boss_recommend_geeks(self, page: int = 1, enc_job_id: str = "") -> dict[str, Any]: - """Get recommended candidates (new greetings sorted by recommendation).""" - params: dict[str, Any] = {"page": page} - if enc_job_id: - params["encJobId"] = enc_job_id - return self._get(BOSS_GREET_REC_SORT_URL, params=params, action="推荐候选人") + """Get the "推荐牛人" feed — the candidate-discovery list shown on the recruiter + recommend page. Truly paginated (15/page), with `hasMore` flag. + + This used to hit ``greetRecSortList`` (a re-sort of already-greeted candidates, + ~10 per job and `page` was a no-op). Now it hits the real ``/wapi/zpjob/rec/geek/list`` + endpoint that the BOSS recruiter web page calls on infinite scroll. + """ + if not enc_job_id: + raise ValueError("enc_job_id (encryptJobId) is required for 推荐牛人 — pass --job") + params: dict[str, Any] = { + "age": "16,-1", + "school": 0, + "activation": 0, + "gender": 0, + "recentNotView": 0, + "exchangeResumeWithColleague": 0, + "major": 0, + "keyword1": -1, + "switchJobFrequency": 0, + "experience": 0, + "degree": 0, + "intention": 0, + "salary": 0, + "jobId": enc_job_id, + "page": page, + "coverScreenMemory": 0, + "cardType": 0, + } + return self._get(BOSS_REC_GEEK_LIST_URL, params=params, action="推荐牛人") def get_boss_view_geek( self, encrypt_geek_id: str, encrypt_job_id: str, security_id: str = "", diff --git a/boss_cli/commands/recruiter.py b/boss_cli/commands/recruiter.py index 3386906..744b7bf 100644 --- a/boss_cli/commands/recruiter.py +++ b/boss_cli/commands/recruiter.py @@ -137,52 +137,69 @@ def _render(data: dict) -> None: @recruiter.command("recommend") @click.option("-n", "--limit", "display_limit", default=0, type=int, help="显示数量 (0=全部)") -@click.option("--job", "enc_job_id", default="", help="关联职位 encryptJobId (切换岗位)") +@click.option("-p", "--page", default=1, type=int, help="页码 (默认: 1, 真分页, 15/页)") +@click.option("--job", "enc_job_id", required=True, help="目标职位 encryptJobId (必填)") @structured_output_options -def recruiter_recommend(display_limit: int, enc_job_id: str, as_json: bool, as_yaml: bool) -> None: - """推荐候选人列表 (支持 --job 切换岗位)""" +def recruiter_recommend( + display_limit: int, page: int, enc_job_id: str, + as_json: bool, as_yaml: bool, +) -> None: + """推荐牛人 (候选人发现池) — 真分页, 每页 15 人, 有 hasMore 标志。 + + 与 BOSS 直聘网页端 "推荐牛人" 一致, 支持滚动翻页继续拉取候选人。 + """ cred = require_auth() def _action(c: BossClient) -> dict: - return c.get_boss_recommend_geeks(page=1, enc_job_id=enc_job_id) + return c.get_boss_recommend_geeks(page=page, enc_job_id=enc_job_id) def _render(data: dict) -> None: - friend_list = data.get("friendList", []) - total = len(friend_list) + geek_list = data.get("geekList", []) + has_more = data.get("hasMore", False) + total = len(geek_list) - if not friend_list: - console.print("[yellow]暂无推荐候选人[/yellow]") + if not geek_list: + console.print("[yellow]暂无推荐候选人 (该岗位推荐池可能已耗尽, 或换岗位试试)[/yellow]") return if display_limit > 0: - friend_list = friend_list[:display_limit] + geek_list = geek_list[:display_limit] + more_hint = " · 有更多 (翻页 -p)" if has_more else " · 已到末页" table = Table( - title=f"推荐候选人 (显示 {len(friend_list)}/{total} 人)", + title=f"推荐牛人 第 {page} 页 (显示 {len(geek_list)}/{total} 人){more_hint}", show_lines=True, ) table.add_column("#", style="dim", width=3) table.add_column("姓名", style="bold cyan", max_width=10) - table.add_column("职位", style="green", max_width=20) - table.add_column("encJobId", style="dim", max_width=28) - table.add_column("新牛人", max_width=4) - table.add_column("时间", style="dim", max_width=10) + table.add_column("年龄", max_width=5) + table.add_column("经验", max_width=10) + table.add_column("学历", max_width=6) + table.add_column("城市", style="green", max_width=10) + table.add_column("期望薪资", max_width=10) + table.add_column("活跃", style="dim", max_width=10) + table.add_column("encryptGeekId", style="dim", max_width=30) - for i, f in enumerate(friend_list, 1): - new_flag = "NEW" if f.get("newGeek") else "" + for i, g in enumerate(geek_list, 1): + card = g.get("geekCard", {}) if isinstance(g.get("geekCard"), dict) else g table.add_row( str(i), - f.get("name", "-"), - f.get("jobName", "-"), - f.get("encryptJobId", "-"), - new_flag, - f.get("lastTime", "-"), + card.get("geekName", "-"), + str(card.get("ageDesc", "-")), + str(card.get("workExpDesc") or card.get("geekWorkYear") or "-"), + str(card.get("degreeCategory") or card.get("geekDegree") or "-"), + str(card.get("cityName") or card.get("expectLocationName") or "-"), + str(card.get("salary") or card.get("salaryDesc") or "-"), + str(card.get("activeTimeDesc") or card.get("actionDateDesc") or "-"), + card.get("encryptGeekId") or g.get("encryptGeekId", "-"), ) console.print(table) - console.print(" [dim]💡 切换岗位: boss recruiter recommend --job [/dim]") - console.print(" [dim] 限制显示: boss recruiter recommend -n 10[/dim]") + next_page = page + 1 + if has_more: + console.print(f" [dim]→ 下一页: boss recruiter recommend --job {enc_job_id} -p {next_page}[/dim]") + console.print(f" [dim] 导出: boss recruiter recommend --job {enc_job_id} -p {page} --json[/dim]") console.print(" [dim] 查看职位: boss recruiter jobs[/dim]") handle_command(cred, action=_action, render=_render, as_json=as_json, as_yaml=as_yaml) diff --git a/boss_cli/constants.py b/boss_cli/constants.py index 7fcb710..1032495 100644 --- a/boss_cli/constants.py +++ b/boss_cli/constants.py @@ -55,6 +55,7 @@ BOSS_FRIEND_NOTE_URL = "/wapi/zprelation/friend/getNoteAndLabels" BOSS_GREET_SORT_LIST_URL = "/wapi/zprelation/friend/greetSort/getList" BOSS_GREET_REC_SORT_URL = "/wapi/zprelation/friend/greetRecSortList" +BOSS_REC_GEEK_LIST_URL = "/wapi/zpjob/rec/geek/list" BOSS_INTERVIEW_LIST_URL = "/wapi/zpinterview/boss/interview/valid/list" BOSS_INTERVIEW_DETAIL_URL = "/wapi/zpinterview/boss/interview/detail" BOSS_GREET_NEW_LIST_URL = "/wapi/zpchat/boss/newgreeting/getHistoryList" From a32839bf59f172d6857045549ee65a29766e89b9 Mon Sep 17 00:00:00 2001 From: Wilson Xu Date: Tue, 12 May 2026 11:59:45 +0800 Subject: [PATCH 13/13] =?UTF-8?q?fix(recruiter):=20make=20`greet`=20actual?= =?UTF-8?q?ly=20greet=20=E2=80=94=20call=20real=20chat/start,=20support=20?= =?UTF-8?q?custom=20first=20message?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `recruiter greet` previously did NOT send a greeting — it just called `view_geek` (with broken params: `encryptGeekId + encryptJobId` returns `code=2 未知的非法参数`; BOSS prefers `securityId` alone) and printed the candidate name. No `chat/start` POST. No actual conversation initiation. Now `greet` calls the real `/wapi/zpjob/chat/start` (the same endpoint the web UI fires when the recruiter clicks "打招呼"). With `-m/--message`, the message is delivered as the chat's first line — saves the extra `sendReplyMsg` round-trip. Also fix `get_boss_view_geek` to accept `security_id` alone (the form BOSS actually wants). Changes: - Add `BOSS_CHAT_START_URL` constant + Referer mapping - Add `boss_chat_start(encrypt_geek_id, encrypt_job_id, security_id, expect_id?, lid?, greet?)` to client - Rewire `recruiter greet` command: required `--job` + `--security-id`, optional `--expect-id`, `--lid`, `-m/--message` - Fix `get_boss_view_geek`: accept `security_id` alone (prefer it), reject the broken `encGid+encJid` combo via ValueError when securityId missing Schema discovered by hooking XMLHttpRequest in a logged-in BOSS recruiter session and clicking the real "打招呼" button — captured the full POST body (gid, suid, jid, expectId, lid, greet, from, securityId, customGreetingGuide). All 9 params required; tested against live account. Co-Authored-By: Claude Opus 4.7 (1M context) --- boss_cli/client.py | 47 ++++++++++++++++++++++++---- boss_cli/commands/recruiter.py | 56 +++++++++++++++++++--------------- boss_cli/constants.py | 1 + 3 files changed, 74 insertions(+), 30 deletions(-) diff --git a/boss_cli/client.py b/boss_cli/client.py index 3b81ff9..a6eaa42 100644 --- a/boss_cli/client.py +++ b/boss_cli/client.py @@ -23,6 +23,7 @@ BOSS_FRIEND_NOTE_URL, BOSS_GREET_REC_SORT_URL, BOSS_REC_GEEK_LIST_URL, + BOSS_CHAT_START_URL, BOSS_GREET_SORT_LIST_URL, BOSS_HISTORY_MSG_URL, BOSS_INTERVIEW_INVITE_URL, @@ -204,6 +205,8 @@ def _headers_for_request(self, url: str, params: dict[str, Any] | None = None) - headers["Referer"] = f"{BASE_URL}/web/chat/search" elif url == BOSS_REC_GEEK_LIST_URL: headers["Referer"] = f"{BASE_URL}/web/frame/recommend/" + elif url == BOSS_CHAT_START_URL: + headers["Referer"] = f"{BASE_URL}/web/frame/recommend/" elif url in (BOSS_VIEW_GEEK_URL, BOSS_SEND_MSG_URL): headers["Referer"] = WEB_BOSS_CHAT_URL elif url in (BOSS_FRIEND_LIST_URL, BOSS_FRIEND_DETAIL_URL, BOSS_LAST_MSG_URL, @@ -587,17 +590,49 @@ def get_boss_recommend_geeks(self, page: int = 1, enc_job_id: str = "") -> dict[ return self._get(BOSS_REC_GEEK_LIST_URL, params=params, action="推荐牛人") def get_boss_view_geek( - self, encrypt_geek_id: str, encrypt_job_id: str, security_id: str = "", + self, encrypt_geek_id: str = "", encrypt_job_id: str = "", security_id: str = "", ) -> dict[str, Any]: - """Get full candidate resume/profile view.""" - params: dict[str, Any] = { - "encryptGeekId": encrypt_geek_id, - "encryptJobId": encrypt_job_id, - } + """Get full candidate resume/profile view. + + BOSS prefers ``securityId`` alone — that's what the web page sends. + The ``encryptGeekId + encryptJobId`` combo returns ``code=2 未知的非法参数``. + """ + params: dict[str, Any] = {} if security_id: params["securityId"] = security_id + else: + if not (encrypt_geek_id and encrypt_job_id): + raise ValueError("view_geek needs either security_id, or (encrypt_geek_id + encrypt_job_id)") + params["encryptGeekId"] = encrypt_geek_id + params["encryptJobId"] = encrypt_job_id return self._get(BOSS_VIEW_GEEK_URL, params=params, action="候选人简历") + def boss_chat_start( + self, encrypt_geek_id: str, encrypt_job_id: str, security_id: str, + expect_id: int | str = "", lid: str = "", greet: str = "", + ) -> dict[str, Any]: + """Open a chat with a candidate ("打招呼"), optionally with a custom first message. + + Hits ``/wapi/zpjob/chat/start`` — the same endpoint the recruiter web UI + calls when you click the "打招呼" button. Returns ``zpData.geekId`` (the + numeric ID needed by ``boss_send_message``). + + If ``greet`` is non-empty, BOSS delivers it as the first message in the + chat — saves a separate ``sendReplyMsg`` round-trip. + """ + data = { + "gid": encrypt_geek_id, + "suid": "", + "jid": encrypt_job_id, + "expectId": str(expect_id) if expect_id else "", + "lid": lid, + "greet": greet, + "from": "", + "securityId": security_id, + "customGreetingGuide": "-1", + } + return self._post(BOSS_CHAT_START_URL, data=data, action="打招呼") + def boss_send_message(self, gid: int, content: str) -> dict[str, Any]: """Send a text message to a candidate as a recruiter.""" return self._post( diff --git a/boss_cli/commands/recruiter.py b/boss_cli/commands/recruiter.py index 744b7bf..e38f18f 100644 --- a/boss_cli/commands/recruiter.py +++ b/boss_cli/commands/recruiter.py @@ -210,36 +210,44 @@ def _render(data: dict) -> None: @recruiter.command("greet") @click.argument("encrypt_geek_id") -@click.option("--job", "encrypt_job_id", default="", help="关联职位 encryptJobId") +@click.option("--job", "encrypt_job_id", required=True, help="目标职位 encryptJobId (必填)") +@click.option("--security-id", "security_id", required=True, help="候选人 securityId (从 recommend/search 结果获取)") +@click.option("--expect-id", "expect_id", default="", help="候选人 expectId (可选, 提升送达率)") +@click.option("--lid", default="", help="推荐链路 lid (可选, 提升送达率)") +@click.option("-m", "--message", "greet_msg", default="", help="自定义首条消息 (留空使用 BOSS 默认招呼模板)") @structured_output_options -def recruiter_greet(encrypt_geek_id: str, encrypt_job_id: str, as_json: bool, as_yaml: bool) -> None: - """向候选人发起沟通 (Initiate conversation with candidate)""" +def recruiter_greet( + encrypt_geek_id: str, encrypt_job_id: str, security_id: str, + expect_id: str, lid: str, greet_msg: str, + as_json: bool, as_yaml: bool, +) -> None: + """打招呼 — 向候选人发起沟通, 可选附带自定义首条消息。 + + 打到 BOSS 真实的 ``/wapi/zpjob/chat/start`` 接口 (web 端 "打招呼" 按钮的同款)。 + 需要 ``--security-id`` (在 ``recruiter recommend`` / ``recruiter search`` 返回里有)。 + 传 ``-m`` 时, BOSS 会把消息作为聊天的第一条投递, 省一次后续 reply 请求。 + """ cred = require_auth() def _action(c: BossClient) -> dict: - # Get job id if not provided - job_id = encrypt_job_id - if not job_id: - jobs = c.get_boss_chatted_jobs() - if jobs: - job_id = jobs[0].get("encryptJobId", "") - - # View the geek first to show info - if job_id: - info = c.get_boss_view_geek( - encrypt_geek_id=encrypt_geek_id, - encrypt_job_id=job_id, - ) - else: - info = {"encryptGeekId": encrypt_geek_id, "note": "无关联职位, 无法获取详情"} - return info + return c.boss_chat_start( + encrypt_geek_id=encrypt_geek_id, + encrypt_job_id=encrypt_job_id, + security_id=security_id, + expect_id=expect_id, + lid=lid, + greet=greet_msg, + ) def _render(data: dict) -> None: - geek_info = data.get("geekDetailInfo", data.get("geekBaseInfo", data)) - base_info = geek_info.get("geekBaseInfo", geek_info) if isinstance(geek_info, dict) else data - name = base_info.get("name", base_info.get("geekName", "-")) - console.print(f"[cyan]候选人: {name}[/cyan] encryptGeekId={encrypt_geek_id}") - console.print("[dim]提示: 使用 boss recruiter reply 发送消息[/dim]") + geek_id = data.get("geekId") + new = data.get("newfriend") == 1 + status = "新沟通" if new else "已沟通" + console.print(f"[green]✓ 已打招呼[/green] encryptGeekId={encrypt_geek_id} geekId={geek_id} 状态={status}") + if greet_msg: + console.print(f"[dim]首条消息已发送: {greet_msg[:60]}{'…' if len(greet_msg) > 60 else ''}[/dim]") + else: + console.print("[dim]使用了 BOSS 默认招呼模板; 后续可用 boss recruiter reply [/dim]") handle_command(cred, action=_action, render=_render, as_json=as_json, as_yaml=as_yaml) diff --git a/boss_cli/constants.py b/boss_cli/constants.py index 1032495..b82d95f 100644 --- a/boss_cli/constants.py +++ b/boss_cli/constants.py @@ -56,6 +56,7 @@ BOSS_GREET_SORT_LIST_URL = "/wapi/zprelation/friend/greetSort/getList" BOSS_GREET_REC_SORT_URL = "/wapi/zprelation/friend/greetRecSortList" BOSS_REC_GEEK_LIST_URL = "/wapi/zpjob/rec/geek/list" +BOSS_CHAT_START_URL = "/wapi/zpjob/chat/start" BOSS_INTERVIEW_LIST_URL = "/wapi/zpinterview/boss/interview/valid/list" BOSS_INTERVIEW_DETAIL_URL = "/wapi/zpinterview/boss/interview/detail" BOSS_GREET_NEW_LIST_URL = "/wapi/zpchat/boss/newgreeting/getHistoryList"