1175 lines
28 KiB
Markdown
1175 lines
28 KiB
Markdown
# 客户端待开发功能文档
|
||
|
||
> 本文档说明客户端需要实现的功能,所有操作通过MQTT接收服务端指令执行
|
||
|
||
---
|
||
|
||
## 一、客户端架构概览
|
||
|
||
### 1.1 技术栈
|
||
- **自动化框架**: Puppeteer / Playwright (推荐Playwright,支持更好的反爬)
|
||
- **运行环境**: Node.js 18+
|
||
- **通信协议**: MQTT (mqtt://192.144.167.231:1883)
|
||
- **消息格式**: JSON
|
||
- **浏览器**: Chromium (无头模式/有头模式可切换)
|
||
|
||
### 1.2 核心模块
|
||
```
|
||
client/
|
||
├── src/
|
||
│ ├── mqtt/ # MQTT通信模块
|
||
│ │ ├── client.js # MQTT客户端
|
||
│ │ └── handler.js # 消息处理器
|
||
│ ├── platforms/ # 平台适配器
|
||
│ │ ├── boss/ # Boss直聘
|
||
│ │ │ ├── login.js # 登录模块
|
||
│ │ │ ├── search.js # 搜索模块 ⚠️ 待开发
|
||
│ │ │ ├── apply.js # 投递模块 ⚠️ 待完善
|
||
│ │ │ ├── resume.js # 简历模块 ⚠️ 待开发
|
||
│ │ │ ├── chat.js # 聊天模块 ⚠️ 待完善
|
||
│ │ │ └── active.js # 保活模块 ⚠️ 待开发
|
||
│ │ └── zhilian/ # 智联招聘 (同上)
|
||
│ ├── utils/ # 工具函数
|
||
│ │ ├── browser.js # 浏览器管理
|
||
│ │ ├── antibot.js # 反爬虫处理
|
||
│ │ └── logger.js # 日志记录
|
||
│ └── index.js # 入口文件
|
||
└── config/
|
||
└── default.json # 配置文件
|
||
```
|
||
|
||
### 1.3 工作流程
|
||
```
|
||
服务端 → MQTT → 客户端接收 → 路由到平台模块 → 执行操作 → 返回结果 → MQTT → 服务端
|
||
```
|
||
|
||
---
|
||
|
||
## 二、高优先级功能 (P0)
|
||
|
||
> 与投递直接相关的核心功能
|
||
|
||
### 2.1 增强搜索功能 ⚠️ 待开发
|
||
|
||
#### 功能说明
|
||
实现Boss直聘完整的搜索功能,支持所有筛选项和URL解析
|
||
|
||
#### MQTT指令
|
||
- `search_jobs_enhanced` - 带完整筛选参数的搜索
|
||
- `search_by_url` - 直接解析URL搜索
|
||
|
||
#### 目标URL示例
|
||
```
|
||
https://www.zhipin.com/web/geek/jobs?city=101020100&query=%E5%85%A8%E6%A0%88%E5%B7%A5%E7%A8%8B%E5%B8%88&experience=103°ree=203&salary=404
|
||
```
|
||
|
||
#### 实现要点
|
||
|
||
**1. URL参数解析**
|
||
```javascript
|
||
// platforms/boss/search.js
|
||
|
||
/**
|
||
* 解析Boss直聘搜索URL
|
||
* @param {string} url - 搜索URL
|
||
* @returns {Object} 解析后的参数对象
|
||
*/
|
||
function parseSearchUrl(url) {
|
||
const urlObj = new URL(url);
|
||
const params = {};
|
||
|
||
// 城市代码
|
||
if (urlObj.searchParams.get('city')) {
|
||
params.city = urlObj.searchParams.get('city');
|
||
}
|
||
|
||
// 搜索关键词
|
||
if (urlObj.searchParams.get('query')) {
|
||
params.query = decodeURIComponent(urlObj.searchParams.get('query'));
|
||
}
|
||
|
||
// 工作经验
|
||
if (urlObj.searchParams.get('experience')) {
|
||
params.experience = urlObj.searchParams.get('experience');
|
||
}
|
||
|
||
// 学历要求
|
||
if (urlObj.searchParams.get('degree')) {
|
||
params.degree = urlObj.searchParams.get('degree');
|
||
}
|
||
|
||
// 薪资范围
|
||
if (urlObj.searchParams.get('salary')) {
|
||
params.salary = urlObj.searchParams.get('salary');
|
||
}
|
||
|
||
// 公司规模
|
||
if (urlObj.searchParams.get('scale')) {
|
||
params.scale = urlObj.searchParams.get('scale');
|
||
}
|
||
|
||
// 融资阶段
|
||
if (urlObj.searchParams.get('stage')) {
|
||
params.stage = urlObj.searchParams.get('stage');
|
||
}
|
||
|
||
// 职位类型
|
||
if (urlObj.searchParams.get('position')) {
|
||
params.position = urlObj.searchParams.get('position');
|
||
}
|
||
|
||
// 行业领域
|
||
if (urlObj.searchParams.get('industry')) {
|
||
params.industry = urlObj.searchParams.get('industry');
|
||
}
|
||
|
||
return params;
|
||
}
|
||
|
||
/**
|
||
* 构建搜索URL
|
||
* @param {Object} params - 搜索参数
|
||
* @returns {string} 完整的搜索URL
|
||
*/
|
||
function buildSearchUrl(params) {
|
||
const baseUrl = 'https://www.zhipin.com/web/geek/jobs';
|
||
const searchParams = new URLSearchParams();
|
||
|
||
// 必填参数
|
||
if (params.city) searchParams.set('city', params.city);
|
||
if (params.query) searchParams.set('query', params.query);
|
||
|
||
// 可选筛选参数
|
||
if (params.experience) searchParams.set('experience', params.experience);
|
||
if (params.degree) searchParams.set('degree', params.degree);
|
||
if (params.salary) searchParams.set('salary', params.salary);
|
||
if (params.scale) searchParams.set('scale', params.scale);
|
||
if (params.stage) searchParams.set('stage', params.stage);
|
||
if (params.position) searchParams.set('position', params.position);
|
||
if (params.industry) searchParams.set('industry', params.industry);
|
||
|
||
return `${baseUrl}?${searchParams.toString()}`;
|
||
}
|
||
```
|
||
|
||
**2. 搜索执行**
|
||
```javascript
|
||
/**
|
||
* 执行增强搜索
|
||
* @param {Page} page - Playwright页面对象
|
||
* @param {Object} params - 搜索参数
|
||
* @returns {Promise<Array>} 职位列表
|
||
*/
|
||
async function searchJobsEnhanced(page, params) {
|
||
const url = buildSearchUrl(params);
|
||
|
||
// 访问搜索页面
|
||
await page.goto(url, { waitUntil: 'networkidle' });
|
||
|
||
// 等待职位列表加载
|
||
await page.waitForSelector('.job-card-wrapper', { timeout: 10000 });
|
||
|
||
// 反爬虫检测
|
||
const isBlocked = await checkBotDetection(page);
|
||
if (isBlocked) {
|
||
throw new Error('检测到反爬虫,需要人工介入');
|
||
}
|
||
|
||
const jobs = [];
|
||
let hasMore = true;
|
||
let scrollCount = 0;
|
||
const maxScrolls = params.maxResults ? Math.ceil(params.maxResults / 30) : 3;
|
||
|
||
while (hasMore && scrollCount < maxScrolls) {
|
||
// 提取当前页面职位
|
||
const pageJobs = await extractJobsFromPage(page);
|
||
jobs.push(...pageJobs);
|
||
|
||
// 滚动加载更多
|
||
hasMore = await scrollToLoadMore(page);
|
||
scrollCount++;
|
||
|
||
// 随机延迟,模拟人类行为
|
||
await randomDelay(1000, 3000);
|
||
}
|
||
|
||
return jobs;
|
||
}
|
||
|
||
/**
|
||
* 从页面提取职位信息
|
||
* @param {Page} page - Playwright页面对象
|
||
* @returns {Promise<Array>} 职位信息数组
|
||
*/
|
||
async function extractJobsFromPage(page) {
|
||
return await page.$$eval('.job-card-wrapper', (cards) => {
|
||
return cards.map(card => {
|
||
const jobName = card.querySelector('.job-name')?.textContent.trim();
|
||
const jobArea = card.querySelector('.job-area')?.textContent.trim();
|
||
const salary = card.querySelector('.salary')?.textContent.trim();
|
||
const companyName = card.querySelector('.company-name a')?.textContent.trim();
|
||
const companyTag = Array.from(card.querySelectorAll('.company-tag-list li'))
|
||
.map(li => li.textContent.trim());
|
||
const bossInfo = card.querySelector('.info-public')?.textContent.trim();
|
||
const jobLink = card.querySelector('.job-card-left')?.href;
|
||
const jobId = jobLink?.match(/job_detail\/(\w+)\.html/)?.[1];
|
||
|
||
// 提取标签(经验、学历)
|
||
const tags = Array.from(card.querySelectorAll('.tag-list li'))
|
||
.map(li => li.textContent.trim());
|
||
|
||
return {
|
||
jobId,
|
||
jobName,
|
||
jobArea,
|
||
salary,
|
||
companyName,
|
||
companyTags: companyTag,
|
||
experience: tags[0] || '',
|
||
degree: tags[1] || '',
|
||
bossInfo,
|
||
jobLink,
|
||
tags
|
||
};
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 滚动加载更多职位
|
||
* @param {Page} page - Playwright页面对象
|
||
* @returns {Promise<boolean>} 是否还有更多内容
|
||
*/
|
||
async function scrollToLoadMore(page) {
|
||
// 滚动到页面底部
|
||
await page.evaluate(() => {
|
||
window.scrollTo(0, document.body.scrollHeight);
|
||
});
|
||
|
||
// 等待新内容加载
|
||
await page.waitForTimeout(2000);
|
||
|
||
// 检查是否有"加载更多"按钮或已到底部
|
||
const loadMoreBtn = await page.$('.load-more');
|
||
if (loadMoreBtn) {
|
||
await loadMoreBtn.click();
|
||
await page.waitForTimeout(1500);
|
||
return true;
|
||
}
|
||
|
||
// 检查是否显示"没有更多了"
|
||
const noMore = await page.$('.no-more-tips');
|
||
return !noMore;
|
||
}
|
||
```
|
||
|
||
**3. 反爬虫处理**
|
||
```javascript
|
||
// utils/antibot.js
|
||
|
||
/**
|
||
* 检测是否被反爬虫拦截
|
||
* @param {Page} page - Playwright页面对象
|
||
* @returns {Promise<boolean>} 是否被拦截
|
||
*/
|
||
async function checkBotDetection(page) {
|
||
const indicators = [
|
||
() => page.$('.captcha-container'), // 验证码
|
||
() => page.$('.security-check'), // 安全检查
|
||
() => page.content().then(c => c.includes('频繁访问')),
|
||
() => page.content().then(c => c.includes('请稍后再试'))
|
||
];
|
||
|
||
for (const check of indicators) {
|
||
if (await check()) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* 随机延迟
|
||
* @param {number} min - 最小毫秒
|
||
* @param {number} max - 最大毫秒
|
||
*/
|
||
async function randomDelay(min, max) {
|
||
const delay = Math.floor(Math.random() * (max - min + 1)) + min;
|
||
await new Promise(resolve => setTimeout(resolve, delay));
|
||
}
|
||
|
||
/**
|
||
* 模拟人类鼠标移动
|
||
* @param {Page} page - Playwright页面对象
|
||
*/
|
||
async function simulateHumanBehavior(page) {
|
||
// 随机移动鼠标
|
||
const randomX = Math.floor(Math.random() * 800) + 100;
|
||
const randomY = Math.floor(Math.random() * 600) + 100;
|
||
await page.mouse.move(randomX, randomY);
|
||
|
||
// 随机滚动
|
||
await page.evaluate(() => {
|
||
const scrollAmount = Math.floor(Math.random() * 300) + 100;
|
||
window.scrollBy(0, scrollAmount);
|
||
});
|
||
|
||
await randomDelay(500, 1500);
|
||
}
|
||
```
|
||
|
||
**4. MQTT指令处理器**
|
||
```javascript
|
||
// mqtt/handler.js - 增强搜索指令处理
|
||
|
||
async function handleSearchJobsEnhanced(payload) {
|
||
const { platform, params, taskId } = payload;
|
||
|
||
try {
|
||
// 获取平台对应的浏览器页面
|
||
const page = await browserManager.getPage(platform);
|
||
|
||
// 确保已登录
|
||
await ensureLoggedIn(page, platform);
|
||
|
||
// 执行搜索
|
||
const searchModule = require(`../platforms/${platform}/search.js`);
|
||
const jobs = await searchModule.searchJobsEnhanced(page, params);
|
||
|
||
// 返回结果
|
||
return {
|
||
code: 0,
|
||
message: '搜索成功',
|
||
data: {
|
||
total: jobs.length,
|
||
jobs: jobs
|
||
},
|
||
taskId
|
||
};
|
||
} catch (error) {
|
||
console.error('搜索失败:', error);
|
||
return {
|
||
code: -1,
|
||
message: error.message,
|
||
taskId
|
||
};
|
||
}
|
||
}
|
||
|
||
async function handleSearchByUrl(payload) {
|
||
const { platform, url, taskId } = payload;
|
||
|
||
try {
|
||
const searchModule = require(`../platforms/${platform}/search.js`);
|
||
|
||
// 解析URL
|
||
const params = searchModule.parseSearchUrl(url);
|
||
|
||
// 执行搜索
|
||
const page = await browserManager.getPage(platform);
|
||
await ensureLoggedIn(page, platform);
|
||
const jobs = await searchModule.searchJobsEnhanced(page, params);
|
||
|
||
return {
|
||
code: 0,
|
||
message: '搜索成功',
|
||
data: {
|
||
total: jobs.length,
|
||
jobs: jobs,
|
||
params: params // 返回解析的参数
|
||
},
|
||
taskId
|
||
};
|
||
} catch (error) {
|
||
console.error('URL搜索失败:', error);
|
||
return {
|
||
code: -1,
|
||
message: error.message,
|
||
taskId
|
||
};
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 参数映射表
|
||
|
||
**工作经验 (experience)**
|
||
- `103`: 应届生
|
||
- `104`: 1年以内
|
||
- `105`: 1-3年
|
||
- `106`: 3-5年
|
||
- `107`: 5-10年
|
||
- `108`: 10年以上
|
||
|
||
**学历要求 (degree)**
|
||
- `203`: 大专
|
||
- `204`: 本科
|
||
- `205`: 硕士
|
||
- `206`: 博士
|
||
|
||
**薪资范围 (salary)**
|
||
- `402`: 3K以下
|
||
- `403`: 3-5K
|
||
- `404`: 5-10K
|
||
- `405`: 10-15K
|
||
- `406`: 15-25K
|
||
- `407`: 25-35K
|
||
- `408`: 35-50K
|
||
- `409`: 50K以上
|
||
|
||
**公司规模 (scale)**
|
||
- `301`: 0-20人
|
||
- `302`: 20-99人
|
||
- `303`: 100-499人
|
||
- `304`: 500-999人
|
||
- `305`: 1000-9999人
|
||
- `306`: 10000人以上
|
||
|
||
**融资阶段 (stage)**
|
||
- `801`: 未融资
|
||
- `802`: 天使轮
|
||
- `803`: A轮
|
||
- `804`: B轮
|
||
- `805`: C轮
|
||
- `806`: D轮及以上
|
||
- `807`: 已上市
|
||
- `808`: 不需要融资
|
||
|
||
#### 测试用例
|
||
```javascript
|
||
// 测试用例1: 参数搜索
|
||
{
|
||
action: 'search_jobs_enhanced',
|
||
platform: 'boss',
|
||
params: {
|
||
city: '101020100', // 上海
|
||
query: '全栈工程师',
|
||
experience: '106', // 3-5年
|
||
degree: '204', // 本科
|
||
salary: '406', // 15-25K
|
||
scale: '303', // 100-499人
|
||
maxResults: 100
|
||
}
|
||
}
|
||
|
||
// 测试用例2: URL搜索
|
||
{
|
||
action: 'search_by_url',
|
||
platform: 'boss',
|
||
url: 'https://www.zhipin.com/web/geek/jobs?city=101020100&query=%E5%85%A8%E6%A0%88%E5%B7%A5%E7%A8%8B%E5%B8%88&experience=106°ree=204&salary=406'
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 2.2 批量投递功能 ⚠️ 待完善
|
||
|
||
#### 功能说明
|
||
支持批量投递职位,带智能过滤和频率控制
|
||
|
||
#### MQTT指令
|
||
- `batch_apply_jobs` - 批量投递
|
||
|
||
#### 实现要点
|
||
|
||
```javascript
|
||
// platforms/boss/apply.js
|
||
|
||
/**
|
||
* 批量投递职位
|
||
* @param {Page} page - Playwright页面对象
|
||
* @param {Array} jobIds - 职位ID列表
|
||
* @param {Object} options - 投递选项
|
||
* @returns {Promise<Object>} 投递结果
|
||
*/
|
||
async function batchApplyJobs(page, jobIds, options = {}) {
|
||
const {
|
||
maxPerHour = 20, // 每小时最多投递数
|
||
delayMin = 10000, // 最小间隔(ms)
|
||
delayMax = 30000, // 最大间隔(ms)
|
||
skipApplied = true, // 跳过已投递
|
||
greetingMessage = '' // 打招呼消息
|
||
} = options;
|
||
|
||
const results = {
|
||
total: jobIds.length,
|
||
success: 0,
|
||
failed: 0,
|
||
skipped: 0,
|
||
details: []
|
||
};
|
||
|
||
let appliedThisHour = 0;
|
||
let hourStartTime = Date.now();
|
||
|
||
for (const jobId of jobIds) {
|
||
// 频率控制
|
||
if (appliedThisHour >= maxPerHour) {
|
||
const elapsed = Date.now() - hourStartTime;
|
||
if (elapsed < 3600000) {
|
||
const waitTime = 3600000 - elapsed;
|
||
console.log(`已达每小时投递上限,等待 ${waitTime/1000} 秒`);
|
||
await new Promise(resolve => setTimeout(resolve, waitTime));
|
||
appliedThisHour = 0;
|
||
hourStartTime = Date.now();
|
||
}
|
||
}
|
||
|
||
try {
|
||
// 检查是否已投递
|
||
if (skipApplied) {
|
||
const applied = await checkIfApplied(page, jobId);
|
||
if (applied) {
|
||
results.skipped++;
|
||
results.details.push({ jobId, status: 'skipped', reason: '已投递' });
|
||
continue;
|
||
}
|
||
}
|
||
|
||
// 投递职位
|
||
await applyJob(page, jobId, greetingMessage);
|
||
|
||
results.success++;
|
||
appliedThisHour++;
|
||
results.details.push({ jobId, status: 'success' });
|
||
|
||
// 随机延迟
|
||
const delay = Math.floor(Math.random() * (delayMax - delayMin + 1)) + delayMin;
|
||
await new Promise(resolve => setTimeout(resolve, delay));
|
||
|
||
} catch (error) {
|
||
results.failed++;
|
||
results.details.push({
|
||
jobId,
|
||
status: 'failed',
|
||
reason: error.message
|
||
});
|
||
}
|
||
}
|
||
|
||
return results;
|
||
}
|
||
|
||
/**
|
||
* 检查职位是否已投递
|
||
*/
|
||
async function checkIfApplied(page, jobId) {
|
||
await page.goto(`https://www.zhipin.com/job_detail/${jobId}.html`);
|
||
await page.waitForTimeout(1000);
|
||
|
||
// 查找"继续沟通"或"已沟通"按钮
|
||
const appliedBtn = await page.$('.btn-applied, .btn-chatted');
|
||
return !!appliedBtn;
|
||
}
|
||
|
||
/**
|
||
* 投递单个职位
|
||
*/
|
||
async function applyJob(page, jobId, greetingMessage) {
|
||
await page.goto(`https://www.zhipin.com/job_detail/${jobId}.html`);
|
||
await page.waitForSelector('.btn-startchat', { timeout: 5000 });
|
||
|
||
// 点击"立即沟通"
|
||
await page.click('.btn-startchat');
|
||
|
||
// 如果有打招呼消息
|
||
if (greetingMessage) {
|
||
await page.waitForSelector('.chat-input', { timeout: 3000 });
|
||
await page.fill('.chat-input', greetingMessage);
|
||
await page.click('.send-btn');
|
||
}
|
||
|
||
await page.waitForTimeout(1000);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 2.3 简历刷新功能 ⚠️ 待开发
|
||
|
||
#### 功能说明
|
||
定时刷新简历,提升曝光度
|
||
|
||
#### MQTT指令
|
||
- `refresh_resume` - 刷新简历
|
||
|
||
#### 实现要点
|
||
|
||
```javascript
|
||
// platforms/boss/resume.js
|
||
|
||
/**
|
||
* 刷新简历
|
||
* @param {Page} page - Playwright页面对象
|
||
* @returns {Promise<Object>} 刷新结果
|
||
*/
|
||
async function refreshResume(page) {
|
||
try {
|
||
// 进入简历管理页面
|
||
await page.goto('https://www.zhipin.com/web/geek/profile', {
|
||
waitUntil: 'networkidle'
|
||
});
|
||
|
||
// 查找刷新按钮
|
||
const refreshBtn = await page.$('.refresh-resume-btn, .update-resume-btn');
|
||
if (!refreshBtn) {
|
||
throw new Error('未找到刷新按钮');
|
||
}
|
||
|
||
// 检查是否可刷新(可能有时间限制)
|
||
const disabled = await refreshBtn.getAttribute('disabled');
|
||
if (disabled) {
|
||
// 获取下次可刷新时间
|
||
const nextRefreshTime = await page.$eval('.next-refresh-time', el => el.textContent);
|
||
throw new Error(`暂时无法刷新,下次可刷新时间: ${nextRefreshTime}`);
|
||
}
|
||
|
||
// 点击刷新
|
||
await refreshBtn.click();
|
||
await page.waitForTimeout(2000);
|
||
|
||
// 确认刷新
|
||
const confirmBtn = await page.$('.confirm-refresh');
|
||
if (confirmBtn) {
|
||
await confirmBtn.click();
|
||
await page.waitForTimeout(1000);
|
||
}
|
||
|
||
return {
|
||
success: true,
|
||
message: '简历刷新成功',
|
||
timestamp: new Date().toISOString()
|
||
};
|
||
} catch (error) {
|
||
throw new Error(`简历刷新失败: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取简历刷新状态
|
||
*/
|
||
async function getResumeRefreshStatus(page) {
|
||
await page.goto('https://www.zhipin.com/web/geek/profile');
|
||
|
||
const status = await page.evaluate(() => {
|
||
const refreshBtn = document.querySelector('.refresh-resume-btn');
|
||
const isDisabled = refreshBtn?.hasAttribute('disabled');
|
||
const nextTime = document.querySelector('.next-refresh-time')?.textContent;
|
||
const lastRefreshTime = document.querySelector('.last-refresh-time')?.textContent;
|
||
|
||
return {
|
||
canRefresh: !isDisabled,
|
||
nextRefreshTime: nextTime,
|
||
lastRefreshTime: lastRefreshTime
|
||
};
|
||
});
|
||
|
||
return status;
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 三、中优先级功能 (P1)
|
||
|
||
### 3.1 账号保活功能 ⚠️ 待开发
|
||
|
||
#### 功能说明
|
||
模拟真实用户行为,防止账号被标记为机器人
|
||
|
||
#### MQTT指令
|
||
- `auto_active` - 执行保活操作
|
||
|
||
#### 实现要点
|
||
|
||
```javascript
|
||
// platforms/boss/active.js
|
||
|
||
/**
|
||
* 执行保活操作
|
||
* @param {Page} page - Playwright页面对象
|
||
* @param {Object} options - 保活选项
|
||
*/
|
||
async function performActiveActions(page, options = {}) {
|
||
const {
|
||
duration = 300000, // 持续时间(ms) 默认5分钟
|
||
actions = ['browse', 'search', 'view_company', 'scroll']
|
||
} = options;
|
||
|
||
const startTime = Date.now();
|
||
const actionsPerformed = [];
|
||
|
||
while (Date.now() - startTime < duration) {
|
||
// 随机选择一个行为
|
||
const action = actions[Math.floor(Math.random() * actions.length)];
|
||
|
||
try {
|
||
switch (action) {
|
||
case 'browse':
|
||
await browseJobList(page);
|
||
break;
|
||
case 'search':
|
||
await performRandomSearch(page);
|
||
break;
|
||
case 'view_company':
|
||
await viewRandomCompany(page);
|
||
break;
|
||
case 'scroll':
|
||
await randomScroll(page);
|
||
break;
|
||
}
|
||
|
||
actionsPerformed.push({
|
||
action,
|
||
timestamp: new Date().toISOString()
|
||
});
|
||
|
||
// 随机延迟
|
||
await randomDelay(5000, 15000);
|
||
|
||
} catch (error) {
|
||
console.error(`保活操作失败: ${action}`, error);
|
||
}
|
||
}
|
||
|
||
return {
|
||
success: true,
|
||
duration: Date.now() - startTime,
|
||
actionsPerformed: actionsPerformed.length,
|
||
details: actionsPerformed
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 浏览职位列表
|
||
*/
|
||
async function browseJobList(page) {
|
||
await page.goto('https://www.zhipin.com/web/geek/jobs', {
|
||
waitUntil: 'networkidle'
|
||
});
|
||
|
||
// 随机滚动
|
||
for (let i = 0; i < 3; i++) {
|
||
await page.evaluate(() => {
|
||
window.scrollBy(0, Math.random() * 500 + 200);
|
||
});
|
||
await randomDelay(1000, 3000);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 执行随机搜索
|
||
*/
|
||
async function performRandomSearch(page) {
|
||
const keywords = ['前端', '后端', 'Java', 'Python', '产品经理', '运营'];
|
||
const keyword = keywords[Math.floor(Math.random() * keywords.length)];
|
||
|
||
await page.goto(`https://www.zhipin.com/web/geek/jobs?query=${encodeURIComponent(keyword)}`);
|
||
await page.waitForTimeout(2000);
|
||
|
||
// 随机点击一个职位查看
|
||
const jobCards = await page.$$('.job-card-wrapper');
|
||
if (jobCards.length > 0) {
|
||
const randomIndex = Math.floor(Math.random() * Math.min(jobCards.length, 5));
|
||
await jobCards[randomIndex].click();
|
||
await page.waitForTimeout(3000);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 查看随机公司
|
||
*/
|
||
async function viewRandomCompany(page) {
|
||
await page.goto('https://www.zhipin.com/web/geek/jobs');
|
||
await page.waitForTimeout(2000);
|
||
|
||
const companyLinks = await page.$$('.company-name a');
|
||
if (companyLinks.length > 0) {
|
||
const randomIndex = Math.floor(Math.random() * Math.min(companyLinks.length, 5));
|
||
await companyLinks[randomIndex].click();
|
||
await page.waitForTimeout(5000);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 随机滚动
|
||
*/
|
||
async function randomScroll(page) {
|
||
const scrollTimes = Math.floor(Math.random() * 5) + 2;
|
||
|
||
for (let i = 0; i < scrollTimes; i++) {
|
||
await page.evaluate(() => {
|
||
const direction = Math.random() > 0.5 ? 1 : -1;
|
||
const distance = Math.random() * 300 + 100;
|
||
window.scrollBy(0, direction * distance);
|
||
});
|
||
|
||
await randomDelay(800, 2000);
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 3.2 智能聊天功能 ⚠️ 待完善
|
||
|
||
#### 功能说明
|
||
自动回复HR消息,提高响应率
|
||
|
||
#### MQTT指令
|
||
- `send_greeting` - 发送打招呼消息
|
||
- `auto_reply_chat` - 自动回复聊天
|
||
|
||
#### 实现要点
|
||
|
||
```javascript
|
||
// platforms/boss/chat.js
|
||
|
||
/**
|
||
* 发送打招呼消息
|
||
* @param {Page} page - Playwright页面对象
|
||
* @param {string} bossId - HRID
|
||
* @param {string} message - 消息内容
|
||
*/
|
||
async function sendGreeting(page, bossId, message) {
|
||
// 进入聊天页面
|
||
await page.goto(`https://www.zhipin.com/web/geek/chat?bosId=${bossId}`);
|
||
await page.waitForSelector('.chat-input', { timeout: 5000 });
|
||
|
||
// 输入消息
|
||
await page.fill('.chat-input', message);
|
||
await randomDelay(500, 1500);
|
||
|
||
// 发送
|
||
await page.click('.send-btn');
|
||
await page.waitForTimeout(1000);
|
||
|
||
return {
|
||
success: true,
|
||
bossId,
|
||
message,
|
||
timestamp: new Date().toISOString()
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 自动回复聊天
|
||
* @param {Page} page - Playwright页面对象
|
||
* @param {Object} options - 回复选项
|
||
*/
|
||
async function autoReplyChat(page, options = {}) {
|
||
const {
|
||
checkInterval = 60000, // 检查间隔(ms)
|
||
replyDelay = 30000, // 回复延迟(ms) 模拟思考时间
|
||
maxReplies = 10 // 最多回复次数
|
||
} = options;
|
||
|
||
let repliedCount = 0;
|
||
|
||
// 获取未读消息
|
||
await page.goto('https://www.zhipin.com/web/geek/chat');
|
||
await page.waitForTimeout(2000);
|
||
|
||
const unreadChats = await page.$$('.chat-item.unread');
|
||
|
||
for (const chat of unreadChats) {
|
||
if (repliedCount >= maxReplies) break;
|
||
|
||
try {
|
||
// 点击进入聊天
|
||
await chat.click();
|
||
await page.waitForTimeout(1000);
|
||
|
||
// 获取最后一条消息
|
||
const lastMessage = await page.$eval('.message-list .message-item:last-child .message-text',
|
||
el => el.textContent.trim()
|
||
);
|
||
|
||
// 调用服务端AI生成回复
|
||
// 这里需要通过MQTT发送消息给服务端,服务端调用AI生成回复后返回
|
||
// 为了简化,这里暂时使用预设回复
|
||
const reply = generateAutoReply(lastMessage);
|
||
|
||
// 等待一段时间,模拟真人思考
|
||
await randomDelay(replyDelay * 0.8, replyDelay * 1.2);
|
||
|
||
// 发送回复
|
||
await page.fill('.chat-input', reply);
|
||
await page.click('.send-btn');
|
||
await page.waitForTimeout(1000);
|
||
|
||
repliedCount++;
|
||
|
||
} catch (error) {
|
||
console.error('自动回复失败:', error);
|
||
}
|
||
}
|
||
|
||
return {
|
||
success: true,
|
||
totalUnread: unreadChats.length,
|
||
replied: repliedCount
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 生成自动回复(简化版,实际应调用服务端AI)
|
||
*/
|
||
function generateAutoReply(message) {
|
||
const lowerMessage = message.toLowerCase();
|
||
|
||
if (lowerMessage.includes('面试') || lowerMessage.includes('interview')) {
|
||
return '好的,感谢您的面试邀请!请问具体的面试时间和地点是什么?';
|
||
}
|
||
|
||
if (lowerMessage.includes('简历') || lowerMessage.includes('resume')) {
|
||
return '您好,我已经投递了简历,请查收。如有任何问题,欢迎随时沟通!';
|
||
}
|
||
|
||
if (lowerMessage.includes('薪资') || lowerMessage.includes('salary')) {
|
||
return '关于薪资,我的期望是根据岗位要求和市场情况来定的,具体可以详细沟通。';
|
||
}
|
||
|
||
// 默认回复
|
||
return '收到,感谢您的消息!';
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 四、低优先级功能 (P2)
|
||
|
||
### 4.1 数据监控上报
|
||
|
||
```javascript
|
||
/**
|
||
* 上报性能数据
|
||
*/
|
||
async function reportMetrics(metrics) {
|
||
const reportData = {
|
||
action: 'report_metrics',
|
||
metrics: {
|
||
...metrics,
|
||
timestamp: new Date().toISOString(),
|
||
deviceInfo: {
|
||
platform: process.platform,
|
||
memory: process.memoryUsage(),
|
||
uptime: process.uptime()
|
||
}
|
||
}
|
||
};
|
||
|
||
await mqttClient.publish(`device/${sn_code}/metrics`, JSON.stringify(reportData));
|
||
}
|
||
```
|
||
|
||
### 4.2 截图上报
|
||
|
||
```javascript
|
||
/**
|
||
* 截图并上报
|
||
*/
|
||
async function captureAndReport(page, reason) {
|
||
const screenshot = await page.screenshot({ fullPage: false });
|
||
const base64 = screenshot.toString('base64');
|
||
|
||
await mqttClient.publish(`device/${sn_code}/screenshot`, JSON.stringify({
|
||
reason,
|
||
image: base64,
|
||
timestamp: new Date().toISOString()
|
||
}));
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 五、开发规范
|
||
|
||
### 5.1 错误处理
|
||
|
||
所有函数必须包含try-catch,并返回标准格式:
|
||
|
||
```javascript
|
||
{
|
||
code: 0, // 0成功,-1失败
|
||
message: '操作成功',
|
||
data: {}, // 返回数据
|
||
taskId: 'xxx', // 任务ID
|
||
timestamp: 'ISO时间'
|
||
}
|
||
```
|
||
|
||
### 5.2 日志记录
|
||
|
||
```javascript
|
||
const logger = require('../utils/logger');
|
||
|
||
logger.info('操作成功', { action: 'search_jobs', jobCount: 50 });
|
||
logger.error('操作失败', { action: 'apply_job', error: error.message });
|
||
logger.warn('检测到异常', { type: 'bot_detection' });
|
||
```
|
||
|
||
### 5.3 配置管理
|
||
|
||
```json
|
||
// config/default.json
|
||
{
|
||
"mqtt": {
|
||
"broker": "mqtt://192.144.167.231:1883",
|
||
"username": "admin",
|
||
"password": "public"
|
||
},
|
||
"browser": {
|
||
"headless": false,
|
||
"slowMo": 50,
|
||
"defaultTimeout": 30000
|
||
},
|
||
"antibot": {
|
||
"randomDelay": {
|
||
"min": 1000,
|
||
"max": 3000
|
||
},
|
||
"humanBehavior": true
|
||
},
|
||
"limits": {
|
||
"maxApplyPerHour": 20,
|
||
"maxApplyPerDay": 100,
|
||
"maxSearchPerHour": 50
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 六、测试计划
|
||
|
||
### 6.1 单元测试
|
||
|
||
为每个模块编写单元测试:
|
||
|
||
```javascript
|
||
// tests/platforms/boss/search.test.js
|
||
describe('Boss搜索模块', () => {
|
||
test('URL解析正确', () => {
|
||
const url = 'https://www.zhipin.com/web/geek/jobs?city=101020100&query=全栈';
|
||
const params = parseSearchUrl(url);
|
||
expect(params.city).toBe('101020100');
|
||
expect(params.query).toBe('全栈');
|
||
});
|
||
|
||
test('搜索执行成功', async () => {
|
||
const jobs = await searchJobsEnhanced(page, {
|
||
city: '101020100',
|
||
query: '前端工程师'
|
||
});
|
||
expect(jobs.length).toBeGreaterThan(0);
|
||
});
|
||
});
|
||
```
|
||
|
||
### 6.2 集成测试
|
||
|
||
测试完整的MQTT指令流程:
|
||
|
||
```javascript
|
||
// tests/integration/search.test.js
|
||
describe('搜索功能集成测试', () => {
|
||
test('通过MQTT执行搜索', async () => {
|
||
const response = await mqttClient.publishAndWait('test_device', {
|
||
action: 'search_jobs_enhanced',
|
||
platform: 'boss',
|
||
params: {
|
||
city: '101020100',
|
||
query: '测试工程师'
|
||
}
|
||
});
|
||
|
||
expect(response.code).toBe(0);
|
||
expect(response.data.jobs).toBeDefined();
|
||
});
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
## 七、部署和运维
|
||
|
||
### 7.1 环境要求
|
||
|
||
- Node.js 18+
|
||
- Chromium/Chrome 浏览器
|
||
- 充足的内存(建议2GB+)
|
||
- 稳定的网络连接
|
||
|
||
### 7.2 启动命令
|
||
|
||
```bash
|
||
# 开发模式
|
||
npm run dev
|
||
|
||
# 生产模式
|
||
npm run start
|
||
|
||
# 后台运行(Linux)
|
||
pm2 start src/index.js --name "job-client"
|
||
```
|
||
|
||
### 7.3 监控指标
|
||
|
||
- 任务执行成功率
|
||
- 平均响应时间
|
||
- 反爬虫触发次数
|
||
- 账号状态(正常/异常)
|
||
|
||
---
|
||
|
||
## 八、开发时间估算
|
||
|
||
| 功能模块 | 优先级 | 预计工时 | 备注 |
|
||
|---------|--------|---------|------|
|
||
| 增强搜索功能 | P0 | 3天 | 含URL解析和参数映射 |
|
||
| 批量投递功能 | P0 | 2天 | 含频率控制 |
|
||
| 简历刷新功能 | P0 | 1天 | 较简单 |
|
||
| 账号保活功能 | P1 | 2天 | 需模拟多种行为 |
|
||
| 智能聊天功能 | P1 | 3天 | 需AI对接 |
|
||
| 数据监控上报 | P2 | 1天 | 辅助功能 |
|
||
| **总计** | - | **12天** | 约2.5周 |
|
||
|
||
---
|
||
|
||
## 九、风险和注意事项
|
||
|
||
### 9.1 反爬虫风险
|
||
|
||
- 操作频率过高可能触发验证码
|
||
- 建议设置合理的延迟和频率限制
|
||
- 模拟真实用户行为(鼠标移动、随机滚动)
|
||
|
||
### 9.2 账号安全
|
||
|
||
- 避免同时操作过多账号
|
||
- 登录后保持Cookie有效期
|
||
- 定期执行保活操作
|
||
|
||
### 9.3 性能问题
|
||
|
||
- 浏览器实例占用较多内存
|
||
- 建议实现浏览器实例池管理
|
||
- 长时间运行需要定期重启
|
||
|
||
---
|
||
|
||
## 十、参考资料
|
||
|
||
- [Playwright 官方文档](https://playwright.dev/)
|
||
- [MQTT.js 文档](https://github.com/mqttjs/MQTT.js)
|
||
- [Boss直聘网站分析](https://www.zhipin.com/)
|
||
- [反爬虫最佳实践](https://github.com/topics/anti-bot)
|
||
|
||
---
|
||
|
||
**文档版本**: v1.0
|
||
**最后更新**: 2025-12-25
|
||
**维护者**: 开发团队
|