新增筛选规则页: Hermes整理的机器人规则总览(rules.md), 顶栏入口所有登录用户可见, marked渲染
This commit is contained in:
@@ -88,12 +88,29 @@ tr.today:hover td{background:#fde68a!important}
|
|||||||
#userModal.hidden{display:none}
|
#userModal.hidden{display:none}
|
||||||
#accessModal{position:fixed;inset:0;background:rgba(0,0,0,.4);z-index:8;display:flex;align-items:center;justify-content:center}
|
#accessModal{position:fixed;inset:0;background:rgba(0,0,0,.4);z-index:8;display:flex;align-items:center;justify-content:center}
|
||||||
#accessModal.hidden{display:none}
|
#accessModal.hidden{display:none}
|
||||||
|
#rulesModal{position:fixed;inset:0;background:rgba(0,0,0,.4);z-index:8;display:flex;align-items:center;justify-content:center}
|
||||||
|
#rulesModal.hidden{display:none}
|
||||||
|
.mdview{font-size:14px;line-height:1.7;color:#374151}
|
||||||
|
.mdview h1{font-size:20px;margin:4px 0 12px;color:var(--txt)}
|
||||||
|
.mdview h2{font-size:16px;margin:22px 0 8px;padding:6px 10px;background:#eff6ff;border-left:4px solid var(--pri);border-radius:4px}
|
||||||
|
.mdview h3{font-size:14px;margin:14px 0 6px}
|
||||||
|
.mdview p{margin:6px 0}
|
||||||
|
.mdview table{width:100%;border-collapse:collapse;font-size:13px;margin:8px 0}
|
||||||
|
.mdview th,.mdview td{border:1px solid var(--line);padding:6px 10px;text-align:left;white-space:normal}
|
||||||
|
.mdview th{background:#f9fafb}
|
||||||
|
.mdview pre{background:#f9fafb;border:1px solid var(--line);border-radius:8px;padding:12px;font-size:12.5px;overflow:auto;margin:8px 0}
|
||||||
|
.mdview code{background:#f1f5f9;padding:1px 5px;border-radius:4px;font-size:12.5px}
|
||||||
|
.mdview pre code{background:none;padding:0}
|
||||||
|
.mdview blockquote{border-left:4px solid #cbd5e1;background:#f8fafc;margin:8px 0;padding:8px 12px;color:var(--sub)}
|
||||||
|
.mdview hr{border:none;border-top:1px solid var(--line);margin:16px 0}
|
||||||
|
.mdview li{margin:3px 0}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<b>📋 招聘数据后台</b><span id="whoami" style="color:#94a3b8;font-size:12px"></span>
|
<b>📋 招聘数据后台</b><span id="whoami" style="color:#94a3b8;font-size:12px"></span>
|
||||||
<span class="sp"></span>
|
<span class="sp"></span>
|
||||||
|
<button id="rulesBtn" class="hidden" onclick="openRulesModal()">筛选规则</button>
|
||||||
<button id="userMgrBtn" class="hidden" onclick="openUserModal()">账号管理</button>
|
<button id="userMgrBtn" class="hidden" onclick="openUserModal()">账号管理</button>
|
||||||
<button id="accessLogBtn" class="hidden" onclick="openAccessModal()">访问记录</button>
|
<button id="accessLogBtn" class="hidden" onclick="openAccessModal()">访问记录</button>
|
||||||
<button id="logoutBtn" class="hidden" onclick="logout()">退出登录</button>
|
<button id="logoutBtn" class="hidden" onclick="logout()">退出登录</button>
|
||||||
@@ -194,11 +211,20 @@ tr.today:hover td{background:#fde68a!important}
|
|||||||
<tbody id="alogBody"></tbody></table>
|
<tbody id="alogBody"></tbody></table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="rulesModal" onclick="if(event.target===this)this.classList.add('hidden')" class="hidden">
|
||||||
|
<div class="panel" style="width:900px;max-width:94vw;max-height:88vh;overflow:auto">
|
||||||
|
<h3>筛选规则与流程总览
|
||||||
|
<button class="btn sub small" style="float:right" onclick="document.getElementById('rulesModal').classList.add('hidden')">关闭</button>
|
||||||
|
</h3>
|
||||||
|
<div class="mdview" id="rulesBody">加载中...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="detailModal" onclick="if(event.target===this)this.classList.add('hidden')" class="hidden">
|
<div id="detailModal" onclick="if(event.target===this)this.classList.add('hidden')" class="hidden">
|
||||||
<div class="panel detail" style="width:820px;max-width:94vw;max-height:88vh;overflow:auto" id="detailBody"></div>
|
<div class="panel detail" style="width:820px;max-width:94vw;max-height:88vh;overflow:auto" id="detailBody"></div>
|
||||||
</div>
|
</div>
|
||||||
<script src="echarts.min.js"></script>
|
<script src="echarts.min.js"></script>
|
||||||
|
<script src="marked.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
let me=null,pageCur=1,pageTotal=1;
|
let me=null,pageCur=1,pageTotal=1;
|
||||||
const $=id=>document.getElementById(id);
|
const $=id=>document.getElementById(id);
|
||||||
@@ -246,6 +272,7 @@ async function logout(){await api('/api/logout',{method:'POST'});location.reload
|
|||||||
async function enter(m){
|
async function enter(m){
|
||||||
me=m;$('loginBox').classList.add('hidden');$('main').classList.remove('hidden');$('logoutBtn').classList.remove('hidden');
|
me=m;$('loginBox').classList.add('hidden');$('main').classList.remove('hidden');$('logoutBtn').classList.remove('hidden');
|
||||||
$('whoami').textContent=`${m.username} · ${m.role==='super'?'超管':'HR'}`;
|
$('whoami').textContent=`${m.username} · ${m.role==='super'?'超管':'HR'}`;
|
||||||
|
$('rulesBtn').classList.remove('hidden');
|
||||||
if(m.role==='super'){$('userMgrBtn').classList.remove('hidden');$('accessLogBtn').classList.remove('hidden')}
|
if(m.role==='super'){$('userMgrBtn').classList.remove('hidden');$('accessLogBtn').classList.remove('hidden')}
|
||||||
loadStats().catch(e=>dataErr(e));loadList(1).catch(e=>dataErr(e));
|
loadStats().catch(e=>dataErr(e));loadList(1).catch(e=>dataErr(e));
|
||||||
}
|
}
|
||||||
@@ -381,6 +408,15 @@ async function addUser(){
|
|||||||
try{await api('/api/users',{method:'POST',body:JSON.stringify({username:$('nu_user').value,password:$('nu_pass').value})});toast('已创建');$('nu_user').value=$('nu_pass').value='';loadUsers()}
|
try{await api('/api/users',{method:'POST',body:JSON.stringify({username:$('nu_user').value,password:$('nu_pass').value})});toast('已创建');$('nu_user').value=$('nu_pass').value='';loadUsers()}
|
||||||
catch(e){toast(e.message)}
|
catch(e){toast(e.message)}
|
||||||
}
|
}
|
||||||
|
async function openRulesModal(){
|
||||||
|
$('rulesModal').classList.remove('hidden');
|
||||||
|
if($('rulesBody').dataset.loaded)return;
|
||||||
|
try{
|
||||||
|
const md=await (await fetch('/api/rules')).text();
|
||||||
|
$('rulesBody').innerHTML=(typeof marked!=='undefined')?marked.parse(md):'<pre>'+esc(md)+'</pre>';
|
||||||
|
$('rulesBody').dataset.loaded='1';
|
||||||
|
}catch(e){$('rulesBody').textContent='加载失败: '+e.message}
|
||||||
|
}
|
||||||
async function openAccessModal(){
|
async function openAccessModal(){
|
||||||
$('accessModal').classList.remove('hidden');
|
$('accessModal').classList.remove('hidden');
|
||||||
loadAccessLog().catch(()=>toast('访问记录加载失败'));
|
loadAccessLog().catch(()=>toast('访问记录加载失败'));
|
||||||
|
|||||||
Vendored
+6
File diff suppressed because one or more lines are too long
+151
@@ -0,0 +1,151 @@
|
|||||||
|
# 智联简历筛选机器人 — 规则与流程总览
|
||||||
|
|
||||||
|
> 更新:2026-08-27 · 代码版本 zhaopin-scrape.js v10.6 / daily-runner.js v1.3
|
||||||
|
> 岗位:二手车出口外贸业务员 · 双账号:hr1(Chrome·合塑)/ hr2(Edge·派卡)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、总体架构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────────────┐
|
||||||
|
│ Hermes 容器(服务器) │
|
||||||
|
│ │
|
||||||
|
│ cron 定时任务 ×3 │
|
||||||
|
│ ├─ 09:30 启动 hr1 runner ─┐ │
|
||||||
|
│ ├─ 09:30 启动 hr2 runner ─┤→ daily-runner.js(守护循环) │
|
||||||
|
│ └─ 18:05 强制收队+异常检测 → enforce-cutoff.js │
|
||||||
|
│ │
|
||||||
|
│ 数据库:/opt/data/recruitment-data/recruitment-live.db │
|
||||||
|
│ 日报:daily-report.js(固定模板,仅18点发) │
|
||||||
|
└──────────────┬─────────────────────────────────────────────┘
|
||||||
|
│ SSH 隧道(9223→Chrome / 9225→Edge)
|
||||||
|
▼
|
||||||
|
任务机 100.64.0.7(Tailscale 组网)
|
||||||
|
├─ Chrome(hr1·合塑账号)→ 智联推荐页
|
||||||
|
└─ Edge (hr2·派卡账号)→ 智联推荐页
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、每日时间线
|
||||||
|
|
||||||
|
```
|
||||||
|
09:30 ─── cron 触发,双账号 runner 启动
|
||||||
|
│ 每批 50-100 人(随机)|批间休息 10-20 分钟(随机)
|
||||||
|
│ 循环跑批,直到 ↓ 三种结束条件之一
|
||||||
|
│
|
||||||
|
├── 聊天次数耗尽(智联每日上限)→ 收工
|
||||||
|
├── 北京时间 18:00 → 收工
|
||||||
|
└── 异常(见第四节)→ 停止/等待
|
||||||
|
│
|
||||||
|
18:05 ─── 强制收队任务:强杀残留进程 → 状态核对 → 日报发群(每日仅此1条统计)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、单个候选人处理流程(核心筛选链)
|
||||||
|
|
||||||
|
```
|
||||||
|
列表卡片(不开弹窗的快速预筛)
|
||||||
|
│
|
||||||
|
├─ 同事已聊过 ──────────────────→ 跳过
|
||||||
|
├─ 已打招呼(显示"继续沟通")─────→ 跳过
|
||||||
|
├─ 年龄 > 35 ───────────────────→ 跳过(一票否决)
|
||||||
|
├─ 卡片文本命中黑名单企业 ────────→ 跳过(当前黑名单:合塑、派卡)
|
||||||
|
│
|
||||||
|
▼ 打开简历弹窗
|
||||||
|
│
|
||||||
|
├─ ⛔ 阻碍即停:弹窗无有效简历数据(日浏览1000份限额/提示页)
|
||||||
|
│ → 不入库 → 二次核查 → 私聊告警 → 当日终态停止
|
||||||
|
│
|
||||||
|
├─ 弹窗"人才处理记录" > 2条 ─────→ 通过筛选也不打招呼(照常评分入库)
|
||||||
|
│
|
||||||
|
▼ 硬性预筛门槛(v10,任一不过即"不相关",不评分不打招呼)
|
||||||
|
│
|
||||||
|
│ A 外贸经验关键词(仅看工作经历):外贸/外销/国际贸易/国贸/海外销售/
|
||||||
|
│ 出口/跨境电商/跨境/二手车出口/汽车出口/整车出口/海外市场 —— 必须命中
|
||||||
|
│ D 毕业年份 2020-2026(教育经历中最晚年份;解析不到不硬拒)
|
||||||
|
│ 年龄 ≤ 35
|
||||||
|
│ (B 专业 / C 证书 命中只作加分记录,不单独放行)
|
||||||
|
│
|
||||||
|
▼ 七维评分(满分35)
|
||||||
|
│
|
||||||
|
│ 行业匹配 / 外贸核心能力 / 语言能力 / 客户开发获客 /
|
||||||
|
│ 专业背景 / 稳定性 / 活跃度 各 1-5 分
|
||||||
|
│ ⚠ highTrade/获客 只从工作经历匹配(求职意向写的不算)
|
||||||
|
│ ⚠ 外贸经验强时语言保底 2 分(语言不一票否决)
|
||||||
|
│
|
||||||
|
▼ 分级(动态阈值)
|
||||||
|
│
|
||||||
|
│ 行业≥4 时:≥24 推荐 ✅ | 其他:≥26 推荐 ✅
|
||||||
|
│ ≥18 待定 ⏳ | <18 不推荐 ❌
|
||||||
|
│
|
||||||
|
▼ 打招呼(弹窗内点击,天然绑定正确的人)
|
||||||
|
│
|
||||||
|
├─ 推荐/待定 且 门槛通过 且 处理记录≤2 且 未打过 → ✋ 打招呼
|
||||||
|
└─ 其余 → 跳过(照常入库)
|
||||||
|
│
|
||||||
|
▼ 入库(弹窗完整内容:raw_text 全文 + 结构化拆分字段 + 评分明细)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、异常场景处理矩阵
|
||||||
|
|
||||||
|
| 场景 | 检测方式 | 动作 | 通知 |
|
||||||
|
|------|---------|------|------|
|
||||||
|
| **登录失效** | URL 跳登录页 / 页面报登录失效 | 刷新登录页抓二维码 → 进入扫码监听 | 🚨 群(文字+二维码+"今日不需要可忽略") |
|
||||||
|
| **被顶号**(他人登录) | km-modal弹窗+"账号已在其他设备或浏览器登录"文案+重登按钮 三重判定 | 点掉弹窗 → 抓二维码 → 进入扫码监听 | 🚨 群(同上样式) |
|
||||||
|
| **扫码监听** | 每30秒读页面:登录成功 / 二维码过期 / 17:30到时 | 成功→自动复工+群内回应扫码人;过期或到时→当日收工 | ✅ 群("扫码登录成功,任务已自动恢复") |
|
||||||
|
| **平台阻碍**(日浏览1000份限额等) | 弹窗有效性核查:缺姓名或缺全部结构信号=无效页 | 碰到即终态停止,当日不恢复(start 命令也拒绝) | 🚨 私聊(附弹窗原文) |
|
||||||
|
| **风控/验证码** | 验证码DOM / "操作过于频繁"等文案 | 立即停止(不自动恢复,需人工评估) | 🚨 私聊 |
|
||||||
|
| **隧道断连** | CDP 端口探测 | 重建隧道(最多等10分钟) | 失败才 🚨 私聊 |
|
||||||
|
| **触底** | .recommend-indicator "已经到底啦" | 立即刷新页面取新数据 | 无(日志) |
|
||||||
|
| **累计200人** | 账号当日累计计数 | 刷新页面(清理DOM/内存) | 无(日志) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、消息策略(渠道纪律)
|
||||||
|
|
||||||
|
| 消息 | 渠道 | 频次 |
|
||||||
|
|------|------|------|
|
||||||
|
| 登录失效/被顶号告警(带二维码) | 群(数字HR) | 事件触发 |
|
||||||
|
| 扫码成功回应 | 群 | 事件触发 |
|
||||||
|
| 📊 每日数据日报 | 群 | 每日 1 条(仅 18:05,异常中止不发) |
|
||||||
|
| 平台阻碍 / 风控 / 隧道失败 | 私聊(管理员) | 事件触发 |
|
||||||
|
| 批次过程消息(人数/聊天剩/累计等) | ❌ 不发 | 只写日志 |
|
||||||
|
|
||||||
|
- 所有消息文案写死在代码模板里,禁止手写改写、不用礼貌用语
|
||||||
|
- 日报由 daily-report.js 确定性生成(双账号分开统计合并在一条消息)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、运行控制命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash stop-tasks.sh status # 查状态(含卡死检测)
|
||||||
|
bash stop-tasks.sh stop # 全停(hr1+hr2)
|
||||||
|
bash stop-tasks.sh stop hr2 # 只停 hr2
|
||||||
|
bash stop-tasks.sh start hr1 # 启动(platform_blocked 当日拒绝)
|
||||||
|
bash stop-tasks.sh restart hr2 # 重启(先停干净再启)
|
||||||
|
```
|
||||||
|
|
||||||
|
停止机制三层:STOP.flag 旗标优雅退出(不打断进行中的打招呼)→ SIGTERM/3秒/SIGKILL → ps 复验无残留。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、关键文件
|
||||||
|
|
||||||
|
| 文件 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| scripts/zhaopin-scrape.js | 单批抓取+评分+打招呼(v10.6) |
|
||||||
|
| scripts/daily-runner.js | 每日守护循环+异常处理+扫码监听(v1.3) |
|
||||||
|
| scripts/enforce-cutoff.js | 18:05 强制收队+异常检测+日报兜底 |
|
||||||
|
| scripts/daily-report.js | 日报固定模板生成 |
|
||||||
|
| scripts/login-qrcode.js | 登录二维码抓取 |
|
||||||
|
| scripts/stop-tasks.sh | 启停控制 |
|
||||||
|
| scripts/scoring.js | 门槛+评分纯逻辑(改关键词改这里) |
|
||||||
|
| templates/report-format.md | 消息模板纪律文档 |
|
||||||
|
|
||||||
|
**改筛选关键词/毕业年份窗口** → 改 `scoring.js` 顶部 CONFIG,跑 `node scoring.js` 自测,然后 git 提交推送。
|
||||||
@@ -113,6 +113,13 @@ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|||||||
ok('访问记录含本次登录', alogText.includes('登录成功') && alogText.includes(SU));
|
ok('访问记录含本次登录', alogText.includes('登录成功') && alogText.includes(SU));
|
||||||
await p.evaluate(() => document.getElementById('accessModal').classList.add('hidden'));
|
await p.evaluate(() => document.getElementById('accessModal').classList.add('hidden'));
|
||||||
|
|
||||||
|
console.log('== 7c. 筛选规则(所有人) ==');
|
||||||
|
ok('顶栏筛选规则入口可见', await $('#rulesBtn').isVisible());
|
||||||
|
await p.evaluate(() => openRulesModal()); await sleep(600);
|
||||||
|
const rulesText = await p.evaluate(() => document.getElementById('rulesBody').innerText);
|
||||||
|
ok('规则页含核心内容', rulesText.includes('硬性预筛门槛') && rulesText.includes('七维评分'));
|
||||||
|
await p.evaluate(() => document.getElementById('rulesModal').classList.add('hidden'));
|
||||||
|
|
||||||
console.log('== 8. 退出 → HR登录 ==');
|
console.log('== 8. 退出 → HR登录 ==');
|
||||||
await p.evaluate(() => logout()); await sleep(800);
|
await p.evaluate(() => logout()); await sleep(800);
|
||||||
await p.goto(BASE + '/', { waitUntil: 'networkidle' });
|
await p.goto(BASE + '/', { waitUntil: 'networkidle' });
|
||||||
@@ -120,6 +127,9 @@ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|||||||
await $('#loginBtn').click(); await sleep(900);
|
await $('#loginBtn').click(); await sleep(900);
|
||||||
ok('HR进入主界面', await $('#main').isVisible());
|
ok('HR进入主界面', await $('#main').isVisible());
|
||||||
ok('HR看不到账号管理入口', await p.evaluate(() => getComputedStyle(document.getElementById('userMgrBtn')).display === 'none'));
|
ok('HR看不到账号管理入口', await p.evaluate(() => getComputedStyle(document.getElementById('userMgrBtn')).display === 'none'));
|
||||||
|
ok('HR能看到筛选规则入口', await p.evaluate(() => getComputedStyle(document.getElementById('rulesBtn')).display !== 'none'));
|
||||||
|
const rulesForbidden = await p.evaluate(async () => (await fetch('/api/rules')).status);
|
||||||
|
ok('HR可访问规则API', rulesForbidden === 200);
|
||||||
ok('HR看不到访问记录入口', await p.evaluate(() => getComputedStyle(document.getElementById('accessLogBtn')).display === 'none'));
|
ok('HR看不到访问记录入口', await p.evaluate(() => getComputedStyle(document.getElementById('accessLogBtn')).display === 'none'));
|
||||||
const alogForbidden = await p.evaluate(async () => (await fetch('/api/access-log')).status);
|
const alogForbidden = await p.evaluate(async () => (await fetch('/api/access-log')).status);
|
||||||
ok('HR访问记录API被403拦截', alogForbidden === 403);
|
ok('HR访问记录API被403拦截', alogForbidden === 403);
|
||||||
|
|||||||
@@ -286,6 +286,11 @@ app.post('/api/users/:id/resetpwd', requireAuth, requireSuper, (req, res) => {
|
|||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------- 筛选规则文档(所有登录用户) ----------
|
||||||
|
app.get('/api/rules', requireAuth, (req, res) => {
|
||||||
|
res.type('text/markdown; charset=utf-8').send(fs.readFileSync(path.join(__dirname, 'public', 'rules.md'), 'utf8'));
|
||||||
|
});
|
||||||
|
|
||||||
// ---------- 访问记录(超管) ----------
|
// ---------- 访问记录(超管) ----------
|
||||||
app.get('/api/access-log', requireAuth, requireSuper, (req, res) => {
|
app.get('/api/access-log', requireAuth, requireSuper, (req, res) => {
|
||||||
res.json(udb.prepare('SELECT id, username, action, detail, ip, created_at FROM access_log ORDER BY id DESC LIMIT 500').all());
|
res.json(udb.prepare('SELECT id, username, action, detail, ip, created_at FROM access_log ORDER BY id DESC LIMIT 500').all());
|
||||||
|
|||||||
Reference in New Issue
Block a user