diff --git a/README.md b/README.md index 7221e90..b61e634 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,8 @@ 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 --cookies - # Paste cookies via stdin (Cookie-Editor JSON or "k=v; k=v") +boss login --cookies cookies.json # Load cookies from a file (headless servers) boss status # Check login status (validates real search session, shows cookie names) boss logout # Clear saved cookies @@ -208,9 +210,23 @@ 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. **Pasted cookies** — `boss login --cookies` / `BOSS_COOKIES` for headless servers where the browser lives on another machine `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. +**Headless / remote servers.** When boss-cli runs on a box with no browser (and QR login is blocked), log in from a normal browser on your own machine, export the `zhipin.com` cookies, and paste them in: + +```bash +# Easiest: install the "Cookie-Editor" extension, log into zhipin.com, +# click Export (JSON), then paste: +boss login --cookies - # paste the JSON, then Ctrl-D +boss login --cookies # opens $EDITOR to paste into +boss login --cookies dump.json +export BOSS_COOKIES='wt2=...; wbg=0; zp_at=...; __zp_stoken__=...' # or a header string +``` + +`--cookies` and `BOSS_COOKIES` accept a Cookie-Editor / EditThisCookie JSON export, a plain `{"name": "value"}` object, or a `"key1=val1; key2=val2"` Cookie header string. The export must include the HttpOnly cookies `__zp_stoken__` and `zp_at` (a browser's `document.cookie` omits them — use the extension or the DevTools request `Cookie` header instead). + `boss recommend` follows the live web app's current recommendation data source and request context, which improves compatibility when the legacy recommendation endpoint is rejected. `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. @@ -303,6 +319,10 @@ uv run ruff check . Your session cookies have expired. Run `boss logout && boss login` to refresh. If QR login only returns a partial cookie set, log in from a browser first and then run `boss login`. +**Q: Running on a headless server — no browser to extract from, and QR login does nothing / DevTools is blocked** + +Log in from a browser on your own machine, then bring the cookies over. The simplest path is the **Cookie-Editor** extension (it can read HttpOnly cookies and needs no DevTools, so site anti-debugging can't block it): log into `zhipin.com`, click *Export* (JSON), then on the server run `boss login --cookies -` and paste, or `boss login --cookies dump.json`. See the **Authentication → Headless / remote servers** section for details. + **Q: `暂无投递记录` but I have applied** Some features require fresh `__zp_stoken__`. Try re-logging in from a browser, then `boss login`. @@ -335,6 +355,8 @@ Check your city filter. Some keywords are city-specific. Use `boss cities` to se # 认证 boss login # 自动提取浏览器 Cookie,失败则二维码 boss login --cookie-source chrome # 指定浏览器 +boss login --cookies - # 粘贴 Cookie 登录(Cookie-Editor JSON 或 "k=v; k=v"),适合无头服务器 +boss login --cookies cookies.json # 从文件读取 Cookie 登录 boss status # 检查登录状态 boss logout # 清除 Cookie diff --git a/boss_cli/auth.py b/boss_cli/auth.py index 8ab9b79..ccdadc0 100644 --- a/boss_cli/auth.py +++ b/boss_cli/auth.py @@ -200,16 +200,45 @@ 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. +def parse_cookie_blob(raw: str) -> dict[str, str]: + """Parse cookies from a pasted blob into a ``{name: value}`` mapping. + + Accepts three shapes so users can paste whatever their browser hands them: - Format: "key1=val1; key2=val2; ..." + * Cookie-Editor / EditThisCookie JSON export — ``{"url": ..., "cookies": + [{"name": ..., "value": ...}, ...]}`` or a bare ``[{"name", "value"}]`` + array. + * A plain ``{"name": "value", ...}`` JSON object. + * A ``"key1=val1; key2=val2"`` Cookie request-header string. + + Unknown / malformed input yields an empty mapping rather than raising. """ - raw = os.environ.get("BOSS_COOKIES", "").strip() + raw = raw.strip() if not raw: - return None - cookies: dict[str, str] = {} - for part in raw.split(";"): + return {} + + if raw[0] in "[{": + try: + data = json.loads(raw) + except (json.JSONDecodeError, ValueError): + return {} + if isinstance(data, dict): + # Cookie-Editor export wraps the list under "cookies". + if isinstance(data.get("cookies"), list): + items: Any = data["cookies"] + else: + # Plain {name: value} object. + return {str(k): str(v) for k, v in data.items() if k and v is not None} + else: + items = data + cookies: dict[str, str] = {} + for item in items if isinstance(items, list) else []: + if isinstance(item, dict) and item.get("name"): + cookies[str(item["name"])] = str(item.get("value", "")) + return cookies + + cookies = {} + for part in raw.replace("\n", ";").split(";"): part = part.strip() if "=" not in part: continue @@ -217,8 +246,32 @@ def load_from_env() -> Credential | None: k, v = k.strip(), v.strip() if k and v: cookies[k] = v + return cookies + + +def credential_from_cookie_blob(raw: str) -> Credential: + """Build a :class:`Credential` from a pasted cookie blob. + + See :func:`parse_cookie_blob` for accepted formats. The returned credential + may be empty or missing required cookies; callers should check before + trusting it. + """ + return Credential(cookies=parse_cookie_blob(raw)) + + +def load_from_env() -> Credential | None: + """Load cookies from the BOSS_COOKIES environment variable. + + Accepts the same formats as :func:`parse_cookie_blob`: a Cookie-Editor JSON + export, a ``{name: value}`` JSON object, or a ``"key1=val1; key2=val2"`` + Cookie header string. + """ + raw = os.environ.get("BOSS_COOKIES", "").strip() + if not raw: + return None + cookies = parse_cookie_blob(raw) if not cookies: - logger.debug("BOSS_COOKIES env set but no valid key=value pairs found") + logger.debug("BOSS_COOKIES env set but no cookies could be parsed") return None cred = Credential(cookies=cookies) logger.info("Loaded %d cookies from BOSS_COOKIES environment variable", len(cookies)) diff --git a/boss_cli/commands/auth.py b/boss_cli/commands/auth.py index c5a4ef2..cfcd8fe 100644 --- a/boss_cli/commands/auth.py +++ b/boss_cli/commands/auth.py @@ -18,11 +18,53 @@ logger = logging.getLogger(__name__) +def _read_cookie_input(src: str) -> str: + """Resolve the --cookies value into a raw cookie blob. + + ``"@editor"`` (the flag's no-value sentinel) opens $EDITOR; ``"-"`` reads + stdin; an existing path is read as a file; anything else is treated as the + pasted blob itself. + """ + import os + + if src == "@editor": + text = click.edit( + "\n# 在此粘贴 Cookie-Editor 导出的 JSON 或浏览器的 Cookie 串," + "保存并退出。以 # 开头的行会被忽略。\n" + ) + if not text: + return "" + return "\n".join(line for line in text.splitlines() if not line.lstrip().startswith("#")) + if src == "-": + return click.get_text_stream("stdin").read() + if os.path.exists(src): + with open(src, encoding="utf-8") as f: + return f.read() + return src + + @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: - """扫码登录 Boss 直聘 APP""" +@click.option( + "--cookies", + "cookies_src", + is_flag=False, + flag_value="@editor", + default=None, + metavar="[BLOB|FILE|-]", + help=( + "用浏览器导出的 Cookie 登录。值可为 Cookie-Editor JSON / Cookie 串、" + "JSON 文件路径,或 '-' 从标准输入读取;不带值则打开编辑器粘贴。" + "适用于无头服务器等无法自动抓取 Cookie 的环境。" + ), +) +def login(qrcode: bool, cookie_source: str | None, cookies_src: str | None) -> None: + """扫码登录 Boss 直聘 APP + + 无参数时自动提取浏览器 Cookie,失败则回退二维码登录。 + 无头服务器可用 --cookies 粘贴浏览器导出的 Cookie 直接登录。 + """ from ..auth import clear_credential, verify_credential def _finalize_login(cred, *, from_qr: bool = False) -> None: @@ -55,6 +97,29 @@ def _finalize_login(cred, *, from_qr: bool = False) -> None: ) raise SystemExit(1) + if cookies_src is not None: + from ..auth import credential_from_cookie_blob, save_credential + + raw = _read_cookie_input(cookies_src) + if not raw.strip(): + console.print("[red]❌ 未读取到任何 Cookie 内容[/red]") + raise SystemExit(1) + cred = credential_from_cookie_blob(raw) + if not cred.cookies: + console.print("[red]❌ 无法解析 Cookie(支持 Cookie-Editor JSON 或 'k=v; k=v' 串)[/red]") + raise SystemExit(1) + missing = cred.missing_required_cookies + if missing: + console.print(f"[red]❌ 缺少关键 Cookie: {', '.join(missing)}[/red]") + console.print( + "[dim]请从已登录的 zhipin.com 导出,并确保包含 HttpOnly Cookie" + "(如 __zp_stoken__、zp_at)。推荐用 Cookie-Editor 扩展导出 JSON。[/dim]" + ) + 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 diff --git a/tests/test_auth.py b/tests/test_auth.py index 38761f5..69a5d47 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -119,6 +119,74 @@ def test_load_from_env_malformed(self): with patch.dict(os.environ, {"BOSS_COOKIES": "no-equals-here; also-bad"}): assert load_from_env() is None + def test_load_from_env_json_export(self): + """BOSS_COOKIES also accepts a Cookie-Editor JSON export.""" + from boss_cli.auth import load_from_env + + blob = '{"url":"https://www.zhipin.com","cookies":[{"name":"wt2","value":"abc"},{"name":"zp_at","value":"ghi"}]}' + with patch.dict(os.environ, {"BOSS_COOKIES": blob}): + cred = load_from_env() + + assert cred is not None + assert cred.cookies == {"wt2": "abc", "zp_at": "ghi"} + + +# ── Cookie blob parsing ───────────────────────────────────────────── + + +class TestParseCookieBlob: + """Test parse_cookie_blob across the formats users paste.""" + + def test_cookie_editor_export(self): + from boss_cli.auth import parse_cookie_blob + + blob = ( + '{"url":"https://www.zhipin.com","cookies":' + '[{"name":"wt2","value":"abc","httpOnly":true},' + '{"name":"__zp_stoken__","value":"tok%2F123"}]}' + ) + assert parse_cookie_blob(blob) == {"wt2": "abc", "__zp_stoken__": "tok%2F123"} + + def test_bare_array(self): + from boss_cli.auth import parse_cookie_blob + + blob = '[{"name":"a","value":"1"},{"name":"b","value":"2"}]' + assert parse_cookie_blob(blob) == {"a": "1", "b": "2"} + + def test_plain_object(self): + from boss_cli.auth import parse_cookie_blob + + assert parse_cookie_blob('{"a":"1","b":"2"}') == {"a": "1", "b": "2"} + + def test_header_string(self): + from boss_cli.auth import parse_cookie_blob + + assert parse_cookie_blob("a=1; b=2; c=3 ") == {"a": "1", "b": "2", "c": "3"} + + def test_header_string_with_equals_in_value(self): + from boss_cli.auth import parse_cookie_blob + + # zp_at / __zp_stoken__ values can contain '=' and '~' + assert parse_cookie_blob("zp_at=eSm=Wd~~; wbg=0") == {"zp_at": "eSm=Wd~~", "wbg": "0"} + + def test_empty_and_malformed(self): + from boss_cli.auth import parse_cookie_blob + + assert parse_cookie_blob("") == {} + assert parse_cookie_blob(" ") == {} + assert parse_cookie_blob("no-equals-here; also-bad") == {} + assert parse_cookie_blob("{not valid json") == {} + + def test_credential_from_blob_requires_cookies(self): + from boss_cli.auth import credential_from_cookie_blob + + cred = credential_from_cookie_blob( + '{"cookies":[{"name":"wt2","value":"a"},{"name":"wbg","value":"0"},' + '{"name":"zp_at","value":"b"},{"name":"__zp_stoken__","value":"c"}]}' + ) + assert cred.has_required_cookies + assert cred.cookies["wt2"] == "a" + # ── Cookie jar extraction ─────────────────────────────────────────── diff --git a/tests/test_cli.py b/tests/test_cli.py index a9edd94..bc6fbbb 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -81,6 +81,7 @@ 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 "--cookies" in result.output def test_history_has_options(self): result = runner.invoke(cli, ["history", "--help"]) @@ -88,6 +89,53 @@ def test_history_has_options(self): assert "--json" in result.output +# ── login --cookies (mocked) ──────────────────────────────────────── + + +class TestLoginWithCookies: + """Test `boss login --cookies` paste-cookie login.""" + + _FULL = ( + '{"cookies":[{"name":"wt2","value":"a"},{"name":"wbg","value":"0"},' + '{"name":"zp_at","value":"b"},{"name":"__zp_stoken__","value":"c"}]}' + ) + + def test_cookies_success(self): + with patch("boss_cli.auth.save_credential") as save, \ + patch("boss_cli.auth.verify_credential", return_value=(True, None)): + result = runner.invoke(cli, ["login", "--cookies", self._FULL]) + assert result.exit_code == 0 + assert "登录成功" in result.output + save.assert_called_once() + + def test_cookies_from_stdin(self): + blob = "wt2=a; wbg=0; zp_at=b; __zp_stoken__=c" + with patch("boss_cli.auth.save_credential"), \ + patch("boss_cli.auth.verify_credential", return_value=(True, None)): + result = runner.invoke(cli, ["login", "--cookies", "-"], input=blob) + assert result.exit_code == 0 + assert "登录成功" in result.output + + def test_cookies_missing_required(self): + result = runner.invoke(cli, ["login", "--cookies", "a=1; b=2"]) + assert result.exit_code == 1 + assert "缺少关键 Cookie" in result.output + + def test_cookies_unparseable(self): + result = runner.invoke(cli, ["login", "--cookies", "garbage-without-equals"]) + assert result.exit_code == 1 + assert "无法解析" in result.output + + def test_cookies_verification_failure_clears(self): + with patch("boss_cli.auth.save_credential"), \ + patch("boss_cli.auth.clear_credential") as clear, \ + patch("boss_cli.auth.verify_credential", return_value=(False, "环境异常")): + result = runner.invoke(cli, ["login", "--cookies", self._FULL]) + assert result.exit_code == 1 + assert "未通过实际接口校验" in result.output + clear.assert_called_once() + + # ── Auth commands (mocked) ──────────────────────────────────────────