// ==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 = `
🤖 AI 建议回复
─────────
`; 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 = `
经历 #${i + 1}
`; 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 = `
🤖 招聘 AI 助手 — 设置
豆包 AI 识别简历 + DeepSeek AI 自动回复

📄 上传简历 — 自动填充

📂
拖拽简历文件到这里,或点击选择
支持 .pdf .docx .txt — PDF 用豆包 Vision 识别,Word/TXT 用豆包文本模型

🔌 API 配置

📋 个人简历

🎯 求职偏好

⚙️ 行为设置

防止异常行为被检测
随机区间

🧠 自定义指令(可选)

这些要求会追加到 system prompt 末尾
`; 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(); } })();