From c230f3e5ad1a8c8271a1b4fa87dcc466a70a00eb Mon Sep 17 00:00:00 2001 From: xuelin Date: Mon, 16 Mar 2026 17:57:16 +0800 Subject: [PATCH 1/3] feat(boss): add job detail adapter Add `boss detail` command to fetch full job posting details using securityId from search results. Fields returned: job description, skills, welfare, boss info (name, title, active time), company info (industry, scale, stage), address. Tested with real API calls against multiple job postings. Usage: opencli boss detail --security_id Co-Authored-By: Claude Opus 4.6 --- src/clis/boss/detail.ts | 111 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 src/clis/boss/detail.ts diff --git a/src/clis/boss/detail.ts b/src/clis/boss/detail.ts new file mode 100644 index 0000000..aaa6c50 --- /dev/null +++ b/src/clis/boss/detail.ts @@ -0,0 +1,111 @@ +/** + * BOSS直聘 job detail — fetch full job posting details via browser cookie API. + * + * Uses securityId from search results to call the detail API. + * Returns: job description, skills, welfare, boss info, company info, address. + */ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +cli({ + site: 'boss', + name: 'detail', + description: 'BOSS直聘查看职位详情', + domain: 'www.zhipin.com', + strategy: Strategy.COOKIE, + + browser: true, + args: [ + { name: 'security_id', required: true, help: 'Security ID from search results (securityId field)' }, + ], + columns: [ + 'name', 'salary', 'experience', 'degree', 'city', 'district', + 'description', 'skills', 'welfare', + 'boss_name', 'boss_title', 'active_time', + 'company', 'industry', 'scale', 'stage', + 'address', 'url', + ], + func: async (page: IPage | null, kwargs) => { + if (!page) throw new Error('Browser page required'); + + const securityId = kwargs.security_id; + + // Navigate to zhipin.com first to establish cookie context + await page.goto('https://www.zhipin.com/web/geek/job'); + await new Promise(r => setTimeout(r, 1000)); + + const targetUrl = `https://www.zhipin.com/wapi/zpgeek/job/detail.json?securityId=${encodeURIComponent(securityId)}`; + + if (process.env.OPENCLI_VERBOSE || process.env.DEBUG?.includes('opencli')) { + console.error(`[opencli:boss] Fetching job detail...`); + } + + const evaluateScript = ` + async () => { + return new Promise((resolve, reject) => { + const xhr = new window.XMLHttpRequest(); + xhr.open('GET', '${targetUrl}', true); + xhr.withCredentials = true; + xhr.timeout = 15000; + xhr.setRequestHeader('Accept', 'application/json, text/plain, */*'); + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + try { + resolve(JSON.parse(xhr.responseText)); + } catch (e) { + reject(new Error('Failed to parse JSON. Raw (200 chars): ' + xhr.responseText.substring(0, 200))); + } + } else { + reject(new Error('XHR HTTP Status: ' + xhr.status)); + } + }; + xhr.onerror = () => reject(new Error('XHR Network Error')); + xhr.ontimeout = () => reject(new Error('XHR Timeout')); + xhr.send(); + }); + } + `; + + let data: any; + try { + data = await page.evaluate(evaluateScript); + } catch (e: any) { + throw new Error('API evaluate failed: ' + e.message); + } + + if (data.code !== 0) { + if (data.code === 37) { + throw new Error('Cookie 已过期!请在当前 Chrome 浏览器中重新登录 BOSS 直聘。'); + } + throw new Error(`BOSS API error: ${data.message || 'Unknown'} (code=${data.code})`); + } + + const zpData = data.zpData || {}; + const jobInfo = zpData.jobInfo || {}; + const bossInfo = zpData.bossInfo || {}; + const brandComInfo = zpData.brandComInfo || {}; + + return [{ + name: jobInfo.jobName || '', + salary: jobInfo.salaryDesc || '', + experience: jobInfo.experienceName || '', + degree: jobInfo.degreeName || '', + city: jobInfo.locationName || '', + district: jobInfo.address || '', + description: jobInfo.postDescription || '', + skills: (jobInfo.showSkills || []).join(', '), + welfare: (brandComInfo.labels || []).join(', '), + boss_name: bossInfo.name || '', + boss_title: bossInfo.title || '', + active_time: bossInfo.activeTimeDesc || '', + company: brandComInfo.brandName || bossInfo.brandName || '', + industry: brandComInfo.industryName || '', + scale: brandComInfo.scaleName || '', + stage: brandComInfo.stageName || '', + address: jobInfo.address || '', + url: jobInfo.encryptId + ? 'https://www.zhipin.com/job_detail/' + jobInfo.encryptId + '.html' + : '', + }]; + }, +}); From e781d40408dcf213b640e7d7a83507d764df2023 Mon Sep 17 00:00:00 2001 From: xuelin Date: Mon, 16 Mar 2026 18:12:46 +0800 Subject: [PATCH 2/3] feat(boss): add security_id to search output Expose securityId in search results so users can pipe it to `boss detail` for full job information. Co-Authored-By: Claude Opus 4.6 --- src/clis/boss/search.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/clis/boss/search.ts b/src/clis/boss/search.ts index 984ee89..8db70ed 100644 --- a/src/clis/boss/search.ts +++ b/src/clis/boss/search.ts @@ -81,7 +81,7 @@ cli({ { name: 'page', type: 'int', default: 1, help: 'Page number' }, { name: 'limit', type: 'int', default: 15, help: 'Number of results' }, ], - columns: ['name', 'salary', 'company', 'area', 'experience', 'degree', 'skills', 'boss', 'url'], + columns: ['name', 'salary', 'company', 'area', 'experience', 'degree', 'skills', 'boss', 'security_id', 'url'], func: async (page: IPage | null, kwargs) => { if (!page) throw new Error('Browser page required'); @@ -191,6 +191,7 @@ cli({ degree: j.jobDegree, skills: (j.skills || []).join(','), boss: j.bossName + ' · ' + j.bossTitle, + security_id: j.securityId || '', url: 'https://www.zhipin.com/job_detail/' + j.encryptJobId + '.html', }); addedInBatch++; From 05b7f1bccf4b9c998082b3a83045d1d34b8e4742 Mon Sep 17 00:00:00 2001 From: jackwener Date: Mon, 16 Mar 2026 19:33:00 +0800 Subject: [PATCH 3/3] fix(boss): improve detail adapter quality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix district/address field duplication (district now uses areaDistrict·businessDistrict) - Fix template string injection risk in evaluate script (use JSON.stringify) - Add jobInfo empty guard with user-friendly error message - Replace raw setTimeout with page.wait for IPage compatibility - Update README docs to include boss detail command --- README.md | 2 +- README.zh-CN.md | 2 +- src/clis/boss/detail.ts | 12 ++++++++---- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index b3adcb1..6846502 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,7 @@ npm install -g @jackwener/opencli@latest | **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `following` `followers` `notifications` `post` `reply` `delete` `like` | 🔐 Browser | | **reddit** | `hot` `frontpage` `search` `subreddit` | 🔐 Browser | | **weibo** | `hot` | 🔐 Browser | -| **boss** | `search` | 🔐 Browser | +| **boss** | `search` `detail` | 🔐 Browser | | **coupang** | `search` `add-to-cart` | 🔐 Browser | | **youtube** | `search` | 🔐 Browser | | **yahoo-finance** | `quote` | 🔐 Browser | diff --git a/README.zh-CN.md b/README.zh-CN.md index ec409c6..fbf30a5 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -138,7 +138,7 @@ npm install -g @jackwener/opencli@latest | **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `following` `followers` `notifications` `post` `reply` `delete` `like` | 🔐 浏览器 | | **reddit** | `hot` `frontpage` `search` `subreddit` | 🔐 浏览器 | | **weibo** | `hot` | 🔐 浏览器 | -| **boss** | `search` | 🔐 浏览器 | +| **boss** | `search` `detail` | 🔐 浏览器 | | **coupang** | `search` `add-to-cart` | 🔐 浏览器 | | **youtube** | `search` | 🔐 浏览器 | | **yahoo-finance** | `quote` | 🔐 浏览器 | diff --git a/src/clis/boss/detail.ts b/src/clis/boss/detail.ts index aaa6c50..26416c2 100644 --- a/src/clis/boss/detail.ts +++ b/src/clis/boss/detail.ts @@ -30,9 +30,9 @@ cli({ const securityId = kwargs.security_id; - // Navigate to zhipin.com first to establish cookie context + // Navigate to zhipin.com first to establish cookie context (referrer + cookies) await page.goto('https://www.zhipin.com/web/geek/job'); - await new Promise(r => setTimeout(r, 1000)); + await page.wait({ time: 1 }); const targetUrl = `https://www.zhipin.com/wapi/zpgeek/job/detail.json?securityId=${encodeURIComponent(securityId)}`; @@ -44,7 +44,7 @@ cli({ async () => { return new Promise((resolve, reject) => { const xhr = new window.XMLHttpRequest(); - xhr.open('GET', '${targetUrl}', true); + xhr.open('GET', ${JSON.stringify(targetUrl)}, true); xhr.withCredentials = true; xhr.timeout = 15000; xhr.setRequestHeader('Accept', 'application/json, text/plain, */*'); @@ -85,13 +85,17 @@ cli({ const bossInfo = zpData.bossInfo || {}; const brandComInfo = zpData.brandComInfo || {}; + if (!jobInfo.jobName) { + throw new Error('该职位信息不存在或已下架'); + } + return [{ name: jobInfo.jobName || '', salary: jobInfo.salaryDesc || '', experience: jobInfo.experienceName || '', degree: jobInfo.degreeName || '', city: jobInfo.locationName || '', - district: jobInfo.address || '', + district: [jobInfo.areaDistrict, jobInfo.businessDistrict].filter(Boolean).join('·'), description: jobInfo.postDescription || '', skills: (jobInfo.showSkills || []).join(', '), welfare: (brandComInfo.labels || []).join(', '),