Domain skill
BOSS-zhipin
Markdown synced from browser-harness domain skills.
- Host
- BOSS-zhipin
- Files
- 3
Agent prompt
Use this skill
Copy this prompt into your coding agent to make it enable browser-harness domain skills and read this exact domain folder before automating.
Set up https://github.com/browser-use/browser-harness for me if it is not already installed. If setup is needed, read `install.md` first to install and connect it to my real browser. Then read `SKILL.md` for normal usage and always read `helpers.py` because that is where the browser-harness functions are. Enable domain skills if they are not already enabled by setting `BH_DOMAIN_SKILLS=1` for browser-harness. Use the `BOSS-zhipin` domain skill from `agent-workspace/domain-skills/BOSS-zhipin/`. Read every markdown file for this domain before inventing an approach: - agent-workspace/domain-skills/BOSS-zhipin/chat.md - agent-workspace/domain-skills/BOSS-zhipin/job-search.md - agent-workspace/domain-skills/BOSS-zhipin/navigation.md Use those domain-skill notes to complete my task for `BOSS-zhipin` in my real browser. When you open a setup, verification, or task tab, activate it so I can see the active browser tab.
Skill contents
What the agent will read
Chat & Messaging
chat.md
- 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.
- ---
- BOSS直聘 uses a hybrid messaging architecture:
Show full markdown
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_msgs
Messages 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.
Job Search & Extraction
job-search.md
- 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).
- ---
- BOSS直聘 is a Vue SPA. There is no SSR JSON blob (NEXTDATA, INITIALSTATE, etc.) — all data loads via XHR/fetch to internal /wapi/ endpoints.
- httpget 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.
Show full markdown
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_jobs
API: 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.