Jobs and people

zhipin.com

BOSS Zhipin: 3 skills for AI agents

Notes an AI agent reads before it works on zhipin.com: what to open, what to call, what breaks. Written by browser-harness contributors after real runs, and kept on GitHub under the MIT license.

Give it to your agent

Any coding agent

Start the task with this line. The Markdown version is the same notes without the page around them.

Read https://browser-use.com/skills/boss-zhipin.md, then <your task>

Browser Harness

Save the files into your workspace and turn domain skills on. The code below calls its helpers, such as http_get and js. What Browser Harness is.

d="${BH_AGENT_WORKSPACE:-$HOME/.config/browser-harness/agent-workspace}/domain-skills/BOSS-zhipin"
mkdir -p "$d" && cd "$d"
curl -sLO https://raw.githubusercontent.com/browser-use/browser-harness/main/agent-workspace/domain-skills/BOSS-zhipin/chat.md
curl -sLO https://raw.githubusercontent.com/browser-use/browser-harness/main/agent-workspace/domain-skills/BOSS-zhipin/job-search.md
curl -sLO https://raw.githubusercontent.com/browser-use/browser-harness/main/agent-workspace/domain-skills/BOSS-zhipin/navigation.md
export BH_DOMAIN_SKILLS=1

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):

TabDescriptionClass
全部All conversations (default)li.selected when active
未读(N)Unread conversations, badge shows count<i> in label shows count
新招呼New greetings from recruitersBadge indicator via <i class="badge">
更多 ▾Dropdown with extra filters.ui-dropmenu

"更多" dropdown (.more-label li):

OptionDescription
仅沟通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

ParamDescription
bossIdRecruiter ID from conversation (format: 9c833990a839f1251Hx92du5GA~~)
maxMsgIdPagination cursor. 0 for first page, then use the smallest mid from previous page
cCount per page (default 20)
pagePage number
srcSource (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.typeMeaningFields
1Plain textbody.text
8Job description cardbody.jobDesc (title, salary, company, boss, city, experience, education), body.headTitle
16System 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:

  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.

chat.md · updated May 2, 2026 · 1,382 words

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

ResourceURL
Job search pagehttps://www.zhipin.com/web/geek/jobs
Job search (with prefs)https://www.zhipin.com/web/geek/jobs?ka=open_joblist
Job detail pagehttps://www.zhipin.com/job_detail/{JOB_ID}.html
Company pagehttps://www.zhipin.com/gongsi/{COMPANY_ID}~.html

API: Job List

GET /wapi/zpgeek/pc/recommend/job/list.json

Query Parameters

ParamDescriptionValues
pagePage number1-N
pageSizeResults per page15 (default)
cityCity code101020100 = Shanghai, 101010100 = Beijing, 101280100 = Guangzhou
experienceExperience filter code0=不限, 104=1-3年, 105=3-5年, 106=5-10年
degreeEducation filter code0=不限
salarySalary filter code0=不限, 405=10-20K, 406=20-50K
industryIndustry filter code(numeric)
scaleCompany size filter code0=不限, 303=100-499人, 305=1000-9999人
jobTypeJob type0=不限, 1=全职, 2=兼职
encryptExpectIdSaved preference IDFrom user's saved preferences (empty string = default)
mixExpectTypeMixed expectation type(empty string for default)
expectInfoExpectation 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:

CityCode
上海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 - 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.

job-search.md · updated May 2, 2026 · 1,491 words

Field-tested against zhipin.com on 2026-05-01.


PageURL
Home (redirects to city)https://www.zhipin.com/ → https://www.zhipin.com/{city}/
Job searchhttps://www.zhipin.com/web/geek/jobs
Company searchhttps://www.zhipin.com/gongsi/
Messages / Chathttps://www.zhipin.com/web/geek/chat
Personal centerhttps://www.zhipin.com/web/geek/recommend
PageURL
Campus recruitmenthttps://www.zhipin.com/school/
Returnee / Overseas talenthttps://www.zhipin.com/returnee_jobs/
Overseas jobshttps://www.zhipin.com/overseas/
Accessibility jobshttps://www.zhipin.com/accessible_job/
Youle (career community)https://youle.zhipin.com/recommend/selected/

BOSS直聘 | 首页 | 职位 | 公司 | 校园 | 海归 | APP | 有了 | 海外 | 无障碍专区

Always visible. First item (BOSS直聘 logo) links to root domain.


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

Root URL redirects to city-specific page based on IP (e.g. /shanghai/). Shows industry category selector and recommended jobs.

互联网/AI, 电子/电气/通信, 产品, 客服/运营, 销售, 人力/行政/法务, 财务/审计/税务, 生产制造, etc.

Each expands to sub-specialties (e.g. 互联网/AI → Java, Python, 前端, AI工程师...).

"input[placeholder='搜索职位、公司']"

  • 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.

navigation.md · updated May 2, 2026 · 343 words

Sites change. These notes describe what worked when they were last updated, not a guarantee they still do. If something broke, the fix is a pull request to browser-harness.