From 00720b6599d469081fb767e892b66e643ba2e1ad Mon Sep 17 00:00:00 2001 From: Wilson Xu Date: Thu, 2 Apr 2026 15:16:45 +0800 Subject: [PATCH 01/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] =?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/16] 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/16] 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/16] 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/16] 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 030fae39e07551b69e77307d4dd9c000ab203013 Mon Sep 17 00:00:00 2001 From: likai Date: Sat, 4 Apr 2026 12:33:24 +0900 Subject: [PATCH 12/16] fix: address PR #17 review issues before merge - Restore CI badge URL to jackwener/boss-cli (was pointing to fork) - Remove fork-specific "Fork note:" from README - Remove duplicate BOSS_VIEW_GEEK_INFO_URL constant (same value as BOSS_VIEW_GEEK_URL, which is the one actually imported and used) Co-Authored-By: Claude Sonnet 4.6 --- README.md | 4 +--- boss_cli/constants.py | 1 - 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/README.md b/README.md index c3fb9d5..d27571c 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,11 @@ # 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/chengyixu/boss-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/chengyixu/boss-cli/actions/workflows/ci.yml) +[![CI](https://github.com/jackwener/boss-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/jackwener/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, 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) | [中文](#功能特性) ## More Tools diff --git a/boss_cli/constants.py b/boss_cli/constants.py index 7fcb710..7239e86 100644 --- a/boss_cli/constants.py +++ b/boss_cli/constants.py @@ -50,7 +50,6 @@ 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" From f6b1f2048785f0b649d3287a92a44749e13bfbd4 Mon Sep 17 00:00:00 2001 From: jackwener Date: Mon, 13 Apr 2026 16:08:12 +0800 Subject: [PATCH 13/16] fix: correct recruiter greet/chat flows --- README.md | 10 +-- boss_cli/client.py | 11 +++- boss_cli/commands/recruiter.py | 111 ++++++++++++++++----------------- 3 files changed, 68 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index d27571c..e908454 100644 --- a/README.md +++ b/README.md @@ -123,13 +123,13 @@ If you are an employer on BOSS直聘, these commands let you manage candidates f 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 -n 20 # Limit display # ─── Greet & Communicate (沟通) ────────────────── boss recruiter greet # Initiate chat with candidate boss recruiter batch-greet "Python" --city 杭州 -n 10 # Batch greet top 10 matches boss recruiter inbox # View candidate messages -boss recruiter inbox --job -p 2 # Filter by job, page 2 +boss recruiter inbox --job -n 20 # Filter by job, limit display boss recruiter reply "感谢您的关注..." # Reply to candidate boss recruiter chat # View chat history @@ -178,7 +178,7 @@ boss recruiter resume-download --job boss recruiter greet # 7. Check inbox and reply -boss recruiter inbox -p 1 +boss recruiter inbox -n 20 boss recruiter reply "感谢您的关注,方便电话聊聊吗?" # 8. Export all candidates @@ -372,12 +372,12 @@ boss -v search "Python" # 详细日志 # 搜索 & 推荐 boss recruiter search "golang" --city 深圳 --exp 3-5年 boss recruiter recommend --job # 按岗位查看推荐牛人 -boss recruiter recommend -p 2 # 翻页 +boss recruiter recommend -n 20 # 限制显示 # 沟通 boss recruiter greet # 向候选人打招呼 boss recruiter batch-greet "Python" -n 10 # 批量打招呼 -boss recruiter inbox -p 1 # 查看候选人消息 +boss recruiter inbox -n 20 # 查看候选人消息 boss recruiter reply "您好..." # 回复候选人 # 沟通页操作 diff --git a/boss_cli/client.py b/boss_cli/client.py index ee801cb..6fd4e61 100644 --- a/boss_cli/client.py +++ b/boss_cli/client.py @@ -18,6 +18,7 @@ BOSS_EXCHANGE_CONTENT_URL, BOSS_EXCHANGE_REQUEST_URL, BOSS_FRIEND_DETAIL_URL, + BOSS_FRIEND_ADD_URL, BOSS_FRIEND_LABELS_URL, BOSS_FRIEND_LIST_URL, BOSS_FRIEND_NOTE_URL, @@ -209,7 +210,7 @@ def _headers_for_request(self, url: str, params: dict[str, Any] | None = None) - 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): + BOSS_SESSION_ENTER_URL, BOSS_FRIEND_ADD_URL): headers["Referer"] = WEB_BOSS_CHAT_URL return headers @@ -579,6 +580,14 @@ def boss_send_message(self, gid: int, content: str) -> dict[str, Any]: action="发送消息", ) + def boss_add_friend(self, encrypt_geek_id: str, encrypt_job_id: str) -> dict[str, Any]: + """Initiate chat/greet a candidate as a recruiter.""" + return self._post( + BOSS_FRIEND_ADD_URL, + data={"encryptGeekId": encrypt_geek_id, "encryptJobId": encrypt_job_id}, + 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="关闭职位") diff --git a/boss_cli/commands/recruiter.py b/boss_cli/commands/recruiter.py index 3386906..feba920 100644 --- a/boss_cli/commands/recruiter.py +++ b/boss_cli/commands/recruiter.py @@ -207,22 +207,24 @@ def _action(c: BossClient) -> dict: 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 + if not job_id: + return {"error": "未找到关联职位, 请通过 --job 指定 encryptJobId"} + + result = c.boss_add_friend(encrypt_geek_id=encrypt_geek_id, encrypt_job_id=job_id) + result["encryptJobId"] = job_id + return result 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]") + if data.get("error"): + console.print(f"[red]{data['error']}[/red]") + return + friend_id = data.get("friendId", data.get("gid", "")) + console.print( + f"[green]已向候选人发起沟通[/green] encryptGeekId={encrypt_geek_id} " + f"(job={data.get('encryptJobId', '-')})" + ) + if friend_id: + console.print(f"[dim]提示: 使用 boss recruiter reply {friend_id} 发送消息[/dim]") handle_command(cred, action=_action, render=_render, as_json=as_json, as_yaml=as_yaml) @@ -303,20 +305,24 @@ def recruiter_batch_greet( for i, geek in enumerate(targets, 1): geek_id = geek.get("encryptGeekId", geek.get("encryptUid", "")) name = geek.get("name", geek.get("geekName", "?")) + job_id = encrypt_job_id or geek.get("encryptJobId", "") if not geek_id: console.print(f" [{i}] [yellow]跳过 {name} (无 encryptGeekId)[/yellow]") continue + if not job_id: + console.print(f" [{i}] [yellow]跳过 {name} (缺少 encryptJobId)[/yellow]") + continue try: run_client_action( cred, - lambda client, gid=geek_id: client.get_boss_view_geek( + lambda client, gid=geek_id, jid=job_id: client.boss_add_friend( encrypt_geek_id=gid, - encrypt_job_id=encrypt_job_id, + encrypt_job_id=jid, ), ) - console.print(f" [{i}] [green]{name} - 已查看[/green]") + console.print(f" [{i}] [green]{name} - 已打招呼[/green]") success += 1 except BossApiError as e: console.print(f" [{i}] [red]{name}: {e}[/red]") @@ -398,7 +404,7 @@ def _render(data: dict) -> None: str(i), friend.get("name", "-"), friend.get("jobName", "-"), - friend.get("salaryDesc", friend.get("lastTime", "-")), + friend.get("salaryDesc", "-"), last_text or "-", msg_info.get("lastTime", friend.get("lastTime", "-")), ) @@ -431,11 +437,13 @@ def recruiter_reply(friend_id: int, message: str, yes: bool, as_json: bool, as_y console.print("[dim]已取消[/dim]") return + uid, _job_id = _resolve_friend_uid_and_job(cred, friend_id) + def _action(c: BossClient) -> dict: - return c.boss_send_message(gid=friend_id, content=message) + return c.boss_send_message(gid=uid, content=message) def _render(data: dict) -> None: - console.print(f"[green]消息已发送 -> friendId={friend_id}[/green]") + console.print(f"[green]消息已发送 -> friendId={friend_id} (uid={uid})[/green]") handle_command(cred, action=_action, render=_render, as_json=as_json, as_yaml=as_yaml) @@ -528,15 +536,7 @@ def _action(c: BossClient) -> dict: # 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 + security_id = _resolve_security_id_by_encrypt_geek(cred, encrypt_geek_id) return c.get_boss_view_geek( encrypt_geek_id=encrypt_geek_id, @@ -668,8 +668,10 @@ def recruiter_chat(friend_id: int, count: int, as_json: bool, as_yaml: bool) -> """查看与候选人的聊天记录 (需要 friendId)""" cred = require_auth() + uid, _job_id = _resolve_friend_uid_and_job(cred, friend_id) + def _action(c: BossClient) -> dict: - return c.get_boss_chat_history(gid=friend_id, count=count) + return c.get_boss_chat_history(gid=uid, count=count) def _render(data: dict) -> None: messages = data.get("messages", []) @@ -732,14 +734,7 @@ def _action(c: BossClient) -> dict: job_id = jobs[0].get("jobId", 0) 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 + security_id = _resolve_security_id_by_encrypt_geek(cred, encrypt_geek_id) return c.get_boss_chat_geek_info( encrypt_geek_id=encrypt_geek_id, @@ -822,15 +817,7 @@ def _fetch(c: BossClient) -> dict: # 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 + security_id = _resolve_security_id_by_encrypt_geek(cred, encrypt_geek_id) return c.get_boss_view_geek( encrypt_geek_id=encrypt_geek_id, @@ -1064,6 +1051,24 @@ def _resolve_friend_uid_and_job(cred, friend_id: int) -> tuple[int, int]: return uid, job_id +def _resolve_security_id_by_encrypt_geek(cred, encrypt_geek_id: str) -> str: + """Resolve securityId by matching encryptGeekId/encryptUid/encryptFriendId.""" + friend_data = run_client_action(cred, lambda c: c.get_boss_friend_list()) + for f in friend_data.get("result", []): + if encrypt_geek_id in { + f.get("encryptGeekId", ""), + f.get("encryptUid", ""), + f.get("encryptFriendId", ""), + }: + details = run_client_action(cred, lambda c, fid=f["friendId"]: c.get_boss_friend_details([fid])) + for fd in details.get("friendList", []): + security_id = fd.get("securityId", "") + if security_id: + return security_id + break + return "" + + @recruiter.command("request-resume") @click.argument("friend_id", type=int) @click.option("-y", "--yes", is_flag=True, help="跳过确认提示") @@ -1190,17 +1195,7 @@ def recruiter_invite_interview( # 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 + security_id = _resolve_security_id_by_encrypt_geek(cred, encrypt_geek_id) except BossApiError: pass # proceed without securityId; API may still accept it From 7455430f81612c47c0259c6fa8cbc180e82cffa9 Mon Sep 17 00:00:00 2001 From: jackwener Date: Mon, 13 Apr 2026 16:56:41 +0800 Subject: [PATCH 14/16] feat: headless login and cookie bridge --- README.md | 47 +++++ boss_cli/auth.py | 164 +++++++++++++----- boss_cli/cli.py | 1 + boss_cli/commands/auth.py | 49 +++++- boss_cli/cookie_server.py | 105 +++++++++++ .../zhipin-cookie-extractor/manifest.json | 19 ++ .../zhipin-cookie-extractor/popup.html | 64 +++++++ .../zhipin-cookie-extractor/popup.js | 76 ++++++++ .../zhipin-cookie-extractor/service_worker.js | 107 ++++++++++++ scripts/cookie_server.py | 7 + tests/test_cli.py | 50 ++++++ 11 files changed, 644 insertions(+), 45 deletions(-) create mode 100644 boss_cli/cookie_server.py create mode 100644 chrome-extension/zhipin-cookie-extractor/manifest.json create mode 100644 chrome-extension/zhipin-cookie-extractor/popup.html create mode 100644 chrome-extension/zhipin-cookie-extractor/popup.js create mode 100644 chrome-extension/zhipin-cookie-extractor/service_worker.js create mode 100644 scripts/cookie_server.py diff --git a/README.md b/README.md index e908454..ed90df5 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,9 @@ uv sync boss login # Auto-detect browser cookies, fallback to QR boss login --cookie-source chrome # Extract from specific browser boss login --qrcode # QR code login only +boss login --cookie-file /path/cred.json # Import cookies from file +boss login --cookie "k=v; k2=v2" # Import cookies from string +boss cookie-server # Start local cookie bridge server boss status # Check login status (validates real search session, shows cookie names) boss logout # Clear saved cookies @@ -208,6 +211,7 @@ boss-cli supports multiple authentication methods: 1. **Saved cookies** — loads from `~/.config/boss-cli/credential.json` 2. **Browser cookies** — auto-detects installed browsers (Chrome, Firefox, Edge, Brave, Arc, Chromium, Opera, Vivaldi, Safari, LibreWolf) 3. **QR code login** — terminal QR output using Unicode half-blocks, scan with Boss 直聘 APP +4. **Cookie import** — `boss login --cookie-file` or `BOSS_COOKIES` env for headless servers `boss login` auto-extracts browser cookies first, falls back to QR login. Use `--cookie-source chrome` to specify a browser, or `--qrcode` to skip browser detection. The command now verifies the saved credential against a real authenticated API before reporting success. @@ -215,6 +219,31 @@ boss-cli supports multiple authentication methods: `boss status --json` now reports per-flow health such as `search_authenticated` and `recommend_authenticated`, which helps diagnose partial-session issues. To avoid turning repeated checks into their own anti-bot problem, health snapshots are cached briefly in-memory. +### Headless / Server Login (无可视化页面) + +If your server has no GUI, use one of these: + +1. **Cookie file import** + - Export cookies from a browser machine and copy to the server + - `boss login --cookie-file /path/credential.json` + +2. **Environment variable** + - `export BOSS_COOKIES="k=v; k2=v2"` + - `boss status --json` + +### Chrome Extension (Real-time Cookie Extraction) + +Some cookies (notably `__zp_stoken__`) are generated by browser JS and may not be written to disk immediately. +The included Chrome extension can extract cookies from browser memory and push them to a local bridge. + +1. Start the local bridge: + - `boss cookie-server` +2. Load the extension: + - Open `chrome://extensions/` + - Enable Developer mode + - Load unpacked: `chrome-extension/zhipin-cookie-extractor` +3. Log in to `https://www.zhipin.com`, click the extension, then **Sync**. + ### Cookie TTL & Auto-Refresh Saved cookies auto-refresh from browser after **7 days**. If browser refresh fails, falls back to stale cookies and logs a warning. @@ -337,6 +366,8 @@ boss login # 自动提取浏览器 Cookie,失败 boss login --cookie-source chrome # 指定浏览器 boss status # 检查登录状态 boss logout # 清除 Cookie +boss login --cookie-file /path/cred.json # 从文件导入 Cookie +boss cookie-server # 启动本地 Cookie Bridge # 搜索 & 详情 boss search "golang" --city 杭州 # 按城市搜索 @@ -406,6 +437,22 @@ boss recruiter export -o candidates.csv # 导出候选人 - `环境异常` — Cookie 过期,执行 `boss logout && boss login` 刷新 - 搜索无结果 — 检查城市筛选或关键词,使用 `boss cities` 查看支持的城市 +## 无可视化页面 / 服务器登录 + +如果服务器没有浏览器界面,推荐以下方案: + +1. **导入 Cookie 文件** + - 在有浏览器的机器上登录后导出 `credential.json` + - 复制到服务器后执行 `boss login --cookie-file /path/credential.json` + +2. **环境变量** + - `export BOSS_COOKIES="k=v; k2=v2"` + - `boss status --json` + +3. **Chrome 扩展实时同步** + - `boss cookie-server` + - 在 Chrome 中加载 `chrome-extension/zhipin-cookie-extractor` + ## License Apache-2.0 diff --git a/boss_cli/auth.py b/boss_cli/auth.py index 8ab9b79..45e99cc 100644 --- a/boss_cli/auth.py +++ b/boss_cli/auth.py @@ -14,6 +14,7 @@ import logging import os import platform +from pathlib import Path import shutil import subprocess import sys @@ -198,16 +199,10 @@ def _diagnose_extraction_issues(diagnostics: list[str]) -> str | None: ) -# ── Environment variable fallback ─────────────────────────────────── - -def load_from_env() -> Credential | None: - """Load cookies from BOSS_COOKIES environment variable. +# ── Cookie import helpers ─────────────────────────────────────────── - Format: "key1=val1; key2=val2; ..." - """ - raw = os.environ.get("BOSS_COOKIES", "").strip() - if not raw: - return None +def _parse_cookie_string(raw: str) -> dict[str, str]: + """Parse cookie string formatted like 'k=v; k2=v2'.""" cookies: dict[str, str] = {} for part in raw.split(";"): part = part.strip() @@ -217,11 +212,67 @@ def load_from_env() -> Credential | None: k, v = k.strip(), v.strip() if k and v: cookies[k] = v + return cookies + + +def load_from_cookie_string(raw: str) -> Credential | None: + raw = (raw or "").strip() + if not raw: + return None + cookies = _parse_cookie_string(raw) if not cookies: - logger.debug("BOSS_COOKIES env set but no valid key=value pairs found") return None cred = Credential(cookies=cookies) - logger.info("Loaded %d cookies from BOSS_COOKIES environment variable", len(cookies)) + logger.info("Loaded %d cookies from cookie string", len(cookies)) + return cred + + +def load_from_cookie_file(path: str) -> Credential | None: + """Load cookies from a file. + + Supported formats: + - credential.json ({cookies:{...}}) or raw {"k":"v"} map + - plain cookie string: "k=v; k2=v2" + """ + if not path: + return None + try: + raw = Path(path).read_text(encoding="utf-8").strip() + except OSError as exc: + logger.warning("Failed to read cookie file: %s", exc) + return None + if not raw: + return None + # Try JSON first + try: + data = json.loads(raw) + if isinstance(data, dict): + cookies = data.get("cookies") if "cookies" in data else data + if isinstance(cookies, dict) and cookies: + cred = Credential(cookies={str(k): str(v) for k, v in cookies.items() if v}) + logger.info("Loaded %d cookies from %s", len(cred.cookies), path) + return cred + except json.JSONDecodeError: + pass + # Fallback to cookie string + return load_from_cookie_string(raw) + + +# ── Environment variable fallback ─────────────────────────────────── + +def load_from_env() -> Credential | None: + """Load cookies from BOSS_COOKIES environment variable. + + Format: "key1=val1; key2=val2; ..." + """ + raw = os.environ.get("BOSS_COOKIES", "").strip() + if not raw: + return None + cred = load_from_cookie_string(raw) + if not cred: + logger.debug("BOSS_COOKIES env set but no valid key=value pairs found") + return None + logger.info("Loaded %d cookies from BOSS_COOKIES environment variable", len(cred.cookies)) return cred @@ -255,34 +306,49 @@ def _iter_chrome_cookie_files(browser_name: str) -> list[str]: if base_dir is None: return [] + roots: list[str] = [] if sys.platform == "darwin": - root = os.path.join(os.path.expanduser("~"), "Library", "Application Support", base_dir) + roots.append(os.path.join(os.path.expanduser("~"), "Library", "Application Support", base_dir)) elif sys.platform == "win32": if browser_name == "edge": - root = os.path.join(os.environ.get("LOCALAPPDATA", ""), "Microsoft", "Edge", "User Data") + roots.append(os.path.join(os.environ.get("LOCALAPPDATA", ""), "Microsoft", "Edge", "User Data")) else: - root = os.path.join(os.environ.get("LOCALAPPDATA", ""), base_dir) + roots.append(os.path.join(os.environ.get("LOCALAPPDATA", ""), base_dir)) else: if browser_name == "edge": - root = os.path.join(os.path.expanduser("~"), ".config", "microsoft-edge") + roots.append(os.path.join(os.path.expanduser("~"), ".config", "microsoft-edge")) + elif browser_name == "chrome": + # Chrome on Linux usually uses google-chrome (lowercase) instead of Google/Chrome. + roots.append(os.path.join(os.path.expanduser("~"), ".config", "google-chrome")) + roots.append(os.path.join(os.path.expanduser("~"), ".config", base_dir)) else: - root = os.path.join(os.path.expanduser("~"), ".config", base_dir) - - if not os.path.isdir(root): - return [] + roots.append(os.path.join(os.path.expanduser("~"), ".config", base_dir)) paths: list[str] = [] - default_cookies = os.path.join(root, "Default", "Cookies") - if os.path.exists(default_cookies): - paths.append(default_cookies) + for root in roots: + if not os.path.isdir(root): + continue + default_cookies = os.path.join(root, "Default", "Cookies") + if os.path.exists(default_cookies): + paths.append(default_cookies) - profile_dirs = sorted(glob.glob(os.path.join(root, "Profile *"))) - for profile_dir in profile_dirs: - cookie_file = os.path.join(profile_dir, "Cookies") - if os.path.exists(cookie_file): - paths.append(cookie_file) + profile_dirs = sorted(glob.glob(os.path.join(root, "Profile *"))) + for profile_dir in profile_dirs: + cookie_file = os.path.join(profile_dir, "Cookies") + if os.path.exists(cookie_file): + paths.append(cookie_file) - return paths + if not paths: + return [] + + def _mtime(path: str) -> float: + try: + return os.path.getmtime(path) + except OSError: + return 0.0 + + # Prefer the most recently updated cookie DB (likely the active profile). + return sorted(paths, key=_mtime, reverse=True) def _extract_cookies_from_jar(jar: Any, source: str = "unknown") -> dict[str, str] | None: @@ -415,29 +481,41 @@ def iter_cookie_files(browser_name): base_dir = CHROMIUM_BASE_DIRS.get(browser_name) if base_dir is None: return [] + roots = [] if sys.platform == "darwin": - root = os.path.join(os.path.expanduser("~"), "Library", "Application Support", base_dir) + roots.append(os.path.join(os.path.expanduser("~"), "Library", "Application Support", base_dir)) elif sys.platform == "win32": if browser_name == "edge": - root = os.path.join(os.environ.get("LOCALAPPDATA", ""), "Microsoft", "Edge", "User Data") + roots.append(os.path.join(os.environ.get("LOCALAPPDATA", ""), "Microsoft", "Edge", "User Data")) else: - root = os.path.join(os.environ.get("LOCALAPPDATA", ""), base_dir) + roots.append(os.path.join(os.environ.get("LOCALAPPDATA", ""), base_dir)) else: if browser_name == "edge": - root = os.path.join(os.path.expanduser("~"), ".config", "microsoft-edge") + roots.append(os.path.join(os.path.expanduser("~"), ".config", "microsoft-edge")) + elif browser_name == "chrome": + roots.append(os.path.join(os.path.expanduser("~"), ".config", "google-chrome")) + roots.append(os.path.join(os.path.expanduser("~"), ".config", base_dir)) else: - root = os.path.join(os.path.expanduser("~"), ".config", base_dir) - if not os.path.isdir(root): - return [] + roots.append(os.path.join(os.path.expanduser("~"), ".config", base_dir)) paths = [] - d = os.path.join(root, "Default", "Cookies") - if os.path.exists(d): - paths.append(d) - for pd in sorted(glob.glob(os.path.join(root, "Profile *"))): - cf = os.path.join(pd, "Cookies") - if os.path.exists(cf): - paths.append(cf) - return paths + for root in roots: + if not os.path.isdir(root): + continue + d = os.path.join(root, "Default", "Cookies") + if os.path.exists(d): + paths.append(d) + for pd in sorted(glob.glob(os.path.join(root, "Profile *"))): + cf = os.path.join(pd, "Cookies") + if os.path.exists(cf): + paths.append(cf) + if not paths: + return [] + def _mtime(path): + try: + return os.path.getmtime(path) + except OSError: + return 0 + return sorted(paths, key=_mtime, reverse=True) browsers = [ ("chrome", bc3.chrome), diff --git a/boss_cli/cli.py b/boss_cli/cli.py index 76bba64..ad06ce4 100644 --- a/boss_cli/cli.py +++ b/boss_cli/cli.py @@ -37,6 +37,7 @@ def cli(ctx, verbose: bool) -> None: cli.add_command(auth.login) cli.add_command(auth.logout) +cli.add_command(auth.cookie_server) cli.add_command(auth.status) cli.add_command(auth.me) diff --git a/boss_cli/commands/auth.py b/boss_cli/commands/auth.py index fffd747..2f9d432 100644 --- a/boss_cli/commands/auth.py +++ b/boss_cli/commands/auth.py @@ -21,9 +21,17 @@ @click.command() @click.option("--qrcode", is_flag=True, help="使用二维码扫码登录") @click.option("--cookie-source", default=None, help="指定浏览器 (chrome/firefox/edge/brave/arc/safari等)") -def login(qrcode: bool, cookie_source: str | None) -> None: +@click.option("--cookie-file", default=None, help="从文件导入 Cookie (credential.json 或 cookie 字符串)") +@click.option("--cookie", "cookie_string", default=None, help="直接传入 Cookie 字符串 (k=v; k2=v2)") +def login(qrcode: bool, cookie_source: str | None, cookie_file: str | None, cookie_string: str | None) -> None: """扫码登录 Boss 直聘 APP""" - from ..auth import clear_credential, verify_credential + from ..auth import ( + clear_credential, + load_from_cookie_file, + load_from_cookie_string, + save_credential, + verify_credential, + ) def _finalize_login(cred, *, from_qr: bool = False) -> None: # QR login cannot obtain __zp_stoken__ (generated by JS). @@ -43,6 +51,18 @@ def _finalize_login(cred, *, from_qr: bool = False) -> None: if authenticated: console.print(f"[green]✅ 登录成功![/green] ({len(cred.cookies)} cookies)") return + + # Retry once if __zp_stoken__ looks stale and we have a browser source. + if not from_qr and message and "__zp_stoken__" in message: + from ..auth import extract_browser_credential + console.print("[yellow]⚠️ 检测到 __zp_stoken__ 失效,尝试重新读取浏览器 Cookie...[/yellow]") + refreshed, _ = extract_browser_credential(cookie_source=cookie_source) + if refreshed: + authenticated, message = verify_credential(refreshed, force_refresh=True) + if authenticated: + console.print(f"[green]✅ 登录成功![/green] ({len(refreshed.cookies)} cookies)") + return + clear_credential() console.print("[red]❌ 登录失败:凭证未通过实际接口校验[/red]") if message: @@ -55,6 +75,22 @@ def _finalize_login(cred, *, from_qr: bool = False) -> None: ) raise SystemExit(1) + if cookie_file or cookie_string: + cred = None + if cookie_file: + cred = load_from_cookie_file(cookie_file) + if not cred: + console.print(f"[red]❌ 无法从文件加载 Cookie: {cookie_file}[/red]") + raise SystemExit(1) + else: + cred = load_from_cookie_string(cookie_string or "") + if not cred: + console.print("[red]❌ Cookie 字符串格式不正确[/red]") + raise SystemExit(1) + save_credential(cred) + _finalize_login(cred) + return + if qrcode: # Prefer browser-assisted login (captures __zp_stoken__ via JS) # Fallback to HTTP-only QR flow when camoufox is unavailable @@ -129,6 +165,15 @@ def logout() -> None: console.print("[green]✅ 已退出登录[/green]") +@click.command("cookie-server") +@click.option("--host", default="127.0.0.1", show_default=True, help="监听地址") +@click.option("--port", default=9876, show_default=True, type=int, help="监听端口") +def cookie_server(host: str, port: int) -> None: + """启动本地 Cookie Bridge 服务 (供浏览器扩展注入 Cookie)""" + from ..cookie_server import run_cookie_server + run_cookie_server(host=host, port=port) + + @click.command() @structured_output_options def status(as_json: bool, as_yaml: bool) -> None: diff --git a/boss_cli/cookie_server.py b/boss_cli/cookie_server.py new file mode 100644 index 0000000..47cc1ec --- /dev/null +++ b/boss_cli/cookie_server.py @@ -0,0 +1,105 @@ +"""Local cookie bridge server for boss-cli.""" + +from __future__ import annotations + +import json +import logging +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any + +from .auth import Credential, load_from_cookie_string, save_credential + +logger = logging.getLogger(__name__) + + +def _cookie_dict_from_payload(payload: Any) -> dict[str, str]: + if isinstance(payload, dict): + cookies = payload.get("cookies") + if isinstance(cookies, dict): + return {str(k): str(v) for k, v in cookies.items() if v} + cookie_str = payload.get("cookie") + if isinstance(cookie_str, str): + cred = load_from_cookie_string(cookie_str) + return cred.cookies if cred else {} + return {} + + +class _CookieHandler(BaseHTTPRequestHandler): + server_version = "boss-cli-cookie-bridge/0.1" + + def _send_json(self, status: int, payload: dict[str, Any]) -> None: + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS, GET") + self.send_header("Access-Control-Allow-Headers", "Content-Type") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_OPTIONS(self) -> None: # noqa: N802 - required by BaseHTTPRequestHandler + self._send_json(HTTPStatus.NO_CONTENT, {"ok": True}) + + def do_GET(self) -> None: # noqa: N802 + if self.path.rstrip("/") in ("", "/health", "/status"): + self._send_json(HTTPStatus.OK, {"ok": True, "service": "boss-cli-cookie-bridge"}) + return + self._send_json(HTTPStatus.NOT_FOUND, {"ok": False, "error": "not_found"}) + + def do_POST(self) -> None: # noqa: N802 + if self.path.rstrip("/") not in ("", "/cookies", "/ingest"): + self._send_json(HTTPStatus.NOT_FOUND, {"ok": False, "error": "not_found"}) + return + + length = int(self.headers.get("Content-Length", "0") or 0) + raw = self.rfile.read(length).decode("utf-8", "ignore") + + payload: Any = None + if raw: + try: + payload = json.loads(raw) + except json.JSONDecodeError: + payload = raw + + cookies: dict[str, str] = {} + if isinstance(payload, str): + cred = load_from_cookie_string(payload) + cookies = cred.cookies if cred else {} + else: + cookies = _cookie_dict_from_payload(payload) + + if not cookies: + self._send_json(HTTPStatus.BAD_REQUEST, {"ok": False, "error": "no_cookies"}) + return + + cred = Credential(cookies=cookies) + save_credential(cred) + self._send_json( + HTTPStatus.OK, + {"ok": True, "cookie_count": len(cookies)}, + ) + + def log_message(self, format: str, *args: Any) -> None: # noqa: A003 + logger.info("%s - %s", self.address_string(), format % args) + + +def run_cookie_server(host: str = "127.0.0.1", port: int = 9876) -> None: + server = ThreadingHTTPServer((host, port), _CookieHandler) + logger.warning("Cookie bridge listening on http://%s:%d", host, port) + try: + server.serve_forever() + except KeyboardInterrupt: + logger.info("Cookie bridge shutdown requested") + finally: + server.server_close() + + +def main() -> None: + logging.basicConfig(level=logging.INFO, format="%(name)s %(message)s") + run_cookie_server() + + +if __name__ == "__main__": + main() diff --git a/chrome-extension/zhipin-cookie-extractor/manifest.json b/chrome-extension/zhipin-cookie-extractor/manifest.json new file mode 100644 index 0000000..0b9d261 --- /dev/null +++ b/chrome-extension/zhipin-cookie-extractor/manifest.json @@ -0,0 +1,19 @@ +{ + "manifest_version": 3, + "name": "Zhipin Cookie Extractor", + "version": "0.1.0", + "description": "Extract zhipin.com cookies and sync to boss-cli local server.", + "permissions": ["cookies", "storage", "alarms", "downloads", "clipboardWrite"], + "host_permissions": [ + "https://www.zhipin.com/*", + "http://127.0.0.1:9876/*", + "http://localhost:9876/*" + ], + "action": { + "default_popup": "popup.html", + "default_title": "Zhipin Cookie Extractor" + }, + "background": { + "service_worker": "service_worker.js" + } +} diff --git a/chrome-extension/zhipin-cookie-extractor/popup.html b/chrome-extension/zhipin-cookie-extractor/popup.html new file mode 100644 index 0000000..1d614b3 --- /dev/null +++ b/chrome-extension/zhipin-cookie-extractor/popup.html @@ -0,0 +1,64 @@ + + + + + + Zhipin Cookie Extractor + + + +

Zhipin Cookie Extractor

+ +
+ Auto-refresh: + +
+ +
+ + + +
+ +
+ + + + diff --git a/chrome-extension/zhipin-cookie-extractor/popup.js b/chrome-extension/zhipin-cookie-extractor/popup.js new file mode 100644 index 0000000..07df1c6 --- /dev/null +++ b/chrome-extension/zhipin-cookie-extractor/popup.js @@ -0,0 +1,76 @@ +const statusEl = document.getElementById("status"); +const intervalSelect = document.getElementById("interval"); + +function setStatus(text) { + statusEl.textContent = text; +} + +function sendMessage(msg) { + return new Promise((resolve) => { + chrome.runtime.sendMessage(msg, (response) => resolve(response)); + }); +} + +function formatTime(ts) { + if (!ts) return "never"; + const d = new Date(ts); + return d.toLocaleString(); +} + +async function refreshStatus() { + const resp = await sendMessage({ type: "get_status" }); + if (!resp || !resp.ok) { + setStatus("status: unavailable"); + return; + } + const lastSync = formatTime(resp.lastSync); + const error = resp.lastError ? `error: ${resp.lastError}` : "error: none"; + setStatus(`last sync: ${lastSync}\n${error}`); + if (resp.intervalMin) { + intervalSelect.value = String(resp.intervalMin); + } +} + +document.getElementById("sync").addEventListener("click", async () => { + setStatus("syncing..."); + const resp = await sendMessage({ type: "sync" }); + if (resp && resp.ok) { + setStatus(`synced: ${resp.cookieCount} cookies\nlast sync: ${formatTime(resp.lastSync)}`); + } else { + setStatus(`sync failed: ${resp && resp.error ? resp.error : "unknown"}`); + } +}); + +document.getElementById("copy").addEventListener("click", async () => { + const resp = await sendMessage({ type: "get_cookies" }); + if (!resp || !resp.ok) { + setStatus("copy failed: no cookies"); + return; + } + await navigator.clipboard.writeText(resp.cookieString || ""); + setStatus("cookie copied to clipboard"); +}); + +document.getElementById("export").addEventListener("click", async () => { + const resp = await sendMessage({ type: "get_cookies" }); + if (!resp || !resp.ok) { + setStatus("export failed: no cookies"); + return; + } + const blob = new Blob([JSON.stringify({ cookies: resp.cookies }, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + chrome.downloads.download({ + url, + filename: "zhipin-cookies.json", + saveAs: true, + }); + setStatus("exported cookies as JSON"); +}); + +intervalSelect.addEventListener("change", async (e) => { + const val = Number(e.target.value); + await sendMessage({ type: "set_interval", intervalMin: val }); + setStatus(`auto-refresh set to ${val} min`); +}); + +refreshStatus(); diff --git a/chrome-extension/zhipin-cookie-extractor/service_worker.js b/chrome-extension/zhipin-cookie-extractor/service_worker.js new file mode 100644 index 0000000..aaceae3 --- /dev/null +++ b/chrome-extension/zhipin-cookie-extractor/service_worker.js @@ -0,0 +1,107 @@ +const DEFAULT_INTERVAL_MIN = 5; +const ALARM_NAME = "auto-sync"; + +async function getAllCookies() { + return await chrome.cookies.getAll({ domain: "zhipin.com" }); +} + +function cookiesToMap(cookies) { + const map = {}; + for (const c of cookies) { + if (c && c.name && c.value) { + map[c.name] = c.value; + } + } + return map; +} + +function cookiesToString(map) { + const parts = []; + for (const [k, v] of Object.entries(map)) { + parts.push(`${k}=${v}`); + } + return parts.join("; "); +} + +async function sendToLocal(map) { + const resp = await fetch("http://127.0.0.1:9876/cookies", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ cookies: map }), + }); + if (!resp.ok) { + const text = await resp.text(); + throw new Error(`HTTP ${resp.status}: ${text}`); + } + return await resp.json(); +} + +async function syncCookies() { + const cookies = await getAllCookies(); + const map = cookiesToMap(cookies); + if (Object.keys(map).length === 0) { + await chrome.storage.local.set({ lastError: "no_cookies", lastSync: null }); + return { ok: false, error: "no_cookies" }; + } + try { + await sendToLocal(map); + const now = Date.now(); + await chrome.storage.local.set({ lastSync: now, lastError: "" }); + return { ok: true, cookieCount: Object.keys(map).length, lastSync: now }; + } catch (err) { + const msg = err && err.message ? err.message : String(err); + await chrome.storage.local.set({ lastError: msg }); + return { ok: false, error: msg }; + } +} + +async function getSettings() { + const data = await chrome.storage.local.get(["intervalMin"]); + return data.intervalMin || DEFAULT_INTERVAL_MIN; +} + +async function scheduleAlarm() { + const intervalMin = await getSettings(); + await chrome.alarms.create(ALARM_NAME, { periodInMinutes: intervalMin }); +} + +chrome.runtime.onInstalled.addListener(async () => { + await chrome.storage.local.set({ intervalMin: DEFAULT_INTERVAL_MIN }); + await scheduleAlarm(); +}); + +chrome.alarms.onAlarm.addListener(async (alarm) => { + if (alarm && alarm.name === ALARM_NAME) { + await syncCookies(); + } +}); + +chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + (async () => { + if (msg && msg.type === "sync") { + const result = await syncCookies(); + sendResponse(result); + return; + } + if (msg && msg.type === "get_status") { + const data = await chrome.storage.local.get(["lastSync", "lastError", "intervalMin"]); + sendResponse({ ok: true, ...data }); + return; + } + if (msg && msg.type === "get_cookies") { + const cookies = await getAllCookies(); + const map = cookiesToMap(cookies); + sendResponse({ ok: true, cookies: map, cookieString: cookiesToString(map) }); + return; + } + if (msg && msg.type === "set_interval") { + const intervalMin = Number(msg.intervalMin || DEFAULT_INTERVAL_MIN); + await chrome.storage.local.set({ intervalMin }); + await scheduleAlarm(); + sendResponse({ ok: true, intervalMin }); + return; + } + sendResponse({ ok: false, error: "unknown_message" }); + })(); + return true; +}); diff --git a/scripts/cookie_server.py b/scripts/cookie_server.py new file mode 100644 index 0000000..c83d1fc --- /dev/null +++ b/scripts/cookie_server.py @@ -0,0 +1,7 @@ +"""Launch the local cookie bridge server.""" + +from boss_cli.cookie_server import main + + +if __name__ == "__main__": + main() diff --git a/tests/test_cli.py b/tests/test_cli.py index a9edd94..6a5591a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import os from unittest.mock import MagicMock, patch import pytest @@ -81,6 +82,8 @@ def test_login_has_cookie_source(self): result = runner.invoke(cli, ["login", "--help"]) assert "--cookie-source" in result.output assert "--qrcode" in result.output + assert "--cookie-file" in result.output + assert "--cookie" in result.output def test_history_has_options(self): result = runner.invoke(cli, ["history", "--help"]) @@ -361,6 +364,53 @@ def test_cookie_header(self): assert "b=2" in header +class TestCookieImport: + """Test cookie import helpers.""" + + def test_load_from_cookie_string(self): + from boss_cli.auth import load_from_cookie_string + cred = load_from_cookie_string("a=1; b=2") + assert cred is not None + assert cred.cookies["a"] == "1" + assert cred.cookies["b"] == "2" + + def test_load_from_cookie_file_json(self, tmp_path): + from boss_cli.auth import load_from_cookie_file + path = tmp_path / "cred.json" + path.write_text('{"cookies":{"a":"1","b":"2"}}', encoding="utf-8") + cred = load_from_cookie_file(str(path)) + assert cred is not None + assert cred.cookies["a"] == "1" + assert cred.cookies["b"] == "2" + + def test_iter_chrome_cookie_files_prefers_recent_profile(self, tmp_path, monkeypatch): + import boss_cli.auth as auth + + home = tmp_path + root = home / ".config" / "google-chrome" + default = root / "Default" / "Cookies" + profile1 = root / "Profile 1" / "Cookies" + profile2 = root / "Profile 2" / "Cookies" + profile2.parent.mkdir(parents=True, exist_ok=True) + profile1.parent.mkdir(parents=True, exist_ok=True) + default.parent.mkdir(parents=True, exist_ok=True) + default.write_text("a", encoding="utf-8") + profile1.write_text("b", encoding="utf-8") + profile2.write_text("c", encoding="utf-8") + + # Force mtime ordering: Profile 2 is newest + os.utime(default, (1, 1)) + os.utime(profile1, (2, 2)) + os.utime(profile2, (3, 3)) + + orig_expanduser = auth.os.path.expanduser + monkeypatch.setattr(auth.sys, "platform", "linux") + monkeypatch.setattr(auth.os.path, "expanduser", lambda p: str(home) if p == "~" else orig_expanduser(p)) + + paths = auth._iter_chrome_cookie_files("chrome") + assert paths[0].endswith("Profile 2/Cookies") + + # ── Exceptions ────────────────────────────────────────────────────── From 12b04fa3e493baa36f60344a7da9ac8a0abe1a17 Mon Sep 17 00:00:00 2001 From: jackwener Date: Mon, 13 Apr 2026 17:08:54 +0800 Subject: [PATCH 15/16] sec: harden cookie bridge --- README.md | 6 +-- boss_cli/commands/auth.py | 5 +- boss_cli/cookie_server.py | 20 ++++++-- .../zhipin-cookie-extractor/popup.html | 5 ++ .../zhipin-cookie-extractor/popup.js | 17 +++++++ .../zhipin-cookie-extractor/service_worker.js | 17 +++++-- tests/test_cli.py | 50 +++++++++++++++++++ 7 files changed, 107 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index ed90df5..61d40ed 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ boss login --cookie-source chrome # Extract from specific browser boss login --qrcode # QR code login only boss login --cookie-file /path/cred.json # Import cookies from file boss login --cookie "k=v; k2=v2" # Import cookies from string -boss cookie-server # Start local cookie bridge server +boss cookie-server --token xxx # Start local cookie bridge server boss status # Check login status (validates real search session, shows cookie names) boss logout # Clear saved cookies @@ -237,12 +237,12 @@ Some cookies (notably `__zp_stoken__`) are generated by browser JS and may not b The included Chrome extension can extract cookies from browser memory and push them to a local bridge. 1. Start the local bridge: - - `boss cookie-server` + - `boss cookie-server --token ` 2. Load the extension: - Open `chrome://extensions/` - Enable Developer mode - Load unpacked: `chrome-extension/zhipin-cookie-extractor` -3. Log in to `https://www.zhipin.com`, click the extension, then **Sync**. +3. Log in to `https://www.zhipin.com`, click the extension, set the token, then **Sync**. ### Cookie TTL & Auto-Refresh diff --git a/boss_cli/commands/auth.py b/boss_cli/commands/auth.py index 2f9d432..dc5c8bb 100644 --- a/boss_cli/commands/auth.py +++ b/boss_cli/commands/auth.py @@ -168,10 +168,11 @@ def logout() -> None: @click.command("cookie-server") @click.option("--host", default="127.0.0.1", show_default=True, help="监听地址") @click.option("--port", default=9876, show_default=True, type=int, help="监听端口") -def cookie_server(host: str, port: int) -> None: +@click.option("--token", default="", help="访问令牌 (请求头 X-Boss-Cookie-Token)") +def cookie_server(host: str, port: int, token: str) -> None: """启动本地 Cookie Bridge 服务 (供浏览器扩展注入 Cookie)""" from ..cookie_server import run_cookie_server - run_cookie_server(host=host, port=port) + run_cookie_server(host=host, port=port, token=token or None) @click.command() diff --git a/boss_cli/cookie_server.py b/boss_cli/cookie_server.py index 47cc1ec..f978c11 100644 --- a/boss_cli/cookie_server.py +++ b/boss_cli/cookie_server.py @@ -7,6 +7,7 @@ from http import HTTPStatus from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Any +from urllib.parse import urlparse from .auth import Credential, load_from_cookie_string, save_credential @@ -27,6 +28,7 @@ def _cookie_dict_from_payload(payload: Any) -> dict[str, str]: class _CookieHandler(BaseHTTPRequestHandler): server_version = "boss-cli-cookie-bridge/0.1" + auth_token: str | None = None def _send_json(self, status: int, payload: dict[str, Any]) -> None: body = json.dumps(payload, ensure_ascii=False).encode("utf-8") @@ -34,7 +36,7 @@ def _send_json(self, status: int, payload: dict[str, Any]) -> None: self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Access-Control-Allow-Origin", "*") self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS, GET") - self.send_header("Access-Control-Allow-Headers", "Content-Type") + self.send_header("Access-Control-Allow-Headers", "Content-Type, X-Boss-Cookie-Token") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) @@ -43,15 +45,22 @@ def do_OPTIONS(self) -> None: # noqa: N802 - required by BaseHTTPRequestHandler self._send_json(HTTPStatus.NO_CONTENT, {"ok": True}) def do_GET(self) -> None: # noqa: N802 - if self.path.rstrip("/") in ("", "/health", "/status"): + path = urlparse(self.path).path.rstrip("/") + if path in ("", "/health", "/status"): self._send_json(HTTPStatus.OK, {"ok": True, "service": "boss-cli-cookie-bridge"}) return self._send_json(HTTPStatus.NOT_FOUND, {"ok": False, "error": "not_found"}) def do_POST(self) -> None: # noqa: N802 - if self.path.rstrip("/") not in ("", "/cookies", "/ingest"): + path = urlparse(self.path).path.rstrip("/") + if path not in ("", "/cookies", "/ingest"): self._send_json(HTTPStatus.NOT_FOUND, {"ok": False, "error": "not_found"}) return + if self.auth_token: + token = self.headers.get("X-Boss-Cookie-Token", "") + if token != self.auth_token: + self._send_json(HTTPStatus.UNAUTHORIZED, {"ok": False, "error": "unauthorized"}) + return length = int(self.headers.get("Content-Length", "0") or 0) raw = self.rfile.read(length).decode("utf-8", "ignore") @@ -85,7 +94,10 @@ def log_message(self, format: str, *args: Any) -> None: # noqa: A003 logger.info("%s - %s", self.address_string(), format % args) -def run_cookie_server(host: str = "127.0.0.1", port: int = 9876) -> None: +def run_cookie_server(host: str = "127.0.0.1", port: int = 9876, token: str | None = None) -> None: + if host not in ("127.0.0.1", "localhost", "::1"): + raise RuntimeError("Cookie bridge只允许绑定到本地回环地址(127.0.0.1/localhost/::1)") + _CookieHandler.auth_token = token server = ThreadingHTTPServer((host, port), _CookieHandler) logger.warning("Cookie bridge listening on http://%s:%d", host, port) try: diff --git a/chrome-extension/zhipin-cookie-extractor/popup.html b/chrome-extension/zhipin-cookie-extractor/popup.html index 1d614b3..8cccaaf 100644 --- a/chrome-extension/zhipin-cookie-extractor/popup.html +++ b/chrome-extension/zhipin-cookie-extractor/popup.html @@ -51,6 +51,11 @@

Zhipin Cookie Extractor

+
+ Token: + +
+
diff --git a/chrome-extension/zhipin-cookie-extractor/popup.js b/chrome-extension/zhipin-cookie-extractor/popup.js index 07df1c6..2518c2f 100644 --- a/chrome-extension/zhipin-cookie-extractor/popup.js +++ b/chrome-extension/zhipin-cookie-extractor/popup.js @@ -1,5 +1,6 @@ const statusEl = document.getElementById("status"); const intervalSelect = document.getElementById("interval"); +const tokenInput = document.getElementById("token"); function setStatus(text) { statusEl.textContent = text; @@ -29,6 +30,9 @@ async function refreshStatus() { if (resp.intervalMin) { intervalSelect.value = String(resp.intervalMin); } + if (resp.token) { + tokenInput.value = resp.token; + } } document.getElementById("sync").addEventListener("click", async () => { @@ -73,4 +77,17 @@ intervalSelect.addEventListener("change", async (e) => { setStatus(`auto-refresh set to ${val} min`); }); +tokenInput.addEventListener("change", async (e) => { + const val = e.target.value || ""; + await sendMessage({ type: "set_token", token: val }); + setStatus("token updated"); +}); + +tokenInput.addEventListener("keyup", async (e) => { + if (e.key !== "Enter") return; + const val = e.target.value || ""; + await sendMessage({ type: "set_token", token: val }); + setStatus("token updated"); +}); + refreshStatus(); diff --git a/chrome-extension/zhipin-cookie-extractor/service_worker.js b/chrome-extension/zhipin-cookie-extractor/service_worker.js index aaceae3..07debd2 100644 --- a/chrome-extension/zhipin-cookie-extractor/service_worker.js +++ b/chrome-extension/zhipin-cookie-extractor/service_worker.js @@ -23,10 +23,13 @@ function cookiesToString(map) { return parts.join("; "); } -async function sendToLocal(map) { +async function sendToLocal(map, token) { const resp = await fetch("http://127.0.0.1:9876/cookies", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + "X-Boss-Cookie-Token": token || "", + }, body: JSON.stringify({ cookies: map }), }); if (!resp.ok) { @@ -44,7 +47,8 @@ async function syncCookies() { return { ok: false, error: "no_cookies" }; } try { - await sendToLocal(map); + const { token } = await chrome.storage.local.get(["token"]); + await sendToLocal(map, token || ""); const now = Date.now(); await chrome.storage.local.set({ lastSync: now, lastError: "" }); return { ok: true, cookieCount: Object.keys(map).length, lastSync: now }; @@ -84,7 +88,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { return; } if (msg && msg.type === "get_status") { - const data = await chrome.storage.local.get(["lastSync", "lastError", "intervalMin"]); + const data = await chrome.storage.local.get(["lastSync", "lastError", "intervalMin", "token"]); sendResponse({ ok: true, ...data }); return; } @@ -101,6 +105,11 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { sendResponse({ ok: true, intervalMin }); return; } + if (msg && msg.type === "set_token") { + await chrome.storage.local.set({ token: String(msg.token || "") }); + sendResponse({ ok: true }); + return; + } sendResponse({ ok: false, error: "unknown_message" }); })(); return true; diff --git a/tests/test_cli.py b/tests/test_cli.py index 6a5591a..18241c1 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import io import os from unittest.mock import MagicMock, patch @@ -411,6 +412,55 @@ def test_iter_chrome_cookie_files_prefers_recent_profile(self, tmp_path, monkeyp assert paths[0].endswith("Profile 2/Cookies") +class TestCookieServer: + """Test cookie bridge ingestion.""" + + def test_cookie_server_rejects_unauthorized(self): + from boss_cli import cookie_server + + handler = cookie_server._CookieHandler + handler.auth_token = "secret" + inst = object.__new__(handler) + inst.headers = {"X-Boss-Cookie-Token": "wrong"} + inst.path = "/cookies" + inst.rfile = io.BytesIO(b'{"cookies":{"a":"1"}}') + inst.wfile = io.BytesIO() + + def _send_json(status, payload): + assert status == 401 + assert payload["error"] == "unauthorized" + + inst._send_json = _send_json # type: ignore[assignment] + inst.do_POST() + + def test_cookie_server_accepts_cookie_string(self, monkeypatch): + from boss_cli import cookie_server + + handler = cookie_server._CookieHandler + handler.auth_token = None + inst = object.__new__(handler) + inst.headers = {"Content-Length": "8"} + inst.path = "/cookies" + inst.rfile = io.BytesIO(b"a=1; b=2") + inst.wfile = io.BytesIO() + + saved: dict[str, str] = {} + + def fake_save_credential(cred): + saved.update(cred.cookies) + + monkeypatch.setattr(cookie_server, "save_credential", fake_save_credential) + + def _send_json(status, payload): + assert status == 200 + assert payload["ok"] is True + + inst._send_json = _send_json # type: ignore[assignment] + inst.do_POST() + assert saved["a"] == "1" + assert saved["b"] == "2" + + # ── Exceptions ────────────────────────────────────────────────────── From 552f1450e92a5d2adb71379559bc628b952e269f Mon Sep 17 00:00:00 2001 From: jackwener Date: Mon, 13 Apr 2026 22:17:08 +0800 Subject: [PATCH 16/16] docs: update Chinese README auth login guidance --- README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 61d40ed..36046e8 100644 --- a/README.md +++ b/README.md @@ -344,7 +344,7 @@ Check your city filter. Some keywords are city-specific. Use `boss cities` to se ## 功能特性 -- 🔐 **认证** — 自动提取浏览器 Cookie(10+ 浏览器),二维码扫码登录,`--cookie-source` 指定浏览器 +- 🔐 **认证** — 自动提取浏览器 Cookie(10+ 浏览器),二维码扫码登录,支持 `--cookie-source` / `--cookie-file` / `--cookie` - 🔍 **搜索** — 按关键词搜索职位,支持城市/薪资/经验/学历/行业/规模/融资阶段/职位类型筛选 - ⭐ **推荐** — 基于求职期望的个性化推荐 - 📋 **详情 & 导出** — 职位详情,编号导航 (`boss show 3`),CSV/JSON 导出 @@ -367,7 +367,8 @@ boss login --cookie-source chrome # 指定浏览器 boss status # 检查登录状态 boss logout # 清除 Cookie boss login --cookie-file /path/cred.json # 从文件导入 Cookie -boss cookie-server # 启动本地 Cookie Bridge +boss login --cookie "k=v; k2=v2" # 从字符串导入 Cookie +boss cookie-server --token xxx # 启动本地 Cookie Bridge # 搜索 & 详情 boss search "golang" --city 杭州 # 按城市搜索 @@ -450,8 +451,9 @@ boss recruiter export -o candidates.csv # 导出候选人 - `boss status --json` 3. **Chrome 扩展实时同步** - - `boss cookie-server` + - `boss cookie-server --token ` - 在 Chrome 中加载 `chrome-extension/zhipin-cookie-extractor` + - 在扩展里填写相同的 `token` 并点击 Sync ## License