// ==UserScript==
// @name 招聘 AI 助手
// @namespace https://github.com/recruit-ai
// @version 1.0.0
// @description AI 自动回复招聘消息,支持多平台(BOSS直聘/智联/51job/猎聘/拉勾)+ 简历OCR自动填充
// @author recruit-ai
// @match https://www.zhipin.com/*
// @match https://*.zhaopin.com/*/chat*
// @match https://*.zhaopin.com/*/im*
// @match https://*.51job.com/*/chat*
// @match https://*.51job.com/*/im*
// @match https://www.liepin.com/*/im*
// @match https://www.lagou.com/*/chat*
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_listValues
// @grant GM_registerMenuCommand
// @grant GM_addStyle
// @grant GM_notification
// @require https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js
// @connect api.deepseek.com
// @connect ark.cn-beijing.volces.com
// @connect api.openai.com
// @connect api.anthropic.com
// @connect api.moonshot.cn
// @connect open.bigmodel.cn
// @connect dashscope.aliyuncs.com
// @connect cdnjs.cloudflare.com
// @license MIT
// ==/UserScript==
(function () {
'use strict';
// ============================================================
// 默认配置
// ============================================================
const DEFAULT_SETTINGS = {
enabled: true,
apiProvider: 'deepseek',
apiKey: '',
apiBaseUrl: 'https://api.deepseek.com/v1/chat/completions',
apiFormat: 'openai',
model: 'deepseek-chat',
doubaoApiKey: '',
// 豆包模型配置
doubaoBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3/chat/completions',
doubaoModel: 'doubao-seed-2-1-pro-260628',
maxTokens: 300,
profile: {
name: '',
education: '',
workYears: '',
experiences: [],
skills: '',
},
preferences: {
desiredPosition: '',
salaryRange: '',
exclusions: '',
customPrompt: '',
},
behavior: {
sendDelayMin: 2,
sendDelayMax: 5,
dailyLimit: 50,
todaySent: 0,
todayDate: '',
},
};
// ============================================================
// CSS 样式(全部通过 GM_addStyle 注入)
// ============================================================
GM_addStyle(`
/* —— 确认面板(悬浮窗)—— */
.ra-panel {
position: fixed; top: 120px; left: calc(100vw - 420px); width: 380px; max-width: calc(100vw - 48px);
background: #fff; border: 1px solid #e5e5e5; border-radius: 12px;
box-shadow: 0 8px 32px rgba(0,0,0,0.14); z-index: 999999;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
font-size: 14px; overflow: hidden; transition: opacity 0.25s;
user-select: none;
}
.ra-hidden { opacity: 0; pointer-events: none; }
.ra-panel.ra-minimized { height: auto !important; }
.ra-panel.ra-minimized .ra-panel-body,
.ra-panel.ra-minimized .ra-panel-footer,
.ra-panel.ra-minimized .ra-panel-status { display: none; }
.ra-panel-header {
display: flex; justify-content: space-between; align-items: center;
padding: 10px 16px; background: #fafafa; border-bottom: 1px solid #eee;
cursor: move; user-select: none;
}
.ra-panel-header:active { cursor: grabbing; }
.ra-panel-title { font-weight: 600; font-size: 13px; color: #333; }
.ra-panel-sender { font-size: 12px; color: #999; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ra-panel-header-btns { display: flex; gap: 4px; margin-left: 8px; flex-shrink: 0; }
.ra-panel-min-btn, .ra-panel-close-btn {
width: 24px; height: 24px; border: none; background: transparent;
border-radius: 4px; cursor: pointer; font-size: 14px; display: flex;
align-items: center; justify-content: center; transition: background 0.15s;
line-height: 1; padding: 0;
}
.ra-panel-min-btn:hover { background: #e5e5e5; }
.ra-panel-close-btn:hover { background: #ff4d4f; color: #fff; }
.ra-panel-body { padding: 14px 16px; max-height: 300px; overflow-y: auto; }
.ra-panel-hr-msg {
color: #666; font-size: 13px; line-height: 1.6; padding: 8px 12px;
background: #f8f8f8; border-radius: 8px; border-left: 3px solid #ddd;
margin-bottom: 8px; word-break: break-word;
}
.ra-panel-divider { text-align: center; color: #ddd; margin: 4px 0 8px; font-size: 12px; }
.ra-panel-ai-reply {
color: #1a1a1a; font-size: 14px; line-height: 1.7; padding: 10px 12px;
background: #f0f7ff; border-radius: 8px; border-left: 3px solid #1677ff;
word-break: break-word; outline: none; transition: background 0.15s;
}
.ra-panel-ai-reply.ra-editing { background: #fff; border-left-color: #ff9800; box-shadow: 0 0 0 2px rgba(255,152,0,0.15); }
.ra-panel-footer { display: flex; gap: 8px; padding: 10px 16px; border-top: 1px solid #eee; background: #fafafa; }
.ra-btn {
flex: 1; padding: 8px 12px; border: 1px solid #d9d9d9; border-radius: 8px;
background: #fff; font-size: 13px; cursor: pointer; transition: all 0.15s; white-space: nowrap;
}
.ra-btn:hover { border-color: #bbb; background: #f5f5f5; }
.ra-btn:active { transform: scale(0.97); }
.ra-btn-send { background: #1677ff; border-color: #1677ff; color: #fff; font-weight: 600; }
.ra-btn-send:hover { background: #4096ff; border-color: #4096ff; }
.ra-btn-send:disabled { background: #a0c4ff; border-color: #a0c4ff; cursor: not-allowed; }
.ra-btn-edit { color: #1677ff; border-color: #1677ff; }
.ra-btn-edit:hover { background: #e6f4ff; }
.ra-btn-ignore { color: #999; }
.ra-panel-status { padding: 6px 16px 10px; font-size: 12px; text-align: center; min-height: 20px; }
.ra-status-ok { color: #52c41a; }
.ra-status-error { color: #ff4d4f; }
.ra-status-thinking { color: #1677ff; animation: ra-pulse 1.2s infinite; }
.ra-status-info { color: #999; }
@keyframes ra-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
/* —— 浮动按钮 —— */
.ra-float-btn {
position: fixed; bottom: 120px; right: 28px; width: 44px; height: 44px;
background: #1677ff; color: #fff; border: none; border-radius: 50%;
font-size: 20px; cursor: pointer; z-index: 999998;
box-shadow: 0 4px 16px rgba(22,119,255,0.3);
display: flex; align-items: center; justify-content: center;
transition: all 0.2s;
}
.ra-float-btn:hover { transform: scale(1.1); box-shadow: 0 6px 24px rgba(22,119,255,0.4); }
.ra-float-btn.ra-off { background: #999; box-shadow: 0 4px 12px rgba(0,0,0,0.2); }
.ra-float-badge {
position: absolute; top: -4px; right: -4px; min-width: 18px; height: 18px;
background: #ff4d4f; color: #fff; font-size: 10px; border-radius: 9px;
display: flex; align-items: center; justify-content: center; padding: 0 4px;
}
/* —— 设置面板(悬浮窗 + 遮罩)—— */
.ra-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.4); z-index: 9999999;
display: flex; align-items: flex-start; justify-content: center;
padding-top: 6vh;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
}
.ra-settings {
width: 720px; max-width: 95vw; max-height: 88vh; background: #fff;
border-radius: 16px; box-shadow: 0 16px 64px rgba(0,0,0,0.2);
display: flex; flex-direction: column; overflow: hidden;
position: relative;
}
.ra-settings-header {
display: flex; justify-content: space-between; align-items: center;
padding: 16px 24px; border-bottom: 1px solid #eee; flex-shrink: 0;
cursor: move; user-select: none;
}
.ra-settings-header:active { cursor: grabbing; }
.ra-settings-title { font-size: 18px; font-weight: 700; }
.ra-settings-desc { font-size: 13px; color: #999; margin-top: 2px; }
.ra-settings-close {
width: 32px; height: 32px; border: none; background: #f5f5f5;
border-radius: 50%; cursor: pointer; font-size: 18px; display: flex;
align-items: center; justify-content: center; transition: background 0.15s;
flex-shrink: 0;
}
.ra-settings-close:hover { background: #e5e5e5; }
.ra-settings-body { flex: 1; overflow-y: auto; padding: 20px 24px; }
.ra-settings-body h3 {
font-size: 15px; font-weight: 600; margin: 0 0 12px 0; padding-bottom: 8px;
border-bottom: 1px solid #f0f0f0; color: #333;
}
.ra-section { margin-bottom: 24px; }
.ra-form-group { margin-bottom: 12px; }
.ra-form-group label { display: block; font-size: 13px; font-weight: 600; color: #555; margin-bottom: 4px; }
.ra-form-group input, .ra-form-group textarea {
width: 100%; padding: 8px 12px; border: 1px solid #d9d9d9; border-radius: 8px;
font-size: 13px; box-sizing: border-box; transition: border-color 0.15s;
font-family: inherit;
}
.ra-form-group input:focus, .ra-form-group textarea:focus { border-color: #1677ff; outline: none; box-shadow: 0 0 0 2px rgba(22,119,255,0.1); }
.ra-form-row { display: flex; gap: 12px; }
.ra-form-row > * { flex: 1; }
.ra-form-hint { font-size: 11px; color: #aaa; margin-top: 2px; }
.ra-form-hint a { color: #1677ff; }
.ra-badge { display: inline-block; font-size: 10px; padding: 1px 6px; border-radius: 4px; vertical-align: middle; margin-left: 4px; }
.ra-badge-provider { background: #e6f7ff; color: #1677ff; }
.ra-badge-ocr { background: #fff7e6; color: #fa8c16; }
/* 上传区域 */
.ra-upload-area {
border: 2px dashed #d9d9d9; border-radius: 12px; padding: 24px;
text-align: center; cursor: pointer; transition: all 0.2s; margin-bottom: 12px;
}
.ra-upload-area:hover { border-color: #1677ff; background: #fafbff; }
.ra-upload-icon { font-size: 36px; margin-bottom: 8px; }
.ra-upload-text { font-size: 14px; color: #333; }
.ra-upload-hint { font-size: 12px; color: #aaa; margin-top: 4px; }
.ra-upload-status { display: flex; align-items: center; gap: 8px; font-size: 13px; min-height: 24px; }
.ra-upload-clear { font-size: 12px; color: #1677ff; cursor: pointer; background: none; border: none; }
/* 工作经历卡片 */
.ra-exp-card {
border: 1px solid #eee; border-radius: 8px; padding: 12px; margin-bottom: 8px;
background: #fafafa; transition: box-shadow 0.3s;
}
.ra-exp-card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.ra-exp-card-num { font-size: 12px; font-weight: 600; color: #999; }
.ra-exp-card-del { font-size: 12px; color: #ff4d4f; cursor: pointer; background: none; border: none; }
.ra-btn-add {
width: 100%; padding: 8px; border: 1px dashed #d9d9d9; border-radius: 8px;
background: #fff; cursor: pointer; font-size: 13px; color: #1677ff;
transition: all 0.15s;
}
.ra-btn-add:hover { border-color: #1677ff; background: #f0f7ff; }
.ra-btn-primary {
width: 100%; padding: 12px; background: #1677ff; color: #fff; border: none;
border-radius: 8px; font-size: 15px; font-weight: 600; cursor: pointer; transition: all 0.15s;
}
.ra-btn-primary:hover { background: #4096ff; }
.ra-btn-primary:active { transform: scale(0.98); }
.ra-save-status { text-align: center; font-size: 13px; min-height: 20px; margin-top: 8px; }
.ra-save-ok { color: #52c41a; }
.ra-save-error { color: #ff4d4f; }
`);
// ============================================================
// 存储工具(封装 GM_setValue / GM_getValue)
// ============================================================
async function loadSettings() {
const raw = await GM_getValue('settings', null);
if (!raw) {
// 首次使用,写入默认值
const s = JSON.parse(JSON.stringify(DEFAULT_SETTINGS));
await GM_setValue('settings', JSON.stringify(s));
return s;
}
try {
return JSON.parse(raw);
} catch (e) {
return JSON.parse(JSON.stringify(DEFAULT_SETTINGS));
}
}
async function saveSettings(s) {
await GM_setValue('settings', JSON.stringify(s));
}
// ============================================================
// API 调用(GM_xmlhttpRequest,支持多模态)
// ============================================================
function gmFetch(url, options) {
return new Promise((resolve, reject) => {
const headers = options.headers || {};
const body = options.body || null;
const method = options.method || 'POST';
GM_xmlhttpRequest({
method: method,
url: url,
headers: headers,
data: body,
timeout: 60000,
onload: (resp) => {
resolve({
ok: resp.status >= 200 && resp.status < 300,
status: resp.status,
json: () => {
try { return JSON.parse(resp.responseText); } catch (e) { return null; }
},
text: () => resp.responseText,
});
},
onerror: (err) => reject(new Error('网络请求失败')),
ontimeout: () => reject(new Error('请求超时')),
});
});
}
// OpenAI 兼容 API
async function callOpenAIAPI(baseUrl, apiKey, model, systemPrompt, userMessage, history, images) {
const messages = [];
messages.push({ role: 'system', content: systemPrompt });
if (history && history.length > 0) {
const recent = history.slice(-8);
for (const turn of recent) {
messages.push({ role: 'user', content: `[HR]: ${turn.hrMessage}` });
messages.push({ role: 'assistant', content: `[我]: ${turn.myReply}` });
}
}
if (images && images.length > 0) {
const contentParts = [];
for (const img of images) {
contentParts.push({ type: 'image_url', image_url: { url: img } });
}
contentParts.push({ type: 'text', text: userMessage });
messages.push({ role: 'user', content: contentParts });
} else {
messages.push({ role: 'user', content: userMessage });
}
const response = await gmFetch(baseUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: model,
max_tokens: 600,
messages: messages,
temperature: 0.3,
}),
});
if (!response.ok) {
const err = response.text();
throw new Error(`API 请求失败 (${response.status}): ${err}`);
}
const data = response.json();
if (!data) throw new Error('API 返回解析失败');
const content = data.choices?.[0]?.message?.content?.trim();
if (!content) throw new Error('API 返回为空');
return content;
}
// Anthropic 格式 API
async function callAnthropicAPI(baseUrl, apiKey, model, systemPrompt, userMessage, history, images) {
const messages = [];
if (history && history.length > 0) {
const recent = history.slice(-8);
for (const turn of recent) {
messages.push({ role: 'user', content: `[HR]: ${turn.hrMessage}` });
messages.push({ role: 'assistant', content: `[我]: ${turn.myReply}` });
}
}
if (images && images.length > 0) {
const contentParts = [];
for (const img of images) {
const mime = img.match(/^data:(image\/\w+);base64,/);
const mediaType = mime ? mime[1] : 'image/jpeg';
const data = img.replace(/^data:image\/\w+;base64,/, '');
contentParts.push({ type: 'image', source: { type: 'base64', media_type: mediaType, data: data } });
}
contentParts.push({ type: 'text', text: userMessage });
messages.push({ role: 'user', content: contentParts });
} else {
messages.push({ role: 'user', content: userMessage });
}
const response = await gmFetch(baseUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: model,
max_tokens: 600,
system: systemPrompt,
messages: messages,
temperature: 0.3,
}),
});
if (!response.ok) {
const err = response.text();
throw new Error(`API 请求失败 (${response.status}): ${err}`);
}
const data = response.json();
if (!data) throw new Error('API 返回解析失败');
const content = data.content?.[0]?.text?.trim();
if (!content) throw new Error('API 返回为空');
return content;
}
async function callMultimodalAPI(config, systemPrompt, userMessage, history, images) {
const format = config.apiFormat || 'openai';
const baseUrl = config.apiBaseUrl;
const model = config.model;
const apiKey = config.apiKey;
if (format === 'anthropic') {
return callAnthropicAPI(baseUrl, apiKey, model, systemPrompt, userMessage, history, images);
} else {
return callOpenAIAPI(baseUrl, apiKey, model, systemPrompt, userMessage, history, images);
}
}
// ============================================================
// System Prompts
// ============================================================
function buildChatSystemPrompt(s) {
const p = s.profile || {};
const pref = s.preferences || {};
let expText = '';
if (p.experiences && p.experiences.length > 0) {
expText = p.experiences
.map(e => `- ${e.company} | ${e.position} | ${e.startEnd}\n 工作内容: ${e.description}`)
.join('\n');
}
return `你是${p.name || '一位求职者'}的招聘沟通助手。你需要以第一人称("我")回复招聘平台上的 HR 消息。
## 我的背景
- 姓名: ${p.name || '(未填写)'}
- 学历: ${p.education || '(未填写)'}
- 工作年限: ${p.workYears || '(未填写)'}
- 技能: ${p.skills || '(未填写)'}
- 工作经历:
${expText || '(未填写)'}
## 求职偏好
- 期望岗位: ${pref.desiredPosition || '(未填写)'}
- 期望薪资: ${pref.salaryRange || '(未填写)'}
- 不接受/排斥: ${pref.exclusions || '(未填写)'}
## 回复规则
1. 使用第一人称"我",语气专业、友好、不卑不亢
2. 回复简洁,控制在 80 字以内(招聘聊天风格偏短)
3. 如果 HR 问薪资,参考期望薪资范围回答,留一点谈判空间
4. 如果 HR 问到简历中没有的信息,宁可说"可以详细沟通"也不要编造
5. 如果岗位明显不匹配,礼貌表达婉拒
6. 不要使用"亲"、"呢"、"哦"等过于随意或女性化的语气词
7. 不要承诺具体面试时间,说"我们可以约个时间"
8. 第一次对话要主动表达兴趣 + 引导 HR 介绍岗位
${pref.customPrompt ? `## 额外要求\n${pref.customPrompt}` : ''}
请根据以上信息,为下面的 HR 消息生成一条回复:`;
}
function buildResumeParseSystemPrompt() {
return `你是一个专业的简历解析器。请从以下文本中提取简历信息,以 JSON 格式返回。
## 提取字段
{
"name": "姓名",
"education": "最高学历,如:本科/硕士/博士",
"workYears": "工作年限,如:5年(从经历推算)",
"skills": "核心技能,逗号分隔,如:Go, Python, Kubernetes",
"experiences": [
{
"company": "公司名称",
"position": "职位",
"startEnd": "起止时间,如:2021.03-2024.06",
"description": "主要工作内容(一两句话)"
}
]
}
## 规则
1. 如果某个字段无法从文本中识别,使用空字符串 ""
2. experiences 按时间倒序排列
3. 只返回 JSON,不要任何额外文字
4. 确保 JSON 格式合法
## 简历文本`;
}
// ============================================================
// 平台检测
// ============================================================
const PLATFORMS = {
zhipin: {
name: 'BOSS直聘',
domain: 'zhipin.com',
selectors: {
messageList: ['.chat-message-list', '.message-list', '.chat-content'],
messageItem: ['.message-item', '.chat-message'],
selfMessage: ['.self', '.is-self', '.message-self'],
inputArea: ['[contenteditable="true"]', '.chat-input-editor'],
sendBtn: ['.send-btn', '.chat-send-btn', 'button'],
contactName: ['.chat-header .name', '.contact-name', '.chat-name'],
contactJob: ['.chat-header .position', '.contact-position'],
},
},
zhaopin: {
name: '智联招聘',
domain: 'zhaopin.com',
selectors: {
messageList: ['.im-chat-window', '.chat-list', '.message-container'],
messageItem: ['.message-item', '.msg-item', '.chat-msg'],
selfMessage: ['.self', '.mine', '.is-owner'],
inputArea: ['[contenteditable="true"]', 'textarea', '.chat-input'],
sendBtn: ['.send-btn', 'button'],
contactName: ['.chat-title', '.contact-info .name'],
contactJob: ['.chat-title .job', '.contact-info .position'],
},
},
job51: {
name: '前程无忧',
domain: '51job.com',
selectors: {
messageList: ['.chat-list', '.im-content', '.msg-list'],
messageItem: ['.msg-item', '.chat-item', '.message-item'],
selfMessage: ['.self', '.mine', '.right'],
inputArea: ['[contenteditable="true"]', 'textarea', '.input-area'],
sendBtn: ['.send-btn', 'button'],
contactName: ['.chat-header .name', '.contact-name'],
contactJob: ['.chat-header .job', '.contact-position'],
},
},
liepin: {
name: '猎聘',
domain: 'liepin.com',
selectors: {
messageList: ['.im-message-list', '.chat-list', '.message-panel'],
messageItem: ['.message-item', '.msg-item', '.chat-record'],
selfMessage: ['.self', '.mine', '.bubble-self'],
inputArea: ['[contenteditable="true"]', 'textarea', '.chat-input-editor'],
sendBtn: ['.send-btn', '.btn-send', 'button'],
contactName: ['.chat-title .name', '.contact-name', '.im-header .name'],
contactJob: ['.chat-title .job', '.contact-job'],
},
},
lagou: {
name: '拉勾',
domain: 'lagou.com',
selectors: {
messageList: ['.im-messages', '.chat-list', '.msg-container'],
messageItem: ['.message-item', '.msg-item'],
selfMessage: ['.self', '.mine'],
inputArea: ['[contenteditable="true"]', 'textarea', '.chat-input'],
sendBtn: ['.send-btn', 'button'],
contactName: ['.chat-header .name', '.contact-name'],
contactJob: ['.chat-header .position'],
},
},
};
let platform = null;
function detectPlatform() {
const host = location.hostname;
for (const [key, cfg] of Object.entries(PLATFORMS)) {
if (host.includes(cfg.domain)) {
platform = { key, ...cfg };
console.log('[招聘AI] 检测到平台:', cfg.name);
return;
}
}
platform = { key: 'unknown', name: '未知平台', selectors: PLATFORMS.zhipin.selectors };
console.log('[招聘AI] 未识别平台,使用通用选择器');
}
// 判断当前页面是否为聊天/IM 页面
function isChatPage() {
const href = location.href;
const chatPatterns = [
/zhipin\.com\/web\/chat/i,
/zhipin\.com\/web\/geek\/chat/i,
/zhaopin\.com\/.*\/(?:chat|im)/i,
/51job\.com\/.*\/(?:chat|im)/i,
/liepin\.com\/.*\/im/i,
/lagou\.com\/.*\/chat/i,
];
return chatPatterns.some(p => p.test(href));
}
// ============================================================
// DOM 工具
// ============================================================
function isVisible(el) {
if (!el) return false;
const style = window.getComputedStyle(el);
return style.display !== 'none' && style.visibility !== 'hidden' && el.offsetHeight > 0;
}
function querySel(selectorList) {
for (const sel of selectorList) {
try {
const el = document.querySelector(sel);
if (el && isVisible(el)) return el;
} catch (e) { /* skip */ }
}
return null;
}
function querySelAll(selectorList) {
for (const sel of selectorList) {
try {
const els = document.querySelectorAll(sel);
if (els.length > 0) return els;
} catch (e) { /* skip */ }
}
return [];
}
function findInputElement() {
const els = platform.selectors.inputArea;
for (const sel of els) {
try {
const matches = document.querySelectorAll(sel);
for (const el of matches) {
if (isVisible(el)) return el;
}
} catch (e) { /* skip */ }
}
return null;
}
function findSendButton() {
const input = findInputElement();
if (input) {
let container = input.parentElement;
for (let i = 0; i < 5 && container; i++) {
const btn = container.querySelector('button');
if (btn) return btn;
container = container.parentElement;
}
}
const buttons = document.querySelectorAll('button');
for (const btn of buttons) {
const text = btn.textContent.trim();
if (/发送|send/i.test(text) && isVisible(btn)) return btn;
}
return querySel(platform.selectors.sendBtn);
}
function getSenderInfo() {
const nameEl = querySel(platform.selectors.contactName);
const jobEl = querySel(platform.selectors.contactJob);
const name = nameEl?.textContent?.trim() || 'HR';
const job = jobEl?.textContent?.trim() || '';
return job ? `${name} - ${job}` : name;
}
function getChatContainer() {
return querySel(platform.selectors.messageList);
}
function getLatestHrMessage() {
const container = getChatContainer();
if (!container) return null;
const items = querySelAll(platform.selectors.messageItem);
if (items.length === 0) {
const allChildren = container.querySelectorAll('div, li, p');
for (let i = allChildren.length - 1; i >= 0; i--) {
const el = allChildren[i];
const text = el.textContent?.trim();
if (text && text.length > 2 && text.length < 500 && el.children.length === 0) {
return text;
}
}
return null;
}
for (let i = items.length - 1; i >= 0; i--) {
const msg = items[i];
let isSelf = false;
for (const selfSel of platform.selectors.selfMessage) {
try {
if (msg.matches(selfSel) || msg.classList.contains(selfSel.replace('.', ''))) {
isSelf = true;
break;
}
} catch (e) { /* skip */ }
}
if (!isSelf) {
const text = msg.textContent?.trim();
if (text && text.length > 0) return text;
}
}
return null;
}
// ============================================================
// 确认面板(回复 UI)
// ============================================================
let pendingReply = null;
function createConfirmPanel() {
if (document.getElementById('__ra_panel__')) return;
const panel = document.createElement('div');
panel.id = '__ra_panel__';
panel.className = 'ra-panel ra-hidden';
panel.innerHTML = `
`;
document.body.appendChild(panel);
// —— 启用拖拽(悬浮窗核心)——
const header = panel.querySelector('.ra-panel-header');
makeDraggable(panel, header, { keepInView: true });
// —— 按钮事件 ——
const minBtn = panel.querySelector('.ra-panel-min-btn');
const closeBtn = panel.querySelector('.ra-panel-close-btn');
const ignoreBtn = panel.querySelector('.ra-btn-ignore');
const editBtn = panel.querySelector('.ra-btn-edit');
const sendBtn = panel.querySelector('.ra-btn-send');
const replyEl = panel.querySelector('.ra-panel-ai-reply');
// 最小化 / 还原
minBtn.addEventListener('click', () => {
const isMin = panel.classList.toggle('ra-minimized');
minBtn.textContent = isMin ? '□' : '─';
minBtn.title = isMin ? '还原' : '最小化';
});
// 关闭(隐藏)
closeBtn.addEventListener('click', () => { hidePanel(); pendingReply = null; });
ignoreBtn.addEventListener('click', () => { hidePanel(); pendingReply = null; });
editBtn.addEventListener('click', () => {
const editing = replyEl.contentEditable === 'true';
replyEl.contentEditable = editing ? 'false' : 'true';
replyEl.classList.toggle('ra-editing', !editing);
editBtn.textContent = editing ? '✏️ 编辑' : '🔒 锁定';
if (!editing) replyEl.focus();
});
sendBtn.addEventListener('click', async () => {
const text = replyEl.textContent.trim();
if (!text) return;
sendBtn.disabled = true;
sendBtn.textContent = '⏳ 发送中...';
const ok = await sendReplyAsHuman(text);
if (ok) {
hidePanel();
pendingReply = null;
} else {
setPanelStatus('⚠️ 发送失败,请手动发送', 'error');
sendBtn.disabled = false;
sendBtn.textContent = '✅ 重试';
}
});
return panel;
}
function showPanel(hrMessage, aiReply, senderInfo) {
const panel = document.getElementById('__ra_panel__') || createConfirmPanel();
if (!panel) return;
panel.querySelector('.ra-panel-sender').textContent = senderInfo || 'HR';
panel.querySelector('.ra-panel-hr-msg').textContent = `"${hrMessage}"`;
panel.querySelector('.ra-panel-ai-reply').textContent = aiReply;
panel.querySelector('.ra-panel-ai-reply').contentEditable = 'false';
panel.querySelector('.ra-panel-ai-reply').classList.remove('ra-editing');
panel.querySelector('.ra-btn-edit').textContent = '✏️ 编辑';
panel.querySelector('.ra-btn-send').disabled = false;
panel.querySelector('.ra-btn-send').textContent = '✅ 发送';
setPanelStatus('', '');
panel.classList.remove('ra-hidden');
pendingReply = aiReply;
}
function hidePanel() {
const panel = document.getElementById('__ra_panel__');
if (panel) panel.classList.add('ra-hidden');
}
function setPanelStatus(msg, type) {
const el = document.querySelector('#__ra_panel__ .ra-panel-status');
if (el) {
el.textContent = msg;
el.className = `ra-panel-status ra-status-${type}`;
}
}
// ============================================================
// 模拟人类输入发送
// ============================================================
function sleep(ms) {
return new Promise(r => setTimeout(r, ms));
}
async function sendReplyAsHuman(text) {
const input = findInputElement();
const sendBtn = findSendButton();
if (!input) { console.error('[招聘AI] 找不到输入框'); return false; }
if (!sendBtn) { console.error('[招聘AI] 找不到发送按钮'); return false; }
try {
if (input.contentEditable === 'true') {
input.textContent = '';
} else {
input.value = '';
}
for (let i = 0; i < text.length; i++) {
if (input.contentEditable === 'true') {
input.textContent += text[i];
} else {
input.value += text[i];
}
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
let delay = 50 + Math.random() * 150;
if (Math.random() < 0.1) delay += 200 + Math.random() * 400;
await sleep(delay);
}
await sleep(600 + Math.random() * 1500);
sendBtn.click();
return true;
} catch (err) {
console.error('[招聘AI] 发送失败:', err);
return false;
}
}
// ============================================================
// 聊天监听(主逻辑)
// ============================================================
let latestHrMessage = null;
let isEnabled = true;
let isProcessing = false;
let settingsCache = null;
async function checkForNewMessage() {
if (!isEnabled || isProcessing) return;
const msg = getLatestHrMessage();
if (!msg || msg === latestHrMessage) return;
latestHrMessage = msg;
isProcessing = true;
setPanelStatus('⏳ AI 思考中...', 'thinking');
if (!document.getElementById('__ra_panel__') || document.getElementById('__ra_panel__').classList.contains('ra-hidden')) {
createConfirmPanel();
}
showPanel(msg, '...', getSenderInfo());
try {
if (!settingsCache) settingsCache = await loadSettings();
if (!settingsCache.apiKey) {
setPanelStatus('⚠️ 请先设置 API Key(点击右下角齿轮按钮)', 'error');
isProcessing = false;
return;
}
const today = new Date().toDateString();
if (settingsCache.behavior.todayDate !== today) {
settingsCache.behavior.todayDate = today;
settingsCache.behavior.todaySent = 0;
await saveSettings(settingsCache);
}
if (settingsCache.behavior.todaySent >= settingsCache.behavior.dailyLimit) {
setPanelStatus(`⚠️ 今日已达回复上限 (${settingsCache.behavior.dailyLimit}条)`, 'error');
isProcessing = false;
return;
}
const systemPrompt = buildChatSystemPrompt(settingsCache);
const userMessage = `[HR]: ${msg}\n\n请生成我的回复(只要回复文本,不要额外解释):`;
const historyKey = `history_${getSenderInfo()}`;
const historyRaw = await GM_getValue(historyKey, '[]');
let history = [];
try { history = JSON.parse(historyRaw); } catch (e) { history = []; }
const apiConfig = {
apiKey: settingsCache.apiKey,
apiBaseUrl: settingsCache.apiBaseUrl,
apiFormat: settingsCache.apiFormat || 'openai',
model: settingsCache.model || 'deepseek-chat',
};
console.log('[招聘AI] 生成回复,厂商:', settingsCache.apiProvider, 'HR消息:', msg.slice(0, 50));
const reply = await callMultimodalAPI(apiConfig, systemPrompt, userMessage, history, null);
// 保存历史
history.push({ hrMessage: msg, myReply: reply, time: Date.now() });
const trimmed = history.length > 50 ? history.slice(-50) : history;
await GM_setValue(historyKey, JSON.stringify(trimmed));
// 更新计数
settingsCache.behavior.todaySent++;
await saveSettings(settingsCache);
showPanel(msg, reply, getSenderInfo());
} catch (err) {
console.error('[招聘AI] 获取 AI 回复失败:', err);
setPanelStatus(`⚠️ ${err.message}`, 'error');
}
isProcessing = false;
}
function setupChatObserver() {
const container = getChatContainer();
if (!container) {
// 只在聊天页面才重试,非聊天页面直接跳过
if (!isChatPage()) {
console.log('[招聘AI] 非聊天页面,跳过聊天监听');
return;
}
// 最多重试 10 次(15秒),之后放弃
setupChatObserver._retries = (setupChatObserver._retries || 0) + 1;
if (setupChatObserver._retries > 10) {
console.log('[招聘AI] 聊天容器查找超时,已放弃');
return;
}
console.log('[招聘AI] 未找到聊天容器,1.5秒后重试...(' + setupChatObserver._retries + '/10)');
setTimeout(setupChatObserver, 1500);
return;
}
setupChatObserver._retries = 0;
console.log('[招聘AI] 已找到聊天容器,开始监听');
createConfirmPanel();
const observer = new MutationObserver(() => {
if (observer._timeout) clearTimeout(observer._timeout);
observer._timeout = setTimeout(checkForNewMessage, 500);
});
observer.observe(container, { childList: true, subtree: true, characterData: true });
// URL 变化监听(切换联系人时重置)
let lastUrl = location.href;
const urlObserver = new MutationObserver(() => {
if (location.href !== lastUrl) {
lastUrl = location.href;
latestHrMessage = null;
hidePanel();
console.log('[招聘AI] 切换到新对话');
setTimeout(checkForNewMessage, 1500);
}
});
urlObserver.observe(document.body, { childList: true, subtree: false });
}
// ============================================================
// 浮动按钮(开关 + 设置入口)
// ============================================================
let floatBtn = null;
let floatBadge = null;
function createFloatButton() {
if (document.getElementById('__ra_float_btn__')) return;
floatBtn = document.createElement('button');
floatBtn.id = '__ra_float_btn__';
floatBtn.className = 'ra-float-btn';
floatBtn.title = '招聘AI 助手 — 点击打开设置';
floatBtn.innerHTML = '⚙️';
floatBadge = document.createElement('span');
floatBadge.className = 'ra-float-badge';
floatBadge.style.display = 'none';
floatBtn.appendChild(floatBadge);
floatBtn.addEventListener('click', openSettingsPanel);
document.body.appendChild(floatBtn);
}
async function updateFloatBadge() {
if (!floatBadge) return;
const s = settingsCache || await loadSettings();
const today = new Date().toDateString();
if (s.behavior.todayDate === today && s.behavior.todaySent > 0) {
floatBadge.textContent = String(s.behavior.todaySent);
floatBadge.style.display = 'flex';
} else {
floatBadge.style.display = 'none';
}
floatBtn.classList.toggle('ra-off', !s.enabled);
}
// ============================================================
// 设置面板(完整功能)
// ============================================================
let settingsOverlay = null;
let currentUploadFile = null;
function openSettingsPanel() {
if (settingsOverlay) {
settingsOverlay.style.display = 'flex';
refreshSettingsUI();
return;
}
buildSettingsPanel();
}
function closeSettingsPanel() {
if (settingsOverlay) settingsOverlay.style.display = 'none';
currentUploadFile = null;
}
function esc(str) {
if (!str) return '';
return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
}
function blankExp() {
return { company: '', position: '', startEnd: '', description: '' };
}
function renderExperiences(experiences) {
const list = document.getElementById('raExpList');
if (!list) return;
list.innerHTML = '';
(experiences || []).forEach((exp, i) => {
const card = document.createElement('div');
card.className = 'ra-exp-card';
card.innerHTML = `
`;
list.appendChild(card);
});
list.querySelectorAll('.ra-exp-card-del').forEach(btn => {
btn.addEventListener('click', (e) => {
const i = parseInt(e.target.dataset.index);
const exps = gatherExperiences();
exps.splice(i, 1);
renderExperiences(exps);
});
});
}
function gatherExperiences() {
const cards = document.querySelectorAll('.ra-exp-card');
const exps = [];
cards.forEach(card => {
exps.push({
company: card.querySelector('.ra-exp-company')?.value?.trim() || '',
position: card.querySelector('.ra-exp-position')?.value?.trim() || '',
startEnd: card.querySelector('.ra-exp-startEnd')?.value?.trim() || '',
description: card.querySelector('.ra-exp-desc')?.value?.trim() || '',
});
});
return exps.filter(e => e.company || e.position);
}
function buildSettingsPanel() {
settingsOverlay = document.createElement('div');
settingsOverlay.className = 'ra-overlay';
settingsOverlay.innerHTML = `
📄 上传简历 — 自动填充
📂
拖拽简历文件到这里,或点击选择
支持 .pdf .docx .txt — PDF 用豆包 Vision 识别,Word/TXT 用豆包文本模型
`;
document.body.appendChild(settingsOverlay);
// —— 启用拖拽(悬浮窗核心)——
const settingsPanel = settingsOverlay.querySelector('.ra-settings');
const settingsHeader = settingsPanel.querySelector('.ra-settings-header');
makeDraggable(settingsPanel, settingsHeader, { keepInView: true });
// 事件绑定
document.getElementById('raSettingsClose').addEventListener('click', closeSettingsPanel);
settingsOverlay.addEventListener('click', (e) => {
if (e.target === settingsOverlay) closeSettingsPanel();
});
// 上传
const uploadArea = document.getElementById('raUploadArea');
const fileInput = document.getElementById('raFileInput');
uploadArea.addEventListener('click', () => fileInput.click());
uploadArea.addEventListener('dragover', (e) => { e.preventDefault(); uploadArea.style.borderColor = '#1677ff'; });
uploadArea.addEventListener('dragleave', () => { uploadArea.style.borderColor = '#d9d9d9'; });
uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
uploadArea.style.borderColor = '#d9d9d9';
const file = e.dataTransfer.files[0];
if (file) handleResumeFile(file);
});
fileInput.addEventListener('change', () => {
const file = fileInput.files[0];
if (file) handleResumeFile(file);
});
document.getElementById('raBtnClearUpload').addEventListener('click', () => {
document.getElementById('raUploadStatusText').textContent = '';
document.getElementById('raBtnClearUpload').style.display = 'none';
fileInput.value = '';
document.getElementById('raBadgeAutoFilled').style.display = 'none';
});
document.getElementById('raBtnAddExp').addEventListener('click', () => {
const exps = gatherExperiences();
exps.push(blankExp());
renderExperiences(exps);
});
document.getElementById('raBtnSave').addEventListener('click', doSaveSettings);
// 自动保存 API Key
document.getElementById('raApiKey').addEventListener('blur', async () => {
const key = document.getElementById('raApiKey').value.trim();
if (key) {
const s = settingsCache || await loadSettings();
s.apiKey = key;
await saveSettings(s);
settingsCache = s;
setSaveStatus('🔑 DeepSeek API Key 已自动保存', 'ok');
}
});
document.getElementById('raDoubaoApiKey').addEventListener('blur', async () => {
const key = document.getElementById('raDoubaoApiKey').value.trim();
if (key) {
const s = settingsCache || await loadSettings();
s.doubaoApiKey = key;
await saveSettings(s);
settingsCache = s;
setSaveStatus('🔑 豆包 API Key 已自动保存', 'ok');
}
});
refreshSettingsUI();
}
async function refreshSettingsUI() {
const s = settingsCache || await loadSettings();
document.getElementById('raApiKey').value = s.apiKey || '';
document.getElementById('raDoubaoApiKey').value = s.doubaoApiKey || '';
const p = s.profile || {};
document.getElementById('raName').value = p.name || '';
document.getElementById('raEducation').value = p.education || '';
document.getElementById('raWorkYears').value = p.workYears || '';
document.getElementById('raSkills').value = p.skills || '';
renderExperiences(p.experiences || []);
const pref = s.preferences || {};
document.getElementById('raDesiredPosition').value = pref.desiredPosition || '';
document.getElementById('raSalaryRange').value = pref.salaryRange || '';
document.getElementById('raExclusions').value = pref.exclusions || '';
document.getElementById('raCustomPrompt').value = pref.customPrompt || '';
const b = s.behavior || {};
document.getElementById('raDailyLimit').value = b.dailyLimit || 50;
document.getElementById('raSendDelayMin').value = b.sendDelayMin || 2;
document.getElementById('raSendDelayMax').value = b.sendDelayMax || 5;
}
function setSaveStatus(msg, type) {
const el = document.getElementById('raSaveStatus');
if (el) {
el.textContent = msg;
el.className = `ra-save-status ra-save-${type}`;
if (type === 'ok') setTimeout(() => { if (el.textContent === msg) el.textContent = ''; }, 3000);
}
}
async function doSaveSettings() {
const s = settingsCache || await loadSettings();
s.apiKey = document.getElementById('raApiKey').value.trim();
s.doubaoApiKey = document.getElementById('raDoubaoApiKey').value.trim();
s.profile = {
name: document.getElementById('raName').value.trim(),
education: document.getElementById('raEducation').value.trim(),
workYears: document.getElementById('raWorkYears').value.trim(),
skills: document.getElementById('raSkills').value.trim(),
experiences: gatherExperiences(),
};
s.preferences = {
desiredPosition: document.getElementById('raDesiredPosition').value.trim(),
salaryRange: document.getElementById('raSalaryRange').value.trim(),
exclusions: document.getElementById('raExclusions').value.trim(),
customPrompt: document.getElementById('raCustomPrompt').value.trim(),
};
s.behavior = {
dailyLimit: parseInt(document.getElementById('raDailyLimit').value) || 50,
sendDelayMin: parseInt(document.getElementById('raSendDelayMin').value) || 2,
sendDelayMax: parseInt(document.getElementById('raSendDelayMax').value) || 5,
todaySent: s.behavior?.todaySent || 0,
todayDate: s.behavior?.todayDate || '',
};
await saveSettings(s);
settingsCache = s;
latestHrMessage = null;
setSaveStatus('✅ 已保存!', 'ok');
}
// ============================================================
// 简历解析(PDF / DOCX / TXT)
// ============================================================
async function handleResumeFile(file) {
const statusEl = document.getElementById('raUploadStatusText');
const ext = file.name.split('.').pop().toLowerCase();
statusEl.textContent = '⏳ 读取文件中...';
statusEl.style.color = '#1677ff';
const s = settingsCache || await loadSettings();
const doubaoApiKey = s.doubaoApiKey || '';
if (!doubaoApiKey) {
statusEl.textContent = '❌ 请先在「豆包 API Key」输入框中填入火山引擎 Ark API Key';
statusEl.style.color = '#ff4d4f';
return;
}
try {
let text = '';
let images = null;
let apiConfig;
if (ext === 'txt') {
text = await readAsText(file);
if (!text || text.trim().length < 10) throw new Error('TXT 文件内容为空或太短');
apiConfig = {
apiKey: doubaoApiKey,
apiBaseUrl: s.doubaoBaseUrl || 'https://ark.cn-beijing.volces.com/api/v3/chat/completions',
apiFormat: 'openai',
model: s.doubaoModel || 'doubao-seed-2-1-pro-260628',
};
} else if (ext === 'docx' || ext === 'doc') {
text = await extractDocxText(file);
if (!text || text.trim().length < 20) throw new Error('Word 文本提取失败。请尝试另存为 TXT 格式');
apiConfig = {
apiKey: doubaoApiKey,
apiBaseUrl: s.doubaoBaseUrl || 'https://ark.cn-beijing.volces.com/api/v3/chat/completions',
apiFormat: 'openai',
model: s.doubaoModel || 'doubao-seed-2-1-pro-260628',
};
} else if (ext === 'pdf') {
statusEl.textContent = '⏳ 渲染 PDF 图片,调用豆包 Vision 识别...';
try { images = await renderPdfToImages(file); } catch (e) {
console.error('[招聘AI] PDF 图片渲染失败:', e);
}
if (!images || images.length === 0) {
throw new Error('PDF 图片渲染失败。可能原因:PDF 文件损坏或加密。建议将简历另存为 TXT 格式后重试。');
}
try { text = await extractPdfText(file); } catch (e) { /* ignore */ }
apiConfig = {
apiKey: doubaoApiKey,
apiBaseUrl: s.doubaoBaseUrl || 'https://ark.cn-beijing.volces.com/api/v3/chat/completions',
apiFormat: 'openai',
model: s.doubaoModel || 'doubao-seed-2-1-pro-260628',
};
} else {
throw new Error(`不支持的格式 .${ext},请上传 PDF / DOCX / TXT`);
}
const charCount = images ? `${images.length} 页图片` : (text ? text.length : '0');
statusEl.textContent = `✅ 已读取 ${file.name}(${charCount}),豆包 AI 解析中...`;
statusEl.style.color = '#1677ff';
// 调试日志:确认 API 调用的目标
console.log('[招聘AI] ===== 简历解析 API 调用 =====');
console.log('[招聘AI] 目标 URL:', apiConfig.apiBaseUrl);
console.log('[招聘AI] 模型:', apiConfig.model);
console.log('[招聘AI] API Key 前缀:', apiConfig.apiKey.slice(0, 8) + '...');
console.log('[招聘AI] ============================');
const systemPrompt = buildResumeParseSystemPrompt();
let userMessage;
if (images && images.length > 0) {
userMessage = '请识别图片中的简历内容并以 JSON 格式返回:';
if (text && text.trim().length >= 10) {
userMessage += '\n\n附:PDF 中已提取的部分文本供参考:\n' + text.slice(0, 3000);
}
} else {
const truncated = text.length > 8000 ? text.slice(0, 8000) : text;
userMessage = truncated + '\n\n请提取上述简历信息为 JSON:';
}
const rawJson = await callMultimodalAPI(apiConfig, systemPrompt, userMessage, null, images);
let jsonStr = rawJson;
const jsonMatch = rawJson.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
if (jsonMatch) jsonStr = jsonMatch[1];
const profile = JSON.parse(jsonStr);
// 自动填充表单
if (profile.name) document.getElementById('raName').value = profile.name;
if (profile.education) document.getElementById('raEducation').value = profile.education;
if (profile.workYears) document.getElementById('raWorkYears').value = profile.workYears;
if (profile.skills) document.getElementById('raSkills').value = profile.skills;
if (profile.experiences && Array.isArray(profile.experiences) && profile.experiences.length > 0) {
renderExperiences(profile.experiences);
}
document.getElementById('raBtnClearUpload').style.display = 'inline-block';
document.getElementById('raBadgeAutoFilled').style.display = 'inline';
statusEl.textContent = `✅ 已自动填充!来自 ${file.name}`;
statusEl.style.color = '#52c41a';
} catch (err) {
statusEl.textContent = `❌ 简历解析失败:${err.message}`;
statusEl.style.color = '#ff4d4f';
}
}
// ============================================================
// PDF 文本提取
// ============================================================
async function extractPdfText(file) {
const buffer = new Uint8Array(await file.arrayBuffer());
const text = new TextDecoder('latin1').decode(buffer);
const decodedTexts = [];
const flateRegex = /\/Filter\s*\/FlateDecode[\s\S]*?stream[\r\n]+([\s\S]*?)endstream/g;
let match;
while ((match = flateRegex.exec(text)) !== null) {
try {
const raw = match[1];
let dataStr = raw;
const endMarker = dataStr.lastIndexOf('\n');
if (endMarker >= 0) dataStr = dataStr.slice(0, endMarker);
const bytes = new Uint8Array(dataStr.length);
for (let i = 0; i < dataStr.length; i++) bytes[i] = dataStr.charCodeAt(i) & 0xff;
const decompressed = await decompressDeflate(bytes);
decodedTexts.push(new TextDecoder('latin1').decode(decompressed));
} catch (e) { /* skip */ }
}
const allText = [text, ...decodedTexts].join('\n');
const btBlocks = allText.match(/BT([\s\S]*?)ET/g);
const lines = [];
if (btBlocks) {
for (const block of btBlocks) {
const tjMatches = block.match(/\(([^)]*)\)\s*Tj/g);
if (tjMatches) {
for (const tj of tjMatches) {
const str = tj.match(/\(([^)]*)\)/);
if (str) {
const decoded = decodePdfString(str[1]);
if (decoded.trim()) lines.push(decoded);
}
}
}
const tjArrayMatches = block.match(/\[([\s\S]*?)\]\s*TJ/g);
if (tjArrayMatches) {
for (const tjArr of tjArrayMatches) {
const inner = tjArr.match(/\[([\s\S]*?)\]/);
if (!inner) continue;
const strMatches = inner[1].match(/\(([^)]*)\)/g);
if (strMatches) {
lines.push(strMatches.map(s => decodePdfString(s.slice(1, -1))).join(''));
}
}
}
const hexTjMatches = block.match(/<([0-9A-Fa-f]+)>\s*Tj/g);
if (hexTjMatches) {
for (const hm of hexTjMatches) {
const hexMatch = hm.match(/<([0-9A-Fa-f]+)>/);
if (hexMatch) {
const decoded = decodeHexString(hexMatch[1]);
if (decoded.trim()) lines.push(decoded);
}
}
}
const hexTjArrMatches = block.match(/\[([\s\S]*?)\]\s*TJ/g);
if (hexTjArrMatches) {
for (const tjArr of hexTjArrMatches) {
const inner = tjArr.match(/\[([\s\S]*?)\]/);
if (!inner) continue;
const hexMatches = inner[1].match(/<([0-9A-Fa-f]+)>/g);
if (hexMatches) {
const parts = hexMatches.map(m => m.match(/<([0-9A-Fa-f]+)>/)?.[1] || '')
.map(h => decodeHexString(h)).join('');
if (parts.trim()) lines.push(parts);
}
}
}
}
}
if (lines.length === 0) {
const strRegex = /[^\\]\(([^()]{2,})\)[^\\]/g;
const allStrMatches = allText.match(strRegex);
if (allStrMatches) {
for (const s of allStrMatches) {
const innerMatch = s.match(/\(([^()]{2,})\)/);
if (innerMatch) {
const decoded = decodePdfString(innerMatch[1]);
if (decoded.trim().length > 1) lines.push(decoded);
}
}
}
}
return lines.join('\n').trim();
}
function decodePdfString(str) {
return str
.replace(/\\n/g, '\n').replace(/\\r/g, '\r').replace(/\\t/g, '\t')
.replace(/\\\(/g, '(').replace(/\\\)/g, ')').replace(/\\\\/g, '\\')
.replace(/\\[0-7]{1,3}/g, m => String.fromCharCode(parseInt(m.slice(1), 8)));
}
function decodeHexString(hex) {
if (hex.length % 2 !== 0) hex += '0';
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
}
return new TextDecoder('utf-16be').decode(bytes);
}
// ============================================================
// PDF → 图片渲染(使用 pdf.js CDN)
// ============================================================
async function renderPdfToImages(file, maxPages) {
if (maxPages === undefined) maxPages = 2;
if (typeof pdfjsLib !== 'undefined') {
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
}
let arrayBuffer;
try {
arrayBuffer = await file.arrayBuffer();
} catch (e) {
throw new Error(`文件读取失败: ${e.message}`);
}
let pdf;
try {
pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
} catch (e) {
throw new Error(`PDF 解析失败(文件可能损坏或加密): ${e.message}`);
}
if (!pdf || pdf.numPages === 0) {
throw new Error('PDF 文件没有页面内容');
}
const images = [];
const pages = Math.min(pdf.numPages, maxPages);
for (let i = 1; i <= pages; i++) {
try {
const page = await pdf.getPage(i);
const viewport = page.getViewport({ scale: 1.5 });
const canvas = document.createElement('canvas');
canvas.width = viewport.width;
canvas.height = viewport.height;
const ctx = canvas.getContext('2d');
await page.render({ canvasContext: ctx, viewport: viewport }).promise;
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
images.push(dataUrl);
} catch (e) {
console.error(`[招聘AI] PDF 第 ${i} 页渲染失败:`, e);
}
}
if (images.length === 0) {
throw new Error('PDF 所有页面渲染失败,无法生成图片');
}
return images;
}
// ============================================================
// DOCX 文本提取
// ============================================================
async function extractDocxText(file) {
const buffer = new Uint8Array(await file.arrayBuffer());
const docXml = await readZipFile(buffer, 'word/document.xml');
if (!docXml) throw new Error('无法从 Word 文档中读取内容');
const matches = docXml.match(/]*>([^<]*)<\/w:t>/g);
if (!matches) throw new Error('无法从 Word 文档中提取文本');
return matches.map(m => m.replace(/]*>/, '').replace(/<\/w:t>/, '')).join('');
}
async function readZipFile(buffer, targetPath) {
let eocdOffset = -1;
const searchStart = Math.max(0, buffer.length - 65557);
for (let i = buffer.length - 22; i >= searchStart; i--) {
if (buffer[i] === 0x50 && buffer[i + 1] === 0x4b &&
buffer[i + 2] === 0x05 && buffer[i + 3] === 0x06) {
eocdOffset = i;
break;
}
}
if (eocdOffset < 0) throw new Error('ZIP 格式无效:找不到 EOCD');
const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
const totalEntries = view.getUint16(eocdOffset + 10, true);
let pos = view.getUint32(eocdOffset + 16, true);
const entries = [];
for (let i = 0; i < totalEntries && pos < buffer.length; i++) {
if (buffer[pos] !== 0x50 || buffer[pos + 1] !== 0x4b ||
buffer[pos + 2] !== 0x01 || buffer[pos + 3] !== 0x02) break;
const fileNameLen = view.getUint16(pos + 28, true);
const extraLen = view.getUint16(pos + 30, true);
const commentLen = view.getUint16(pos + 32, true);
const localHeaderOffset = view.getUint32(pos + 42, true);
const fileName = new TextDecoder('utf-8').decode(buffer.slice(pos + 46, pos + 46 + fileNameLen));
entries.push({ path: fileName, localOffset: localHeaderOffset });
pos += 46 + fileNameLen + extraLen + commentLen;
}
const target = entries.find(e => e.path === targetPath || e.path.endsWith(targetPath));
if (!target) return null;
let lp = target.localOffset;
const compression = view.getUint16(lp + 8, true);
const compressedSize = view.getUint32(lp + 18, true);
const localFileNameLen = view.getUint16(lp + 26, true);
const localExtraLen = view.getUint16(lp + 28, true);
const dataStart = lp + 30 + localFileNameLen + localExtraLen;
const fileData = buffer.slice(dataStart, dataStart + compressedSize);
if (compression === 0) {
return new TextDecoder('utf-8').decode(fileData);
} else if (compression === 8) {
const decompressed = await decompressDeflate(fileData);
return new TextDecoder('utf-8').decode(decompressed);
}
throw new Error(`不支持的压缩方式: ${compression}`);
}
// ============================================================
// 拖拽工具(悬浮窗核心)
// ============================================================
function makeDraggable(panelEl, handleEl, options) {
const opts = Object.assign({ minLeft: 0, minTop: 0, keepInView: true }, options || {});
let isDragging = false;
let startX, startY, startLeft, startTop;
let origPosition, origLeft, origTop, origMargin;
handleEl.addEventListener('mousedown', (e) => {
// 不拦截按钮点击
if (e.target.tagName === 'BUTTON' || e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (e.button !== 0) return; // 只响应左键
isDragging = true;
const rect = panelEl.getBoundingClientRect();
// 保存原始样式,切换到 fixed 定位(兼容 relative/flex 等)
origPosition = panelEl.style.position;
origLeft = panelEl.style.left;
origTop = panelEl.style.top;
origMargin = panelEl.style.margin;
panelEl.style.position = 'fixed';
panelEl.style.left = rect.left + 'px';
panelEl.style.top = rect.top + 'px';
panelEl.style.right = 'auto';
panelEl.style.bottom = 'auto';
panelEl.style.margin = '0';
panelEl.style.transition = 'none';
startX = e.clientX;
startY = e.clientY;
startLeft = rect.left;
startTop = rect.top;
document.body.style.userSelect = 'none';
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
let newLeft = startLeft + dx;
let newTop = startTop + dy;
if (opts.keepInView) {
const panelW = panelEl.offsetWidth;
const panelH = panelEl.offsetHeight;
const maxLeft = window.innerWidth - panelW;
const maxTop = window.innerHeight - 40; // 保留顶部 40px 最小可见
newLeft = Math.max(0, Math.min(newLeft, maxLeft));
newTop = Math.max(0, Math.min(newTop, maxTop));
} else {
newLeft = Math.max(opts.minLeft, newLeft);
newTop = Math.max(opts.minTop, newTop);
}
panelEl.style.left = newLeft + 'px';
panelEl.style.top = newTop + 'px';
});
document.addEventListener('mouseup', () => {
if (!isDragging) return;
isDragging = false;
panelEl.style.transition = '';
document.body.style.userSelect = '';
// 保持 fixed 定位(已拖拽过),不恢复原始 position
});
}
// ============================================================
// 通用工具
// ============================================================
function readAsText(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(new Error('文件读取失败'));
reader.readAsText(file);
});
}
async function decompressDeflate(data) {
let rawDeflate = data;
if (data.length > 6 && data[0] === 0x78) {
rawDeflate = data.slice(2);
if (rawDeflate.length > 4) rawDeflate = rawDeflate.slice(0, -4);
}
try {
const ds = new DecompressionStream('deflate');
const writer = ds.writable.getWriter();
const reader = ds.readable.getReader();
writer.write(rawDeflate);
writer.close();
const chunks = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
}
const totalLen = chunks.reduce((s, c) => s + c.length, 0);
const result = new Uint8Array(totalLen);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.length;
}
return result;
} catch (e) {
if (rawDeflate !== data) {
const ds = new DecompressionStream('deflate');
const writer = ds.writable.getWriter();
const reader = ds.readable.getReader();
writer.write(data);
writer.close();
const chunks = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
}
const totalLen = chunks.reduce((s, c) => s + c.length, 0);
const result = new Uint8Array(totalLen);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.length;
}
return result;
}
throw e;
}
}
// ============================================================
// 菜单命令(脚本猫右键菜单)
// ============================================================
GM_registerMenuCommand('⚙️ 招聘AI 设置', openSettingsPanel);
GM_registerMenuCommand('🔄 切换自动回复', async () => {
const s = settingsCache || await loadSettings();
s.enabled = !s.enabled;
await saveSettings(s);
settingsCache = s;
isEnabled = s.enabled;
if (isEnabled) {
latestHrMessage = null;
setupChatObserver();
} else {
hidePanel();
}
updateFloatBadge();
if (typeof GM_notification === 'function') {
GM_notification({
title: '招聘 AI 助手',
text: isEnabled ? '✅ 自动回复已开启' : '⏸️ 自动回复已暂停',
timeout: 2000,
});
}
});
// ============================================================
// 初始化
// ============================================================
async function init() {
detectPlatform();
console.log('[招聘AI] 脚本已加载 —', platform.name);
settingsCache = await loadSettings();
isEnabled = settingsCache.enabled !== false;
if (isEnabled) {
setupChatObserver();
}
createFloatButton();
updateFloatBadge();
console.log('[招聘AI] 状态:', isEnabled ? '开启' : '关闭', '| 今日已发送:', settingsCache.behavior.todaySent || 0);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();