Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 12 additions & 14 deletions cli-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -5469,41 +5469,37 @@
"description": "BOSS直聘查看职位详情",
"access": "read",
"domain": "www.zhipin.com",
"strategy": "cookie",
"strategy": "ui",
"browser": true,
"args": [
{
"name": "security-id",
"type": "str",
"required": true,
"positional": true,
"help": "Security ID from search results (securityId field)"
"help": "Security ID from search results (security_id field)"
}
],
"columns": [
"name",
"salary",
"experience",
"degree",
"city",
"district",
"location",
"description",
"skills",
"welfare",
"boss_name",
"boss_title",
"active_time",
"recruiter",
"company",
"industry",
"scale",
"stage",
"address",
"companyInfo",
"url"
],
"type": "js",
"modulePath": "boss/detail.js",
"sourceFile": "boss/detail.js",
"navigateBefore": false
"navigateBefore": false,
"siteSession": "persistent",
"defaultWindowMode": "background"
},
{
"site": "boss",
Expand Down Expand Up @@ -5794,7 +5790,7 @@
"description": "BOSS直聘搜索职位(不带关键词时返回为你推荐职位)",
"access": "read",
"domain": "www.zhipin.com",
"strategy": "cookie",
"strategy": "intercept",
"browser": true,
"args": [
{
Expand Down Expand Up @@ -5877,7 +5873,9 @@
"type": "js",
"modulePath": "boss/search.js",
"sourceFile": "boss/search.js",
"navigateBefore": false
"navigateBefore": false,
"siteSession": "persistent",
"defaultWindowMode": "background"
},
{
"site": "boss",
Expand Down
30 changes: 30 additions & 0 deletions clis/boss/__fixtures__/detail.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<!doctype html>
<html>
<head><title>供应链实习生_OpenCLI招聘-BOSS直聘</title></head>
<body>
<section class="job-primary">
<div class="name"><h1 title="供应链实习生">供应链实习生</h1><span class="salary">180-230元/天</span></div>
<p><a class="text-desc text-city" href="/shanghai/">上海</a><span class="text-desc text-experiece">5天/周 6个月</span><span class="text-desc text-degree">本科</span></p>
<div class="tag-all job-tags"><span>五险一金</span><span>餐补</span></div>
</section>
<main class="job-detail">
<section class="job-detail-section">
<ul class="job-keyword-list"><li>供应链/物流类专业</li><li>采购/供应商管理经验</li></ul>
<div class="job-sec-text">负责订单、仓储与采购协同,使用 Excel 制作供应链报表。</div>
</section>
<div class="job-boss-info">
<h2 class="name">张女士<span class="boss-active-time">本周活跃</span></h2>
<div class="boss-info-attr">OpenCLI<em class="vdot">·</em>供应链负责人</div>
</div>
<div class="detail-section-item company-address"><h3>工作地址</h3><div class="location-address">上海示例路 8 号</div><p>点击查看地图</p></div>
</main>
<aside class="job-sider">
<p class="title">公司基本信息</p>
<a href="/gongsi/example.html" title="OpenCLI">OpenCLI</a>
<p>D轮及以上</p>
<p>1000-9999人</p>
<p><a href="/i101001/">互联网</a></p>
<a class="look-all">查看全部职位</a>
</aside>
</body>
</html>
3 changes: 2 additions & 1 deletion clis/boss/auth.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
import { registerSiteAuthCommands } from '../_shared/site-auth.js';
// Keep the helper within the adapter so `opencli adapter eject boss` remains runnable.
import { registerSiteAuthCommands } from './site-auth.js';

const BOSS_GEEK_JOBS_URL = 'https://www.zhipin.com/web/geek/jobs';

Expand Down
7 changes: 7 additions & 0 deletions clis/boss/auth.test.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import { describe, expect, it, vi } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { __test__ } from './auth.js';

describe('boss auth identity probe', () => {
it('uses an adapter-local helper so an ejected BOSS adapter has no missing shared import', () => {
const source = readFileSync(fileURLToPath(new URL('./auth.js', import.meta.url)), 'utf8');
expect(source).toContain("from './site-auth.js'");
});

it('navigates to the current geek jobs route instead of the retired reload-loop route', async () => {
const page = {
getCookies: vi.fn().mockResolvedValue([{ name: 'wt2' }]),
Expand Down
187 changes: 144 additions & 43 deletions clis/boss/detail.js
Original file line number Diff line number Diff line change
@@ -1,63 +1,164 @@
/**
* BOSS直聘 job detail — fetch full job posting details via browser cookie API.
* BOSS直聘 job detail — extract the fully rendered job page. Read-only.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { requirePage, navigateTo, bossFetch, verbose } from './utils.js';
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
import { readRequiredString, requirePage, navigateTo, verbose } from './utils.js';

function cleanText(value) {
return String(value || '')
.replace(/\u00a0/g, ' ')
.replace(/[ \t]+\n/g, '\n')
.replace(/\n[ \t]+/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}

function domSnapshotToRow(raw, jobId) {
if (!raw || typeof raw !== 'object' || !cleanText(raw.jobName)) return null;
const unique = values => [...new Set(values.map(cleanText).filter(Boolean))];
const nullable = value => cleanText(value) || null;
return {
name: cleanText(raw.jobName),
salary: cleanText(raw.salaryText),
experience: cleanText(raw.experienceText),
degree: cleanText(raw.degreeText),
location: {
city: nullable(raw.cityText),
district: nullable(raw.districtText),
address: nullable(raw.addressText),
},
description: cleanText(raw.descriptionText),
skills: unique(Array.isArray(raw.skillTexts) ? raw.skillTexts : []).join(', '),
welfare: unique(Array.isArray(raw.welfareTexts) ? raw.welfareTexts : []).join(', '),
recruiter: {
name: nullable(raw.recruiterName),
title: nullable(raw.recruiterTitle),
activeTime: nullable(raw.recruiterActiveTime),
},
company: cleanText(raw.companyName),
companyInfo: {
industry: nullable(raw.industryText),
scale: nullable(raw.scaleText),
stage: nullable(raw.stageText),
},
url: `https://www.zhipin.com/job_detail/${jobId}.html`,
};
}

export function extractRenderedJob() {
const text = (selector, root = document) => {
const element = root.querySelector(selector);
return element ? (element.innerText || element.textContent || '').trim() : '';
};
const texts = (selector, root = document) => Array.from(root.querySelectorAll(selector))
.map(element => (element.innerText || element.textContent || '').trim())
.filter(Boolean);
const first = selectors => {
for (const selector of selectors) {
const value = text(selector);
if (value) return value;
}
return '';
};
const title = first(['.job-primary .name h1', '.job-primary h1', 'h1[title]']);
const limits = texts('.job-primary .job-limit a, .job-primary .job-limit span, .job-primary p a, .job-primary p span');
const companyCandidates = Array.from(document.querySelectorAll('.job-sider a[href^="/gongsi/"], .detail-op a[href*="/gongsi/"]'))
.map(element => (element.getAttribute('title') || element.innerText || element.textContent || '').trim())
.filter(value => value && !/查看.*职位/.test(value));
const companyFromTitle = document.title.match(/_([^_]+)招聘-BOSS直聘$/)?.[1] || '';
const company = companyCandidates[0] || companyFromTitle;
const sideFacts = texts('.job-sider p, .job-sider a[href^="/i"]')
.filter(value => value !== '公司基本信息' && value !== '查看全部职位');
const bossBlock = document.querySelector('.job-boss-info');
const bossHeading = bossBlock?.querySelector('h2');
const bossName = bossHeading
? Array.from(bossHeading.childNodes)
.filter(node => node.nodeType === 3)
.map(node => node.textContent.trim()).filter(Boolean).join(' ')
: '';
const bossLines = bossBlock
? (bossBlock.innerText || bossBlock.textContent || '').split(/\n|·/).map(value => value.trim()).filter(Boolean)
: [];
const activeTime = bossHeading ? text('span', bossHeading) : bossLines.find(value => /活跃|在线/.test(value)) || '';
const resolvedBossName = bossName || bossLines[0] || '';
const bossAttributes = text('.boss-info-attr', bossBlock).split('·').map(value => value.trim()).filter(Boolean);
const bossTitle = bossAttributes.findLast(value => value !== company) ||
bossLines.findLast(value => value !== resolvedBossName && value !== activeTime && value !== company) || '';
const addressBlock = document.querySelector('.company-address');
const address = text('.location-address', addressBlock) || (addressBlock
? (addressBlock.innerText || addressBlock.textContent || '').split('\n').map(value => value.trim())
.filter(value => value && value !== '工作地址' && value !== '点击查看地图')[0] || ''
: '');
return {
jobName: title,
salaryText: first(['.job-primary .salary', '.job-banner .salary', '.job-primary .name + span']),
cityText: limits[0] || '',
experienceText: limits[1] || '',
degreeText: limits[2] || '',
districtText: '',
descriptionText: first(['.job-detail .job-detail-section .job-sec-text:not(.fold-text)', '.job-detail .job-sec-text:not(.fold-text)', '.job-sec-text:not(.fold-text)']),
skillTexts: texts('.job-detail .job-keyword-list li, .job-keyword-list li'),
welfareTexts: texts('.job-banner .job-tags span, .job-primary .job-tags span'),
recruiterName: resolvedBossName,
recruiterTitle: bossTitle,
recruiterActiveTime: activeTime,
companyName: company,
industryText: sideFacts.find(value => !/人|融资|上市|轮/.test(value)) || '',
scaleText: sideFacts.find(value => /\d+.*人/.test(value)) || '',
stageText: sideFacts.find(value => /融资|上市|不需要融资/.test(value)) || '',
addressText: address,
};
}

async function readRenderedPage(page, jobId) {
const raw = await page.evaluate(extractRenderedJob);
return domSnapshotToRow(raw, jobId);
}

async function captureJobDetail(page, jobId) {
const url = `https://www.zhipin.com/job_detail/${encodeURIComponent(jobId)}.html`;
await navigateTo(page, 'https://www.zhipin.com/web/geek/jobs', 2);
for (let navigationAttempt = 0; navigationAttempt < 2; navigationAttempt++) {
await navigateTo(page, url, 5);
for (let attempt = 0; attempt < 5; attempt++) {
try {
const domRow = await readRenderedPage(page, jobId);
if (domRow?.name && domRow?.description && domRow?.company) return domRow;
} catch { /* wait for a complete render */ }
if (attempt < 4) await page.wait(1);
}
}
throw new CommandExecutionError('BOSS detail page did not expose a complete job posting');
}
cli({
site: 'boss',
name: 'detail',
access: 'read',
description: 'BOSS直聘查看职位详情',
domain: 'www.zhipin.com',
strategy: Strategy.COOKIE,
strategy: Strategy.UI,
navigateBefore: false,
browser: true,
defaultWindowMode: 'background',
siteSession: 'persistent',
args: [
{ name: 'security-id', positional: true, required: true, help: 'Security ID from search results (securityId field)' },
{ name: 'security-id', positional: true, required: true, help: 'Security ID from search results (security_id field)' },
],
columns: [
'name', 'salary', 'experience', 'degree', 'city', 'district',
'description', 'skills', 'welfare',
'boss_name', 'boss_title', 'active_time',
'company', 'industry', 'scale', 'stage',
'address', 'url',
'name', 'salary', 'experience', 'degree', 'location',
'description', 'skills', 'welfare', 'recruiter',
'company', 'companyInfo', 'url',
],
func: async (page, kwargs) => {
requirePage(page);
const securityId = kwargs['security-id'];
verbose('Fetching job detail...');
// Navigate to zhipin.com first to establish cookie context (referrer + cookies)
await navigateTo(page, 'https://www.zhipin.com/web/geek/job');
const targetUrl = `https://www.zhipin.com/wapi/zpgeek/job/detail.json?securityId=${encodeURIComponent(securityId)}`;
const data = await bossFetch(page, targetUrl);
const zpData = data.zpData || {};
const jobInfo = zpData.jobInfo || {};
const bossInfo = zpData.bossInfo || {};
const brandComInfo = zpData.brandComInfo || {};
if (!jobInfo.jobName) {
throw new Error('该职位信息不存在或已下架');
const jobId = readRequiredString(kwargs['security-id'], 'security-id');
if (!/^[A-Za-z0-9_-]+$/.test(jobId)) {
throw new ArgumentError('boss security-id contains unsupported characters', 'Pass the security_id returned by `opencli boss search`');
}
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'
: '',
}];
verbose('Fetching job detail from the rendered BOSS page...');
return [await captureJobDetail(page, jobId)];
},
});

export const __test__ = { cleanText, domSnapshotToRow };
Loading