diff --git a/README.md b/README.md index 7cfe9ac..36046e8 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![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, 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 ๐Ÿค [English](#features) | [ไธญๆ–‡](#ๅŠŸ่ƒฝ็‰นๆ€ง) @@ -31,6 +31,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 @@ -67,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 --token xxx # Start local cookie bridge server boss status # Check login status (validates real search session, shows cookie names) boss logout # Clear saved cookies @@ -113,6 +117,77 @@ 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 # Recommended candidates +boss recruiter recommend --job # Switch to different ๅฒ—ไฝ +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 -n 20 # Filter by job, limit display +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 -n 20 +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)): @@ -136,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. @@ -143,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 --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, set the token, 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. @@ -201,7 +302,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 @@ -242,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 ๅฏผๅ‡บ @@ -254,6 +356,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) ## ไฝฟ็”จ็คบไพ‹ @@ -263,6 +366,9 @@ boss login # ่‡ชๅŠจๆๅ–ๆต่งˆๅ™จ Cookie๏ผŒๅคฑ่ดฅ boss login --cookie-source chrome # ๆŒ‡ๅฎšๆต่งˆๅ™จ boss status # ๆฃ€ๆŸฅ็™ปๅฝ•็Šถๆ€ boss logout # ๆธ…้™ค Cookie +boss login --cookie-file /path/cred.json # ไปŽๆ–‡ไปถๅฏผๅ…ฅ Cookie +boss login --cookie "k=v; k2=v2" # ไปŽๅญ—็ฌฆไธฒๅฏผๅ…ฅ Cookie +boss cookie-server --token xxx # ๅฏๅŠจๆœฌๅœฐ Cookie Bridge # ๆœ็ดข & ่ฏฆๆƒ… boss search "golang" --city ๆญๅทž # ๆŒ‰ๅŸŽๅธ‚ๆœ็ดข @@ -292,11 +398,63 @@ boss cities # ๅŸŽๅธ‚ๅˆ—่กจ boss -v search "Python" # ่ฏฆ็ป†ๆ—ฅๅฟ— ``` +## ๆ‹›่˜ๆ–นๆจกๅผ + +```bash +# ๆœ็ดข & ๆŽจ่ +boss recruiter search "golang" --city ๆทฑๅœณ --exp 3-5ๅนด +boss recruiter recommend --job # ๆŒ‰ๅฒ—ไฝๆŸฅ็œ‹ๆŽจ่็‰›ไบบ +boss recruiter recommend -n 20 # ้™ๅˆถๆ˜พ็คบ + +# ๆฒŸ้€š +boss recruiter greet # ๅ‘ๅ€™้€‰ไบบๆ‰“ๆ‹›ๅ‘ผ +boss recruiter batch-greet "Python" -n 10 # ๆ‰น้‡ๆ‰“ๆ‹›ๅ‘ผ +boss recruiter inbox -n 20 # ๆŸฅ็œ‹ๅ€™้€‰ไบบๆถˆๆฏ +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` ๅˆทๆ–ฐ - ๆœ็ดขๆ— ็ป“ๆžœ โ€” ๆฃ€ๆŸฅๅŸŽๅธ‚็ญ›้€‰ๆˆ–ๅ…ณ้”ฎ่ฏ๏ผŒไฝฟ็”จ `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 --token ` + - ๅœจ Chrome ไธญๅŠ ่ฝฝ `chrome-extension/zhipin-cookie-extractor` + - ๅœจๆ‰ฉๅฑ•้‡Œๅกซๅ†™็›ธๅŒ็š„ `token` ๅนถ็‚นๅ‡ป Sync + ## 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 7a0703c..ad06ce4 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() @@ -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) @@ -61,6 +62,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..6fd4e61 100644 --- a/boss_cli/client.py +++ b/boss_cli/client.py @@ -13,6 +13,28 @@ 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_ADD_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_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 +50,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 +177,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 +199,19 @@ 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 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, BOSS_FRIEND_ADD_URL): + headers["Referer"] = WEB_BOSS_CHAT_URL return headers def _handle_response(self, data: dict[str, Any], action: str) -> dict[str, Any]: @@ -186,6 +227,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 +283,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 +455,202 @@ 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 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="ๅ‘้€ๆถˆๆฏ", + ) + + 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="ๅ…ณ้—ญ่Œไฝ") + + 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/auth.py b/boss_cli/commands/auth.py index fffd747..dc5c8bb 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,16 @@ 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="็›‘ๅฌ็ซฏๅฃ") +@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, token=token or None) + + @click.command() @structured_output_options def status(as_json: bool, as_yaml: bool) -> None: diff --git a/boss_cli/commands/recruiter.py b/boss_cli/commands/recruiter.py new file mode 100644 index 0000000..feba920 --- /dev/null +++ b/boss_cli/commands/recruiter.py @@ -0,0 +1,1258 @@ +"""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("--job", "enc_job_id", default="", help="ๅ…ณ่”่Œไฝ encryptJobId (ๅˆ‡ๆขๅฒ—ไฝ)") +@structured_output_options +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=1, enc_job_id=enc_job_id) + + def _render(data: dict) -> None: + friend_list = data.get("friendList", []) + 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)}/{total} ไบบ)", + 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) + + 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) + + +# โ”€โ”€ 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", "") + + 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: + 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) + + +# โ”€โ”€ 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", "?")) + 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, jid=job_id: client.boss_add_friend( + encrypt_geek_id=gid, + encrypt_job_id=jid, + ), + ) + 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", "-"), + 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 + + uid, _job_id = _resolve_friend_uid_and_job(cred, friend_id) + + def _action(c: BossClient) -> dict: + return c.boss_send_message(gid=uid, content=message) + + def _render(data: dict) -> None: + 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) + + +# โ”€โ”€ 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: + security_id = _resolve_security_id_by_encrypt_geek(cred, encrypt_geek_id) + + 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() + + uid, _job_id = _resolve_friend_uid_and_job(cred, friend_id) + + def _action(c: BossClient) -> dict: + return c.get_boss_chat_history(gid=uid, 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: + 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, + 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: + security_id = _resolve_security_id_by_encrypt_geek(cred, encrypt_geek_id) + + 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 + + +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="่ทณ่ฟ‡็กฎ่ฎคๆ็คบ") +@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: + security_id = _resolve_security_id_by_encrypt_geek(cred, encrypt_geek_id) + 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..7239e86 100644 --- a/boss_cli/constants.py +++ b/boss_cli/constants.py @@ -41,6 +41,36 @@ 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_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" +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": ( diff --git a/boss_cli/cookie_server.py b/boss_cli/cookie_server.py new file mode 100644 index 0000000..f978c11 --- /dev/null +++ b/boss_cli/cookie_server.py @@ -0,0 +1,117 @@ +"""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 urllib.parse import urlparse + +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" + 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") + 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, X-Boss-Cookie-Token") + 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 + 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 + 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") + + 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, 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: + 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..8cccaaf --- /dev/null +++ b/chrome-extension/zhipin-cookie-extractor/popup.html @@ -0,0 +1,69 @@ + + + + + + Zhipin Cookie Extractor + + + +

Zhipin Cookie Extractor

+ +
+ Auto-refresh: + +
+ +
+ Token: + +
+ +
+ + + +
+ +
+ + + + diff --git a/chrome-extension/zhipin-cookie-extractor/popup.js b/chrome-extension/zhipin-cookie-extractor/popup.js new file mode 100644 index 0000000..2518c2f --- /dev/null +++ b/chrome-extension/zhipin-cookie-extractor/popup.js @@ -0,0 +1,93 @@ +const statusEl = document.getElementById("status"); +const intervalSelect = document.getElementById("interval"); +const tokenInput = document.getElementById("token"); + +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); + } + if (resp.token) { + tokenInput.value = resp.token; + } +} + +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`); +}); + +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 new file mode 100644 index 0000000..07debd2 --- /dev/null +++ b/chrome-extension/zhipin-cookie-extractor/service_worker.js @@ -0,0 +1,116 @@ +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, token) { + const resp = await fetch("http://127.0.0.1:9876/cookies", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Boss-Cookie-Token": token || "", + }, + 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 { + 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 }; + } 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", "token"]); + 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; + } + 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/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..18241c1 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3,6 +3,8 @@ from __future__ import annotations import json +import io +import os from unittest.mock import MagicMock, patch import pytest @@ -81,6 +83,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 +365,102 @@ 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") + + +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 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€