Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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

Expand Down
69 changes: 61 additions & 8 deletions boss_cli/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,25 +200,78 @@ 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
k, v = part.split("=", 1)
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))
Expand Down
69 changes: 67 additions & 2 deletions boss_cli/commands/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
68 changes: 68 additions & 0 deletions tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────

Expand Down
48 changes: 48 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,61 @@ 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"])
assert "--page" in result.output or "-p" in result.output
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) ──────────────────────────────────────────


Expand Down