Files
workbuddy_skills/browser-harness/agent-workspace/domain-skills/BOSS-zhipin/job-search.md
T
admin 777f7fe5d0 重开仓库内容:改推五个技能(browser-harness / humanizer / humanizer-zh / product-planning / session-mechanism)
按授权清空原有内容后重新提交(原 oil-ui-pro 一并移出,可从历史恢复)。
browser-harness 剔除 .venv 等运行环境;根 .gitignore 补记 .venv/ 与 node_modules/。
2026-10-07 16:06:02 +08:00

13 KiB
Raw Blame History

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_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 — 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.