重开仓库内容:改推五个技能(browser-harness / humanizer / humanizer-zh / product-planning / session-mechanism)
按授权清空原有内容后重新提交(原 oil-ui-pro 一并移出,可从历史恢复)。 browser-harness 剔除 .venv 等运行环境;根 .gitignore 补记 .venv/ 与 node_modules/。
This commit is contained in:
1 parent
9a152a1952
commit
777f7fe5d0
240 files changed
+52518
-4409
No files matched your search
@@ -0,0 +1,382 @@
|
||||
# BOSS直聘 — Chat & Messaging
|
||||
|
||||
Field-tested against zhipin.com on 2026-05-01.
|
||||
Login required. Messages are loaded via WebSocket + REST API.
|
||||
|
||||
**IMPORTANT**: Never send messages without the user's explicit permission. This skill documents the read/retrieval mechanics only.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
BOSS直聘 uses a hybrid messaging architecture:
|
||||
|
||||
- **Conversation list** — loaded via WebSocket (`ws6.zhipin.com`) on page load, NOT via REST
|
||||
- **Message history** — REST API `/wapi/zpchat/geek/historyMsg`
|
||||
- **Real-time messages** — WebSocket push from `ws6.zhipin.com`
|
||||
|
||||
---
|
||||
|
||||
## Chat Page (`/web/geek/chat`)
|
||||
|
||||
### Page Structure
|
||||
|
||||
```
|
||||
Left panel:
|
||||
.chat-user.v2 — filter bar + search input
|
||||
.label-list > ul
|
||||
li.selected — active filter tab
|
||||
li — "未读(N)" shows count badge in <i>
|
||||
li > .ui-dropmenu — "更多" dropdown (仅沟通/有交换/有面试/不感兴趣)
|
||||
li.filter-item — "AI筛选" dropdown with natural language input
|
||||
.boss-search-input — contact search (placeholder: "搜索30天内的联系人")
|
||||
.user-list
|
||||
.user-list-content
|
||||
.friend-content-warp
|
||||
.friend-content — conversation item (click to open)
|
||||
.friend-content.friend-top — pinned/top conversation
|
||||
|
||||
Right panel (visible after clicking a conversation):
|
||||
.chat-record — message history container
|
||||
.message-item.item-myself — message sent by user
|
||||
.item-time > .time — timestamp
|
||||
.message-content > .text — message body
|
||||
.message-status.status-read — read receipt ("已读")
|
||||
.message-item.item-friend — message from recruiter
|
||||
.item-time > .time
|
||||
.message-content > .text
|
||||
```
|
||||
|
||||
### Filter Tabs
|
||||
|
||||
Top-level tabs (`.chat-user.v2 .label-list li`):
|
||||
|
||||
| Tab | Description | Class |
|
||||
|-----|-------------|-------|
|
||||
| 全部 | All conversations (default) | `li.selected` when active |
|
||||
| 未读(N) | Unread conversations, badge shows count | `<i>` in label shows count |
|
||||
| 新招呼 | New greetings from recruiters | Badge indicator via `<i class="badge">` |
|
||||
| 更多 ▾ | Dropdown with extra filters | `.ui-dropmenu` |
|
||||
|
||||
"更多" dropdown (`.more-label li`):
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| 仅沟通 | Conversations with messages exchanged |
|
||||
| 有交换 | Conversations with file/contact exchange |
|
||||
| 有面试 | Conversations with interview invitations |
|
||||
| 不感兴趣 | Conversations marked "not interested" |
|
||||
|
||||
"AI筛选" (`.filter-item > .ui-dropmenu`): Opens a panel with a `<textarea>` for natural language filter input (e.g. "后端开发 上海 高薪").
|
||||
|
||||
#### Clicking Filter Tabs
|
||||
|
||||
```python
|
||||
def click_filter(label_text):
|
||||
"""Click a filter tab by its text label."""
|
||||
js(f"""
|
||||
(function() {{
|
||||
var labels = document.querySelectorAll('.chat-user .label-list li .label-name');
|
||||
for (var i = 0; i < labels.length; i++) {{
|
||||
if (labels[i].textContent.trim().indexOf('{label_text}') === 0) {{
|
||||
labels[i].closest('li').click();
|
||||
return true;
|
||||
}}
|
||||
}}
|
||||
return false;
|
||||
}})()
|
||||
""")
|
||||
wait(1)
|
||||
|
||||
def click_more_filter(label_text):
|
||||
"""Click an option inside the '更多' dropdown."""
|
||||
# First open the dropdown
|
||||
click_filter("更多")
|
||||
wait(0.5)
|
||||
js(f"""
|
||||
(function() {{
|
||||
var items = document.querySelectorAll('.more-label li span');
|
||||
for (var i = 0; i < items.length; i++) {{
|
||||
if (items[i].textContent.trim() === '{label_text}') {{
|
||||
items[i].closest('li').click();
|
||||
return true;
|
||||
}}
|
||||
}}
|
||||
return false;
|
||||
}})()
|
||||
""")
|
||||
wait(1)
|
||||
```
|
||||
|
||||
### Conversation Item (DOM)
|
||||
|
||||
Each `.friend-content` contains:
|
||||
- Timestamp (e.g. "04月13日", "昨天")
|
||||
- Recruiter name (e.g. "刘女士")
|
||||
- Company name (e.g. "Soul App")
|
||||
- Recruiter title (e.g. "招聘专家")
|
||||
- Last message preview
|
||||
- Unread count badge (numeric)
|
||||
|
||||
### Read Conversation List (DOM)
|
||||
|
||||
```python
|
||||
def get_conversations():
|
||||
raw = js("""
|
||||
(function() {
|
||||
var items = document.querySelectorAll('.friend-content');
|
||||
var results = [];
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
var el = items[i];
|
||||
var text = el.textContent;
|
||||
var badge = el.querySelector('[class*="badge"], [class*="unread"], [class*="count"]');
|
||||
var unread = badge ? parseInt(badge.textContent) || 0 : 0;
|
||||
results.push({
|
||||
text: text.trim().substring(0, 150),
|
||||
is_top: el.classList.contains('friend-top'),
|
||||
unread: unread
|
||||
});
|
||||
}
|
||||
return JSON.stringify(results);
|
||||
})()
|
||||
""")
|
||||
return json.loads(raw)
|
||||
```
|
||||
|
||||
### Open a Conversation
|
||||
|
||||
Click the `.friend-content` element:
|
||||
|
||||
```python
|
||||
def open_conversation(index=0):
|
||||
js(f"document.querySelectorAll('.friend-content')[{index}].click()")
|
||||
wait(2)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API: Message History
|
||||
|
||||
```
|
||||
GET /wapi/zpchat/geek/historyMsg?bossId={bossId}&maxMsgId=0&c=20&page=1&src=0
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
| Param | Description |
|
||||
|-------|-------------|
|
||||
| `bossId` | Recruiter ID from conversation (format: `9c833990a839f1251Hx92du5GA~~`) |
|
||||
| `maxMsgId` | Pagination cursor. `0` for first page, then use the smallest `mid` from previous page |
|
||||
| `c` | Count per page (default 20) |
|
||||
| `page` | Page number |
|
||||
| `src` | Source (0 for web) |
|
||||
|
||||
The `bossId` can be found in performance entries after clicking a conversation, or extracted from the WebSocket connection data on page load.
|
||||
|
||||
### Response (`zpData.messages[]`)
|
||||
|
||||
Each message has:
|
||||
|
||||
```python
|
||||
{
|
||||
"mid": 337069469603329, # message ID (numeric, for pagination)
|
||||
"type": 3, # 3=regular message, 4=system message
|
||||
"received": true, # whether you received it
|
||||
"body": {
|
||||
"type": 1, # 1=text, 8=job card
|
||||
"text": "message text here...", # present when body.type=1
|
||||
"jobDesc": { ... } # present when body.type=8
|
||||
},
|
||||
"from": {
|
||||
"uid": 502838021, # sender user ID
|
||||
"name": "张女士",
|
||||
"avatar": "https://img.bosszhipin.com/..."
|
||||
},
|
||||
"to": {
|
||||
"uid": 680839465 # recipient user ID
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Message Body Types
|
||||
|
||||
| `body.type` | Meaning | Fields |
|
||||
|-------------|---------|--------|
|
||||
| `1` | Plain text | `body.text` |
|
||||
| `8` | Job description card | `body.jobDesc` (title, salary, company, boss, city, experience, education), `body.headTitle` |
|
||||
| `16` | System notification | (file received, etc.) |
|
||||
|
||||
### Job Card Messages (`body.type=8`)
|
||||
|
||||
```python
|
||||
{
|
||||
"body": {
|
||||
"type": 8,
|
||||
"headTitle": "您正在与Boss刘女士直接沟通如下职位",
|
||||
"jobDesc": {
|
||||
"title": "AI Agent工程师",
|
||||
"salary": "35-60K·16薪", # REAL salary — not font-encoded
|
||||
"company": "Soul App",
|
||||
"city": "上海 浦东新区 金桥",
|
||||
"experience": "经验不限",
|
||||
"education": "硕士",
|
||||
"stage": "D轮及以上",
|
||||
"positionCategory": "算法工程师",
|
||||
"boss": {
|
||||
"uid": 3872648,
|
||||
"name": "刘女士",
|
||||
"avatar": "https://img.bosszhipin.com/..."
|
||||
},
|
||||
"bossTitle": "招聘专家",
|
||||
"jobId": 509933581
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Fetch Message History
|
||||
|
||||
```python
|
||||
def fetch_messages(boss_id, page=1, count=20):
|
||||
raw = js(f"""
|
||||
(async function() {{
|
||||
var url = '/wapi/zpchat/geek/historyMsg?bossId={boss_id}&maxMsgId=0&c={count}&page={page}&src=0';
|
||||
var r = await fetch(url);
|
||||
var d = await r.json();
|
||||
if (d.code !== 0 || !d.zpData) {{
|
||||
return JSON.stringify({{code: d.code, hasMore: false, count: 0, messages: [], error: d.msg || 'API error'}});
|
||||
}}
|
||||
var msgs = d.zpData.messages || [];
|
||||
return JSON.stringify({{
|
||||
code: d.code,
|
||||
hasMore: d.zpData.hasMore,
|
||||
count: msgs.length,
|
||||
messages: msgs.map(function(m) {{
|
||||
var b = m.body || {{}};
|
||||
return {{
|
||||
mid: m.mid,
|
||||
type: m.type,
|
||||
body_type: b.type,
|
||||
text: b.text || null,
|
||||
job: b.jobDesc ? {{
|
||||
title: b.jobDesc.title,
|
||||
salary: b.jobDesc.salary,
|
||||
company: b.jobDesc.company,
|
||||
city: b.jobDesc.city,
|
||||
boss_name: (b.jobDesc.boss || {{}}).name,
|
||||
job_id: b.jobDesc.jobId
|
||||
}} : null,
|
||||
from_name: (m.from || {{}}).name,
|
||||
from_uid: (m.from || {{}}).uid,
|
||||
received: m.received
|
||||
}};
|
||||
}})
|
||||
}});
|
||||
}})()
|
||||
""")
|
||||
return json.loads(raw)
|
||||
```
|
||||
|
||||
### Pagination
|
||||
|
||||
Use `maxMsgId` (not `page`) for efficient pagination. Set `maxMsgId` to the smallest `mid` from the previous batch:
|
||||
|
||||
```python
|
||||
def fetch_all_messages(boss_id):
|
||||
all_msgs = []
|
||||
max_msg_id = 0
|
||||
while True:
|
||||
raw = js(f"""
|
||||
(async function() {{
|
||||
var r = await fetch('/wapi/zpchat/geek/historyMsg?bossId={boss_id}&maxMsgId={max_msg_id}&c=20&page=1&src=0');
|
||||
var d = await r.json();
|
||||
if (d.code !== 0 || !d.zpData) {{
|
||||
return JSON.stringify({{messages: [], hasMore: false}});
|
||||
}}
|
||||
return JSON.stringify(d.zpData);
|
||||
}})()
|
||||
""")
|
||||
data = json.loads(raw)
|
||||
msgs = data.get("messages", [])
|
||||
if not msgs:
|
||||
break
|
||||
all_msgs.extend(msgs)
|
||||
if not data.get("hasMore"):
|
||||
break
|
||||
max_msg_id = msgs[-1]["mid"] # smallest mid
|
||||
wait(0.5)
|
||||
return all_msgs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Messages Read from DOM (after opening a conversation)
|
||||
|
||||
```python
|
||||
def read_messages_dom():
|
||||
raw = js("""
|
||||
(function() {
|
||||
var items = document.querySelectorAll('.message-item');
|
||||
var results = [];
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
var el = items[i];
|
||||
var timeEl = el.querySelector('.time');
|
||||
var textEl = el.querySelector('.text');
|
||||
var statusEl = el.querySelector('.message-status');
|
||||
results.push({
|
||||
from_me: el.classList.contains('item-myself'),
|
||||
time: timeEl ? timeEl.textContent.trim() : '',
|
||||
text: textEl ? textEl.textContent.trim().substring(0, 300) : '',
|
||||
status: statusEl ? statusEl.textContent.trim() : ''
|
||||
});
|
||||
}
|
||||
return JSON.stringify(results);
|
||||
})()
|
||||
""")
|
||||
return json.loads(raw)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Extracting bossId from the Page
|
||||
|
||||
The `bossId` is embedded in WebSocket payloads and API calls. To discover it after clicking a conversation:
|
||||
|
||||
```python
|
||||
def get_current_boss_id():
|
||||
return js("""
|
||||
(function() {
|
||||
var entries = performance.getEntriesByType('resource');
|
||||
for (var i = entries.length - 1; i >= 0; i--) {
|
||||
var url = entries[i].name;
|
||||
if (url.indexOf('/wapi/zpchat/geek/historyMsg') === -1) continue;
|
||||
var match = url.match(/bossId=([^&]+)/);
|
||||
if (match) return match[1];
|
||||
}
|
||||
return null;
|
||||
})()
|
||||
""")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Navigating from Job Detail to Chat
|
||||
|
||||
Opening a job detail page and clicking "立即沟通" initiates a conversation with that job's recruiter. The API needed:
|
||||
|
||||
1. Navigate to `/job_detail/{JOB_ID}.html`
|
||||
2. Find the chat button (`.btn-startchat`) element
|
||||
3. The button's `href` or click handler contains the `bossId` and `securityId`
|
||||
|
||||
---
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Conversation list is WebSocket-loaded** — no REST API for the list. Use DOM extraction (`.friend-content`) or monitor WebSocket frames to get the initial conversation list.
|
||||
- **Message history uses `bossId`, not `encryptBossId`** — the `bossId` format is `"9c833990a839f1251Hx92du5GA~~"` (trailing `~~`), different from the job list's `encryptBossId`.
|
||||
- **`maxMsgId` pagination** — use the smallest `mid` from the current batch for the next page, not `page` parameter.
|
||||
- **Job cards in messages have real salary** — `body.jobDesc.salary` returns `"35-60K·16薪"` unlike the DOM which uses font-encoded digits.
|
||||
- **System messages (type=4)** — these include read receipts, file transfers ("对方已同意,您的附件简历已发送给对方"), and competitor analysis cards.
|
||||
- **After clicking a conversation, `wait(2)`** — the message history needs time to render.
|
||||
- **`item-myself` vs `item-friend`** — user messages have `item-myself` class, recruiter messages have `item-friend`.
|
||||
- **Contact search input** — `.boss-search-input` searches within 30 days of contacts, not a general message compose box.
|
||||
@@ -0,0 +1,350 @@
|
||||
# BOSS直聘 — Job Search & Extraction
|
||||
|
||||
Field-tested against zhipin.com on 2026-05-01.
|
||||
Login required for API access; job browsing is accessible without auth.
|
||||
Last browser-verified: 2026-05-01 (all functions re-tested against live site).
|
||||
|
||||
---
|
||||
|
||||
## Anti-bot / API verdict
|
||||
|
||||
BOSS直聘 is a Vue SPA. There is no SSR JSON blob (`__NEXT_DATA__`, `__INITIAL_STATE__`, etc.) — all data loads via XHR/fetch to internal `/wapi/` endpoints.
|
||||
|
||||
**`http_get` does NOT work** — the `/wapi/` endpoints require browser session cookies (not just a CSRF token). All API calls must be made via `js()` + `fetch()` inside the browser session.
|
||||
|
||||
**However, the API returns real salary numbers** (`"salaryDesc": "18-22K"`) unlike the DOM which renders salary digits via a custom `@font-face` with private-use Unicode characters (U+E000–U+F8FF). **Always prefer the API over DOM extraction.**
|
||||
|
||||
---
|
||||
|
||||
## Quickstart
|
||||
|
||||
```python
|
||||
import json
|
||||
|
||||
goto_url("https://www.zhipin.com/web/geek/jobs?ka=open_joblist")
|
||||
wait_for_load()
|
||||
wait(3)
|
||||
|
||||
jobs = json.loads(js("""
|
||||
(async function() {
|
||||
var r = await fetch('/wapi/zpgeek/pc/recommend/job/list.json?page=1&pageSize=15&city=101020100');
|
||||
var d = await r.json();
|
||||
if (d.code !== 0 || !d.zpData) { return JSON.stringify([]); }
|
||||
return JSON.stringify(d.zpData.jobList || []);
|
||||
})()
|
||||
"""))
|
||||
|
||||
for j in jobs:
|
||||
print(j["salaryDesc"], j["jobName"], "|", j["brandName"])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## URL Patterns
|
||||
|
||||
| Resource | URL |
|
||||
|----------|-----|
|
||||
| Job search page | `https://www.zhipin.com/web/geek/jobs` |
|
||||
| Job search (with prefs) | `https://www.zhipin.com/web/geek/jobs?ka=open_joblist` |
|
||||
| Job detail page | `https://www.zhipin.com/job_detail/{JOB_ID}.html` |
|
||||
| Company page | `https://www.zhipin.com/gongsi/{COMPANY_ID}~.html` |
|
||||
|
||||
---
|
||||
|
||||
## API: Job List
|
||||
|
||||
```
|
||||
GET /wapi/zpgeek/pc/recommend/job/list.json
|
||||
```
|
||||
|
||||
### Query Parameters
|
||||
|
||||
| Param | Description | Values |
|
||||
|-------|-------------|--------|
|
||||
| `page` | Page number | 1-N |
|
||||
| `pageSize` | Results per page | 15 (default) |
|
||||
| `city` | City code | `101020100` = Shanghai, `101010100` = Beijing, `101280100` = Guangzhou |
|
||||
| `experience` | Experience filter code | `0`=不限, `104`=1-3年, `105`=3-5年, `106`=5-10年 |
|
||||
| `degree` | Education filter code | `0`=不限 |
|
||||
| `salary` | Salary filter code | `0`=不限, `405`=10-20K, `406`=20-50K |
|
||||
| `industry` | Industry filter code | (numeric) |
|
||||
| `scale` | Company size filter code | `0`=不限, `303`=100-499人, `305`=1000-9999人 |
|
||||
| `jobType` | Job type | `0`=不限, `1`=全职, `2`=兼职 |
|
||||
| `encryptExpectId` | Saved preference ID | From user's saved preferences (empty string = default) |
|
||||
| `mixExpectType` | Mixed expectation type | (empty string for default) |
|
||||
| `expectInfo` | Expectation info | (empty string for default) |
|
||||
|
||||
Filter codes come from `/wapi/zpgeek/pc/all/filter/conditions.json`.
|
||||
Setting `experience`, `salary`, or `degree` to non-zero values without a valid `encryptExpectId` may return no results. The page always sends all filter params (even empty) in its API calls.
|
||||
|
||||
### Response (`zpData.jobList[]`)
|
||||
|
||||
```python
|
||||
{
|
||||
"securityId": "nbyXZvE4kfp6Y...", # opaque ID for detail API
|
||||
"encryptJobId": "cbcbfca3...", # job detail page ID
|
||||
"encryptBossId": "37a64419...", # recruiter ID
|
||||
"jobName": "Aone/GitHub开发工程师",
|
||||
"salaryDesc": "18-22K", # REAL salary — not font-encoded
|
||||
"jobLabels": ["3-5年", "本科", "Java", "Golang"],
|
||||
"skills": ["Java", "Golang", "Aone", "GitLab"],
|
||||
"jobExperience": "3-5年",
|
||||
"jobDegree": "本科",
|
||||
"cityName": "上海",
|
||||
"areaDistrict": "浦东新区",
|
||||
"businessDistrict": "张江",
|
||||
"brandName": "软通动力",
|
||||
"brandIndustry": "计算机软件",
|
||||
"brandScaleName": "10000人以上",
|
||||
"brandStageName": "未融资",
|
||||
"bossName": "杨女士",
|
||||
"bossTitle": "人事",
|
||||
"bossOnline": false,
|
||||
"bossAvatar": "https://img.bosszhipin.com/...",
|
||||
"brandLogo": "https://img.bosszhipin.com/...",
|
||||
"welfareList": [],
|
||||
"gps": {"longitude": 121.609707, "latitude": 31.185578}
|
||||
}
|
||||
```
|
||||
|
||||
### Fetch Jobs (browser API)
|
||||
|
||||
```python
|
||||
import json
|
||||
|
||||
def fetch_job_list(page=1, page_size=15, city="101020100", **filters):
|
||||
"""Fetch jobs from the BOSS直聘 API. Must be on a zhipin.com page first."""
|
||||
# Default params (matching what the real page sends)
|
||||
defaults = {
|
||||
"encryptExpectId": "",
|
||||
"mixExpectType": "",
|
||||
"expectInfo": "",
|
||||
"jobType": "",
|
||||
"salary": "",
|
||||
"experience": "",
|
||||
"degree": "",
|
||||
"industry": "",
|
||||
"scale": ""
|
||||
}
|
||||
defaults.update(filters)
|
||||
params = f"page={page}&pageSize={page_size}&city={city}"
|
||||
for k, v in defaults.items():
|
||||
params += f"&{k}={v}"
|
||||
|
||||
raw = js(f"""
|
||||
(async function() {{
|
||||
var r = await fetch('/wapi/zpgeek/pc/recommend/job/list.json?{params}');
|
||||
var d = await r.json();
|
||||
if (d.code !== 0 || !d.zpData) {{
|
||||
return JSON.stringify({{code: d.code, hasMore: false, jobs: [], error: d.msg || 'API error'}});
|
||||
}}
|
||||
return JSON.stringify({{code: d.code, hasMore: d.zpData.hasMore, jobs: d.zpData.jobList || []}});
|
||||
}})()
|
||||
""")
|
||||
return json.loads(raw)
|
||||
|
||||
# Usage
|
||||
result = fetch_job_list(page=1, experience=105) # 3-5年
|
||||
print(f"Total: {len(result['jobs'])} jobs, hasMore: {result['hasMore']}")
|
||||
for j in result["jobs"]:
|
||||
print(f" {j['salaryDesc']:12s} {j['jobName']:30s} {j['brandName']}")
|
||||
```
|
||||
|
||||
### Pagination
|
||||
|
||||
The API uses `hasMore` (boolean), not a total count. **Page-based pagination is unreliable** — `page=2` often returns 0 results even when `page=1` says `hasMore: true`. Use the initial page 1 results and consider widening filters (e.g., smaller `pageSize`, different city) rather than paginating.
|
||||
|
||||
The actual job search page loads recommendations once at `page=1` and lazy-loads more via scroll, which triggers a different API path. For bulk extraction, vary filters (city, experience, salary) to get different result sets:
|
||||
|
||||
```python
|
||||
def fetch_all_jobs(city="101020100", max_pages=10):
|
||||
all_jobs = []
|
||||
for page in range(1, max_pages + 1):
|
||||
result = fetch_job_list(page=page, city=city)
|
||||
all_jobs.extend(result["jobs"])
|
||||
if not result["hasMore"] or len(result["jobs"]) == 0:
|
||||
break
|
||||
wait(0.5) # polite delay
|
||||
return all_jobs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API: Job Detail
|
||||
|
||||
```
|
||||
GET /wapi/zpgeek/job/detail.json?securityId={securityId}
|
||||
```
|
||||
|
||||
Use the `securityId` from the job list response (NOT `encryptJobId`).
|
||||
|
||||
```python
|
||||
def fetch_job_detail(security_id):
|
||||
raw = js(f"""
|
||||
(async function() {{
|
||||
var r = await fetch('/wapi/zpgeek/job/detail.json?securityId={security_id}');
|
||||
var d = await r.json();
|
||||
if (d.code !== 0 || !d.zpData) {{
|
||||
return JSON.stringify({{code: d.code, error: d.msg || 'API error'}});
|
||||
}}
|
||||
var zp = d.zpData;
|
||||
var job = zp.jobInfo;
|
||||
var boss = zp.bossInfo;
|
||||
var brand = zp.brandComInfo;
|
||||
return JSON.stringify({{
|
||||
code: d.code,
|
||||
title: job.jobName,
|
||||
salary: job.salaryDesc,
|
||||
experience: job.experienceName,
|
||||
degree: job.degreeName,
|
||||
location: job.locationName,
|
||||
address: job.address,
|
||||
gps: {{lng: job.longitude, lat: job.latitude}},
|
||||
description: job.postDescription,
|
||||
skills: job.showSkills,
|
||||
boss_name: boss.name,
|
||||
boss_title: boss.title,
|
||||
boss_avatar: boss.large,
|
||||
boss_online: boss.online,
|
||||
company_name: brand.brandName,
|
||||
company_logo: brand.logo,
|
||||
company_industry: brand.industryName,
|
||||
company_scale: brand.scaleName,
|
||||
company_stage: brand.stageName
|
||||
}});
|
||||
}})()
|
||||
""")
|
||||
return json.loads(raw)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API: Filter Conditions
|
||||
|
||||
```
|
||||
GET /wapi/zpgeek/pc/all/filter/conditions.json
|
||||
```
|
||||
|
||||
Returns all available filter options with their numeric codes:
|
||||
|
||||
```python
|
||||
def get_filter_conditions():
|
||||
raw = js("""
|
||||
(async function() {
|
||||
var r = await fetch('/wapi/zpgeek/pc/all/filter/conditions.json');
|
||||
var d = await r.json();
|
||||
if (d.code !== 0 || !d.zpData) { return JSON.stringify({}); }
|
||||
return JSON.stringify(d.zpData);
|
||||
})()
|
||||
""")
|
||||
return json.loads(raw)
|
||||
|
||||
# Returns: {
|
||||
# experienceList: [{code: 105, name: "3-5年"}, ...],
|
||||
# salaryList: [{code: 406, name: "20-50K", lowSalary: 20, highSalary: 50}, ...],
|
||||
# degreeList: [{code: 203, name: "本科"}, ...],
|
||||
# scaleList: [{code: 305, name: "1000-9999人"}, ...],
|
||||
# stageList: [{code: 807, name: "已上市"}, ...],
|
||||
# industryList: [...],
|
||||
# payTypeList: [...],
|
||||
# partTimeList: [...]
|
||||
# }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## City Codes
|
||||
|
||||
City is identified by numeric code, not name:
|
||||
|
||||
| City | Code |
|
||||
|------|------|
|
||||
| 上海 | `101020100` |
|
||||
| 北京 | `101010100` |
|
||||
| 深圳 | `101280200` |
|
||||
| 广州 | `101280100` |
|
||||
| 杭州 | `101210100` |
|
||||
| 成都 | `101270100` |
|
||||
|
||||
To discover a city code, check the `city` param in the job list API call or use the city data API:
|
||||
|
||||
```
|
||||
GET /wapi/zpgeek/common/data/city/site.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DOM Extraction (fallback)
|
||||
|
||||
If the API path is blocked, fall back to DOM extraction. Note that salary text uses font-encoded private-use Unicode characters (U+E000–U+F8FF) — DOM `textContent` returns unreadable PUA codepoints like `"-K"` instead of the rendered digits.
|
||||
|
||||
### Job Card DOM
|
||||
|
||||
```
|
||||
li.job-card-box
|
||||
div.job-info
|
||||
div.job-title.clearfix
|
||||
a.job-name[href="/job_detail/{ID}.html"] — job title
|
||||
span.job-salary — FONT-ENCODED (see above)
|
||||
ul.tag-list
|
||||
li — experience / education / skill tags
|
||||
div.job-card-footer
|
||||
a.boss-info[href="/gongsi/{ID}~.html"]
|
||||
span.boss-name — company name
|
||||
span.company-location — e.g. "上海·徐汇区·龙华"
|
||||
```
|
||||
|
||||
```python
|
||||
def extract_job_cards_dom():
|
||||
raw = js("""
|
||||
(function() {
|
||||
var cards = document.querySelectorAll('.job-card-box');
|
||||
var results = [];
|
||||
for (var i = 0; i < cards.length; i++) {
|
||||
var card = cards[i];
|
||||
function getText(sel) {
|
||||
var el = card.querySelector(sel);
|
||||
return el ? el.textContent.trim() : '';
|
||||
}
|
||||
function getHref(sel) {
|
||||
var el = card.querySelector(sel);
|
||||
return el ? el.href : '';
|
||||
}
|
||||
var tags = [];
|
||||
var tagEls = card.querySelectorAll('.tag-list li');
|
||||
for (var t = 0; t < tagEls.length; t++) {
|
||||
tags.push(tagEls[t].textContent.trim());
|
||||
}
|
||||
results.push({
|
||||
title: getText('.job-name'),
|
||||
salary_raw: getText('.job-salary'),
|
||||
tags: tags,
|
||||
company: getText('.boss-name'),
|
||||
location: getText('.company-location'),
|
||||
job_url: getHref('.job-name'),
|
||||
company_url: getHref('.boss-info')
|
||||
});
|
||||
}
|
||||
return JSON.stringify(results);
|
||||
})()
|
||||
""")
|
||||
return json.loads(raw)
|
||||
```
|
||||
|
||||
15 cards per page. Scroll to lazy-load more.
|
||||
|
||||
---
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Always prefer the API** — `salaryDesc` returns human-readable salary. DOM salary uses font-encoded PUA chars that require OCR or font-file reversal to decode.
|
||||
- **API needs browser session** — `/wapi/` endpoints require cookies from a real browser page load. Use `js()` + `fetch()` inside the browser, not Python `http_get`.
|
||||
- **securityId vs encryptJobId** — the job detail API uses `securityId` (long opaque string), NOT `encryptJobId`. Both come from the job list response.
|
||||
- **`brandComInfo` not `brandInfo`** — the job detail response uses `zpData.brandComInfo` (not `brandInfo`). Company name is `brandName` (not `companyName`), logo is `logo` (not `brandLogo`). Industry/scale/stage are numeric codes — use `industryName`/`scaleName`/`stageName` for display strings.
|
||||
- **SPA routing** — URL doesn't change when filters are applied via the DOM. With the API, filters are explicit query params.
|
||||
- **Page-based pagination is unreliable** — `page=2` often returns 0 results even when `hasMore` is true on page 1. Vary filters instead of paginating deep.
|
||||
- **Filter params need context** — setting `experience`, `salary`, or `degree` to non-zero values may return `code: 200404` without a valid `encryptExpectId`. Use empty strings for default/no-preference browsing.
|
||||
- **City codes are numeric** — not city names. Use the filter conditions API or city/site.json to look up codes.
|
||||
- **`wait(2-3)` after `goto_url`** — the SPA needs time to establish the session before API calls work.
|
||||
- **Anti-bot detection** — zhipin.com may redirect to about:blank after ~1-2 seconds of page load. Run API calls immediately after navigation in the same execution context.
|
||||
- **City code 101280100 = Guangzhou, NOT Shenzhen** — the code previously documented for Shenzhen is actually Guangzhou.
|
||||
@@ -0,0 +1,75 @@
|
||||
# BOSS直聘 — Site Navigation & Structure
|
||||
|
||||
Field-tested against zhipin.com on 2026-05-01.
|
||||
|
||||
---
|
||||
|
||||
## URL Patterns
|
||||
|
||||
| Page | URL |
|
||||
|------|-----|
|
||||
| Home (redirects to city) | `https://www.zhipin.com/` → `https://www.zhipin.com/{city}/` |
|
||||
| Job search | `https://www.zhipin.com/web/geek/jobs` |
|
||||
| Company search | `https://www.zhipin.com/gongsi/` |
|
||||
| Messages / Chat | `https://www.zhipin.com/web/geek/chat` |
|
||||
| Personal center | `https://www.zhipin.com/web/geek/recommend` |
|
||||
|
||||
### Special channels
|
||||
|
||||
| Page | URL |
|
||||
|------|-----|
|
||||
| Campus recruitment | `https://www.zhipin.com/school/` |
|
||||
| Returnee / Overseas talent | `https://www.zhipin.com/returnee_jobs/` |
|
||||
| Overseas jobs | `https://www.zhipin.com/overseas/` |
|
||||
| Accessibility jobs | `https://www.zhipin.com/accessible_job/` |
|
||||
| Youle (career community) | `https://youle.zhipin.com/recommend/selected/` |
|
||||
|
||||
---
|
||||
|
||||
## Top Navigation Bar
|
||||
|
||||
```
|
||||
BOSS直聘 | 首页 | 职位 | 公司 | 校园 | 海归 | APP | 有了 | 海外 | 无障碍专区
|
||||
```
|
||||
|
||||
Always visible. First item (BOSS直聘 logo) links to root domain.
|
||||
|
||||
---
|
||||
|
||||
## User Menu (login required)
|
||||
|
||||
Dropdown on the right side of the top bar. Shows user's real name when logged in. Entries include:
|
||||
|
||||
- 消息 — chat with recruiters
|
||||
- 简历 — resume management
|
||||
- 升级VIP — paid membership
|
||||
- 规则中心 — platform rules
|
||||
- 切换为招聘者/切换为求职者 — **dual-mode switch** between job-seeker and recruiter
|
||||
|
||||
---
|
||||
|
||||
## Home Page
|
||||
|
||||
Root URL redirects to city-specific page based on IP (e.g. `/shanghai/`). Shows industry category selector and recommended jobs.
|
||||
|
||||
### Industry Categories (top-level)
|
||||
|
||||
互联网/AI, 电子/电气/通信, 产品, 客服/运营, 销售, 人力/行政/法务, 财务/审计/税务, 生产制造, etc.
|
||||
|
||||
Each expands to sub-specialties (e.g. 互联网/AI → Java, Python, 前端, AI工程师...).
|
||||
|
||||
### Search Bar
|
||||
|
||||
```python
|
||||
"input[placeholder='搜索职位、公司']"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Root URL redirects to city** — `zhipin.com` → `zhipin.com/{city}/` based on IP. Always check final URL after navigation.
|
||||
- **Dual-mode accounts** — same account switches between job-seeker and recruiter. UI changes completely.
|
||||
- **Search is SPA-based** — `/web/geek/jobs` uses client-side routing. URL params don't reflect active filters.
|
||||
- **city slug is pinyin** — `/shanghai/`, `/beijing/`, `/shenzhen/`, `/hangzhou/`, etc. (English transliteration, not Chinese characters). Note: the job search API uses numeric city codes (e.g. `city=101020100`), not pinyin slugs — see the city code table in job-search.md.
|
||||
- **`wait_for_load()` may not be enough** — heavy SPA, add `wait(2)` for hydration.
|
||||
Reference in new issue
Block a user