Merge PR #20: feat(boss): add detail adapter + security_id in search

Closes #20

Added boss detail command with fixes:
- district/address field dedup
- template string injection safety
- empty jobInfo guard
- IPage-compatible wait
This commit is contained in:
jackwener committed 2026-03-16 19:33:17 +08:00
commit 77193a0003
4 files changed
+119 -3

No files matched your search

+1 -1
View File
@@ -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 |
+1 -1
View File
@@ -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` | 🔐 浏览器 |
+115
View File
@@ -0,0 +1,115 @@
/**
* 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 (referrer + cookies)
await page.goto('https://www.zhipin.com/web/geek/job');
await page.wait({ time: 1 });
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', ${JSON.stringify(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 || {};
if (!jobInfo.jobName) {
throw new Error('该职位信息不存在或已下架');
}
return [{
name: jobInfo.jobName || '',
salary: jobInfo.salaryDesc || '',
experience: jobInfo.experienceName || '',
degree: jobInfo.degreeName || '',
city: jobInfo.locationName || '',
district: [jobInfo.areaDistrict, jobInfo.businessDistrict].filter(Boolean).join('·'),
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'
: '',
}];
},
});
+2 -1
View File
@@ -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++;