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
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)
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:
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:
{
"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)
{
"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
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:
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_msgsMessages Read from DOM (after opening a conversation)
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:
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:
- Navigate to
/job_detail/{JOB_ID}.html - Find the chat button (
.btn-startchat) element - The button's
hrefor click handler contains thebossIdandsecurityId
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, notencryptBossId- thebossIdformat is"9c833990a839f1251Hx92du5GA~~"(trailing~~), different from the job list'sencryptBossId. maxMsgIdpagination - use the smallestmidfrom the current batch for the next page, notpageparameter.- Job cards in messages have real salary -
body.jobDesc.salaryreturns"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-myselfvsitem-friend- user messages haveitem-myselfclass, recruiter messages haveitem-friend.- Contact search input -
.boss-search-inputsearches within 30 days of contacts, not a general message compose box.
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
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[])
{
"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)
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:
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_jobsAPI: Job Detail
GET /wapi/zpgeek/job/detail.json?securityId={securityId}
Use the securityId from the job list response (NOT encryptJobId).
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:
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. "上海·徐汇区·龙华"
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 -
salaryDescreturns 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. Usejs()+fetch()inside the browser, not Pythonhttp_get. - securityId vs encryptJobId - the job detail API uses
securityId(long opaque string), NOTencryptJobId. Both come from the job list response. brandComInfonotbrandInfo- the job detail response useszpData.brandComInfo(notbrandInfo). Company name isbrandName(notcompanyName), logo islogo(notbrandLogo). Industry/scale/stage are numeric codes - useindustryName/scaleName/stageNamefor 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=2often returns 0 results even whenhasMoreis true on page 1. Vary filters instead of paginating deep. - Filter params need context - setting
experience,salary, ordegreeto non-zero values may returncode: 200404without a validencryptExpectId. 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)aftergoto_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.
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
"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/jobsuses 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, addwait(2)for hydration.