diff --git a/README.md b/README.md index 7cfe9ac..33770f5 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,76 @@ 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 +# โ”€โ”€โ”€ Search & Discover (ๆœ็ดข & ๅ‘็Žฐ) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +boss recruiter search "golang" --city ๆทฑๅœณ --exp 3-5ๅนด # Search candidates +boss recruiter recommend --job # ๆŽจ่็‰›ไบบ (paginated, 15/page) +boss recruiter recommend --job -p 2 # Next page (use hasMore flag) + +# โ”€โ”€โ”€ Greet & Communicate (ๆฒŸ้€š) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +boss recruiter greet # Initiate chat with candidate +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 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 geek --job-id 526908510 # Quick candidate info + +# โ”€โ”€โ”€ 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 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 + +# 2. Browse recommended candidates for a specific job +boss recruiter recommend --job f806096ea327cd610nZ80t21FVNQ + +# 3. Search for specific skills +boss recruiter search "golang" --city ๆทฑๅœณ + +# 4. View a candidate's full resume +boss recruiter resume --job + +# 5. Download resume for offline review +boss recruiter resume-download --job + +# 6. Start a conversation +boss recruiter greet + +# 7. Check inbox and reply +boss recruiter inbox -p 1 +boss recruiter reply "ๆ„Ÿ่ฐขๆ‚จ็š„ๅ…ณๆณจ๏ผŒๆ–นไพฟ็”ต่ฏ่Š่Šๅ—๏ผŸ" + +# 8. Export all candidates +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 +274,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 +328,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 +367,41 @@ boss cities # ๅŸŽๅธ‚ๅˆ—่กจ boss -v search "Python" # ่ฏฆ็ป†ๆ—ฅๅฟ— ``` +## ๆ‹›่˜ๆ–นๆจกๅผ + +```bash +# ๆœ็ดข & ๆŽจ่ +boss recruiter search "golang" --city ๆทฑๅœณ --exp 3-5ๅนด +boss recruiter recommend --job # ๆŽจ่็‰›ไบบ (15/้กต, ็œŸๅˆ†้กต) +boss recruiter recommend --job -p 2 # ็ฟป้กต + +# ๆฒŸ้€š +boss recruiter greet # ๅ‘ๅ€™้€‰ไบบๆ‰“ๆ‹›ๅ‘ผ +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 + +# ่Œไฝ็ฎก็† +boss recruiter jobs # ๆŸฅ็œ‹ๆ‹›่˜่Œไฝ +boss recruiter job-close # ๅ…ณ้—ญ่Œไฝ +boss recruiter job-reopen # ้‡ๆ–ฐๅผ€ๅฏ + +# ๅฏผๅ‡บ +boss recruiter labels # ๆŸฅ็œ‹ๆ ‡็ญพ +boss recruiter export -o candidates.csv # ๅฏผๅ‡บๅ€™้€‰ไบบ +``` + ## ๅธธ่ง้—ฎ้ข˜ - `็Žฏๅขƒๅผ‚ๅธธ` โ€” Cookie ่ฟ‡ๆœŸ๏ผŒๆ‰ง่กŒ `boss logout && boss login` ๅˆทๆ–ฐ diff --git a/boss_cli/cli.py b/boss_cli/cli.py index 7a0703c..76bba64 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,10 @@ 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) + if __name__ == "__main__": cli() diff --git a/boss_cli/client.py b/boss_cli/client.py index 05e38f9..a6eaa42 100644 --- a/boss_cli/client.py +++ b/boss_cli/client.py @@ -13,6 +13,29 @@ from .constants import ( 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, + BOSS_FRIEND_NOTE_URL, + BOSS_GREET_REC_SORT_URL, + BOSS_REC_GEEK_LIST_URL, + BOSS_CHAT_START_URL, + BOSS_GREET_SORT_LIST_URL, + BOSS_HISTORY_MSG_URL, + BOSS_INTERVIEW_INVITE_URL, + 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, FRIEND_ADD_URL, @@ -28,6 +51,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, @@ -154,8 +178,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"): @@ -171,6 +200,23 @@ 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 == BOSS_SEARCH_GEEK_URL: + headers["Referer"] = f"{BASE_URL}/web/chat/search" + elif url == BOSS_REC_GEEK_LIST_URL: + headers["Referer"] = f"{BASE_URL}/web/frame/recommend/" + elif url == BOSS_CHAT_START_URL: + headers["Referer"] = f"{BASE_URL}/web/frame/recommend/" + elif url in (BOSS_VIEW_GEEK_URL, BOSS_SEND_MSG_URL): + headers["Referer"] = WEB_BOSS_CHAT_URL + elif url in (BOSS_FRIEND_LIST_URL, BOSS_FRIEND_DETAIL_URL, BOSS_LAST_MSG_URL, + 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_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 def _handle_response(self, data: dict[str, Any], action: str) -> dict[str, Any]: @@ -186,6 +232,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 @@ -235,6 +288,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) @@ -399,6 +460,250 @@ 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 = "", 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, **kwargs) + 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 = "", page: int = 1) -> dict[str, Any]: + """Get boss friend list (candidates who have chatted).""" + data: dict[str, Any] = {"labelId": label_id, "page": page} + 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="้ข่ฏ•ๅˆ—่กจ") + + 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 the "ๆŽจ่็‰›ไบบ" feed โ€” the candidate-discovery list shown on the recruiter + recommend page. Truly paginated (15/page), with `hasMore` flag. + + This used to hit ``greetRecSortList`` (a re-sort of already-greeted candidates, + ~10 per job and `page` was a no-op). Now it hits the real ``/wapi/zpjob/rec/geek/list`` + endpoint that the BOSS recruiter web page calls on infinite scroll. + """ + if not enc_job_id: + raise ValueError("enc_job_id (encryptJobId) is required for ๆŽจ่็‰›ไบบ โ€” pass --job") + params: dict[str, Any] = { + "age": "16,-1", + "school": 0, + "activation": 0, + "gender": 0, + "recentNotView": 0, + "exchangeResumeWithColleague": 0, + "major": 0, + "keyword1": -1, + "switchJobFrequency": 0, + "experience": 0, + "degree": 0, + "intention": 0, + "salary": 0, + "jobId": enc_job_id, + "page": page, + "coverScreenMemory": 0, + "cardType": 0, + } + return self._get(BOSS_REC_GEEK_LIST_URL, params=params, action="ๆŽจ่็‰›ไบบ") + + def get_boss_view_geek( + self, encrypt_geek_id: str = "", encrypt_job_id: str = "", security_id: str = "", + ) -> dict[str, Any]: + """Get full candidate resume/profile view. + + BOSS prefers ``securityId`` alone โ€” that's what the web page sends. + The ``encryptGeekId + encryptJobId`` combo returns ``code=2 ๆœช็Ÿฅ็š„้žๆณ•ๅ‚ๆ•ฐ``. + """ + params: dict[str, Any] = {} + if security_id: + params["securityId"] = security_id + else: + if not (encrypt_geek_id and encrypt_job_id): + raise ValueError("view_geek needs either security_id, or (encrypt_geek_id + encrypt_job_id)") + params["encryptGeekId"] = encrypt_geek_id + params["encryptJobId"] = encrypt_job_id + return self._get(BOSS_VIEW_GEEK_URL, params=params, action="ๅ€™้€‰ไบบ็ฎ€ๅކ") + + def boss_chat_start( + self, encrypt_geek_id: str, encrypt_job_id: str, security_id: str, + expect_id: int | str = "", lid: str = "", greet: str = "", + ) -> dict[str, Any]: + """Open a chat with a candidate ("ๆ‰“ๆ‹›ๅ‘ผ"), optionally with a custom first message. + + Hits ``/wapi/zpjob/chat/start`` โ€” the same endpoint the recruiter web UI + calls when you click the "ๆ‰“ๆ‹›ๅ‘ผ" button. Returns ``zpData.geekId`` (the + numeric ID needed by ``boss_send_message``). + + If ``greet`` is non-empty, BOSS delivers it as the first message in the + chat โ€” saves a separate ``sendReplyMsg`` round-trip. + """ + data = { + "gid": encrypt_geek_id, + "suid": "", + "jid": encrypt_job_id, + "expectId": str(expect_id) if expect_id else "", + "lid": lid, + "greet": greet, + "from": "", + "securityId": security_id, + "customGreetingGuide": "-1", + } + return self._post(BOSS_CHAT_START_URL, data=data, action="ๆ‰“ๆ‹›ๅ‘ผ") + + def boss_send_message(self, gid: int, content: str) -> dict[str, Any]: + """Send a text message to a candidate as a recruiter.""" + return self._post( + BOSS_SEND_MSG_URL, + data={"gid": gid, "content": content}, + 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="ๅผ€ๅฏ่Œไฝ") + + # โ”€โ”€ 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, "gid": uid}, + 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="็บฆ้ข่ฏ•", json_body=True) + + 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 new file mode 100644 index 0000000..e38f18f --- /dev/null +++ b/boss_cli/commands/recruiter.py @@ -0,0 +1,1288 @@ +"""Recruiter (Boss) commands โ€” Click subcommand group with 8+ commands.""" + +from __future__ import annotations + +import csv +import io +import json +import logging +import time + +import click +from rich.panel import Panel +from rich.table import Table + +from ..client import BossClient, resolve_city +from ..constants import DEGREE_CODES, EXP_CODES, SALARY_CODES +from ..exceptions import BossApiError +from ._common import ( + console, + handle_command, + require_auth, + run_client_action, + structured_output_options, +) + +logger = logging.getLogger(__name__) + + +@click.group() +def recruiter() -> None: + """ๆ‹›่˜ๆ–น/้›‡ไธป็ซฏๆ“ไฝœ (Recruiter mode)""" + + +# โ”€โ”€ recruiter jobs โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +@recruiter.command("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 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", "-")), + ) + + 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("-n", "--limit", "display_limit", default=0, type=int, help="ๆ˜พ็คบๆ•ฐ้‡ (0=ๅ…จ้ƒจ)") +@click.option("-p", "--page", default=1, type=int, help="้กต็  (้ป˜่ฎค: 1, ็œŸๅˆ†้กต, 15/้กต)") +@click.option("--job", "enc_job_id", required=True, help="็›ฎๆ ‡่Œไฝ encryptJobId (ๅฟ…ๅกซ)") +@structured_output_options +def recruiter_recommend( + display_limit: int, page: int, enc_job_id: str, + as_json: bool, as_yaml: bool, +) -> None: + """ๆŽจ่็‰›ไบบ (ๅ€™้€‰ไบบๅ‘็Žฐๆฑ ) โ€” ็œŸๅˆ†้กต, ๆฏ้กต 15 ไบบ, ๆœ‰ hasMore ๆ ‡ๅฟ—ใ€‚ + + ไธŽ BOSS ็›ด่˜็ฝ‘้กต็ซฏ "ๆŽจ่็‰›ไบบ" ไธ€่‡ด, ๆ”ฏๆŒๆปšๅŠจ็ฟป้กต็ปง็ปญๆ‹‰ๅ–ๅ€™้€‰ไบบใ€‚ + """ + cred = require_auth() + + def _action(c: BossClient) -> dict: + return c.get_boss_recommend_geeks(page=page, enc_job_id=enc_job_id) + + def _render(data: dict) -> None: + geek_list = data.get("geekList", []) + has_more = data.get("hasMore", False) + total = len(geek_list) + + if not geek_list: + console.print("[yellow]ๆš‚ๆ— ๆŽจ่ๅ€™้€‰ไบบ (่ฏฅๅฒ—ไฝๆŽจ่ๆฑ ๅฏ่ƒฝๅทฒ่€—ๅฐฝ, ๆˆ–ๆขๅฒ—ไฝ่ฏ•่ฏ•)[/yellow]") + return + + if display_limit > 0: + geek_list = geek_list[:display_limit] + + more_hint = " ยท ๆœ‰ๆ›ดๅคš (็ฟป้กต -p)" if has_more else " ยท ๅทฒๅˆฐๆœซ้กต" + table = Table( + title=f"ๆŽจ่็‰›ไบบ ็ฌฌ {page} ้กต (ๆ˜พ็คบ {len(geek_list)}/{total} ไบบ){more_hint}", + show_lines=True, + ) + table.add_column("#", style="dim", width=3) + table.add_column("ๅง“ๅ", style="bold cyan", max_width=10) + table.add_column("ๅนด้พ„", max_width=5) + table.add_column("็ป้ชŒ", max_width=10) + table.add_column("ๅญฆๅކ", max_width=6) + table.add_column("ๅŸŽๅธ‚", style="green", max_width=10) + table.add_column("ๆœŸๆœ›่–ช่ต„", max_width=10) + table.add_column("ๆดป่ทƒ", style="dim", max_width=10) + table.add_column("encryptGeekId", style="dim", max_width=30) + + for i, g in enumerate(geek_list, 1): + card = g.get("geekCard", {}) if isinstance(g.get("geekCard"), dict) else g + table.add_row( + str(i), + card.get("geekName", "-"), + str(card.get("ageDesc", "-")), + str(card.get("workExpDesc") or card.get("geekWorkYear") or "-"), + str(card.get("degreeCategory") or card.get("geekDegree") or "-"), + str(card.get("cityName") or card.get("expectLocationName") or "-"), + str(card.get("salary") or card.get("salaryDesc") or "-"), + str(card.get("activeTimeDesc") or card.get("actionDateDesc") or "-"), + card.get("encryptGeekId") or g.get("encryptGeekId", "-"), + ) + + console.print(table) + + next_page = page + 1 + if has_more: + console.print(f" [dim]โ†’ ไธ‹ไธ€้กต: boss recruiter recommend --job {enc_job_id} -p {next_page}[/dim]") + console.print(f" [dim] ๅฏผๅ‡บ: boss recruiter recommend --job {enc_job_id} -p {page} --json[/dim]") + console.print(" [dim] ๆŸฅ็œ‹่Œไฝ: boss recruiter jobs[/dim]") + + handle_command(cred, action=_action, render=_render, as_json=as_json, as_yaml=as_yaml) + + +# โ”€โ”€ recruiter greet โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +@recruiter.command("greet") +@click.argument("encrypt_geek_id") +@click.option("--job", "encrypt_job_id", required=True, help="็›ฎๆ ‡่Œไฝ encryptJobId (ๅฟ…ๅกซ)") +@click.option("--security-id", "security_id", required=True, help="ๅ€™้€‰ไบบ securityId (ไปŽ recommend/search ็ป“ๆžœ่Žทๅ–)") +@click.option("--expect-id", "expect_id", default="", help="ๅ€™้€‰ไบบ expectId (ๅฏ้€‰, ๆๅ‡้€่พพ็އ)") +@click.option("--lid", default="", help="ๆŽจ่้“พ่ทฏ lid (ๅฏ้€‰, ๆๅ‡้€่พพ็އ)") +@click.option("-m", "--message", "greet_msg", default="", help="่‡ชๅฎšไน‰้ฆ–ๆกๆถˆๆฏ (็•™็ฉบไฝฟ็”จ BOSS ้ป˜่ฎคๆ‹›ๅ‘ผๆจกๆฟ)") +@structured_output_options +def recruiter_greet( + encrypt_geek_id: str, encrypt_job_id: str, security_id: str, + expect_id: str, lid: str, greet_msg: str, + as_json: bool, as_yaml: bool, +) -> None: + """ๆ‰“ๆ‹›ๅ‘ผ โ€” ๅ‘ๅ€™้€‰ไบบๅ‘่ตทๆฒŸ้€š, ๅฏ้€‰้™„ๅธฆ่‡ชๅฎšไน‰้ฆ–ๆกๆถˆๆฏใ€‚ + + ๆ‰“ๅˆฐ BOSS ็œŸๅฎž็š„ ``/wapi/zpjob/chat/start`` ๆŽฅๅฃ (web ็ซฏ "ๆ‰“ๆ‹›ๅ‘ผ" ๆŒ‰้’ฎ็š„ๅŒๆฌพ)ใ€‚ + ้œ€่ฆ ``--security-id`` (ๅœจ ``recruiter recommend`` / ``recruiter search`` ่ฟ”ๅ›ž้‡Œๆœ‰)ใ€‚ + ไผ  ``-m`` ๆ—ถ, BOSS ไผšๆŠŠๆถˆๆฏไฝœไธบ่Šๅคฉ็š„็ฌฌไธ€ๆกๆŠ•้€’, ็œไธ€ๆฌกๅŽ็ปญ reply ่ฏทๆฑ‚ใ€‚ + """ + cred = require_auth() + + def _action(c: BossClient) -> dict: + return c.boss_chat_start( + encrypt_geek_id=encrypt_geek_id, + encrypt_job_id=encrypt_job_id, + security_id=security_id, + expect_id=expect_id, + lid=lid, + greet=greet_msg, + ) + + def _render(data: dict) -> None: + geek_id = data.get("geekId") + new = data.get("newfriend") == 1 + status = "ๆ–ฐๆฒŸ้€š" if new else "ๅทฒๆฒŸ้€š" + console.print(f"[green]โœ“ ๅทฒๆ‰“ๆ‹›ๅ‘ผ[/green] encryptGeekId={encrypt_geek_id} geekId={geek_id} ็Šถๆ€={status}") + if greet_msg: + console.print(f"[dim]้ฆ–ๆกๆถˆๆฏๅทฒๅ‘้€: {greet_msg[:60]}{'โ€ฆ' if len(greet_msg) > 60 else ''}[/dim]") + else: + console.print("[dim]ไฝฟ็”จไบ† BOSS ้ป˜่ฎคๆ‹›ๅ‘ผๆจกๆฟ; ๅŽ็ปญๅฏ็”จ boss recruiter reply [/dim]") + + handle_command(cred, action=_action, render=_render, as_json=as_json, as_yaml=as_yaml) + + +# โ”€โ”€ 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 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +@recruiter.command("inbox") +@click.option("--job", "enc_job_id", default="", help="ๆŒ‰่Œไฝ encryptJobId ็ญ›้€‰") +@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, 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) + 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")] + + details = c.get_boss_friend_details(friend_ids) + detail_list = details.get("friendList", []) + + 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 + + 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 + + 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) + 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 resume ๆŸฅ็œ‹ๅ€™้€‰ไบบ็ฎ€ๅކ[/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) + + +# โ”€โ”€ 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 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +@recruiter.command("resume") +@click.argument("encrypt_geek_id") +@click.option("--job", "encrypt_job_id", default="", help="ๅ…ณ่”่Œไฝ encryptJobId") +@click.option("--security-id", default="", help="ๅ€™้€‰ไบบ securityId") +@structured_output_options +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: + 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, + ) + + def _render(data: dict) -> None: + if data.get("error"): + console.print(f"[red]{data['error']}[/red]") + return + + # 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"ๅญฆๅކ: {degree} | ๅทฅไฝœๅนด้™: {work_year}\n" + f"ๆฑ‚่Œ็Šถๆ€: {apply_status}\n" + f"\n" + f"[bold yellow]ๆœŸๆœ›:[/bold yellow] {expect_position} | {expect_city} | {expect_salary}\n" + ) + + # 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 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, + ) + + +# โ”€โ”€ 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 +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) + 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) + + +# โ”€โ”€ recruiter geek (legacy - kept as alias for resume) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +@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_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: + 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: + 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 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 + + +# โ”€โ”€ 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 fd11ae1..b82d95f 100644 --- a/boss_cli/constants.py +++ b/boss_cli/constants.py @@ -41,6 +41,39 @@ 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_REC_GEEK_LIST_URL = "/wapi/zpjob/rec/geek/list" +BOSS_CHAT_START_URL = "/wapi/zpjob/chat/start" +BOSS_INTERVIEW_LIST_URL = "/wapi/zpinterview/boss/interview/valid/list" +BOSS_INTERVIEW_DETAIL_URL = "/wapi/zpinterview/boss/interview/detail" +BOSS_GREET_NEW_LIST_URL = "/wapi/zpchat/boss/newgreeting/getHistoryList" +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" +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": (