// ==UserScript== // @name 觅星小臣 - BOSS海投助手 // @namespace mifox-boss-helper // @version 3.4.9 // @description BOSS直聘AI海投助手:一键批量沟通、双模式(打招呼发送自我介绍 / AI全自动回复消息,单串行worker+发送前窗口校验,杜绝回复串人)、自动发送简历、可视化数据看板 // @author 小臣 // @match *://www.zhipin.com/web/* // @require https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js // @require https://cdn.jsdelivr.net/npm/crypto-js@4.2.0/crypto-js.min.js // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @grant GM_addStyle // @grant GM_notification // @connect localhost // @connect 127.0.0.1 // @connect jasun.xyz // @connect spark-api-open.xf-yun.com // @connect api.siliconflow.cn // @connect ark.cn-beijing.volces.com // @connect api.openai.com // @connect api.deepseek.com // @connect cdn.jsdelivr.net // @run-at document-idle // ==/UserScript== (function () { "use strict"; /** * @typedef {Object} HRInteraction * @property {string} hrKey - HR唯一标识 * @property {boolean} hasGreeted - 是否已打招呼 * @property {boolean} hasSentResume - 是否已发送简历 * @property {boolean} hasSentImageResume - 是否已发送图片简历 */ /** * @typedef {Object} JobInfo * @property {string} jobId - 职位ID * @property {string} title - 职位标题 * @property {string} company - 公司名称 * @property {string} salary - 薪资范围 * @property {string} location - 工作地点 * @property {string} hrKey - HR标识 */ /** * @typedef {Object} GreetingItem * @property {string} id - 问候语ID * @property {string} content - 问候语内容 */ /** * @typedef {Object} ImageResume * @property {string} id - 图片简历ID * @property {string} name - 图片简历名称 * @property {string} data - Base64编码的图片数据 */ /** * @typedef {Object} ErrorInfo * @property {string} message - 错误消息 * @property {string} stack - 错误堆栈 * @property {string} context - 错误上下文 * @property {string} timestamp - 时间戳 */ const CONFIG = { BASIC_INTERVAL: 1000, OPERATION_INTERVAL: 1200, DELAYS: { SHORT: 30, MEDIUM_SHORT: 200, }, MINI_ICON_SIZE: 40, STORAGE_KEYS: { PROCESSED_HRS: "processedHRs", SENT_GREETINGS_HRS: "sentGreetingsHRs", SENT_RESUME_HRS: "sentResumeHRs", SENT_IMAGE_RESUME_HRS: "sentImageResumeHRs", AI_REPLY_COUNT: "aiReplyCount", LAST_AI_DATE: "lastAiDate", }, STORAGE_LIMITS: { PROCESSED_HRS: 500, SENT_GREETINGS_HRS: 500, SENT_RESUME_HRS: 300, SENT_IMAGE_RESUME_HRS: 300, }, API: { TIMEOUT: 30000, RETRY_COUNT: 3, RETRY_DELAY: 1000 }, UI: { MINI_ICON_SIZE: 40, ANIMATION_DURATION: 300, DEBOUNCE_DELAY: 300 }, PERFORMANCE: { DOM_CACHE_MAX_AGE: 5000, BATCH_SIZE: 10, CONCURRENT_LIMIT: 3 } }; const getStoredJSON = (key, defaultValue) => { try { const val = localStorage.getItem(key); return val ? JSON.parse(val) : defaultValue; } catch (e) { console.error(`Error parsing ${key}:`, e); return defaultValue; } }; // 安全地存储大文本到localStorage(自动截断) const setLargeItem = (key, value, maxLength = 500000) => { try { let textToStore = value; // 如果文本太长,截断它 if (textToStore && textToStore.length > maxLength) { console.warn(`文本太长(${textToStore.length}字符),已截断到${maxLength}字符`); textToStore = textToStore.substring(0, maxLength) + "\n[内容已截断,仅保存前" + maxLength + "字符]"; } const jsonString = JSON.stringify(textToStore); // 检查是否超过localStorage限制 if (jsonString.length > 2000000) { // 约2MB console.warn(`存储数据太大(${jsonString.length}字节),尝试进一步截断`); textToStore = textToStore.substring(0, Math.floor(maxLength / 2)) + "\n[内容已大幅截断以符合存储限制]"; } localStorage.setItem(key, JSON.stringify(textToStore)); return true; } catch (e) { if (e.name === 'QuotaExceededError' || e.message.includes('quota')) { console.error(`存储空间不足,无法保存${key}`); // 尝试保存截断版本 try { const truncated = String(value).substring(0, 100000) + "\n[因存储限制已截断]"; localStorage.setItem(key, JSON.stringify(truncated)); return 'truncated'; } catch (e2) { console.error(`即使截断后仍无法保存${key}`); return false; } } console.error(`Error saving ${key}:`, e); return false; } }; const state = { isRunning: false, currentIndex: 0, currentCityIndex: 0, includeKeywords: [], locationKeywords: [], jobList: [], ui: { isMinimized: false, theme: localStorage.getItem("theme") || "light", }, hrInteractions: { processedHRs: new Set(getStoredJSON("processedHRs", [])), sentGreetingsHRs: new Set(getStoredJSON("sentGreetingsHRs", [])), sentResumeHRs: new Set(getStoredJSON("sentResumeHRs", [])), sentImageResumeHRs: new Set(getStoredJSON("sentImageResumeHRs", [])), lastMessageTime: getStoredJSON("lastMessageTime", {}), }, ai: { replyCount: getStoredJSON("aiReplyCount", 0), lastAiDate: localStorage.getItem("lastAiDate") || "", useAiReply: true, }, settings: { useAutoSendResume: getStoredJSON("useAutoSendResume", false), actionDelays: { click: parseInt(localStorage.getItem("clickDelay") || "130"), }, ai: { role: localStorage.getItem("aiRole") || "你是一个正在BOSS直聘上与HR聊天的求职者。回复要求:简短口语化(30字内),像朋友微信聊天;有经验说经验,没经验说能力;工资说范围、到岗给时间、离职说成长;不说您好、不模板化、不写小作文。", // 免费版本:用户自定义AI API配置 apiKey: localStorage.getItem("aiApiKey") || "", apiUrl: localStorage.getItem("aiApiUrl") || "https://spark-api-open.xf-yun.com/v1/chat/completions", model: localStorage.getItem("aiModel") || "lite", useCustomApi: localStorage.getItem("useCustomApi") === "true", }, autoReply: getStoredJSON("autoReply", false), useAutoSendImageResume: getStoredJSON("useAutoSendImageResume", false), imageResumeData: localStorage.getItem("imageResumeData") || null, communicationMode: getStoredJSON("communicationMode", "ai-auto-reply"), recruiterActivityStatus: getStoredJSON( "recruiterActivityStatus", ["不限"] ), excludeHeadhunters: getStoredJSON("excludeHeadhunters", false), salaryRange: { min: 0, max: 100 }, salaryFilter: "all", experienceFilter: ["不限"], educationFilter: ["不限"], candidateExperience: getStoredJSON("candidateExperience", ""), imageResumes: getStoredJSON("imageResumes", []), resumeText: getStoredJSON("resumeText", ""), resumeAnalysis: getStoredJSON("resumeAnalysis", ""), greetingsList: getStoredJSON("greetingsList", [ ]), }, comments: { currentCompanyName: "", commentsList: [], isLoading: false, isCommentMode: false, }, applicationRecords: getStoredJSON("applicationRecords", []), }; const elements = { panel: null, controlBtn: null, log: null, includeInput: null, locationInput: null, miniIcon: null, }; class ErrorHandler { static handle(error, context = '') { const errorInfo = { message: error.message, stack: error.stack, context, timestamp: new Date().toISOString() }; console.error(`[${context}]`, error); if (state.settings && state.settings.errorReporting) { this.report(errorInfo); } return errorInfo; } static async wrap(fn, context) { try { return await fn(); } catch (error) { return this.handle(error, context); } } static report(errorInfo) { console.log('Error reported:', errorInfo); } } class DOMCache { static cache = new Map(); static maxAge = CONFIG.PERFORMANCE.DOM_CACHE_MAX_AGE; static get(selector) { const cached = this.cache.get(selector); if (cached && Date.now() - cached.time < this.maxAge) { return cached.element; } const element = document.querySelector(selector); if (element) { this.cache.set(selector, { element, time: Date.now() }); } return element; } static getAll(selector) { return document.querySelectorAll(selector); } static clear() { this.cache.clear(); } static remove(selector) { this.cache.delete(selector); } } class ManagedSet { constructor(maxSize = 500) { this.items = new Set(); this.maxSize = maxSize; } add(item) { if (this.items.size >= this.maxSize) { const firstItem = this.items.values().next().value; this.items.delete(firstItem); } this.items.add(item); } has(item) { return this.items.has(item); } delete(item) { return this.items.delete(item); } clear() { this.items.clear(); } get size() { return this.items.size; } toArray() { return Array.from(this.items); } } class EventManager { static listeners = new Map(); static add(element, event, handler, options = {}) { const key = `${element.id || element.className || element.tagName}-${event}-${Date.now()}`; if (this.listeners.has(key)) { this.remove(key); } element.addEventListener(event, handler, options); this.listeners.set(key, { element, event, handler }); return key; } static remove(key) { const listener = this.listeners.get(key); if (listener) { listener.element.removeEventListener( listener.event, listener.handler ); this.listeners.delete(key); } } static removeAll() { this.listeners.forEach((_, key) => this.remove(key)); } static getByElement(element) { const results = []; this.listeners.forEach((listener, key) => { if (listener.element === element) { results.push({ key, ...listener }); } }); return results; } } class DOMUtils { static async waitForAndAct(selector, action, options = {}) { const { timeout = 5000, retryInterval = 100, maxRetries = 3 } = options; for (let i = 0; i < maxRetries; i++) { try { const element = await Core.waitForElement(selector, timeout); if (element) { const result = await action(element); return result; } } catch (error) { if (i === maxRetries - 1) throw error; await Core.delay(retryInterval); } } return null; } static async clickElement(selector, options = {}) { return this.waitForAndAct(selector, async (element) => { await Core.simulateClick(element); return true; }, options); } static async inputText(selector, text, options = {}) { return this.waitForAndAct(selector, async (element) => { element.textContent = ""; element.focus(); document.execCommand("insertText", false, text); return true; }, options); } static debounce(fn, delay = CONFIG.UI.DEBOUNCE_DELAY) { let timer = null; return function (...args) { if (timer) clearTimeout(timer); timer = setTimeout(() => fn.apply(this, args), delay); }; } static throttle(fn, delay = CONFIG.UI.DEBOUNCE_DELAY) { let lastTime = 0; return function (...args) { const now = Date.now(); if (now - lastTime >= delay) { lastTime = now; return fn.apply(this, args); } }; } } class StorageManager { static setItem(key, value) { try { localStorage.setItem( key, typeof value === "string" ? value : JSON.stringify(value) ); return true; } catch (error) { Core.log(`设置存储项 ${key} 失败: ${error.message}`); return false; } } static getItem(key, defaultValue = null) { try { const value = localStorage.getItem(key); return value !== null ? value : defaultValue; } catch (error) { Core.log(`获取存储项 ${key} 失败: ${error.message}`); return defaultValue; } } static removeItem(key) { try { localStorage.removeItem(key); return true; } catch (error) { Core.log(`删除存储项 ${key} 失败: ${error.message}`); return false; } } static addRecordWithLimit(storageKey, record, currentSet, limit) { try { if (currentSet.has(record)) { return; } let records = this.getParsedItem(storageKey, []); records = Array.isArray(records) ? records : []; if (records.length >= limit) { records.shift(); } records.push(record); currentSet.add(record); this.setItem(storageKey, records); console.log( `存储管理: 添加记录${records.length >= limit ? "并删除最早记录" : "" },当前${storageKey}数量: ${records.length}/${limit}` ); } catch (error) { console.log(`存储管理出错: ${error.message}`); } } static getParsedItem(storageKey, defaultValue = []) { try { const data = this.getItem(storageKey); return data ? JSON.parse(data) : defaultValue; } catch (error) { Core.log(`解析存储记录出错: ${error.message}`); return defaultValue; } } static ensureStorageLimits() { const limitConfigs = [ { key: CONFIG.STORAGE_KEYS.PROCESSED_HRS, set: state.hrInteractions.processedHRs, limit: CONFIG.STORAGE_LIMITS.PROCESSED_HRS, }, { key: CONFIG.STORAGE_KEYS.SENT_GREETINGS_HRS, set: state.hrInteractions.sentGreetingsHRs, limit: CONFIG.STORAGE_LIMITS.SENT_GREETINGS_HRS, }, { key: CONFIG.STORAGE_KEYS.SENT_RESUME_HRS, set: state.hrInteractions.sentResumeHRs, limit: CONFIG.STORAGE_LIMITS.SENT_RESUME_HRS, }, { key: CONFIG.STORAGE_KEYS.SENT_IMAGE_RESUME_HRS, set: state.hrInteractions.sentImageResumeHRs, limit: CONFIG.STORAGE_LIMITS.SENT_IMAGE_RESUME_HRS, }, ]; limitConfigs.forEach(({ key, set, limit }) => { const records = this.getParsedItem(key, []); if (records.length > limit) { const trimmedRecords = records.slice(-limit); this.setItem(key, trimmedRecords); set.clear(); trimmedRecords.forEach((record) => set.add(record)); console.log( `存储管理: 清理${key}记录,从${records.length}减少到${trimmedRecords.length}` ); } }); } } class StatePersistence { static saveState() { try { const stateMap = { aiReplyCount: state.ai.replyCount, lastAiDate: state.ai.lastAiDate, useAiReply: state.ai.useAiReply, useAutoSendResume: state.settings.useAutoSendResume, useAutoSendImageResume: state.settings.useAutoSendImageResume, imageResumeData: state.settings.imageResumeData, imageResumes: state.settings.imageResumes || [], greetingsList: state.settings.greetingsList || [], theme: state.ui.theme, clickDelay: state.settings.actionDelays.click, includeKeywords: state.includeKeywords, locationKeywords: state.locationKeywords, }; Object.entries(stateMap).forEach(([key, value]) => { StorageManager.setItem(key, value); }); } catch (error) { Core.log(`保存状态失败: ${error.message}`); } } static loadState() { try { state.includeKeywords = StorageManager.getParsedItem( "includeKeywords", [] ); state.locationKeywords = StorageManager.getParsedItem("locationKeywords") || StorageManager.getParsedItem("excludeKeywords", []); const imageResumes = StorageManager.getParsedItem("imageResumes", []); if (Array.isArray(imageResumes)) state.settings.imageResumes = imageResumes; const greetingsList = StorageManager.getParsedItem("greetingsList", []); if (Array.isArray(greetingsList)) state.settings.greetingsList = greetingsList; StorageManager.ensureStorageLimits(); } catch (error) { Core.log(`加载状态失败: ${error.message}`); } } } class HRInteractionManager { /** * 从聊天界面获取岗位名称 * @returns {string} */ static getPositionNameFromChat() { try { // 尝试多种选择器获取岗位名称 const selectors = [ '.chat-header .position-name', '.chat-header .job-name', '.chat-header .name', '.chat-basic-info .name', '.chat-basic-info .position-name', '.position-card .name', '.chat-top-info .name', '.chat-top-info .position-name' ]; for (const selector of selectors) { const element = document.querySelector(selector); if (element) { const text = element.textContent.trim(); if (text && text.length > 0) { return text; } } } // 尝试从聊天标题获取 const titleEl = document.querySelector('.chat-header-title, .chat-title'); if (titleEl) { const text = titleEl.textContent.trim(); if (text) return text; } return ''; } catch (e) { return ''; } } /** * 处理HR交互 * @param {string} hrKey - HR唯一标识 * @returns {Promise} */ static async handleHRInteraction(hrKey) { // AI全自动回复消息模式:只回复 HR 的消息,不再走打招呼/自我介绍流程 if (Core.getChatMode() === "ai-auto-reply") { await Core.aiReply(); return; } if (!state.hrInteractions.sentGreetingsHRs.has(hrKey)) { await this._handleFirstInteraction(hrKey); return; } if ( !state.hrInteractions.sentResumeHRs.has(hrKey) || !state.hrInteractions.sentImageResumeHRs.has(hrKey) ) { // 「HR 是否回过话」只有这条分支用得到,所以改成用到时才读聊天记录。 // 首次打招呼那条路(上面已 return)因此完全不碰聊天历史,避免读到上一个会话的内容。 const hasResponded = await this.hasHRResponded(); if (hasResponded) { await this._handleFollowUpResponse(hrKey); } // 无论是否发送简历,都尝试AI回复 await Core.aiReply(); return; } // 所有流程完成后,调用AI回复 await Core.aiReply(); } static async _handleFirstInteraction(hrKey) { Core.log(`首次沟通: ${hrKey}`); const sentGreeting = await this.sendGreetings(); if (sentGreeting) { StorageManager.addRecordWithLimit( CONFIG.STORAGE_KEYS.SENT_GREETINGS_HRS, hrKey, state.hrInteractions.sentGreetingsHRs, CONFIG.STORAGE_LIMITS.SENT_GREETINGS_HRS ); await this._handleResumeSending(hrKey); } } static async _handleResumeSending(hrKey) { if ( state.settings.useAutoSendResume && !state.hrInteractions.sentResumeHRs.has(hrKey) ) { const sentResume = await this.sendResume(); if (sentResume) { StorageManager.addRecordWithLimit( CONFIG.STORAGE_KEYS.SENT_RESUME_HRS, hrKey, state.hrInteractions.sentResumeHRs, CONFIG.STORAGE_LIMITS.SENT_RESUME_HRS ); } } if ( state.settings.useAutoSendImageResume && !state.hrInteractions.sentImageResumeHRs.has(hrKey) ) { const sentImageResume = await this.sendImageResume(); if (sentImageResume) { StorageManager.addRecordWithLimit( CONFIG.STORAGE_KEYS.SENT_IMAGE_RESUME_HRS, hrKey, state.hrInteractions.sentImageResumeHRs, CONFIG.STORAGE_LIMITS.SENT_IMAGE_RESUME_HRS ); } } } static async _handleFollowUpResponse(hrKey) { if (this.hasCardMessage()) { const handled = await this.handleCardMessage(hrKey); if (handled) { return; } } const lastMessage = await Core.getLastFriendMessageText(); if ( lastMessage && (lastMessage.includes("简历") || lastMessage.includes("发送简历")) ) { Core.log(`HR提到"简历",发送简历: ${hrKey}`); if ( state.settings.useAutoSendImageResume && !state.hrInteractions.sentImageResumeHRs.has(hrKey) ) { const sentImageResume = await this.sendImageResume(); if (sentImageResume) { state.hrInteractions.sentImageResumeHRs.add(hrKey); StatePersistence.saveState(); Core.log(`已向 ${hrKey} 发送图片简历`); return; } } if (!state.hrInteractions.sentResumeHRs.has(hrKey)) { const sentResume = await this.sendResume(); if (sentResume) { state.hrInteractions.sentResumeHRs.add(hrKey); StatePersistence.saveState(); Core.log(`已向 ${hrKey} 发送简历`); } } } } /** * 发送自定义回复 * @param {string} replyText - 回复文本 * @returns {Promise} 是否发送成功 */ static async sendCustomReply(replyText) { try { const inputBox = await Core.waitForElement("#chat-input"); if (!inputBox) { Core.log("未找到聊天输入框"); return false; } inputBox.textContent = ""; inputBox.focus(); document.execCommand("insertText", false, replyText); await Core.delay(CONFIG.OPERATION_INTERVAL / 10); const sendButton = DOMCache.get(".btn-send"); if (sendButton) { await Core.simulateClick(sendButton); } else { const enterKeyEvent = new KeyboardEvent("keydown", { key: "Enter", keyCode: 13, code: "Enter", which: 13, bubbles: true, }); inputBox.dispatchEvent(enterKeyEvent); } return true; } catch (error) { ErrorHandler.handle(error, 'HRInteractionManager.sendCustomReply'); Core.log(`发送自定义回复出错: ${error.message}`); return false; } } static async hasHRResponded() { await Core.delay(state.settings.actionDelays.click); const chatContainer = DOMCache.get(".chat-message .im-list"); if (!chatContainer) return false; const friendMessages = Array.from( chatContainer.querySelectorAll("li.message-item.item-friend") ); return friendMessages.length > 0; } static hasCardMessage() { try { const chatContainer = DOMCache.get(".chat-message .im-list"); if (!chatContainer) return false; const friendMessages = Array.from( chatContainer.querySelectorAll("li.message-item.item-friend") ); if (friendMessages.length === 0) return false; const lastMessageEl = friendMessages[friendMessages.length - 1]; const cardWrap = lastMessageEl.querySelector(".message-card-wrap"); return cardWrap !== null; } catch (error) { Core.log(`检测卡片消息出错: ${error.message}`); return false; } } static async handleCardMessage(hrKey) { try { const chatContainer = DOMCache.get(".chat-message .im-list"); if (!chatContainer) { Core.log("未找到聊天容器"); return false; } const friendMessages = Array.from( chatContainer.querySelectorAll("li.message-item.item-friend") ); if (friendMessages.length === 0) { Core.log("未找到HR消息"); return false; } const lastMessageEl = friendMessages[friendMessages.length - 1]; const cardButtons = lastMessageEl.querySelectorAll(".card-btn"); if (!cardButtons || cardButtons.length === 0) { Core.log("未找到卡片按钮"); return false; } for (const btn of cardButtons) { if (btn.textContent.trim() === "同意") { await Core.simulateClick(btn); await Core.delay(state.settings.actionDelays.click); return true; } } Core.log(`未找到"同意"按钮`); return false; } catch (error) { Core.log(`处理卡片消息出错: ${error.message}`); return false; } } static async sendGreetings() { try { if ( !state.settings.greetingsList || state.settings.greetingsList.length === 0 ) { return false; } for (let i = 0; i < state.settings.greetingsList.length; i++) { const greeting = state.settings.greetingsList[i]; if (!greeting.content || !greeting.content.trim()) { continue; } Core.log( `发送自我介绍:第${i + 1}条/共${state.settings.greetingsList.length}条` ); await this.sendCustomReply(greeting.content); await Core.delay(state.settings.actionDelays.click); } return true; } catch (error) { Core.log(`发送自我介绍出错: ${error.message}`); return false; } } static _findMatchingResume(resumeItems, positionName) { try { const positionNameLower = positionName.toLowerCase(); const twoCharKeywords = Core.extractTwoCharKeywords(positionNameLower); for (const keyword of twoCharKeywords) { for (const item of resumeItems) { const resumeNameElement = item.querySelector(".resume-name"); if (!resumeNameElement) continue; const resumeName = resumeNameElement.textContent .trim() .toLowerCase(); if (resumeName.includes(keyword)) { const resumeNameText = resumeNameElement.textContent.trim(); Core.log(`智能匹配: "${resumeNameText}" 依据: "${keyword}"`); return item; } } } return null; } catch (error) { Core.log(`简历匹配出错: ${error.message}`); return null; } } static async sendResume() { try { const resumeBtn = await Core.waitForElement(() => { return [...document.querySelectorAll(".toolbar-btn")].find( (el) => el.textContent.trim() === "发简历" ); }); if (!resumeBtn) { Core.log("无法发送简历,未找到发简历按钮"); return false; } if (resumeBtn.classList.contains("unable")) { Core.log("对方未回复,您无权发送简历"); return false; } let positionName = Core.getPositionName(); if (!positionName) { Core.log("未找到岗位名称元素"); } await Core.simulateClick(resumeBtn); await Core.smartDelay(state.settings.actionDelays.click, "click"); await Core.smartDelay(800, "resume_load"); const confirmDialog = document.querySelector( ".panel-resume.sentence-popover" ); if (confirmDialog) { Core.log("您只有一份附件简历"); const confirmBtn = confirmDialog.querySelector(".btn-sure-v2"); if (!confirmBtn) { Core.log("未找到确认按钮"); return false; } await Core.simulateClick(confirmBtn); return true; } const resumeList = await Core.waitForElement("ul.resume-list"); if (!resumeList) { Core.log("未找到简历列表"); return false; } const resumeItems = Array.from( resumeList.querySelectorAll("li.list-item") ); if (resumeItems.length === 0) { Core.log("未找到简历列表项"); return false; } let selectedResumeItem = null; if (positionName) { selectedResumeItem = this._findMatchingResume( resumeItems, positionName ); } if (!selectedResumeItem) { selectedResumeItem = resumeItems[0]; const resumeName = selectedResumeItem .querySelector(".resume-name") .textContent.trim(); Core.log('使用第一个简历: "' + resumeName + '"'); } await Core.simulateClick(selectedResumeItem); await Core.smartDelay(state.settings.actionDelays.click, "click"); await Core.smartDelay(500, "selection"); const sendBtn = await Core.waitForElement( "button.btn-v2.btn-sure-v2.btn-confirm" ); if (!sendBtn) { Core.log("未找到发送按钮"); return false; } if (sendBtn.disabled) { Core.log("发送按钮不可用,可能简历未正确选择"); return false; } await Core.simulateClick(sendBtn); return true; } catch (error) { Core.log(`发送简历出错: ${error.message}`); return false; } } static selectImageResume(positionName) { try { const positionNameLower = positionName.toLowerCase(); if (state.settings.imageResumes.length === 1) { return state.settings.imageResumes[0]; } const twoCharKeywords = Core.extractTwoCharKeywords(positionNameLower); for (const keyword of twoCharKeywords) { for (const resume of state.settings.imageResumes) { const resumeNameLower = resume.path.toLowerCase(); if (resumeNameLower.includes(keyword)) { Core.log(`智能匹配: "${resume.path}" 依据: "${keyword}"`); return resume; } } } return state.settings.imageResumes[0]; } catch (error) { Core.log(`选择图片简历出错: ${error.message}`); return state.settings.imageResumes[0] || null; } } static async sendImageResume() { try { if ( !state.settings.useAutoSendImageResume || !state.settings.imageResumes || state.settings.imageResumes.length === 0 ) { return false; } let positionName = Core.getPositionName(); if (!positionName) { Core.log("未找到岗位名称元素"); } const imageSendBtn = await Core.waitForElement( '.toolbar-btn-content.icon.btn-sendimg input[type="file"]' ); if (!imageSendBtn) { Core.log("未找到图片发送按钮"); return false; } // 发送所有图片简历 let sentCount = 0; for (const resume of state.settings.imageResumes) { if (!resume || !resume.data) { Core.log(`跳过无效简历: ${resume?.path || 'unknown'}`); continue; } try { const byteCharacters = atob(resume.data.split(",")[1]); const byteNumbers = new Array(byteCharacters.length); for (let i = 0; i < byteCharacters.length; i++) { byteNumbers[i] = byteCharacters.charCodeAt(i); } const byteArray = new Uint8Array(byteNumbers); const blob = new Blob([byteArray], { type: "image/jpeg" }); const file = new File([blob], resume.path, { type: "image/jpeg", lastModified: new Date().getTime(), }); const dataTransfer = new DataTransfer(); dataTransfer.items.add(file); imageSendBtn.files = dataTransfer.files; const event = new Event("change", { bubbles: true }); imageSendBtn.dispatchEvent(event); sentCount++; Core.log(`已发送图片简历: ${resume.path}`); // 等待一下再发送下一张,避免过快 if (sentCount < state.settings.imageResumes.length) { await new Promise(resolve => setTimeout(resolve, 1000)); } } catch (err) { Core.log(`发送图片简历失败 ${resume.path}: ${err.message}`); } } Core.log(`共发送 ${sentCount} 张图片简历`); return sentCount > 0; } catch (error) { Core.log(`发送图片出错: ${error.message}`); return false; } } } const UI = { PAGE_TYPES: { JOB_LIST: "jobList", CHAT: "chat", }, currentPageType: null, init() { this.currentPageType = location.pathname.includes("/chat") ? this.PAGE_TYPES.CHAT : this.PAGE_TYPES.JOB_LIST; this._applyTheme(); this.createControlPanel(); this.createMiniIcon(); this.setupJobCardClickListener(); }, setupJobCardClickListener() { // 评论功能已移除 }, createControlPanel() { if (document.getElementById("boss-pro-panel")) { document.getElementById("boss-pro-panel").remove(); } elements.panel = this._createPanel(); const header = this._createHeader(); const controls = this._createPageControls(); elements.log = this._createLogger(); const footer = this._createFooter(); elements.panel.append(header, controls, elements.log, footer); document.body.appendChild(elements.panel); this._makeDraggable(elements.panel); }, _applyTheme() { CONFIG.COLORS = this.currentPageType === this.PAGE_TYPES.JOB_LIST ? this.THEMES.JOB_LIST : this.THEMES.CHAT; document.documentElement.style.setProperty( "--primary-color", CONFIG.COLORS.primary ); document.documentElement.style.setProperty( "--secondary-color", CONFIG.COLORS.secondary ); document.documentElement.style.setProperty( "--accent-color", CONFIG.COLORS.accent ); document.documentElement.style.setProperty( "--neutral-color", CONFIG.COLORS.neutral ); }, THEMES: { JOB_LIST: { primary: "#4285f4", secondary: "#f5f7fa", accent: "#e8f0fe", neutral: "#6b7280", }, CHAT: { primary: "#34a853", secondary: "#f0fdf4", accent: "#dcfce7", neutral: "#6b7280", }, }, _createPanel() { const panel = document.createElement("div"); panel.id = "boss-pro-panel"; panel.className = this.currentPageType === this.PAGE_TYPES.JOB_LIST ? "boss-joblist-panel" : "boss-chat-panel"; const baseStyles = ` position: fixed; top: 36px; right: 24px; width: clamp(300px, 80vw, 400px); border-radius: 12px; padding: 12px; font-family: 'Segoe UI', system-ui, sans-serif; z-index: 2147483647; display: flex; flex-direction: column; transition: all 0.3s ease; background: #ffffff; box-shadow: 0 10px 25px rgba(var(--primary-rgb), 0.15); border: 1px solid var(--accent-color); cursor: default; `; panel.style.cssText = baseStyles; const rgbColor = this._hexToRgb(CONFIG.COLORS.primary); document.documentElement.style.setProperty("--primary-rgb", rgbColor); return panel; }, _createHeader() { const header = document.createElement("div"); header.className = this.currentPageType === this.PAGE_TYPES.JOB_LIST ? "boss-header" : "boss-chat-header"; header.style.cssText = ` display: flex; justify-content: space-between; align-items: center; padding: 0 10px 15px; margin-bottom: 15px; border-bottom: 1px solid var(--accent-color); `; const title = this._createTitle(); const buttonContainer = document.createElement("div"); buttonContainer.style.cssText = ` display: flex; gap: 8px; `; const buttonTitles = this.currentPageType === this.PAGE_TYPES.JOB_LIST ? { ai: "AI配置", settings: "插件设置", close: "最小化海投面板", } : { ai: "AI配置", settings: "海投设置", close: "最小化聊天面板", }; // AI配置按钮图标(使用机器人/AI图标) const aiConfigIcon = ``; const aiConfigBtn = this._createIconButton( aiConfigIcon, () => { showAIConfigDialog(); }, buttonTitles.ai ); aiConfigBtn.style.color = "#fff"; aiConfigBtn.title = "AI配置"; const settingsBtn = this._createIconButton( "⚙", () => { showSettingsDialog(); }, buttonTitles.settings ); const closeBtn = this._createIconButton( "✕", () => { state.isMinimized = true; elements.panel.style.transform = "translateY(160%)"; elements.miniIcon.style.display = "flex"; }, buttonTitles.close ); // 数据看板按钮 const dashboardBtn = this._createIconButton( "📊", () => { toggleInlineDashboard(); }, "数据看板" ); dashboardBtn.title = "数据看板"; // 联系作者按钮 const contactBtn = this._createIconButton( "📱", () => { this._showContactDialog(); }, "联系作者" ); contactBtn.title = "联系作者"; buttonContainer.append(aiConfigBtn, settingsBtn, dashboardBtn, contactBtn, closeBtn); header.append(title, buttonContainer); return header; }, _createTitle() { const title = document.createElement("div"); title.style.display = "flex"; title.style.alignItems = "center"; title.style.gap = "10px"; const customSvg = ` `; const titleConfig = this.currentPageType === this.PAGE_TYPES.JOB_LIST ? { main: `AI-Boss海投助手`, sub: "高效求职 · 智能匹配", } : { main: `AI-Boss智能聊天`, sub: "智能对话 · 高效沟通", }; title.innerHTML = `
${customSvg}

${titleConfig.main}

${titleConfig.sub}
`; return title; }, _createPageControls() { if (this.currentPageType === this.PAGE_TYPES.JOB_LIST) { return this._createJobListControls(); } else { return this._createChatControls(); } }, _createJobListControls() { const container = document.createElement("div"); container.className = "boss-joblist-controls"; container.style.marginBottom = "15px"; container.style.padding = "0 10px"; const filterContainer = this._createFilterContainer(); container.append(filterContainer); return container; }, _createChatControls() { const container = document.createElement("div"); container.className = "boss-chat-controls"; container.style.cssText = ` background: var(--secondary-color); border-radius: 12px; padding: 15px; margin-left: 10px; margin-right: 10px; margin-bottom: 15px; `; const configRow = document.createElement("div"); configRow.style.cssText = ` display: flex; gap: 10px; margin-bottom: 15px; `; const communicationIncludeCol = this._createInputControl( "沟通岗位包含:", "communication-include", "如:技术,产品,设计" ); const communicationModeCol = this._createSelectControl( "沟通模式:", "communication-mode-selector", [ { value: "greeting", text: "打招呼(发送自我介绍)" }, { value: "ai-auto-reply", text: "AI全自动回复消息" }, ] ); elements.communicationIncludeInput = communicationIncludeCol.querySelector("input"); elements.communicationModeSelector = communicationModeCol.querySelector("select"); configRow.append(communicationIncludeCol, communicationModeCol); // 设置沟通模式初始值(旧值 new-only / suggested-reply / auto 统一按 AI全自动回复处理) const initialMode = localStorage.getItem("communicationMode") === "greeting" ? "greeting" : "ai-auto-reply"; settings.communicationMode = initialMode; elements.communicationModeSelector.value = initialMode; elements.communicationModeSelector.addEventListener("change", (e) => { settings.communicationMode = e.target.value; // 沟通模式即功能开关:AI全自动回复时自动打开「Ai回复模式」;打招呼模式自动关闭 const isAutoReply = e.target.value === "ai-auto-reply"; settings.autoReply = isAutoReply; localStorage.setItem("autoReply", String(isAutoReply)); const autoReplyToggleInput = document.querySelector( "#toggle-auto-reply-mode input" ); if (autoReplyToggleInput) { autoReplyToggleInput.checked = isAutoReply; } Core.log( isAutoReply ? "已切换到「AI全自动回复消息」模式:自动回复所有 HR 的消息" : "已切换到「打招呼」模式:批量发送自我介绍,不进行 AI 回复" ); saveSettings(); }); elements.communicationIncludeInput.addEventListener("input", (e) => { settings.communicationIncludeKeywords = e.target.value; saveSettings(); }); // 布局保持原样:只保留一个主按钮,不再加「AI自检」等额外按钮 elements.controlBtn = this._createTextButton( Core.getChatMode() === "greeting" ? "开始打招呼" : "开始智能回复", "var(--primary-color)", () => { toggleChatProcess(); } ); container.append(configRow, elements.controlBtn); // 自检功能保留,但不占界面:需要排障时在浏览器控制台执行 bossDiagnose() try { const w = typeof unsafeWindow !== "undefined" ? unsafeWindow : window; w.bossDiagnose = () => Core.diagnoseAiReply(); } catch (e) { /* 沙箱环境不支持时忽略,不影响其它功能 */ } return container; }, _createFilterContainer() { const filterContainer = document.createElement("div"); filterContainer.style.cssText = ` background: var(--secondary-color); border-radius: 12px; padding: 20px; margin-bottom: 0px; `; const titleRow = document.createElement("div"); titleRow.style.cssText = ` font-size: 14px; font-weight: 600; color: #333; margin-bottom: 15px; padding-bottom: 10px; border-bottom: 1px solid #e5e7eb; `; titleRow.textContent = "🎯 筛选条件"; const inputRow = document.createElement("div"); inputRow.style.cssText = ` display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 15px; `; const includeFilterCol = this._createInputControl( "职位名包含", "include-filter", "如:前端、开发" ); const locationFilterCol = this._createInputControl( "工作地包含", "location-filter", "如:北京、杭州" ); elements.includeInput = includeFilterCol.querySelector("input"); elements.locationInput = locationFilterCol.querySelector("input"); inputRow.append(includeFilterCol, locationFilterCol); const selectRow = document.createElement("div"); selectRow.style.cssText = ` display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; margin-bottom: 15px; `; const expSelect = document.createElement("select"); expSelect.id = "experience-select"; expSelect.style.cssText = ` width: 100%; padding: 10px 12px; border-radius: 8px; border: 1px solid #d1d5db; font-size: 14px; background: white; color: #333; cursor: pointer; `; const experienceOptions = [ { value: "不限", text: "经验不限", bossText: "不限" }, { value: "应届生", text: "应届生", bossText: "应届生" }, { value: "1年以内", text: "1年以内", bossText: "1年以内" }, { value: "1-3年", text: "1-3年", bossText: "1-3年" }, { value: "3-5年", text: "3-5年", bossText: "3-5年" }, { value: "5-10年", text: "5-10年", bossText: "5-10年" }, { value: "10年以上", text: "10年以上", bossText: "10年以上" }, ]; experienceOptions.forEach((opt) => { const o = document.createElement("option"); o.value = opt.value; o.textContent = opt.text; expSelect.appendChild(o); }); // 从 localStorage 读取保存的经验筛选值 expSelect.value = "不限"; const expFilterCol = document.createElement("div"); const expLabel = document.createElement("label"); expLabel.textContent = "工作经验"; expLabel.style.cssText = "display:block; margin-bottom:5px; font-weight: 500; color: #333; font-size: 0.85rem;"; expFilterCol.appendChild(expLabel); expFilterCol.appendChild(expSelect); const clickBossExpFilter = async (bossText) => { try { const expFilterBtn = Array.from(document.querySelectorAll('.filter-select-box, .filter-item, [class*="filter"], button, div')) .find(el => el.textContent?.includes('经验') || el.textContent?.includes('工作年限')); if (expFilterBtn) { expFilterBtn.click(); await new Promise(resolve => setTimeout(resolve, 500)); const options = document.querySelectorAll('.filter-select-dropdown li, .dropdown-item, [class*="option"], li'); for (const option of options) { if (option.textContent?.includes(bossText)) { option.click(); Core.log(`已选择经验: ${bossText}`); return true; } } } return false; } catch (error) { console.error('点击经验筛选失败:', error); return false; } }; expSelect.addEventListener("change", async (e) => { const selected = experienceOptions.find(opt => opt.value === e.target.value); // 保存到 localStorage if (selected && selected.value !== "不限") { await clickBossExpFilter(selected.bossText); } }); const eduSelect = document.createElement("select"); eduSelect.id = "education-select"; eduSelect.style.cssText = ` width: 100%; padding: 10px 12px; border-radius: 8px; border: 1px solid #d1d5db; font-size: 14px; background: white; color: #333; cursor: pointer; `; const educationOptions = [ { value: "不限", text: "学历不限", bossText: "不限" }, { value: "初中及以下", text: "初中及以下", bossText: "初中及以下" }, { value: "中专/中技", text: "中专/中技", bossText: "中专/中技" }, { value: "高中", text: "高中", bossText: "高中" }, { value: "大专", text: "大专", bossText: "大专" }, { value: "本科", text: "本科", bossText: "本科" }, { value: "硕士", text: "硕士", bossText: "硕士" }, { value: "博士", text: "博士", bossText: "博士" }, ]; educationOptions.forEach((opt) => { const o = document.createElement("option"); o.value = opt.value; o.textContent = opt.text; eduSelect.appendChild(o); }); // 从 localStorage 读取保存的学历筛选值 eduSelect.value = "不限"; const eduFilterCol = document.createElement("div"); const eduLabel = document.createElement("label"); eduLabel.textContent = "学历要求"; eduLabel.style.cssText = "display:block; margin-bottom:5px; font-weight: 500; color: #333; font-size: 0.85rem;"; eduFilterCol.appendChild(eduLabel); eduFilterCol.appendChild(eduSelect); const clickBossEduFilter = async (bossText) => { try { const eduFilterBtn = Array.from(document.querySelectorAll('.filter-select-box, .filter-item, [class*="filter"], button, div')) .find(el => el.textContent?.includes('学历') || el.textContent?.includes('教育')); if (eduFilterBtn) { eduFilterBtn.click(); await new Promise(resolve => setTimeout(resolve, 500)); const options = document.querySelectorAll('.filter-select-dropdown li, .dropdown-item, [class*="option"], li'); for (const option of options) { if (option.textContent?.includes(bossText)) { option.click(); Core.log(`已选择学历: ${bossText}`); return true; } } } return false; } catch (error) { console.error('点击学历筛选失败:', error); return false; } }; eduSelect.addEventListener("change", async (e) => { const selected = educationOptions.find(opt => opt.value === e.target.value); // 保存到 localStorage if (selected && selected.value !== "不限") { await clickBossEduFilter(selected.bossText); } }); const salarySelect = document.createElement("select"); salarySelect.id = "salary-filter"; salarySelect.style.cssText = ` width: 100%; padding: 10px 12px; border-radius: 8px; border: 1px solid #d1d5db; font-size: 14px; background: white; color: #333; cursor: pointer; `; const salaryOptions = [ { value: "all", text: "薪资不限", bossText: "不限" }, { value: "below-3k", text: "3K以下", bossText: "3K以下" }, { value: "3-5k", text: "3-5K", bossText: "3-5K" }, { value: "5-10k", text: "5-10K", bossText: "5-10K" }, { value: "10-20k", text: "10-20K", bossText: "10-20K" }, { value: "20-50k", text: "20-50K", bossText: "20-50K" }, { value: "50k+", text: "50K以上", bossText: "50K以上" }, ]; salaryOptions.forEach((opt) => { const o = document.createElement("option"); o.value = opt.value; o.textContent = opt.text; salarySelect.appendChild(o); }); salarySelect.value = settings.salaryFilter || "all"; const salaryFilterCol = document.createElement("div"); const salaryLabel = document.createElement("label"); salaryLabel.textContent = "薪资范围"; salaryLabel.style.cssText = "display:block; margin-bottom:5px; font-weight: 500; color: #333; font-size: 0.85rem;"; salaryFilterCol.appendChild(salaryLabel); salaryFilterCol.appendChild(salarySelect); const clickBossSalaryFilter = async (bossText) => { try { const salaryFilterBtn = Array.from(document.querySelectorAll('.filter-select-box, .filter-item, [class*="filter"], button, div')) .find(el => el.textContent?.includes('薪资') || el.textContent?.includes('薪资待遇')); if (salaryFilterBtn) { salaryFilterBtn.click(); await new Promise(resolve => setTimeout(resolve, 500)); const options = document.querySelectorAll('.filter-select-dropdown li, .dropdown-item, [class*="option"], li'); for (const option of options) { if (option.textContent?.includes(bossText)) { option.click(); Core.log(`已选择薪资: ${bossText}`); return true; } } } return false; } catch (error) { console.error('点击薪资筛选失败:', error); return false; } }; salarySelect.addEventListener("change", async (e) => { const selectedOption = salaryOptions.find(opt => opt.value === e.target.value); if (selectedOption) { settings.salaryFilter = selectedOption.value; if (selectedOption.value !== "all") { await clickBossSalaryFilter(selectedOption.bossText); } } }); selectRow.append(expFilterCol, eduFilterCol, salaryFilterCol); elements.controlBtn = this._createTextButton( "🚀 启动海投", "var(--primary-color)", () => { toggleProcess(); } ); elements.controlBtn.style.cssText += ` width: 100%; padding: 12px; font-size: 15px; font-weight: 600; border-radius: 10px; margin-top: 5px; `; filterContainer.append(titleRow, inputRow, selectRow, elements.controlBtn); return filterContainer; }, _createInputControl(labelText, id, placeholder) { const controlCol = document.createElement("div"); controlCol.style.cssText = "flex: 1;"; const label = document.createElement("label"); label.textContent = labelText; label.style.cssText = "display:block; margin-bottom:5px; font-weight: 500; color: #333; font-size: 0.9rem;"; const input = document.createElement("input"); input.id = id; input.placeholder = placeholder; input.style.cssText = ` width: 100%; padding: 8px 10px; border-radius: 8px; border: 1px solid #d1d5db; font-size: 14px; box-shadow: 0 1px 2px rgba(0,0,0,0.05); transition: all 0.2s ease; `; controlCol.append(label, input); return controlCol; }, _createSelectControl(labelText, id, options) { const controlCol = document.createElement("div"); controlCol.style.cssText = "flex: 1;"; const label = document.createElement("label"); label.textContent = labelText; label.style.cssText = "display:block; margin-bottom:5px; font-weight: 500; color: #333; font-size: 0.9rem;"; const select = document.createElement("select"); select.id = id; select.style.cssText = ` width: 100%; padding: 8px 10px; border-radius: 8px; border: 1px solid #d1d5db; font-size: 14px; background: white; color: #333; box-shadow: 0 1px 2px rgba(0,0,0,0.05); transition: all 0.2s ease; `; options.forEach((option) => { const opt = document.createElement("option"); opt.value = option.value; opt.textContent = option.text; select.appendChild(opt); }); controlCol.append(label, select); return controlCol; }, _createLogger() { const log = document.createElement("div"); log.id = "pro-log"; log.className = this.currentPageType === this.PAGE_TYPES.JOB_LIST ? "boss-joblist-log" : "boss-chat-log"; const height = this.currentPageType === this.PAGE_TYPES.JOB_LIST ? "260px" : "260px"; log.style.cssText = ` height: ${height}; overflow-y: auto; background: var(--secondary-color); border-radius: 12px; padding: 12px; font-size: 13px; line-height: 1.5; margin-bottom: 15px; margin-left: 10px; margin-right: 10px; transition: all 0.3s ease; user-select: text; scrollbar-width: thin; scrollbar-color: var(--primary-color) var(--secondary-color); `; log.innerHTML += ` `; return log; }, _createFooter() { const footer = document.createElement("div"); footer.className = this.currentPageType === this.PAGE_TYPES.JOB_LIST ? "boss-joblist-footer" : "boss-chat-footer"; footer.style.cssText = ` text-align: center; font-size: 0.8em; color: var(--neutral-color); padding-top: 15px; border-top: 1px solid var(--accent-color); margin-top: auto; padding: 0px; `; const statsContainer = document.createElement("div"); statsContainer.style.cssText = ` display: flex; justify-content: space-around; margin-bottom: 15px; `; const rewardTip = document.createElement("div"); rewardTip.style.cssText = ` margin-bottom: 10px; padding: 8px 12px; background: linear-gradient(135deg, #fff5f5 0%, #fff0f0 100%); border-radius: 8px; cursor: pointer; transition: all 0.3s ease; display: flex; align-items: center; justify-content: center; gap: 8px; `; // GitHub 桌面版横幅 const githubBanner = document.createElement("a"); githubBanner.href = "https://github.com/xiao-chen-ai/seeking-stars"; githubBanner.target = "_blank"; githubBanner.style.cssText = ` display: flex; align-items: center; justify-content: center; gap: 8px; padding: 10px 16px; margin-bottom: 12px; background: linear-gradient(135deg, #24292e 0%, #2b3137 100%); color: #fff; text-decoration: none; border-radius: 10px; font-size: 13px; font-weight: 500; transition: all 0.3s ease; cursor: pointer; `; githubBanner.innerHTML = "⭐ 觅星小臣桌面版 · AI简历 + 投递追踪 + 海投同步"; githubBanner.addEventListener("mouseenter", () => { githubBanner.style.transform = "translateY(-1px)"; githubBanner.style.boxShadow = "0 4px 12px rgba(36,41,46,0.4)"; }); githubBanner.addEventListener("mouseleave", () => { githubBanner.style.transform = "translateY(0)"; githubBanner.style.boxShadow = "none"; }); footer.append( statsContainer, githubBanner, document.createTextNode("© 2026 小臣版AI-Boss海投助手") ); return footer; }, _createTextButton(text, bgColor, onClick) { const btn = document.createElement("button"); btn.className = "boss-btn"; btn.textContent = text; btn.style.cssText = ` width: 100%; padding: 10px 16px; background: ${bgColor}; color: #fff; border: none; border-radius: 10px; cursor: pointer; font-size: 15px; font-weight: 500; transition: all 0.3s ease; display: flex; justify-content: center; align-items: center; box-shadow: 0 4px 10px rgba(0,0,0,0.1); transform: translateY(0px); margin: 0 auto; `; this._addButtonHoverEffects(btn); btn.addEventListener("click", onClick); return btn; }, _createIconButton(icon, onClick, title) { const btn = document.createElement("button"); btn.className = "boss-icon-btn"; btn.innerHTML = icon; btn.title = title; btn.style.cssText = ` width: 32px; height: 32px; border-radius: 50%; border: none; background: ${this.currentPageType === this.PAGE_TYPES.JOB_LIST ? "var(--accent-color)" : "var(--accent-color)" }; cursor: pointer; font-size: 16px; transition: all 0.2s ease; display: flex; justify-content: center; align-items: center; color: var(--primary-color); overflow: hidden; opacity: 1; `; if (icon.includes(" { btn.style.backgroundColor = "var(--primary-color)"; btn.style.color = "#fff"; btn.style.transform = "scale(1.1)"; if (icon.includes(" { btn.style.backgroundColor = this.currentPageType === this.PAGE_TYPES.JOB_LIST ? "var(--accent-color)" : "var(--accent-color)"; btn.style.color = "var(--primary-color)"; btn.style.transform = "scale(1)"; // 如果按钮包含 SVG,恢复 SVG 的原始颜色 if (icon.includes(" { btn.style.boxShadow = `0 6px 15px rgba(var(--primary-rgb), 0.3)`; }); btn.addEventListener("mouseleave", () => { btn.style.boxShadow = "0 4px 10px rgba(0,0,0,0.1)"; }); }, _showContactDialog() { // 创建遮罩层 const overlay = document.createElement("div"); overlay.style.cssText = ` position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 2147483647; display: flex; justify-content: center; align-items: center; `; // 创建对话框 const dialog = document.createElement("div"); dialog.style.cssText = ` background: white; border-radius: 12px; padding: 24px; width: 90%; max-width: 400px; box-shadow: 0 10px 40px rgba(0,0,0,0.2); animation: fadeInUp 0.3s ease-out; `; dialog.innerHTML = `
👋

联系作者

有问题欢迎进群,互帮互助,资源共享

`; overlay.appendChild(dialog); document.body.appendChild(overlay); // 关闭按钮事件 dialog.querySelector("#contact-close-btn").addEventListener("click", () => { overlay.remove(); }); // 点击遮罩关闭 overlay.addEventListener("click", (e) => { if (e.target === overlay) { overlay.remove(); } }); }, _makeDraggable(panel) { const header = panel.querySelector(".boss-header, .boss-chat-header"); if (!header) return; header.style.cursor = "move"; let isDragging = false; let startX = 0, startY = 0; let initialX = panel.offsetLeft, initialY = panel.offsetTop; header.addEventListener("mousedown", (e) => { isDragging = true; startX = e.clientX; startY = e.clientY; initialX = panel.offsetLeft; initialY = panel.offsetTop; panel.style.transition = "none"; panel.style.zIndex = "2147483647"; }); document.addEventListener("mousemove", (e) => { if (!isDragging) return; const dx = e.clientX - startX; const dy = e.clientY - startY; panel.style.left = `${initialX + dx}px`; panel.style.top = `${initialY + dy}px`; panel.style.right = "auto"; }); document.addEventListener("mouseup", () => { if (isDragging) { isDragging = false; panel.style.transition = "all 0.3s ease"; panel.style.zIndex = "2147483646"; } }); }, createMiniIcon() { elements.miniIcon = document.createElement("div"); elements.miniIcon.style.cssText = ` width: ${CONFIG.MINI_ICON_SIZE || 48}px; height: ${CONFIG.MINI_ICON_SIZE || 48}px; position: fixed; bottom: 40px; left: 40px; background: var(--primary-color); border-radius: 50%; box-shadow: 0 6px 16px rgba(var(--primary-rgb), 0.4); cursor: pointer; display: none; justify-content: center; align-items: center; color: #fff; z-index: 2147483647; transition: all 0.3s ease; overflow: hidden; `; const customSvg = ` `; elements.miniIcon.innerHTML = customSvg; elements.miniIcon.addEventListener("mouseenter", () => { elements.miniIcon.style.transform = "scale(1.1)"; elements.miniIcon.style.boxShadow = `0 8px 20px rgba(var(--primary-rgb), 0.5)`; }); elements.miniIcon.addEventListener("mouseleave", () => { elements.miniIcon.style.transform = "scale(1)"; elements.miniIcon.style.boxShadow = `0 6px 16px rgba(var(--primary-rgb), 0.4)`; }); elements.miniIcon.addEventListener("click", () => { state.isMinimized = false; elements.panel.style.transform = "translateY(0)"; elements.miniIcon.style.display = "none"; }); document.body.appendChild(elements.miniIcon); }, _hexToRgb(hex) { hex = hex.replace("#", ""); const r = parseInt(hex.substring(0, 2), 16); const g = parseInt(hex.substring(2, 4), 16); const b = parseInt(hex.substring(4, 6), 16); return `${r}, ${g}, ${b}`; }, }; const settings = { useAutoSendResume: JSON.parse( localStorage.getItem("useAutoSendResume") || "false" ), actionDelays: { click: parseInt(localStorage.getItem("clickDelay") || "130"), }, ai: { role: localStorage.getItem("aiRole") || "你是一个正在BOSS直聘上与HR聊天的求职者。回复要求:简短口语化(30字内),像朋友微信聊天;有经验说经验,没经验说能力;工资说范围、到岗给时间、离职说成长;不说您好、不模板化、不写小作文。", }, autoReply: JSON.parse(localStorage.getItem("autoReply") || "false"), useAutoSendImageResume: JSON.parse( localStorage.getItem("useAutoSendImageResume") || "false" ), imageResumeData: localStorage.getItem("imageResumeData") || null, communicationMode: localStorage.getItem("communicationMode") || "ai-auto-reply", recruiterActivityStatus: JSON.parse( localStorage.getItem("recruiterActivityStatus") || '["不限"]' ), excludeHeadhunters: JSON.parse( localStorage.getItem("excludeHeadhunters") || "false" ), }; // 沟通模式提示:greeting=打招呼(发送自我介绍) / ai-auto-reply=AI全自动回复消息 if (settings.communicationMode === "greeting") { console.log("[Boss扩展] 打招呼模式已激活:将批量发送自我介绍,不进行 AI 回复"); } else { console.log("[Boss扩展] AI全自动回复消息模式已激活:自动回复所有 HR 的消息"); } function saveSettings() { localStorage.setItem( "useAutoSendResume", settings.useAutoSendResume.toString() ); localStorage.setItem("clickDelay", settings.actionDelays.click.toString()); localStorage.setItem("aiRole", settings.ai.role); localStorage.setItem("autoReply", settings.autoReply.toString()); localStorage.setItem( "useAutoSendImageResume", settings.useAutoSendImageResume.toString() ); if (settings.imageResumes) { localStorage.setItem( "imageResumes", JSON.stringify(settings.imageResumes) ); } if (settings.imageResumeData) { localStorage.setItem("imageResumeData", settings.imageResumeData); } else { localStorage.removeItem("imageResumeData"); } localStorage.setItem( "recruiterActivityStatus", JSON.stringify(settings.recruiterActivityStatus) ); localStorage.setItem( "excludeHeadhunters", settings.excludeHeadhunters.toString() ); // 保存新增设置 localStorage.setItem("communicationMode", settings.communicationMode); if (state.settings) { Object.assign(state.settings, settings); } } function createSettingsDialog() { const dialog = document.createElement("div"); dialog.id = "boss-settings-dialog"; dialog.style.cssText = ` position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: clamp(300px, 90vw, 550px); height: 80vh; background: #ffffff; border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,0.15); z-index: 999999; display: none; flex-direction: column; font-family: 'Segoe UI', sans-serif; overflow: hidden; transition: all 0.3s ease; `; dialog.innerHTML += ` `; const dialogHeader = createDialogHeader("AI-Boss海投助手·设置"); const dialogContent = document.createElement("div"); dialogContent.style.cssText = ` padding: 18px; flex: 1; overflow-y: auto; scrollbar-width: thin; scrollbar-color: rgba(0, 123, 255, 0.5) rgba(0, 0, 0, 0.05); `; dialogContent.innerHTML += ` `; const tabsContainer = document.createElement("div"); tabsContainer.style.cssText = ` display: flex; border-bottom: 1px solid rgba(0, 123, 255, 0.2); margin-bottom: 20px; `; const aiTab = document.createElement("button"); aiTab.textContent = "聊天设置"; aiTab.className = "settings-tab active"; aiTab.style.cssText = ` padding: 9px 15px; background: rgba(0, 123, 255, 0.9); color: white; border: none; border-radius: 8px 8px 0 0; font-weight: 500; cursor: pointer; transition: all 0.2s ease; margin-right: 5px; `; const advancedTab = document.createElement("button"); advancedTab.textContent = "高级设置"; advancedTab.className = "settings-tab"; advancedTab.style.cssText = ` padding: 9px 15px; background: rgba(0, 0, 0, 0.05); color: #333; border: none; border-radius: 8px 8px 0 0; font-weight: 500; cursor: pointer; transition: all 0.2s ease; margin-right: 5px; `; tabsContainer.append(aiTab, advancedTab); const aiSettingsPanel = document.createElement("div"); aiSettingsPanel.id = "ai-settings-panel"; const roleSettingResult = createSettingItem( "AI角色定位", "定义AI在对话中的角色和语气特点", () => document.getElementById("ai-role-input") ); const roleSetting = roleSettingResult.settingItem; const roleInput = document.createElement("textarea"); roleInput.id = "ai-role-input"; roleInput.rows = 5; roleInput.style.cssText = ` width: 100%; padding: 12px; border-radius: 8px; border: 1px solid #d1d5db; resize: vertical; font-size: 14px; transition: all 0.2s ease; margin-top: 10px; opacity: 1; pointer-events: auto; `; addFocusBlurEffects(roleInput); roleSetting.append(roleInput); aiSettingsPanel.append(roleSetting); // 简历上传和分析设置 const resumeUploadSettingResult = createSettingItem( "简历上传与AI分析", "上传简历文件(PDF/Word/TXT),AI将自动分析并生成个性化回复", () => document.getElementById("resume-upload-container") ); const resumeUploadSetting = resumeUploadSettingResult.settingItem; const resumeUploadContainer = document.createElement("div"); resumeUploadContainer.id = "resume-upload-container"; resumeUploadContainer.style.cssText = ` width: 100%; margin-top: 10px; `; // 文件上传区域 const fileUploadArea = document.createElement("div"); fileUploadArea.style.cssText = ` border: 2px dashed #d1d5db; border-radius: 8px; padding: 20px; text-align: center; background: #f9fafb; cursor: pointer; transition: all 0.3s ease; `; fileUploadArea.addEventListener("mouseenter", () => { fileUploadArea.style.borderColor = "#667eea"; fileUploadArea.style.background = "#f0f4ff"; }); fileUploadArea.addEventListener("mouseleave", () => { fileUploadArea.style.borderColor = "#d1d5db"; fileUploadArea.style.background = "#f9fafb"; }); // 隐藏的文件输入 const fileInput = document.createElement("input"); fileInput.type = "file"; fileInput.accept = ".pdf,.doc,.docx,.txt"; fileInput.style.display = "none"; // 上传图标和文字 const uploadIcon = document.createElement("div"); uploadIcon.innerHTML = "📄"; uploadIcon.style.cssText = ` font-size: 48px; margin-bottom: 10px; `; const uploadText = document.createElement("div"); uploadText.textContent = "点击上传简历文件"; uploadText.style.cssText = ` font-size: 16px; color: #374151; margin-bottom: 5px; `; const uploadSubText = document.createElement("div"); uploadSubText.textContent = "支持 PDF、Word、TXT 格式"; uploadSubText.style.cssText = ` font-size: 12px; color: #6b7280; `; const resumeFileNameDisplay = document.createElement("div"); resumeFileNameDisplay.id = "resume-file-name"; resumeFileNameDisplay.style.cssText = ` margin-top: 10px; font-size: 13px; color: #667eea; font-weight: 500; `; fileUploadArea.append(uploadIcon, uploadText, uploadSubText, resumeFileNameDisplay); // 点击上传区域触发文件选择 fileUploadArea.addEventListener("click", () => { fileInput.click(); }); // 文件选择处理 fileInput.addEventListener("change", async (e) => { const file = e.target.files[0]; if (!file) return; // 检查文件类型 const validTypes = ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'text/plain']; const validExtensions = ['.pdf', '.doc', '.docx', '.txt']; const fileExtension = '.' + file.name.split('.').pop().toLowerCase(); if (!validTypes.includes(file.type) && !validExtensions.includes(fileExtension)) { showNotification("不支持的文件格式,请上传 PDF、Word 或 TXT 文件", "error"); return; } // 检查文件大小 (最大10MB) if (file.size > 10 * 1024 * 1024) { showNotification("文件太大,请上传小于 10MB 的文件", "error"); return; } resumeFileNameDisplay.textContent = `已选择: ${file.name}`; try { showNotification("正在读取文件内容...", "info"); const result = await Core.readResumeFile(file); if (result.success) { // 保存简历文本(使用安全存储) state.settings.resumeText = result.text; const saveResult = setLargeItem("resumeText", result.text); // 显示在文本框中(可编辑) resumeTextArea.value = result.text; if (saveResult === 'truncated') { showNotification(`文件读取成功!共 ${result.text.length} 字符。(内容已截断以符合存储限制)`, "warning"); } else if (saveResult === false) { showNotification(`文件读取成功!共 ${result.text.length} 字符。(无法保存到本地存储,但当前会话可用)`, "warning"); } else { showNotification(`文件读取成功!共 ${result.text.length} 字符。点击 AI分析简历 进行分析`, "success"); } } else { // 提取失败,显示部分内容和提示 if (result.text && result.text.length > 0) { resumeTextArea.value = result.text + "\n\n" + result.message; showNotification("文件内容提取不完整,请检查文本框中的提示", "warning"); } else { resumeTextArea.value = result.message; showNotification(result.message, "error"); } } } catch (error) { showNotification("读取文件失败: " + error.message, "error"); } }); // 简历文本编辑区域(读取文件后可编辑) const resumeTextLabel = document.createElement("div"); resumeTextLabel.textContent = "简历内容(可编辑):"; resumeTextLabel.style.cssText = ` font-size: 14px; font-weight: 600; color: #374151; margin-top: 15px; margin-bottom: 8px; `; // 添加提示说明 const pasteHint = document.createElement("div"); pasteHint.innerHTML = `
💡 提示:如果文件上传失败,请直接打开简历文件, Ctrl+A 全选后 Ctrl+C 复制,然后粘贴到下方文本框中。
`; const resumeTextArea = document.createElement("textarea"); resumeTextArea.id = "resume-text-input"; resumeTextArea.placeholder = "请上传简历文件,或在此直接粘贴简历内容...\n\n如果PDF/Word文件上传后显示乱码,请直接复制粘贴文本内容到这里"; resumeTextArea.value = state.settings.resumeText || ""; resumeTextArea.style.cssText = ` width: 100%; min-height: 200px; padding: 12px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 14px; resize: vertical; font-family: inherit; line-height: 1.5; `; // 按钮容器 const resumeBtnContainer = document.createElement("div"); resumeBtnContainer.style.cssText = ` display: flex; gap: 10px; margin-top: 10px; `; // AI分析按钮 const analyzeBtn = document.createElement("button"); analyzeBtn.textContent = "AI分析简历"; analyzeBtn.style.cssText = ` flex: 1; padding: 10px 16px; border-radius: 6px; border: none; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; cursor: pointer; font-size: 14px; font-weight: 600; transition: all 0.3s ease; `; analyzeBtn.addEventListener("mouseenter", () => { analyzeBtn.style.transform = "translateY(-2px)"; analyzeBtn.style.boxShadow = "0 4px 12px rgba(102, 126, 234, 0.4)"; }); analyzeBtn.addEventListener("mouseleave", () => { analyzeBtn.style.transform = "translateY(0)"; analyzeBtn.style.boxShadow = "none"; }); analyzeBtn.addEventListener("click", async () => { const resumeText = resumeTextArea.value.trim(); if (!resumeText) { showNotification("请先上传简历文件或输入简历内容", "error"); return; } // 保存简历文本(使用安全存储) state.settings.resumeText = resumeText; const saveResult = setLargeItem("resumeText", resumeText); if (saveResult === 'truncated') { showNotification("简历内容已截断以符合存储限制", "warning"); } else if (saveResult === false) { showNotification("无法保存到本地存储,但当前会话可用", "warning"); } analyzeBtn.textContent = "🔄 分析中,请稍候..."; analyzeBtn.disabled = true; try { showNotification("正在分析简历,预计需要 5-15 秒...", "info"); const startTime = Date.now(); const analysis = await Core.analyzeResumeWithAI(resumeText); const duration = ((Date.now() - startTime) / 1000).toFixed(1); if (analysis) { state.settings.resumeAnalysis = analysis; setLargeItem("resumeAnalysis", analysis); analysisResultArea.value = analysis; showNotification(`简历分析完成!耗时 ${duration} 秒`, "success"); // 询问是否生成打招呼语 if (confirm("简历分析完成!是否自动生成打招呼语?")) { analyzeBtn.textContent = "🔄 生成打招呼语..."; await Core.generateGreetingsFromResume(resumeText, analysis); } } } catch (error) { showNotification("分析失败: " + error.message, "error"); } finally { analyzeBtn.textContent = "🤖 AI分析简历"; analyzeBtn.disabled = false; } }); // 保存按钮 const saveResumeBtn = document.createElement("button"); saveResumeBtn.textContent = "保存简历"; saveResumeBtn.style.cssText = ` padding: 10px 16px; border-radius: 6px; border: 1px solid #10b981; background: rgba(16, 185, 129, 0.1); color: #10b981; cursor: pointer; font-size: 14px; font-weight: 600; transition: all 0.3s ease; `; saveResumeBtn.addEventListener("mouseenter", () => { saveResumeBtn.style.background = "rgba(16, 185, 129, 0.2)"; }); saveResumeBtn.addEventListener("mouseleave", () => { saveResumeBtn.style.background = "rgba(16, 185, 129, 0.1)"; }); saveResumeBtn.addEventListener("click", () => { state.settings.resumeText = resumeTextArea.value; const saveResult = setLargeItem("resumeText", resumeTextArea.value); if (saveResult === 'truncated') { showNotification("简历已保存(内容已截断以符合存储限制)", "warning"); } else if (saveResult === false) { showNotification("无法保存到本地存储,但当前会话可用", "warning"); } else { showNotification("简历已保存!", "success"); } }); resumeBtnContainer.append(analyzeBtn, saveResumeBtn); // 分析结果显示区域 const analysisLabel = document.createElement("div"); analysisLabel.textContent = "AI分析结果(用于智能回复):"; analysisLabel.style.cssText = ` font-size: 14px; font-weight: 600; color: #374151; margin-top: 15px; margin-bottom: 8px; `; const analysisResultArea = document.createElement("textarea"); analysisResultArea.id = "resume-analysis-result"; analysisResultArea.placeholder = "AI分析结果将显示在这里..."; analysisResultArea.value = state.settings.resumeAnalysis || ""; analysisResultArea.style.cssText = ` width: 100%; min-height: 100px; padding: 12px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 13px; resize: vertical; font-family: inherit; line-height: 1.5; background: #f9fafb; `; resumeUploadContainer.append( fileUploadArea, fileInput, pasteHint, resumeTextLabel, resumeTextArea, resumeBtnContainer, analysisLabel, analysisResultArea ); resumeUploadSetting.append(resumeUploadContainer); aiSettingsPanel.append(resumeUploadSetting); // 候选人自身工作经验设置 const candidateExperienceSettingResult = createSettingItem( "我的工作经验", "选择您的工作经验年限,用于匹配适合的职位", () => document.getElementById("candidate-experience-select .select-header") ); const candidateExperienceSetting = candidateExperienceSettingResult.settingItem; const candidateExperienceSelect = document.createElement("div"); candidateExperienceSelect.id = "candidate-experience-select"; candidateExperienceSelect.className = "custom-select"; candidateExperienceSelect.style.cssText = ` position: relative; width: 100%; margin-top: 10px; `; const candidateExpHeader = document.createElement("div"); candidateExpHeader.className = "select-header"; candidateExpHeader.style.cssText = ` display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; border-radius: 8px; border: 1px solid #e2e8f0; background: white; cursor: pointer; transition: all 0.2s ease; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); min-height: 44px; `; const candidateExpDisplay = document.createElement("div"); candidateExpDisplay.className = "select-value"; candidateExpDisplay.style.cssText = ` flex: 1; text-align: left; color: #334155; font-size: 14px; `; function getCandidateExpDisplayText() { const exp = settings.candidateExperience || ""; if (!exp) return "请选择您的工作经验"; const expMap = { "fresher": "应届生", "1year": "1年以内", "1-3year": "1-3年", "3-5year": "3-5年", "5-10year": "5-10年", "10+year": "10年以上" }; return expMap[exp] || exp; } candidateExpDisplay.textContent = getCandidateExpDisplayText(); const candidateExpIcon = document.createElement("div"); candidateExpIcon.className = "select-icon"; candidateExpIcon.innerHTML = "▼"; candidateExpIcon.style.cssText = ` margin-left: 10px; color: #64748b; transition: transform 0.2s ease; `; candidateExpHeader.append(candidateExpDisplay, candidateExpIcon); const candidateExpOptions = document.createElement("div"); candidateExpOptions.className = "select-options"; candidateExpOptions.style.cssText = ` position: absolute; top: calc(100% + 6px); left: 0; right: 0; max-height: 280px; overflow-y: auto; border-radius: 8px; border: 1px solid #e2e8f0; background: white; z-index: 100; box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); display: none; transition: all 0.2s ease; `; const candidateExpOptionsList = [ { value: "", text: "请选择工作经验" }, { value: "fresher", text: "应届生(无工作经验)" }, { value: "1year", text: "1年以内" }, { value: "1-3year", text: "1-3年" }, { value: "3-5year", text: "3-5年" }, { value: "5-10year", text: "5-10年" }, { value: "10+year", text: "10年以上" } ]; candidateExpOptionsList.forEach((option) => { const expOption = document.createElement("div"); const isSelected = settings.candidateExperience === option.value; expOption.className = "select-option" + (isSelected ? " selected" : ""); expOption.dataset.value = option.value; expOption.style.cssText = ` padding: 12px 16px; cursor: pointer; transition: all 0.2s ease; display: flex; align-items: center; font-size: 14px; color: #334155; `; const checkIcon = document.createElement("span"); checkIcon.className = "check-icon"; checkIcon.innerHTML = "✓"; checkIcon.style.cssText = ` margin-right: 8px; color: rgba(0, 123, 255, 0.9); font-weight: bold; display: ${isSelected ? "inline" : "none"}; `; const textSpan = document.createElement("span"); textSpan.textContent = option.text; expOption.append(checkIcon, textSpan); expOption.addEventListener("click", (e) => { e.stopPropagation(); settings.candidateExperience = option.value; candidateExpDisplay.textContent = getCandidateExpDisplayText(); candidateExpOptions.style.display = "none"; candidateExpIcon.style.transform = "rotate(0)"; candidateExpOptions.querySelectorAll(".select-option").forEach((opt) => { const optCheck = opt.querySelector(".check-icon"); optCheck.style.display = opt.dataset.value === option.value ? "inline" : "none"; opt.classList.toggle("selected", opt.dataset.value === option.value); }); StatePersistence.saveState(); showNotification("工作经验已保存", "success"); }); candidateExpOptions.appendChild(expOption); }); candidateExpHeader.addEventListener("click", () => { candidateExpOptions.style.display = candidateExpOptions.style.display === "block" ? "none" : "block"; candidateExpIcon.style.transform = candidateExpOptions.style.display === "block" ? "rotate(180deg)" : "rotate(0)"; }); document.addEventListener("click", (e) => { if (!candidateExperienceSelect.contains(e.target)) { candidateExpOptions.style.display = "none"; candidateExpIcon.style.transform = "rotate(0)"; } }); candidateExpHeader.addEventListener("mouseenter", () => { candidateExpHeader.style.borderColor = "rgba(0, 123, 255, 0.5)"; candidateExpHeader.style.boxShadow = "0 0 0 3px rgba(0, 123, 255, 0.1)"; }); candidateExpHeader.addEventListener("mouseleave", () => { candidateExpHeader.style.borderColor = "#e2e8f0"; candidateExpHeader.style.boxShadow = "0 1px 2px rgba(0, 0, 0, 0.05)"; }); candidateExperienceSelect.append(candidateExpHeader, candidateExpOptions); candidateExperienceSetting.append(candidateExperienceSelect); aiSettingsPanel.append(candidateExperienceSetting); // 原有的自我介绍设置(保留但折叠) const greetingsSettingResult = createSettingItem( "自我介绍(可选)", "AI分析后会自动生成,也可手动编辑", () => document.getElementById("greetings-container") ); const greetingsSetting = greetingsSettingResult.settingItem; const greetingsContainer = document.createElement("div"); greetingsContainer.id = "greetings-container"; greetingsContainer.style.cssText = ` width: 100%; margin-top: 10px; display: none; `; const toggleGreetingsBtn = document.createElement("button"); toggleGreetingsBtn.textContent = "显示/隐藏自我介绍设置"; toggleGreetingsBtn.style.cssText = ` padding: 6px 12px; border-radius: 4px; border: 1px solid #6b7280; background: rgba(107, 114, 128, 0.1); color: #6b7280; cursor: pointer; font-size: 13px; width: 100%; margin-bottom: 10px; `; toggleGreetingsBtn.addEventListener("click", () => { greetingsContainer.style.display = greetingsContainer.style.display === "none" ? "block" : "none"; }); const greetingsList = document.createElement("div"); greetingsList.id = "greetings-list"; greetingsList.style.cssText = ` max-height: 200px; overflow-y: auto; margin-bottom: 10px; border: 1px solid #d1d5db; border-radius: 8px; padding: 10px; `; const addGreetingBtn = document.createElement("button"); addGreetingBtn.textContent = "添加自我介绍"; addGreetingBtn.style.cssText = ` padding: 6px 12px; border-radius: 4px; border: 1px solid rgba(0, 123, 255, 0.7); background: rgba(0, 123, 255, 0.1); color: rgba(0, 123, 255, 0.9); cursor: pointer; font-size: 13px; transition: all 0.2s ease; width: 100%; margin-top: 8px; `; addGreetingBtn.addEventListener("mouseenter", () => { addGreetingBtn.style.backgroundColor = "rgba(0, 123, 255, 0.2)"; }); addGreetingBtn.addEventListener("mouseleave", () => { addGreetingBtn.style.backgroundColor = "rgba(0, 123, 255, 0.1)"; }); addGreetingBtn.addEventListener("click", () => { addGreetingItem(); }); greetingsContainer.append(greetingsList, addGreetingBtn); greetingsSetting.append(toggleGreetingsBtn, greetingsContainer); aiSettingsPanel.append(greetingsSetting); const advancedSettingsPanel = document.createElement("div"); advancedSettingsPanel.id = "advanced-settings-panel"; advancedSettingsPanel.style.display = "none"; const autoReplySettingResult = createSettingItem( "Ai回复模式", "与「沟通模式」联动:开启=AI全自动回复消息,关闭=打招呼发送自我介绍", () => document.querySelector("#toggle-auto-reply-mode input") ); const autoReplySetting = autoReplySettingResult.settingItem; const autoReplyDescriptionContainer = autoReplySettingResult.descriptionContainer; const autoReplyToggle = createToggleSwitch( "auto-reply-mode", settings.autoReply, (checked) => { settings.autoReply = checked; // 与「沟通模式」双向联动 settings.communicationMode = checked ? "ai-auto-reply" : "greeting"; localStorage.setItem("communicationMode", settings.communicationMode); const modeSelect = document.querySelector( "#communication-mode-selector select" ); if (modeSelect) modeSelect.value = settings.communicationMode; Core.log( checked ? "已开启 AI 全自动回复消息" : "已切换为打招呼模式(发送自我介绍,不进行 AI 回复)" ); } ); autoReplyDescriptionContainer.append(autoReplyToggle); const autoSendResumeSettingResult = createSettingItem( "自动发送附件简历", "开启后系统将自动发送附件简历给HR", () => document.querySelector("#toggle-auto-send-resume input") ); const autoSendResumeSetting = autoSendResumeSettingResult.settingItem; const autoSendResumeDescriptionContainer = autoSendResumeSettingResult.descriptionContainer; const autoSendResumeToggle = createToggleSwitch( "auto-send-resume", settings.useAutoSendResume, (checked) => { settings.useAutoSendResume = checked; } ); autoSendResumeDescriptionContainer.append(autoSendResumeToggle); const excludeHeadhuntersSettingResult = createSettingItem( "投递时排除猎头", "开启后将不会向猎头职位自动投递简历", () => document.querySelector("#toggle-exclude-headhunters input") ); const excludeHeadhuntersSetting = excludeHeadhuntersSettingResult.settingItem; const excludeHeadhuntersDescriptionContainer = excludeHeadhuntersSettingResult.descriptionContainer; const excludeHeadhuntersToggle = createToggleSwitch( "exclude-headhunters", settings.excludeHeadhunters, (checked) => { settings.excludeHeadhunters = checked; } ); excludeHeadhuntersDescriptionContainer.append(excludeHeadhuntersToggle); const imageResumeSettingResult = createSettingItem( "发送图片简历", "首次沟通发送图片简历(需先选择JPG格式图片)", () => document.querySelector("#toggle-auto-send-image-resume input") ); const imageResumeSetting = imageResumeSettingResult.settingItem; const imageResumeDescriptionContainer = imageResumeSettingResult.descriptionContainer; if (!state.settings.imageResumes) { state.settings.imageResumes = []; } const fileInputContainer = document.createElement("div"); fileInputContainer.style.cssText = ` display: flex; flex-direction: column; gap: 10px; width: 100%; margin-top: 10px; `; const addResumeBtn = document.createElement("button"); addResumeBtn.id = "add-image-resume-btn"; addResumeBtn.textContent = "添加图片简历"; addResumeBtn.style.cssText = ` padding: 8px 16px; border-radius: 6px; border: 1px solid rgba(0, 123, 255, 0.7); background: rgba(0, 123, 255, 0.1); color: rgba(0, 123, 255, 0.9); cursor: pointer; font-size: 14px; transition: all 0.2s ease; align-self: flex-start; white-space: nowrap; `; const fileNameDisplay = document.createElement("div"); fileNameDisplay.id = "image-resume-filename"; fileNameDisplay.style.cssText = ` flex: 1; padding: 8px; border-radius: 6px; border: 1px solid #d1d5db; background: #f8fafc; color: #334155; font-size: 14px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; `; const resumeCount = state.settings.imageResumes ? state.settings.imageResumes.length : 0; fileNameDisplay.textContent = resumeCount > 0 ? `已上传 ${resumeCount} 个简历` : "未选择文件"; const autoSendImageResumeToggle = (() => { const hasImageResumes = state.settings.imageResumes && state.settings.imageResumes.length > 0; const isValidState = hasImageResumes && settings.useAutoSendImageResume; if (!hasImageResumes) settings.useAutoSendImageResume = false; return createToggleSwitch( "auto-send-image-resume", isValidState, (checked) => { if ( checked && (!state.settings.imageResumes || state.settings.imageResumes.length === 0) ) { showNotification("请先选择图片文件", "error"); const slider = document.querySelector( "#toggle-auto-send-image-resume .toggle-slider" ); const container = document.querySelector( "#toggle-auto-send-image-resume .toggle-switch" ); container.style.backgroundColor = "#e5e7eb"; slider.style.transform = "translateX(0)"; document.querySelector( "#toggle-auto-send-image-resume input" ).checked = false; } settings.useAutoSendImageResume = checked; return true; }, true ); })(); const hiddenFileInput = document.createElement("input"); hiddenFileInput.id = "image-resume-input"; hiddenFileInput.type = "file"; hiddenFileInput.accept = ".jpg,.jpeg"; hiddenFileInput.style.display = "none"; const uploadedResumesContainer = document.createElement("div"); uploadedResumesContainer.id = "uploaded-resumes-container"; uploadedResumesContainer.style.cssText = ` display: flex; flex-direction: column; gap: 8px; width: 100%; `; function renderResumeItem(index, resume) { const resumeItem = document.createElement("div"); resumeItem.style.cssText = ` display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; border-radius: 6px; background: rgba(0, 0, 0, 0.05); font-size: 14px; `; const fileNameSpan = document.createElement("span"); fileNameSpan.textContent = resume.path; fileNameSpan.style.cssText = ` flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-right: 8px; `; const deleteBtn = document.createElement("button"); deleteBtn.textContent = "删除"; deleteBtn.style.cssText = ` padding: 4px 12px; border-radius: 4px; border: 1px solid rgba(255, 70, 70, 0.7); background: rgba(255, 70, 70, 0.1); color: rgba(255, 70, 70, 0.9); cursor: pointer; font-size: 12px; `; deleteBtn.addEventListener("click", () => { state.settings.imageResumes.splice(index, 1); resumeItem.remove(); if (state.settings.imageResumes.length === 0) { state.settings.useAutoSendImageResume = false; const toggleInput = document.querySelector( "#toggle-auto-send-image-resume input" ); if (toggleInput) { toggleInput.checked = false; toggleInput.dispatchEvent(new Event("change")); } } if ( typeof StatePersistence !== "undefined" && StatePersistence.saveState ) { StatePersistence.saveState(); } }); resumeItem.appendChild(fileNameSpan); resumeItem.appendChild(deleteBtn); return resumeItem; } if (state.settings.imageResumes && state.settings.imageResumes.length > 0) { state.settings.imageResumes.forEach((resume, index) => { const resumeItem = renderResumeItem(index, resume); uploadedResumesContainer.appendChild(resumeItem); }); } addResumeBtn.addEventListener("click", () => { if (state.settings.imageResumes.length >= 5) { if (typeof showNotification !== "undefined") { showNotification("免费版最多添加5个图片简历", "info"); } else { alert("免费版最多添加5个图片简历"); } } else { hiddenFileInput.click(); } }); hiddenFileInput.addEventListener("change", (e) => { if (e.target.files && e.target.files[0]) { const file = e.target.files[0]; const fileName = file.name.toLowerCase(); if (!fileName.endsWith('.jpg') && !fileName.endsWith('.jpeg')) { if (typeof showNotification !== "undefined") { showNotification("仅支持JPG格式的图片文件", "error"); } else { alert("仅支持JPG格式的图片文件"); } hiddenFileInput.value = ""; return; } const isDuplicate = state.settings.imageResumes.some( (resume) => resume.path === file.name ); if (isDuplicate) { if (typeof showNotification !== "undefined") { showNotification("该文件名已存在", "error"); } else { alert("该文件名已存在"); } return; } const reader = new FileReader(); reader.onload = function (event) { const newResume = { path: file.name, data: event.target.result, }; state.settings.imageResumes.push(newResume); const index = state.settings.imageResumes.length - 1; const resumeItem = renderResumeItem(index, newResume); uploadedResumesContainer.appendChild(resumeItem); if (!state.settings.useAutoSendImageResume) { state.settings.useAutoSendImageResume = true; const toggleInput = document.querySelector( "#toggle-auto-send-image-resume input" ); if (toggleInput) { toggleInput.checked = true; toggleInput.dispatchEvent(new Event("change")); } } if ( typeof StatePersistence !== "undefined" && StatePersistence.saveState ) { StatePersistence.saveState(); } }; reader.readAsDataURL(file); } }); fileInputContainer.append( addResumeBtn, uploadedResumesContainer, hiddenFileInput ); imageResumeDescriptionContainer.append(autoSendImageResumeToggle); imageResumeSetting.append(fileInputContainer); const recruiterStatusSettingResult = createSettingItem( "投递招聘者状态(多选)", "筛选活跃状态符合要求的招聘者进行投递", () => document.querySelector("#recruiter-status-select .select-header") ); const recruiterStatusSetting = recruiterStatusSettingResult.settingItem; const statusSelect = document.createElement("div"); statusSelect.id = "recruiter-status-select"; statusSelect.className = "custom-select"; statusSelect.style.cssText = ` position: relative; width: 100%; margin-top: 10px; `; const statusHeader = document.createElement("div"); statusHeader.className = "select-header"; statusHeader.style.cssText = ` display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; border-radius: 8px; border: 1px solid #e2e8f0; background: white; cursor: pointer; transition: all 0.2s ease; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); min-height: 44px; `; const statusDisplay = document.createElement("div"); statusDisplay.className = "select-value"; statusDisplay.style.cssText = ` flex: 1; text-align: left; color: #334155; font-size: 14px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; `; statusDisplay.textContent = getStatusDisplayText(); const statusIcon = document.createElement("div"); statusIcon.className = "select-icon"; statusIcon.innerHTML = "▼"; statusIcon.style.cssText = ` margin-left: 10px; color: #64748b; transition: transform 0.2s ease; `; const statusClear = document.createElement("button"); statusClear.className = "select-clear"; statusClear.innerHTML = "×"; statusClear.style.cssText = ` background: none; border: none; color: #94a3b8; cursor: pointer; font-size: 16px; margin-left: 8px; display: none; transition: color 0.2s ease; `; statusHeader.append(statusDisplay, statusClear, statusIcon); const statusOptions = document.createElement("div"); statusOptions.className = "select-options"; statusOptions.style.cssText = ` position: absolute; top: calc(100% + 6px); left: 0; right: 0; max-height: 240px; overflow-y: auto; border-radius: 8px; border: 1px solid #e2e8f0; background: white; z-index: 100; box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); display: none; transition: all 0.2s ease; scrollbar-width: thin; scrollbar-color: #cbd5e1 #f1f5f9; `; statusOptions.innerHTML += ` `; const statusOptionsList = [ { value: "不限", text: "不限" }, { value: "在线", text: "在线" }, { value: "刚刚活跃", text: "刚刚活跃" }, { value: "今日活跃", text: "今日活跃" }, { value: "3日内活跃", text: "3日内活跃" }, { value: "本周活跃", text: "本周活跃" }, { value: "本月活跃", text: "本月活跃" }, { value: "半年前活跃", text: "半年前活跃" }, ]; statusOptionsList.forEach((option) => { const statusOption = document.createElement("div"); statusOption.className = "select-option" + (settings.recruiterActivityStatus && Array.isArray(settings.recruiterActivityStatus) && settings.recruiterActivityStatus.includes(option.value) ? " selected" : ""); statusOption.dataset.value = option.value; statusOption.style.cssText = ` padding: 12px 16px; cursor: pointer; transition: all 0.2s ease; display: flex; align-items: center; font-size: 14px; color: #334155; `; const checkIcon = document.createElement("span"); checkIcon.className = "check-icon"; checkIcon.innerHTML = "✓"; checkIcon.style.cssText = ` margin-right: 8px; color: rgba(0, 123, 255, 0.9); font-weight: bold; display: ${settings.recruiterActivityStatus && Array.isArray(settings.recruiterActivityStatus) && settings.recruiterActivityStatus.includes(option.value) ? "inline" : "none" }; `; const textSpan = document.createElement("span"); textSpan.textContent = option.text; statusOption.append(checkIcon, textSpan); statusOption.addEventListener("click", (e) => { e.stopPropagation(); toggleStatusOption(option.value); }); statusOptions.appendChild(statusOption); }); statusHeader.addEventListener("click", () => { statusOptions.style.display = statusOptions.style.display === "block" ? "none" : "block"; statusIcon.style.transform = statusOptions.style.display === "block" ? "rotate(180deg)" : "rotate(0)"; }); statusClear.addEventListener("click", (e) => { e.stopPropagation(); settings.recruiterActivityStatus = []; updateStatusOptions(); }); document.addEventListener("click", (e) => { if (!statusSelect.contains(e.target)) { statusOptions.style.display = "none"; statusIcon.style.transform = "rotate(0)"; } }); statusHeader.addEventListener("mouseenter", () => { statusHeader.style.borderColor = "rgba(0, 123, 255, 0.5)"; statusHeader.style.boxShadow = "0 0 0 3px rgba(0, 123, 255, 0.1)"; }); statusHeader.addEventListener("mouseleave", () => { if (!statusHeader.contains(document.activeElement)) { statusHeader.style.borderColor = "#e2e8f0"; statusHeader.style.boxShadow = "0 1px 2px rgba(0, 0, 0, 0.05)"; } }); statusHeader.addEventListener("focus", () => { statusHeader.style.borderColor = "rgba(0, 123, 255, 0.7)"; statusHeader.style.boxShadow = "0 0 0 3px rgba(0, 123, 255, 0.2)"; }); statusHeader.addEventListener("blur", () => { statusHeader.style.borderColor = "#e2e8f0"; statusHeader.style.boxShadow = "0 1px 2px rgba(0, 0, 0, 0.05)"; }); statusSelect.append(statusHeader, statusOptions); recruiterStatusSetting.append(statusSelect); advancedSettingsPanel.append( autoReplySetting, autoSendResumeSetting, excludeHeadhuntersSetting, imageResumeSetting, recruiterStatusSetting ); aiTab.addEventListener("click", () => { setActiveTab(aiTab, aiSettingsPanel); }); advancedTab.addEventListener("click", () => { setActiveTab(advancedTab, advancedSettingsPanel); }); const dialogFooter = document.createElement("div"); dialogFooter.style.cssText = ` padding: 15px 20px; border-top: 1px solid #e5e7eb; display: flex; justify-content: flex-end; gap: 10px; background: rgba(0, 0, 0, 0.03); `; const cancelBtn = createTextButton("取消", "#e5e7eb", () => { dialog.style.display = "none"; }); const saveBtn = createTextButton( "保存设置", "rgba(0, 123, 255, 0.9)", () => { try { const aiRoleInput = document.getElementById("ai-role-input"); settings.ai.role = aiRoleInput ? aiRoleInput.value : ""; saveSettings(); showNotification("设置已保存"); dialog.style.display = "none"; } catch (error) { showNotification("保存失败: " + error.message, "error"); console.error("保存设置失败:", error); } } ); dialogFooter.append(cancelBtn, saveBtn); dialogContent.append( tabsContainer, aiSettingsPanel, advancedSettingsPanel ); dialog.append(dialogHeader, dialogContent, dialogFooter); dialog.addEventListener("click", (e) => { if (e.target === dialog) { dialog.style.display = "none"; } }); return dialog; } function showSettingsDialog() { let dialog = document.getElementById("boss-settings-dialog"); if (!dialog) { dialog = createSettingsDialog(); document.body.appendChild(dialog); } dialog.style.display = "flex"; setTimeout(() => { dialog.classList.add("active"); setTimeout(loadSettingsIntoUI, 100); }, 10); } function toggleStatusOption(value) { if (value === "不限") { settings.recruiterActivityStatus = settings.recruiterActivityStatus.includes("不限") ? [] : ["不限"]; } else { if (settings.recruiterActivityStatus.includes("不限")) { settings.recruiterActivityStatus = [value]; } else { if (settings.recruiterActivityStatus.includes(value)) { settings.recruiterActivityStatus = settings.recruiterActivityStatus.filter((v) => v !== value); } else { settings.recruiterActivityStatus.push(value); } if (settings.recruiterActivityStatus.length === 0) { settings.recruiterActivityStatus = ["不限"]; } } } if (state.settings) { state.settings.recruiterActivityStatus = settings.recruiterActivityStatus; } updateStatusOptions(); } function updateStatusOptions() { const options = document.querySelectorAll( "#recruiter-status-select .select-option" ); options.forEach((option) => { const isSelected = settings.recruiterActivityStatus.includes( option.dataset.value ); option.className = "select-option" + (isSelected ? " selected" : ""); option.querySelector(".check-icon").style.display = isSelected ? "inline" : "none"; if (option.dataset.value === "不限") { if (isSelected) { options.forEach((opt) => { if (opt.dataset.value !== "不限") { opt.className = "select-option"; opt.querySelector(".check-icon").style.display = "none"; } }); } } else if (settings.recruiterActivityStatus.includes("不限")) { option.querySelector(".check-icon").style.display = "none"; option.className = "select-option"; } }); document.querySelector( "#recruiter-status-select .select-value" ).textContent = getStatusDisplayText(); document.querySelector( "#recruiter-status-select .select-clear" ).style.display = settings.recruiterActivityStatus.length > 0 && !settings.recruiterActivityStatus.includes("不限") ? "inline" : "none"; if (state.settings) { state.settings.recruiterActivityStatus = settings.recruiterActivityStatus; } } function getStatusDisplayText() { if (settings.recruiterActivityStatus.includes("不限")) { return "不限"; } if (settings.recruiterActivityStatus.length === 0) { return "请选择"; } if (settings.recruiterActivityStatus.length <= 2) { return settings.recruiterActivityStatus.join("、"); } return `${settings.recruiterActivityStatus[0]}、${settings.recruiterActivityStatus[1]}等${settings.recruiterActivityStatus.length}项`; } function loadSettingsIntoUI() { const aiRoleInput = document.getElementById("ai-role-input"); if (aiRoleInput) { aiRoleInput.value = settings.ai.role; } const autoReplyInput = document.querySelector( "#toggle-auto-reply-mode input" ); if (autoReplyInput) { autoReplyInput.checked = settings.autoReply; } const autoSendResumeInput = document.querySelector( "#toggle-auto-send-resume input" ); if (autoSendResumeInput) { autoSendResumeInput.checked = settings.useAutoSendResume; } const excludeHeadhuntersInput = document.querySelector( "#toggle-exclude-headhunters input" ); if (excludeHeadhuntersInput) { excludeHeadhuntersInput.checked = settings.excludeHeadhunters; } const autoSendImageResumeInput = document.querySelector( "#toggle-auto-send-image-resume input" ); if (autoSendImageResumeInput) { autoSendImageResumeInput.checked = settings.useAutoSendImageResume && settings.imageResumes && settings.imageResumes.length > 0; } // 沟通模式(两个模式之一): // greeting = 打招呼(发送自我介绍) // ai-auto-reply = AI全自动回复消息(默认,旧值 new-only / suggested-reply / auto 都归到这一类) const communicationModeSelector = document.querySelector( "#communication-mode-selector select" ); const savedCommunicationMode = localStorage.getItem("communicationMode") === "greeting" ? "greeting" : "ai-auto-reply"; settings.communicationMode = savedCommunicationMode; localStorage.setItem("communicationMode", savedCommunicationMode); if (communicationModeSelector) { communicationModeSelector.value = savedCommunicationMode; } // 沟通模式即功能开关:同步「Ai回复模式」开关状态 const autoReplyToggleInput = document.querySelector( "#toggle-auto-reply-mode input" ); if (autoReplyToggleInput) { settings.autoReply = savedCommunicationMode === "ai-auto-reply"; localStorage.setItem("autoReply", String(settings.autoReply)); autoReplyToggleInput.checked = settings.autoReply; } if (elements.communicationIncludeInput) { elements.communicationIncludeInput.value = settings.communicationIncludeKeywords || ""; } updateStatusOptions(); } function createDialogHeader(title, dialogId = "boss-settings-dialog") { const header = document.createElement("div"); header.style.cssText = ` padding: 16px 20px; background: #4285f4; color: white; font-size: 18px; font-weight: 600; display: flex; justify-content: space-between; align-items: center; position: relative; border-radius: 12px 12px 0 0; `; const titleElement = document.createElement("div"); titleElement.textContent = title; titleElement.style.fontWeight = "600"; const closeBtn = document.createElement("button"); closeBtn.innerHTML = "✕"; closeBtn.title = "关闭"; closeBtn.style.cssText = ` width: 28px; height: 28px; background: rgba(255, 255, 255, 0.2); color: white; border-radius: 50%; display: flex; justify-content: center; align-items: center; cursor: pointer; transition: all 0.2s ease; border: none; font-size: 16px; font-weight: bold; `; closeBtn.addEventListener("mouseenter", () => { closeBtn.style.backgroundColor = "rgba(255, 255, 255, 0.3)"; closeBtn.style.transform = "scale(1.1)"; }); closeBtn.addEventListener("mouseleave", () => { closeBtn.style.backgroundColor = "rgba(255, 255, 255, 0.2)"; closeBtn.style.transform = "scale(1)"; }); closeBtn.addEventListener("click", () => { const dialog = document.getElementById(dialogId); if (dialog) { dialog.style.display = "none"; } }); header.append(titleElement, closeBtn); return header; } function showAIConfigDialog() { let dialog = document.getElementById("boss-ai-config-dialog"); if (!dialog) { dialog = createAIConfigDialog(); document.body.appendChild(dialog); } dialog.style.display = "flex"; setTimeout(() => { dialog.classList.add("active"); }, 10); } function createAIConfigDialog() { const dialog = document.createElement("div"); dialog.id = "boss-ai-config-dialog"; dialog.style.cssText = ` position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: clamp(360px, 90vw, 480px); max-height: 85vh; background: #ffffff; border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,0.2); z-index: 999999; display: none; flex-direction: column; font-family: 'Segoe UI', 'Microsoft YaHei', sans-serif; overflow: hidden; transition: all 0.3s ease; `; dialog.innerHTML = `
AI配置

配置硅基流动AI API,自动填充推荐配置

`; setTimeout(() => { // AI平台预设配置 - 仅支持硅基流动 const aiPresets = { siliconflow: { url: "https://api.siliconflow.cn/v1/chat/completions", model: "deepseek-ai/DeepSeek-V4-Flash" } }; // 加载已保存的配置 const apiKeyInput = document.getElementById("ai-api-key"); const apiUrlInput = document.getElementById("ai-api-url"); const modelInput = document.getElementById("ai-model"); const roleInput = document.getElementById("ai-role-config"); const statusDiv = document.getElementById("ai-config-status"); if (apiKeyInput) apiKeyInput.value = localStorage.getItem("aiApiKey") || ""; if (apiUrlInput) apiUrlInput.value = localStorage.getItem("aiApiUrl") || ""; if (modelInput) modelInput.value = localStorage.getItem("aiModel") || ""; if (roleInput) roleInput.value = localStorage.getItem("aiRole") || "你是一个正在BOSS直聘上与HR聊天的求职者。回复要求:简短口语化(30字内),像朋友微信聊天;有经验说经验,没经验说能力;工资说范围、到岗给时间、离职说成长;不说您好、不模板化、不写小作文。"; // 平台预设按钮点击事件 document.querySelectorAll(".ai-preset-btn").forEach(btn => { btn.addEventListener("click", () => { const preset = btn.dataset.preset; const config = aiPresets[preset]; if (config && apiUrlInput && modelInput) { apiUrlInput.value = config.url; modelInput.value = config.model; if (statusDiv) statusDiv.textContent = `已选择:${btn.textContent}`; } }); }); // 硅基流动访问按钮 const siliconflowVisitBtn = document.getElementById("siliconflow-visit-btn"); if (siliconflowVisitBtn) { siliconflowVisitBtn.addEventListener("click", () => { window.open("https://cloud.siliconflow.cn/i/8Wt6MyMe", "_blank"); }); } // 测试连接按钮 const testBtn = document.getElementById("ai-test-btn"); if (testBtn) { testBtn.addEventListener("click", async () => { const apiKey = apiKeyInput?.value?.trim(); const apiUrl = apiUrlInput?.value?.trim(); const model = modelInput?.value?.trim(); if (!apiKey || !apiUrl || !model) { if (statusDiv) { statusDiv.textContent = "请填写完整的API配置信息"; statusDiv.style.color = "#ea4335"; } return; } testBtn.disabled = true; testBtn.textContent = "测试中..."; if (statusDiv) statusDiv.textContent = "正在测试API连接..."; try { const result = await testAiConnection(apiKey, apiUrl, model); if (result.success) { if (statusDiv) { statusDiv.textContent = "✓ 连接成功!AI回复:" + result.message; statusDiv.style.color = "#34a853"; } } else { if (statusDiv) { statusDiv.textContent = "✗ 连接失败:" + result.message; statusDiv.style.color = "#ea4335"; } } } catch (error) { if (statusDiv) { statusDiv.textContent = "✗ 测试出错:" + error.message; statusDiv.style.color = "#ea4335"; } } finally { testBtn.disabled = false; testBtn.textContent = "测试连接"; } }); } // 保存配置按钮 const saveBtn = document.getElementById("ai-save-btn"); if (saveBtn) { saveBtn.addEventListener("click", () => { const apiKey = apiKeyInput?.value?.trim(); const apiUrl = apiUrlInput?.value?.trim(); const model = modelInput?.value?.trim(); const role = roleInput?.value?.trim(); if (apiKey) localStorage.setItem("aiApiKey", apiKey); if (apiUrl) localStorage.setItem("aiApiUrl", apiUrl); if (model) localStorage.setItem("aiModel", model); if (role) localStorage.setItem("aiRole", role); // 更新状态 state.settings.ai.apiKey = apiKey; state.settings.ai.apiUrl = apiUrl; state.settings.ai.model = model; state.settings.ai.role = role; if (statusDiv) { statusDiv.textContent = "✓ 配置已保存!"; statusDiv.style.color = "#34a853"; } setTimeout(() => { dialog.style.display = "none"; }, 1000); }); } }, 100); // 测试AI连接的辅助函数 async function testAiConnection(apiKey, apiUrl, model) { return new Promise((resolve) => { const testMessage = "你好,请回复'连接成功'即可。"; // 构建请求体,兼容不同API格式 const requestBody = { model: model, messages: [ { role: "user", content: testMessage } ], max_tokens: 50 }; // 只有非硅基流动API才添加system role和额外参数 if (!apiUrl.includes("siliconflow.cn")) { requestBody.messages.unshift({ role: "system", content: "你是一个 helpful assistant。" }); requestBody.temperature = 0.7; requestBody.stream = false; } // 火山引擎特殊处理 const isVolcano = apiUrl.includes("volces.com"); const headers = { "Content-Type": "application/json" }; if (isVolcano) { // 火山引擎使用API Key格式不同 headers["Authorization"] = "Bearer " + apiKey; } else { headers["Authorization"] = "Bearer " + apiKey; } GM_xmlhttpRequest({ method: "POST", url: apiUrl, headers: headers, data: JSON.stringify(requestBody), timeout: 30000, onload: (response) => { console.log("API响应:", response.status, response.responseText); try { const result = JSON.parse(response.responseText); // 检查HTTP状态码 if (response.status !== 200) { resolve({ success: false, message: `HTTP错误 ${response.status}: ${result.error?.message || result.message || "未知错误"}` }); return; } // OpenAI格式 if (result.choices && result.choices[0] && result.choices[0].message) { resolve({ success: true, message: result.choices[0].message.content.trim() }); } else if (result.choices && result.choices[0] && result.choices[0].text) { // 某些API使用text字段 resolve({ success: true, message: result.choices[0].text.trim() }); } else if (result.code !== undefined && result.code !== 0) { // 讯飞格式错误 resolve({ success: false, message: result.message || "API返回错误" }); } else if (result.error) { // 标准错误格式 resolve({ success: false, message: result.error.message || "API错误" }); } else { resolve({ success: false, message: "无法解析API响应: " + JSON.stringify(result).substring(0, 100) }); } } catch (error) { resolve({ success: false, message: "响应解析失败: " + error.message + ",原始响应: " + response.responseText.substring(0, 200) }); } }, onerror: (error) => { console.error("网络请求失败:", error); resolve({ success: false, message: "网络请求失败,请检查网络连接和API地址" }); }, ontimeout: () => { resolve({ success: false, message: "请求超时(30秒),请检查API地址是否正确" }); } }); }); } return dialog; } function createSettingItem(title, description, controlGetter) { const settingItem = document.createElement("div"); settingItem.className = "setting-item"; settingItem.style.cssText = ` padding: 15px; border-radius: 10px; margin-bottom: 15px; background: white; box-shadow: 0 1px 3px rgba(0,0,0,0.05); border: 1px solid rgba(0, 123, 255, 0.1); display: flex; flex-direction: column; `; const titleElement = document.createElement("h4"); titleElement.textContent = title; titleElement.style.cssText = ` margin: 0 0 5px; color: #333; font-size: 16px; font-weight: 500; `; const descElement = document.createElement("p"); descElement.textContent = description; descElement.style.cssText = ` margin: 0; color: #666; font-size: 13px; line-height: 1.4; `; const descriptionContainer = document.createElement("div"); descriptionContainer.style.cssText = ` display: flex; justify-content: space-between; align-items: center; width: 100%; `; const textContainer = document.createElement("div"); textContainer.append(titleElement, descElement); descriptionContainer.append(textContainer); settingItem.append(descriptionContainer); settingItem.addEventListener("click", () => { const control = controlGetter(); if (control && typeof control.focus === "function") { control.focus(); } }); return { settingItem, descriptionContainer, }; } function createToggleSwitch( id, isChecked, onChange ) { const container = document.createElement("div"); container.className = "toggle-container"; container.style.cssText = "display: flex; justify-content: space-between; align-items: center;"; const switchContainer = document.createElement("div"); switchContainer.className = "toggle-switch"; switchContainer.style.cssText = ` position: relative; width: 50px; height: 26px; border-radius: 13px; background-color: ${isChecked ? "rgba(0, 123, 255, 0.9)" : "#e5e7eb"}; cursor: pointer; `; const checkbox = document.createElement("input"); checkbox.type = "checkbox"; checkbox.id = `toggle-${id}`; checkbox.checked = isChecked; checkbox.style.display = "none"; const slider = document.createElement("span"); slider.className = "toggle-slider"; slider.style.cssText = ` position: absolute; top: 3px; left: ${isChecked ? "27px" : "3px"}; width: 20px; height: 20px; border-radius: 50%; background-color: white; box-shadow: 0 1px 3px rgba(0,0,0,0.2); transition: none; `; const forceUpdateUI = (checked) => { checkbox.checked = checked; switchContainer.style.backgroundColor = checked ? "rgba(0, 123, 255, 0.9)" : "#e5e7eb"; slider.style.left = checked ? "27px" : "3px"; }; checkbox.addEventListener("change", () => { let allowChange = true; if (onChange) { allowChange = onChange(checkbox.checked) !== false; } if (!allowChange) { forceUpdateUI(!checkbox.checked); return; } forceUpdateUI(checkbox.checked); }); switchContainer.addEventListener("click", () => { const newState = !checkbox.checked; if (onChange) { if (onChange(newState) !== false) { forceUpdateUI(newState); } } else { forceUpdateUI(newState); } }); switchContainer.append(checkbox, slider); container.append(switchContainer); return container; } function createTextButton(text, backgroundColor, onClick) { const button = document.createElement("button"); button.textContent = text; button.style.cssText = ` padding: 9px 18px; border-radius: 8px; border: none; font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.2s ease; background: ${backgroundColor}; color: white; `; button.addEventListener("click", onClick); return button; } function addFocusBlurEffects(element) { element.addEventListener("focus", () => { element.style.borderColor = "rgba(0, 123, 255, 0.7)"; element.style.boxShadow = "0 0 0 3px rgba(0, 123, 255, 0.2)"; }); element.addEventListener("blur", () => { element.style.borderColor = "#d1d5db"; element.style.boxShadow = "none"; }); } function setActiveTab(tab, panel) { const tabs = document.querySelectorAll(".settings-tab"); const panels = [ document.getElementById("ai-settings-panel"), document.getElementById("advanced-settings-panel"), ]; tabs.forEach((t) => { t.classList.remove("active"); t.style.backgroundColor = "rgba(0, 0, 0, 0.05)"; t.style.color = "#333"; }); panels.forEach((p) => { p.style.display = "none"; }); tab.classList.add("active"); tab.style.backgroundColor = "rgba(0, 123, 255, 0.9)"; tab.style.color = "white"; panel.style.display = "block"; } function showNotification(message, type = "success") { const notification = document.createElement("div"); const bgColor = type === "success" ? "rgba(40, 167, 69, 0.9)" : "rgba(220, 53, 69, 0.9)"; notification.style.cssText = ` position: fixed; top: 20px; left: 50%; transform: translateX(-50%); background: ${bgColor}; color: white; padding: 10px 15px; border-radius: 8px; box-shadow: 0 4px 10px rgba(0,0,0,0.2); z-index: 9999999; opacity: 0; transition: opacity 0.3s ease; `; notification.textContent = message; document.body.appendChild(notification); setTimeout(() => (notification.style.opacity = "1"), 10); setTimeout(() => { notification.style.opacity = "0"; setTimeout(() => document.body.removeChild(notification), 300); }, 2000); } const Core = { CONFIG, messageObserver: null, lastProcessedMessage: null, processingMessage: false, currentMonitoredHR: null, domCache: {}, // 检查薪资范围 checkSalaryRange(salaryText, range) { if (!salaryText || range.min === 0 && range.max === 100) { return true; // 未设置筛选条件,全部通过 } // 提取薪资数字(支持 10-20K、15K·14薪、20-30k 等格式) const salaryMatch = salaryText.match(/(\d+)(?:\s*-\s*(\d+))?/i); if (!salaryMatch) return true; // 无法解析,默认通过 const minSalary = parseInt(salaryMatch[1]); const maxSalary = salaryMatch[2] ? parseInt(salaryMatch[2]) : minSalary; // 检查是否在范围内 return minSalary >= range.min && maxSalary <= range.max; }, // 检查经验要求是否符合筛选条件 checkExperienceMatch(experienceText, filter) { if (!filter || filter.includes("不限")) { return true; } if (!experienceText) { return filter.includes("无要求") || filter.includes("不限"); } const expLower = experienceText.toLowerCase(); // 经验筛选映射 const experienceMapping = { "应届生": ["应届", "在校", "实习", "无经验", "经验不限"], "1年以内": ["1年", "1年以下", "不满1年", "小于1年"], "1-3年": ["1-3年", "1~3年", "1~3年", "1至3年", "1-5年", "1~5年"], "3-5年": ["3-5年", "3~5年", "3至5年", "3-10年", "3~10年"], "5-10年": ["5-10年", "5~10年", "5至10年", "10年以下"], "10年以上": ["10年以上", "10年~", "10年及以上"] }; for (const filterItem of filter) { if (filterItem === "不限") continue; const matchedKeywords = experienceMapping[filterItem] || []; const hasMatch = matchedKeywords.some(keyword => expLower.includes(keyword)); if (hasMatch) { return true; } } return false; }, getCachedElement(selector, forceRefresh = false) { if (forceRefresh || !this.domCache[selector]) { this.domCache[selector] = document.querySelector(selector); } return this.domCache[selector]; }, getCachedElements(selector, forceRefresh = false) { if (forceRefresh || !this.domCache[selector + "[]"]) { this.domCache[selector + "[]"] = document.querySelectorAll(selector); } return this.domCache[selector + "[]"]; }, clearDomCache() { this.domCache = {}; }, async startProcessing() { // 在开始投递前,先滚动加载职位 // 注意:筛选条件(工作经验、学历、薪资)已经在用户选择时同步到BOSS页面 // 不需要在这里重复设置,否则会导致之前的选择被取消 if (location.pathname.includes("/jobs")) { await this.autoScrollJobList(); // 逐条处理职位,直到完成或被停止 while (state.isRunning && state.currentIndex < (state.jobList?.length || Infinity)) { await this.processJobList(); if (!state.isRunning) break; // 如果 jobList 被重置(如切换城市),重新加载 if (!state.jobList || state.jobList.length === 0) { await this.delay(CONFIG.BASIC_INTERVAL); } await this.delay(CONFIG.BASIC_INTERVAL / 2); } } else if (location.pathname.includes("/chat")) { if (this.getChatMode() === "ai-auto-reply") { // —— AI全自动回复消息:单一串行 worker,其他入口只负责「叫醒」它 —— try { this.setupChatListObserver(); } catch (e) { this.log(`聊天列表监听启动失败: ${e.message}`); } try { Promise.resolve(this.setupMessageObserver("")).catch((e) => this.log(`当前会话监听启动失败: ${e.message}`) ); } catch (e) { this.log(`当前会话监听启动失败: ${e.message}`); } this.startAiReplyWorker(); } else { // —— 打招呼(发送自我介绍):沿用它原来的框架 —— // 进页面先处理「当前/最新」这个会话,之后完全由聊天列表监听驱动: // 列表一变(新会话、新消息、我方发完消息导致列表预览更新)就再走一次。 try { await this.processNewMessagesOnly(); } catch (e) { this.log(`当前会话预处理失败: ${e.message}`); } try { this.setupChatListObserver(); } catch (e) { this.log(`聊天列表监听启动失败: ${e.message}`); } } } else { this.log( "当前不在职位页或聊天页,自动回复/打招呼不会生效(请在 /web/geek/chat 使用)" ); } }, // 监听聊天列表变化,检测新消息并自动切换(替代轮询) setupChatListObserver() { if (this.chatListObserver) { this.chatListObserver.disconnect(); } const userList = document.querySelector(".user-list-content") || document.querySelector(".user-list") || document.querySelector(".chat-user") || document.querySelector('ul[role="group"]') || document.querySelector(".chat-list") || document.querySelector(".friend-list"); if (!userList) { this.log("⚠️ 未找到聊天列表容器,事件监听未启动(轮询监听不受影响)"); return; } this.chatListObserver = new MutationObserver(async () => { if (!state.isRunning) return; if (this.getChatMode() === "ai-auto-reply") { // AI 回复:只「叫醒」串行 worker,绝不在这里直接切窗口(避免多入口抢窗口导致回复串人) this.kickAiCycle(); return; } // 打招呼:保持原来的框架 —— 列表一变就处理当前/最新会话 await this.processNewMessagesOnly(); }); this.chatListObserver.observe(userList, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ["class"], }); this.log("聊天列表监听已启动(事件驱动,无需轮询)"); }, async processNewMessagesOnly() { // 获取当前激活的聊天:通过右侧聊天面板的标题来识别 // ⚠️ 打招呼模式用「原版读法」(legacySelectors):读不到头部时不走兜底, // 直接落到下面的「取列表最上面的会话」—— 原版就是这样追上发新消息的人的。 // 增强版的兜底(最近点击/选中项)会让方式1 永远成功, // 于是永远命中「同一个 hrKey 直接 return」,别人来的新消息就再也检测不到。 const isGreeting = this.getChatMode() === "greeting"; const activeHR = await this.getActiveChatFromHeader({ legacySelectors: isGreeting, }); let targetLi = null; let hrKey = ""; if (activeHR) { // 方式1:通过右侧聊天头部的姓名匹配左侧列表 targetLi = await this.getLatestChatLi(activeHR.name); hrKey = `${activeHR.name}-${activeHR.company}`.toLowerCase(); } if (!activeHR || !targetLi) { // 方式2:兜底取最新消息 targetLi = await this.waitForElement(this.getLatestChatLi); if (!targetLi) { this.log("未找到聊天窗口"); return; } const nameEl = targetLi.querySelector(".name-text"); const companyEl = targetLi.querySelector(".name-box span:nth-child(2)"); const name = (nameEl?.textContent || "未知").trim(); const company = (companyEl?.textContent || "").trim(); hrKey = `${name}-${company}`.toLowerCase(); } // 如果当前监控的是同一个人,跳过 if (this.currentMonitoredHR === hrKey && this.messageObserver) { return; } // 切换到新的 HR:断开旧监听 if (this.currentMonitoredHR && this.currentMonitoredHR !== hrKey) { this.log(`切换到新聊天: ${hrKey}`); } this.currentMonitoredHR = hrKey; this.resetMessageState(); if (this.messageObserver) { this.messageObserver.disconnect(); this.messageObserver = null; } // 检查关键词过滤 if ( settings.communicationIncludeKeywords && settings.communicationIncludeKeywords.trim() ) { await this.simulateClick(targetLi.querySelector(".figure")); await this.delay(CONFIG.OPERATION_INTERVAL * 2); const positionName = this.getPositionName(); const includeKeywords = settings.communicationIncludeKeywords .toLowerCase() .split(/[,]/) .map((kw) => kw.trim()) .filter((kw) => kw.length > 0); const positionNameLower = positionName.toLowerCase(); const isMatch = includeKeywords.some((keyword) => positionNameLower.includes(keyword) ); if (!isMatch) { this.log(`跳过岗位,不含关键词[${includeKeywords.join(", ")}] - ${positionName}`); return; } } // 点击并处理 if (!targetLi.classList.contains("last-clicked")) { await this.simulateClick(targetLi.querySelector(".figure")); targetLi.classList.add("last-clicked"); await this.delay(CONFIG.OPERATION_INTERVAL); await HRInteractionManager.handleHRInteraction(hrKey); } // 设置消息监听 await this.setupMessageObserver(hrKey); this.log(`正在监听新消息: ${hrKey}`); }, // 从右侧聊天面板头部获取当前激活的 HR 信息 // 文本规范化小工具 normText(s) { return String(s == null ? "" : s).replace(/\s+/g, " ").trim(); }, chatTextOf(el) { return el && el.textContent ? this.normText(el.textContent) : ""; }, // 从任意文本里解析 HR 姓名:「罗先生-蓝思科技」→「罗先生」;「陈露·长沙蓝博」→「陈露」 parseNameFromText(text) { let t = this.normText(text).replace(/\s+/g, ""); if (!t) return ""; // 会话列表项文本形如「15:58钟立辉湖南中科智联科技总监esp32方面呢」, // 开头的时间/日期不是姓名的一部分,先剥掉再解析(否则会解析出「8钟立辉…总监」这种脏名字) t = t .replace(/^\d{1,2}:\d{2}/, "") .replace(/^(昨天|前天|今天|星期[一二三四五六日天]|周[一二三四五六日天])/, "") .replace(/^\d{1,2}[-/月]\d{1,2}日?/, ""); if (!t) return ""; const m = t.match( /([\u4e00-\u9fa5A-Za-z0-9]{1,12}?)(先生|女士|小姐|老师|同学|经理|总监|HR|hr|boss)/ ); if (m) return m[1] + m[2]; const seg = t.split(/[-·||/、,,\s]/)[0] || ""; return seg && seg.length <= 15 ? seg : ""; }, // 从任意文本里解析公司名:「罗先生-蓝思科技」→「蓝思科技」 parseCompanyFromText(text) { const t = this.normText(text); if (!t) return ""; const segs = t .split(/[-·||/、,,]/) .map((s) => s.trim()) .filter(Boolean); if (segs.length < 2) return ""; const cand = segs[1]; return cand && cand.length <= 30 ? cand : ""; }, // 两个姓名是否指向同一位 HR(容忍「罗先生」vs「罗先生-蓝思科技」) isSameHr(a, b) { const norm = (s) => String(s == null ? "" : s) .replace(/\s+/g, "") .replace(/[-·||/、,,]/g, "") .trim(); const na = norm(a); const nb = norm(b); if (!na || !nb) return true; // 信息不足时不拦截,由「消息内容一致性」兜底 if (na === nb || na.includes(nb) || nb.includes(na)) return true; const ca = norm(this.parseNameFromText(a)); const cb = norm(this.parseNameFromText(b)); return !!ca && !!cb && (ca === cb || ca.includes(cb) || cb.includes(ca)); }, // BOSS 页面上「AI · Boss智能聊天 / 智能对话」这一类常驻节点的文字。 // ⚠️ 本轮故障根因:宽泛的头部选择器([class*='chat-header'] 等)会命中这类常驻节点, // 于是不管切到哪个会话,读到的永远是「AI」,造成两处连锁误判: // ① 打开会话的校验永远失败 → 日志「点击后窗口仍是「AI」,本轮放弃」; // ② 发送前二次校验认为「当前窗口是 AI ≠ 目标 HR」→ 直接放弃发送。 // 结果就是:窗口其实已经切过去了,但一条都不回复。 isBogusHeaderText(text) { const t = this.normText(text).replace(/\s+/g, ""); if (!t) return true; return /智能聊天|智能对话|Boss智能|BOSS智能|AI助手|智能助手|求职助手|简历助手|系统消息|官方账号|^AI$|^系统$/.test( t ); }, // 页面上是否存在这种「常驻 AI 节点」(仅用于自检提示,便于以后一眼看出问题) findBogusHeaderText() { try { const sels = [ ".chat-header", ".chat-title", ".chat-content-header", ".message-top", ".im-header", "[class*='chat-header']", "[class*='chatHeader']", ]; for (const sel of sels) { for (const el of document.querySelectorAll(sel)) { const t = this.chatTextOf(el); if (t && this.isBogusHeaderText(t)) return t; } } } catch (e) { /* 忽略 */ } return ""; }, // 会话列表项是否处于「选中 / 激活」状态(当前打开的会话会被标记) isConversationItemActive(item) { try { if (!item || !item.classList) return false; const cls = Array.from(item.classList).join(" ").toLowerCase(); if (/\b(active|selected|current|checked)\b/.test(cls)) return true; if (item.getAttribute("aria-selected") === "true") return true; const p = item.parentElement; if (p && p.classList) { const pc = Array.from(p.classList).join(" ").toLowerCase(); if (/\b(active|selected|current)\b/.test(pc)) return true; } // 有些改版把选中样式加在 li 内部的子节点上 if ( item.querySelector( "[class*='active'][class*='item'], [class*='selected'], [class*='current']" ) ) { return true; } return false; } catch (e) { return false; } }, // 当前处于「选中态」的会话列表项 → 解析出 HR 姓名。 // 用作「窗口标题读不到」时的可靠替代信号(BOSS 会给当前打开的会话项加选中样式)。 // 只有当「恰好只有一个」会话项处于选中态时才返回姓名; // 若同时命中多个(class 规则误判),宁可不返回,交给内容校验兜底,避免拿错名字拦下正确回复。 getActiveConversationName() { try { let items = this.collectConversationNodes({ requireAvatar: true }); if (!items.length) { items = this.collectConversationNodes({ requireAvatar: false }); } const actives = items.filter((li) => this.isConversationItemActive(li)); if (actives.length !== 1) return ""; return this.parseNameFromText(this.chatTextOf(actives[0])) || ""; } catch (e) { /* 忽略 */ } return ""; }, // 从列表项文本里剥离「时间 + 姓名 + 公司」,剩下的就是最后一条消息预览 parsePreviewFromItemText(itemText, name, company) { let t = this.normText(itemText); if (!t) return ""; t = t .replace(/^\s*\d{1,2}:\d{2}\s*/, "") .replace(/^\s*(昨天|前天|今天|星期[一二三四五六日天])\s*/, "") .replace(/^\s*\d{1,2}[-/月]\d{1,2}日?\s*/, ""); // ⚠️ 关键:只能剥掉「第一处」姓名 / 公司。 // BOSS 的列表项形如「16:16全先生淼知教育人事主管淼知教育邀请您现」—— // 公司名在「公司字段」和「消息正文」里各出现一次。若用 split/join 全部剥掉, // 会把消息正文里的公司名也删掉,预览被削成「人事主管邀请您现」, // 于是与聊天窗口内容「对不上」,发送前校验误判「串人」→ 放弃发送。 // (这就是「全先生」一直不回复、何慕冰却能正常回复的根因) const stripFirst = (s) => { const v = this.normText(s); if (v) t = t.replace(v, ""); }; stripFirst(name); stripFirst(company); return this.normText(t); }, // 内容校验(正向):当前聊天窗口里的消息,和「我点的那个列表项」能对上吗? // 列表项预览里就含着最后一条消息,用它反查窗口内容,比读页面标题可靠得多。 historyMatchesPreview(history, preview) { try { const p = this.normText(preview).replace(/\s+/g, ""); if (!p || p.length < 3) return false; for (const m of history.slice(-2)) { const t = this.normText(m && m.text).replace(/\s+/g, ""); if (!t || t.length < 3) continue; const a = t.slice(0, Math.min(8, t.length)); if (a.length >= 3 && p.includes(a)) return true; const b = p.slice(0, Math.min(8, p.length)); if (b.length >= 3 && t.includes(b)) return true; } return false; } catch (e) { return false; } }, // 内容校验(反向):能确定「窗口里这个人不是我点的那个人」时返回 true。 // 刻意做到「极难判冲突」:只有预览足够长(≥8 字),且预览与窗口里最近两条消息 // 「连 4 个字都完全重合不上」时才判为冲突。 // 教训:这条校验只要稍微激进一点,就会把「列表预览被裁剪 / 更新滞后」误伤成 // 「串人」,结果一条消息都不回复(比回复串人更糟)。所以宁可漏判,不可误判。 historyContradictsPreview(history, preview) { try { const p = this.normText(preview).replace(/\s+/g, ""); if (!p || p.length < 8) return false; const tail = history.slice(-2).filter((m) => m && m.text); if (!tail.length) return false; // 是否存在长度 ≥ n 的连续公共子串 const hasCommonRun = (a, b, n) => { if (a.length < n || b.length < n) return false; for (let i = 0; i + n <= a.length; i++) { if (b.includes(a.slice(i, i + n))) return true; } return false; }; for (const m of tail) { const t = this.normText(m.text).replace(/\s+/g, ""); if (!t || t.length < 3) continue; if (hasCommonRun(p, t, 4)) return false; // 有 4 字连续重合 → 认为对得上 } return true; } catch (e) { return false; } }, // ⚠️ 历史故障点:原来只认 `.chat-header .name-text`,页面结构一变就永远读到空, // 上层把「读不到姓名」误判成「未找到会话」,导致一条都不回复。 // 现在改为:头部容器多选择器 → 节点取不到就从头部整段文本解析 → 再兜底用最近点击的会话项 / 页面选中项 // allowClickedFallback=false 时,不使用「最近点击的列表项」兜底, // 只返回页面上真实读到的姓名(供「打开会话后是否真的切过去了」的严格校验使用) async getActiveChatFromHeader({ allowClickedFallback = true, legacySelectors = false, } = {}) { try { // 打招呼模式专用:完全复刻原版的读法 —— 只认原版那几个窄选择器, // 读不到就返回 null(不走路由兜底)。原版正是靠「读不到 → 取列表最上面 // 的会话」来追上发新消息的人,这一环不能被增强版挡掉。 if (legacySelectors) { const nameSels = [ ".chat-header .name-text", ".chat-header .name", ".chat-title .name", ".header-name", ".friend-name", ]; const companySels = [ ".chat-header .company-name", ".chat-header .company", ".chat-title .company", ".name-box span:nth-child(2)", ]; let nameEl = null; for (const sel of nameSels) { nameEl = document.querySelector(sel); if (nameEl && nameEl.textContent.trim()) break; } let companyEl = null; for (const sel of companySels) { companyEl = document.querySelector(sel); if (companyEl && companyEl.textContent.trim()) break; } const name = (nameEl?.textContent || "").trim(); const company = (companyEl?.textContent || "").trim(); if (name && !this.isBogusHeaderText(name)) { return { name, company }; } return null; } // ⚠️ 关键修复:头部只在「聊天面板」范围内找,不再全页撒网。 // 之前用 document.querySelectorAll("[class*='chat-header']") 会在整页匹配, // 命中左侧会话列表里的「AI · Boss智能聊天」常驻节点 —— 于是不管切到谁, // 读到的标题永远是「AI」,打开会话校验与发送前校验双双误判,一条都不回。 // 现在以「聊天记录容器」为锚点,只在它的祖先链(即聊天面板)里找头部。 const container = this.getChatContainer(); const roots = []; if (container) { let el = container; for (let i = 0; i < 5 && el && el.parentElement; i++) { el = el.parentElement; const tag = (el.tagName || "").toUpperCase(); if (tag === "BODY" || tag === "HTML") break; roots.push(el); } } roots.push(document); // 容器还没渲染出来时,退回全页(老行为) const headerSels = [ ".chat-header", ".chat-title", ".chat-content-header", ".chat-main .header", ".chat-record-top", ".message-top", ".im-header", ".friend-header", ".chat-conversation-header", ".name-text", ".friend-name", "[class*='chat-header']", "[class*='chatHeader']", "[class*='name-text']", ]; let header = null; for (const root of roots) { for (const sel of headerSels) { let list; try { list = root.querySelectorAll(sel); } catch (e) { continue; // 个别选择器不被支持时跳过 } for (const el of list) { const t = this.chatTextOf(el); if (!t) continue; if (this.isBogusHeaderText(t)) continue; // 头部不能是消息记录容器本身(或它的祖先),否则会把整屏聊天内容当成标题 if (container && (el === container || el.contains(container))) continue; header = el; break; } if (header) break; } if (header) break; } let name = ""; let company = ""; if (header) { const nameEl = header.querySelector( ".name-text, .name, .friend-name, .header-name, [class*='name']" ); name = this.chatTextOf(nameEl); const companyEl = header.querySelector( ".company-name, .company, .brand-name, .name-box span:nth-child(2), [class*='company'], [class*='brand']" ); company = this.chatTextOf(companyEl); if (!name) { const t = this.chatTextOf(header); name = this.parseNameFromText(t); if (!company) company = this.parseCompanyFromText(t); } } // 兜底 1:最近一次点击打开的会话列表项 if (!name && allowClickedFallback && this.lastClickedConversationText) { const t = this.lastClickedConversationText; name = this.parseNameFromText(t); if (!company) company = this.parseCompanyFromText(t); } // 兜底 2:页面上被标记为「选中/激活」的会话列表项 if (!name) { const activeSels = [ "ul[role='group'] li.active", "ul[role='group'] li.selected", ".user-list-content li.active", ".user-list li.active", "[class*='user-item'][class*='active']", "[class*='friend'][class*='active']", "[class*='conversation'][class*='active']", ]; for (const sel of activeSels) { const el = document.querySelector(sel); if (!el) continue; const t = this.chatTextOf(el); const n = this.parseNameFromText(t); if (n) { name = n; if (!company) company = this.parseCompanyFromText(t); break; } } } // ⚠️ 名字必须做「无效值」清洗:读到「AI」「系统」这类伪会话名时,宁可当作读不到, // 交给「点击的列表项 + 聊天内容一致性」去校验,也不能返回一个假名字把流程卡死。 if (name && (this.isBogusHeaderText(name) || name.length < 2)) { name = ""; company = ""; } if (name || company) { // 头部节点可能是个"名字+职位+公司"的容器,文本过长时解析成纯姓名 if (name && name.length > 15) name = this.parseNameFromText(name) || name; if (company && company.length > 30) { company = this.parseCompanyFromText(company) || company; } return { name, company }; } } catch (e) { // 交由上层兜底处理 } return null; }, async getCurrentHRName() { // 尝试多种选择器获取HR名称 const selectors = [ '.chat-header .name-text', '.chat-header .name', '.chat-title .name', '.chat-basic-info .name', '.header-name', '.user-name', '.friend-name', '.chat-user-name', '.name-box .name-text', '[class*="name"]' ]; for (const selector of selectors) { const element = document.querySelector(selector); if (element) { const text = element.textContent.trim(); if (text && text.length > 0 && text !== "未知") { return text; } } } return "未知"; }, async getCurrentHRCompany() { // 尝试多种选择器获取公司名称 const selectors = [ '.chat-header .company-name', '.chat-header .company', '.chat-title .company', '.chat-basic-info .company', '.company-name', '.user-company', '.friend-company', '.name-box span:nth-child(2)', '[class*="company"]' ]; for (const selector of selectors) { const element = document.querySelector(selector); if (element) { const text = element.textContent.trim(); if (text && text.length > 0) { return text; } } } return ""; }, async scrollToLoadAllChats() { // 找到聊天列表容器 - 使用截图中的class const userListContent = document.querySelector(".user-list-content") || document.querySelector(".chat-list") || document.querySelector('ul[role="group"]') || document.querySelector(".friend-list") || document.querySelector(".message-list"); if (!userListContent) { this.log("未找到聊天列表容器"); return; } this.log("正在加载更多聊天窗口..."); let previousCount = 0; let sameCountTimes = 0; const maxAttempts = 10; for (let i = 0; i < maxAttempts; i++) { if (!state.isRunning) break; // 使用截图中的class: friend-content const currentItems = userListContent.querySelectorAll('.friend-content, .friend-content.selected'); const currentCount = currentItems.length; this.log(`已加载 ${currentCount} 个聊天窗口...`); if (currentCount === previousCount) { sameCountTimes++; if (sameCountTimes >= 3) { this.log("聊天窗口加载完成"); break; } } else { sameCountTimes = 0; } previousCount = currentCount; // 滚动到底部加载更多 userListContent.scrollTo({ top: userListContent.scrollHeight, behavior: 'smooth' }); await this.delay(1000); } // 滚动回顶部 userListContent.scrollTo({ top: 0, behavior: 'smooth' }); await this.delay(500); }, // 获取聊天面板消息容器(多选择器兜底) getChatMessageContainer() { // 首先尝试找到聊天详情区域的容器(不是左侧列表) // BOSS直聘结构:左侧是聊天列表,右侧是聊天详情 const chatDetailSelectors = [ '.chat-message .im-list', '.chat-content .im-list', '.chat-detail .im-list', '.chat-window .im-list', '.message-container .im-list', '.chat-message [class*="im-list"]', '.chat-content [class*="im-list"]', '[class*="chat-detail"] [class*="im-list"]', '[class*="chat-message"] [class*="im-list"]', '[class*="chat-content"] [class*="im-list"]', ]; for (const sel of chatDetailSelectors) { const el = document.querySelector(sel); if (el && el.querySelectorAll('li').length > 0) { // 验证这是消息列表而不是聊天列表 // 消息列表的li应该有 message-item 类 const firstLi = el.querySelector('li'); if (firstLi && (firstLi.className.includes('message-item') || firstLi.className.includes('item-'))) { return el; } } } // 备选:查找包含消息项的容器 const allImLists = document.querySelectorAll('.im-list, [class*="im-list"]'); for (const el of allImLists) { if (el.querySelectorAll('li').length > 0) { const firstLi = el.querySelector('li'); const liClass = firstLi?.className || ''; // 确保是消息列表(包含 item-myself, item-friend, item-system 等) if (liClass.includes('item-myself') || liClass.includes('item-friend') || liClass.includes('item-system') || liClass.includes('message-item')) { return el; } } } return null; }, // 渐进式向上滚动聊天面板,触发虚拟列表加载历史消息 async scrollChatToLoadHistory() { const imList = this.getChatMessageContainer(); if (!imList) return; const initialCount = imList.querySelectorAll('li').length; const maxScrolls = 20; let lastMsgCount = initialCount; for (let i = 0; i < maxScrolls; i++) { const currentTop = imList.scrollTop; if (currentTop <= 0) break; // 已到顶部 // 向上滚动一大步 imList.scrollBy({ top: -600, behavior: 'instant' }); await this.delay(500); const newMsgCount = imList.querySelectorAll('li').length; if (newMsgCount > lastMsgCount) { lastMsgCount = newMsgCount; } else if (i >= 3 && newMsgCount === lastMsgCount) { // 连续几次没新消息,可能已经加载完毕 if (imList.scrollTop <= 0) break; } } // 最后确保滚到顶部 imList.scrollTo({ top: 0, behavior: 'instant' }); await this.delay(300); const finalCount = imList.querySelectorAll('li').length; if (finalCount > initialCount) { this.log(`[历史加载] 消息从${initialCount}条增加到${finalCount}条`); } }, async handleSingleChat(hrKey, positionName) { try { // 获取最后一条HR消息 const lastMessage = await this.getLastFriendMessageText(); if (!lastMessage) { this.log(`${hrKey}: 未找到HR消息`); return; } this.log(`${hrKey} 的最后消息: ${lastMessage.slice(0, 30)}...`); // 检查是否已经回复过这条消息 const messageKey = `${hrKey}-${this.cleanMessage(lastMessage)}`; if (this.lastProcessedMessage === messageKey) { this.log(`${hrKey}: 已回复过此消息`); return; } // 检测HR是否明确拒绝 const rejectionPatterns = [ "不合适", "不符合", "不需要", "暂时不招", "已招满", "谢谢", "再见", "不合适", "不符合条件", "不符合要求", "我们要求", "您的条件", "经验不足", "学历不够", "暂不匹配", "不匹配", "不太合适", "不是很合适", "暂时没有", "暂无需求", "没有需求", "不需要人", "已经招到", "已经招满", "编制已满", "坑位已满", "已结束", "已截止", "职位已下", "岗位已撤" ]; const isRejection = rejectionPatterns.some(pattern => lastMessage.includes(pattern) ); if (isRejection) { this.log(`检测到HR拒绝,停止对话: "${lastMessage.slice(0, 30)}..."`); // 关闭聊天窗口 const chatItem = document.querySelector(`[data-hr-key="${hrKey}"]`); if (chatItem) { const closeBtn = chatItem.querySelector(".close-btn, [class*='close']"); if (closeBtn) closeBtn.click(); } return; } // 生成个性化回复 const replyText = await this.generatePersonalizedReply(lastMessage); if (!replyText) { this.log(`${hrKey}: 无法生成回复`); return; } // 发送回复 const inputBox = await this.waitForElement("#chat-input"); if (!inputBox) { this.log(`${hrKey}: 未找到输入框`); return; } inputBox.textContent = ""; inputBox.focus(); document.execCommand("insertText", false, replyText); await this.delay(500); const sendButton = document.querySelector(".btn-send"); if (sendButton) { await this.simulateClick(sendButton); this.log(`已回复 ${hrKey}: ${replyText.slice(0, 30)}...`); // 记录已处理 this.lastProcessedMessage = messageKey; if (!this.repliedMessages) { this.repliedMessages = new Set(); } this.repliedMessages.add(messageKey); state.hrInteractions.processedHRs.add(hrKey); if (!state.hrInteractions.lastMessageTime) { state.hrInteractions.lastMessageTime = {}; } state.hrInteractions.lastMessageTime[hrKey] = Date.now(); StatePersistence.saveState(); } // 如果HR提到简历,发送简历 if (lastMessage.includes("简历") || lastMessage.includes("发送")) { await this.delay(1000); const sent = await HRInteractionManager.sendResume(); if (sent) { this.log(`已向 ${hrKey} 发送简历`); } } } catch (error) { this.log(`处理 ${hrKey} 的聊天出错: ${error.message}`); } }, async autoScrollJobList() { return new Promise((resolve) => { const cardSelector = "li.job-card-box"; const maxHistory = 3; const waitTime = CONFIG.BASIC_INTERVAL; let cardCountHistory = []; let isStopped = false; const scrollStep = async () => { if (isStopped) return; window.scrollTo({ top: document.documentElement.scrollHeight, behavior: "smooth", }); await this.delay(waitTime); const cards = document.querySelectorAll(cardSelector); const currentCount = cards.length; cardCountHistory.push(currentCount); if (cardCountHistory.length > maxHistory) cardCountHistory.shift(); if ( cardCountHistory.length === maxHistory && new Set(cardCountHistory).size === 1 ) { this.log("当前页面岗位加载完成,开始沟通"); resolve(cards); return; } scrollStep(); }; scrollStep(); this.stopAutoScroll = () => { isStopped = true; resolve(null); }; }); }, async processJobList() { if (!state.isRunning) return; const activeStatusFilter = settings.recruiterActivityStatus; const experienceFilter = settings.experienceFilter || ["不限"]; if (!state.jobList || state.jobList.length === 0) { const excludeHeadhunters = settings.excludeHeadhunters; const salaryRange = settings.salaryRange || { min: 0, max: 100 }; // 先滚动页面加载更多职位 await this.scrollToLoadMoreJobs(); state.jobList = Array.from( document.querySelectorAll("li.job-card-box") ).filter((card) => { const title = card.querySelector(".job-name")?.textContent?.toLowerCase() || ""; const addressText = ( card.querySelector(".job-address-desc")?.textContent || card.querySelector(".company-location")?.textContent || card.querySelector(".job-area")?.textContent || "" ) .toLowerCase() .trim(); const headhuntingElement = card.querySelector(".job-tag-icon"); const altText = headhuntingElement ? headhuntingElement.alt : ""; // 猎头检测 - 检查多种可能表示猎头的元素 const headhuntingKeywords = ["猎头", "猎头职位", "猎", "headhunter", "人力资源", "外包", "外包职位"]; const companyText = card.querySelector(".company-name")?.textContent?.toLowerCase() || ""; const tagTexts = Array.from(card.querySelectorAll(".job-tag, .tag, [class*='tag']")) .map(el => el.textContent?.toLowerCase() || "") .join(" "); const allText = (altText + " " + companyText + " " + tagTexts).toLowerCase(); const isHeadhunting = headhuntingKeywords.some(keyword => allText.includes(keyword)); const includeMatch = state.includeKeywords.length === 0 || state.includeKeywords.some((kw) => kw && title.includes(kw.trim())); const locationMatch = state.locationKeywords.length === 0 || state.locationKeywords.some( (kw) => kw && addressText.includes(kw.trim()) ); const excludeHeadhunterMatch = !excludeHeadhunters || !isHeadhunting; // 薪资筛选 const salaryText = card.querySelector(".salary")?.textContent || ""; const salaryMatch = this.checkSalaryRange(salaryText, salaryRange); // 经验筛选 const experienceText = ( card.querySelector(".job-exp")?.textContent || card.querySelector(".experience")?.textContent || card.querySelector("[class*='exp']")?.textContent || "" ).trim().toLowerCase(); const experienceMatch = this.checkExperienceMatch(experienceText, experienceFilter); return includeMatch && locationMatch && excludeHeadhunterMatch && salaryMatch && experienceMatch; }); if (!state.jobList.length) { this.log("没有符合条件的职位"); toggleProcess(); return; } this.log(`已加载 ${state.jobList.length} 个符合条件的职位`); } if (state.currentIndex >= state.jobList.length) { this.resetCycle(); state.jobList = []; return; } if (!state.isRunning) return; const currentCard = state.jobList[state.currentIndex]; currentCard.scrollIntoView({ behavior: "smooth", block: "center" }); currentCard.click(); await this.delay(CONFIG.OPERATION_INTERVAL * 2); if (!state.isRunning) return; let activeTime = "未知"; const onlineTag = document.querySelector(".boss-online-tag"); if (onlineTag && onlineTag.textContent.trim() === "在线") { activeTime = "在线"; } else { const activeTimeElement = document.querySelector(".boss-active-time"); activeTime = activeTimeElement?.textContent?.trim() || "未知"; } const isActiveStatusMatch = activeStatusFilter.includes("不限") || activeStatusFilter.includes(activeTime); if (!isActiveStatusMatch) { this.log(`跳过: 招聘者状态 "${activeTime}"`); state.currentIndex++; return; } const includeLog = state.includeKeywords.length ? `职位名包含[${state.includeKeywords.join("、")}]` : "职位名不限"; const locationLog = state.locationKeywords.length ? `工作地包含[${state.locationKeywords.join("、")}]` : "工作地不限"; const salaryRange = settings.salaryRange || { min: 0, max: 100 }; const salaryLog = (salaryRange.min > 0 || salaryRange.max < 100) ? `薪资${salaryRange.min}K-${salaryRange.max}K` : "薪资不限"; state.currentIndex++; this.log( `正在沟通:${state.currentIndex}/${state.jobList.length },${includeLog},${locationLog},${salaryLog},招聘者"${activeTime}"` ); const chatBtn = document.querySelector("a.op-btn-chat"); if (chatBtn) { const btnText = chatBtn.textContent.trim(); if (btnText === "立即沟通") { if (!state.isRunning) return; chatBtn.click(); await this.delay(CONFIG.OPERATION_INTERVAL); if (!state.isRunning) return; await this.handleGreetingModal(); await this.delay(1000); if (!state.isRunning) return; await this.recordApplication(currentCard, activeTime); } } }, cleanRecordText(text) { return (text || "") .replace(/\s+/g, " ") .replace(/[​-‍]/g, "") .trim(); }, pickRecordText(root, selectors) { for (const selector of selectors) { const el = root?.querySelector?.(selector); const text = this.cleanRecordText(el?.textContent || ""); if (text) return text; } return ""; }, getRecordTexts(root, selectors) { const texts = []; selectors.forEach((selector) => { root?.querySelectorAll?.(selector).forEach((el) => { const text = this.cleanRecordText(el.textContent); if (text && !texts.includes(text)) texts.push(text); }); }); return texts; }, getCardLines(card) { const text = card?.innerText || card?.textContent || ""; return text .split(/\n+/) .map((line) => line.trim()) .filter(Boolean); }, isExperienceText(text) { return /^(经验不限|在校\/应届|应届|不限|[0-9]+\s*-\s*[0-9]+\s*年|[0-9]+年以内|[0-9]+年以上)$/.test(this.cleanRecordText(text)); }, isEducationText(text) { return /^(学历不限|本科|大专|硕士|博士|中专\/中技|中专|高中|初中及以下|初中|MBA|EMBA)$/.test(this.cleanRecordText(text)); }, isLocationText(text) { const line = this.cleanRecordText(text); if (!line || line.length > 18) return false; if (/公司|科技|股份|有限|智能|电子|软件|网络|信息|集团|招聘|经验|本科|薪|K/.test(line)) return false; if (line.includes("·")) return /北京|上海|广州|深圳|杭州|苏州|南京|成都|武汉|西安|重庆|天津|长沙|郑州|合肥|宁波|厦门|青岛|无锡|佛山|东莞|常州|珠海|中山|惠州|嘉兴|南通|昆山/.test(line); return /^(北京|上海|广州|深圳|杭州|苏州|南京|成都|武汉|西安|重庆|天津|长沙|郑州|合肥|宁波|厦门|青岛|无锡|佛山|东莞|常州|珠海|中山|惠州|嘉兴|南通|昆山)$/.test(line) || /(相城|虎丘|高新|滨江|余杭|萧山|上城|西湖|工业园|园区|新区|区|县|镇|街道)$/.test(line); }, findLocationFromLines(lines) { const exclude = /K|薪|经验|本科|大专|硕士|博士|学历|融资|上市|人|HR|招聘|沟通/; return lines.find((line) => !exclude.test(line) && this.isLocationText(line) ) || ""; }, findCompanyFromLines(lines, jobName, location) { const badLine = /立即沟通|继续沟通|刚刚活跃|今日活跃|在线|经验|不限|应届|在校|本科|大专|硕士|博士|学历|K|薪|区|市|县|收藏|地图|搜索|职位描述|职位要求|工作地址|猎头/; return lines.find((line) => line !== jobName && line !== location && line.length >= 2 && line.length <= 30 && !badLine.test(line) && !this.isExperienceText(line) && !this.isEducationText(line) && !this.isLocationText(line) ) || ""; }, sanitizeCompanyName(value, lines, jobName, location) { const companyName = this.cleanRecordText(value); const invalid = !companyName || companyName === jobName || companyName === location || this.isExperienceText(companyName) || this.isEducationText(companyName) || this.isLocationText(companyName) || /K|薪|经验|学历|刚刚活跃|今日活跃|在线|立即沟通|继续沟通/.test(companyName); return invalid ? this.findCompanyFromLines(lines, jobName, location) : companyName; }, async recordApplication(card, activeTime) { try { const detailRoot = document; const cardLines = this.getCardLines(card); const footer = card.querySelector(".job-card-footer, .job-card-bottom, .card-footer, .job-card-right, .company-info"); const footerLines = this.getCardLines(footer); const rawJobName = this.pickRecordText(card, [ ".job-name", ".job-title .job-name", ".job-title", ".position-name", "[class*='job-name']" ]) || cardLines[0] || ""; const jobName = rawJobName; // 地区 const rawLocation = this.pickRecordText(card, [ ".job-area", ".job-address-desc", ".job-area-wrapper", ".job-location", ".job-info .job-area", ".job-card-left .job-area", ".company-location" ]); const location = this.isLocationText(rawLocation) ? rawLocation : this.findLocationFromLines(footerLines) || this.findLocationFromLines(cardLines); // 公司名称 - 优先从 footer 选择器提取 const rawCompanyName = this.pickRecordText(footer || card, [ ".company-name", ".company-info .company-name", ".job-card-right .company-name", ".job-card-right .company-info h3", ".company-info h3", "h3.company-name", "a.company-name" ]); const companyName = this.sanitizeCompanyName( rawCompanyName, footerLines.length ? footerLines : cardLines, jobName, location ) || this.findCompanyFromLines(cardLines, jobName, location); // 经验和学历 - 从 .tag-list li/span 提取(BOSS直聘的关键做法) const jobTags = this.getRecordTexts(card, [ ".tag-list li", ".tag-list span", ".job-card-left .tag-list li", ".job-card-left .tag-list span", ".job-info .tag-list li", ".job-info .tag-list span", ".job-primary .tag-list li", ".job-primary .tag-list span" ]); const experience = jobTags.find((tag) => /经验|年|应届|在校|不限/.test(tag) && !/本科|大专|硕士|博士|学历|中专|高中|MBA|EMBA|初中/.test(tag) ) || ""; const education = jobTags.find((tag) => /本科|大专|硕士|博士|学历|中专|高中|MBA|EMBA|初中/.test(tag) ) || ""; // 公司标签(规模、融资阶段) const companyTags = this.getRecordTexts(card, [ ".company-tag-list li", ".company-tag-list span", ".company-info li", ".company-info span", ".company-desc li", ".company-desc span", ".job-card-right li", ".job-card-right span" ]); const companySize = companyTags.find((tag) => /\d+人|人以上|人以下/.test(tag)) || ""; const financingStage = companyTags.find((tag) => /未融资|融资|天使轮|A轮|B轮|C轮|D轮|上市|不需要融资/.test(tag)) || ""; // HR 职位信息 const hrPosition = this.pickRecordText(card, [ ".boss-title", ".boss-info .boss-title", ".job-boss-info .boss-title", ".job-card-footer .boss-title", ]) || this.pickRecordText(detailRoot, [ ".boss-title", ".boss-info .boss-title", ".boss-info-attr", ".boss-info .gray", ".job-boss-info .boss-title" ]) || ""; // 生成唯一 ID - 使用与原始脚本相同的 MD5 方案 const idBase = [ companyName, jobName, location, card.querySelector("a[href*='job_detail']")?.href || location.href ].filter(Boolean).join("|"); const uid = typeof CryptoJS !== "undefined" && CryptoJS.MD5 ? `boss-${CryptoJS.MD5(idBase).toString()}` : `boss-${idBase.replace(/\s+/g, "").slice(0, 80)}`; const record = { id: uid, uid, companyName: companyName || "未知公司", jobName: jobName || "未知岗位", location: location || "", companySize, financingStage, experience, education, hrPosition: hrPosition || "", status: "applied", applyTime: Date.now(), notes: "" }; // 更新内存 let records = state.applicationRecords || []; const existingIndex = records.findIndex((item) => item.id === record.id || item.uid === record.uid); if (existingIndex >= 0) { records[existingIndex] = { ...records[existingIndex], ...record }; } else { records.unshift(record); } if (records.length > 1000) { records = records.slice(0, 1000); } state.applicationRecords = records; // 保存到 localStorage StorageManager.setItem("applicationRecords", records); // 同步到 GM 存储(供看板页面读取),同时清除 lastClearedAt try { GM_setValue('applicationRecords', JSON.stringify(records)); GM_deleteValue('lastClearedAt'); } catch (e) { // GM 存储写入失败不阻塞主流程 } this.log(`已记录投递: ${companyName || "未知公司"} - ${jobName || "未知岗位"}`); } catch (e) { this.log(`记录投递信息失败: ${e.message}`); } }, async handleGreetingModal() { await this.delay(CONFIG.OPERATION_INTERVAL * 4); const btn = [ ...document.querySelectorAll(".default-btn.cancel-btn"), ].find((b) => b.textContent.trim() === "留在此页"); if (btn) { btn.click(); await this.delay(CONFIG.OPERATION_INTERVAL * 2); } }, async handleChatPage() { const latestChatLi = await this.waitForElement(this.getLatestChatLi); if (!latestChatLi) return; const nameEl = latestChatLi.querySelector(".name-text"); const companyEl = latestChatLi.querySelector( ".name-box span:nth-child(2)" ); const name = (nameEl?.textContent || "未知").trim(); const company = (companyEl?.textContent || "").trim(); const hrKey = `${name}-${company}`.toLowerCase(); // 如果当前正在监控同一个 HR,且 observer 正常,则跳过繁重逻辑 if (this.currentMonitoredHR === hrKey && this.messageObserver) { return; } this.currentMonitoredHR = hrKey; this.resetMessageState(); if (this.messageObserver) { this.messageObserver.disconnect(); this.messageObserver = null; } if ( settings.communicationIncludeKeywords && settings.communicationIncludeKeywords.trim() ) { await this.simulateClick(latestChatLi.querySelector(".figure")); await this.delay(CONFIG.OPERATION_INTERVAL * 2); const positionName = this.getPositionName(); const includeKeywords = settings.communicationIncludeKeywords .toLowerCase() .split(/[,,]/) .map((kw) => kw.trim()) .filter((kw) => kw.length > 0); const positionNameLower = positionName.toLowerCase(); const isMatch = includeKeywords.some((keyword) => positionNameLower.includes(keyword) ); if (!isMatch) { this.log(`跳过岗位,不含关键词[${includeKeywords.join(", ")}]`); if (Core.getChatMode() === "ai-auto-reply") { await this.scrollUserList(); } return; } } if (!latestChatLi.classList.contains("last-clicked")) { await this.simulateClick(latestChatLi.querySelector(".figure")); latestChatLi.classList.add("last-clicked"); await this.delay(CONFIG.OPERATION_INTERVAL); await HRInteractionManager.handleHRInteraction(hrKey); if (Core.getChatMode() === "ai-auto-reply") { await this.scrollUserList(); } } await this.setupMessageObserver(hrKey); }, async scrollUserList() { const userListContent = document.querySelector(".user-list-content"); if (userListContent) { const totalHeight = userListContent.scrollHeight; const clientHeight = userListContent.clientHeight; const maxScrollTop = totalHeight - clientHeight; if (maxScrollTop <= 0) { return; } const scrollSteps = Math.floor(Math.random() * 3) + 3; for (let i = 0; i < scrollSteps; i++) { const randomTop = Math.floor(Math.random() * maxScrollTop); userListContent.scrollTo({ top: randomTop, behavior: "smooth", }); const randomDelay = Math.floor(Math.random() * 2000) + 1000; await this.delay(randomDelay); } const finalPosition = Math.random() > 0.5 ? maxScrollTop : 0; userListContent.scrollTo({ top: finalPosition, behavior: "smooth", }); } }, resetMessageState() { this.lastProcessedMessage = null; this.processingMessage = false; if (!this.repliedMessages) { this.repliedMessages = new Set(); } }, async setBossFilters() { try { // 获取UI上选择的经验和学历 const expSelect = document.getElementById("experience-select"); const eduSelect = document.getElementById("education-select"); const salarySelect = document.getElementById("salary-filter"); const expValue = expSelect?.value || "不限"; const eduValue = eduSelect?.value || "不限"; const salaryValue = salarySelect?.value || "all"; // 检查页面上是否已经设置了相同的筛选条件 const currentFilters = this.getCurrentBossFilters(); // 经验筛选 if (expValue !== "不限" && !currentFilters.experience?.includes(expValue)) { const expMap = { "应届生": "应届生", "1年以内": "1年以内", "1-3年": "1-3年", "3-5年": "3-5年", "5-10年": "5-10年", "10年以上": "10年以上" }; const bossText = expMap[expValue] || expValue; // 点击经验按钮 const expFilterBtn = Array.from(document.querySelectorAll('.filter-select-box, .filter-item, [class*="filter"], button, div')) .find(el => el.textContent?.includes('经验') || el.textContent?.includes('工作年限')); if (expFilterBtn) { expFilterBtn.click(); await this.delay(500); const options = document.querySelectorAll('.filter-select-dropdown li, .dropdown-item, [class*="option"], li'); for (const option of options) { if (option.textContent?.includes(bossText)) { option.click(); this.log(`已设置BOSS经验筛选: ${bossText}`); break; } } await this.delay(300); } } // 学历筛选 if (eduValue !== "不限" && !currentFilters.education?.includes(eduValue)) { // 点击学历按钮 const eduFilterBtn = Array.from(document.querySelectorAll('.filter-select-box, .filter-item, [class*="filter"], button, div')) .find(el => el.textContent?.includes('学历') || el.textContent?.includes('教育')); if (eduFilterBtn) { eduFilterBtn.click(); await this.delay(500); const options = document.querySelectorAll('.filter-select-dropdown li, .dropdown-item, [class*="option"], li'); for (const option of options) { if (option.textContent?.includes(eduValue)) { option.click(); this.log(`已设置BOSS学历筛选: ${eduValue}`); break; } } await this.delay(300); } } // 薪资筛选 if (salaryValue !== "all" && !currentFilters.salary?.includes(salaryValue)) { const salaryMap = { "below-3k": "3K以下", "3-5k": "3-5K", "5-10k": "5-10K", "10-20k": "10-20K", "20-50k": "20-50K", "50k+": "50K以上" }; const bossText = salaryMap[salaryValue] || salaryValue; const salaryFilterBtn = Array.from(document.querySelectorAll('.filter-select-box, .filter-item, [class*="filter"], button, div')) .find(el => el.textContent?.includes('薪资') || el.textContent?.includes('薪资待遇')); if (salaryFilterBtn) { salaryFilterBtn.click(); await this.delay(500); const options = document.querySelectorAll('.filter-select-dropdown li, .dropdown-item, [class*="option"], li'); for (const option of options) { if (option.textContent?.includes(bossText)) { option.click(); this.log(`已设置BOSS薪资筛选: ${bossText}`); break; } } await this.delay(300); } } // 等待筛选结果加载 await this.delay(1000); } catch (error) { this.log(`设置BOSS筛选失败: ${error.message}`); } }, // 获取当前BOSS页面上已设置的筛选条件 getCurrentBossFilters() { const filters = { experience: [], education: [], salary: [] }; try { // 查找已选中的筛选标签 const selectedTags = document.querySelectorAll('.filter-select-box .selected, .filter-item .selected, .filter-tag.selected, [class*="selected"]'); selectedTags.forEach(tag => { const text = tag.textContent?.trim() || ''; if (text.includes('年') || text.includes('应届') || text.includes('经验')) { filters.experience.push(text); } else if (text.includes('学历') || ['初中', '中专', '高中', '大专', '本科', '硕士', '博士'].some(e => text.includes(e))) { filters.education.push(text); } else if (text.includes('K') || text.includes('薪') || text.includes('薪资')) { filters.salary.push(text); } }); // 也检查筛选按钮上的文字 const filterBtns = document.querySelectorAll('.filter-select-box, .filter-item, [class*="filter"]'); filterBtns.forEach(btn => { const text = btn.textContent?.trim() || ''; // 如果按钮文字不是默认的"经验"、"学历"、"薪资",说明已经设置了筛选 if (text.includes('经验') && !text.includes('经验不限') && text !== '经验') { const expMatch = text.match(/经验[::]?(.+)/); if (expMatch) filters.experience.push(expMatch[1]); } if (text.includes('学历') && !text.includes('学历不限') && text !== '学历') { const eduMatch = text.match(/学历[::]?(.+)/); if (eduMatch) filters.education.push(eduMatch[1]); } if (text.includes('薪资') && !text.includes('薪资不限') && text !== '薪资') { const salaryMatch = text.match(/薪资[::]?(.+)/); if (salaryMatch) filters.salary.push(salaryMatch[1]); } }); } catch (error) { console.log('获取当前筛选条件失败:', error); } return filters; }, async scrollToLoadMoreJobs() { const jobListContainer = document.querySelector('.job-list-box') || document.querySelector('.search-job-result') || window; const scrollHeight = document.body.scrollHeight; const viewportHeight = window.innerHeight; // 滚动3次加载更多职位 for (let i = 0; i < 3; i++) { const currentScroll = window.scrollY || document.documentElement.scrollTop; const targetScroll = currentScroll + viewportHeight * 0.8; window.scrollTo({ top: targetScroll, behavior: 'smooth' }); this.log(`正在加载更多职位... (${i + 1}/3)`); await this.delay(1500); // 等待职位加载 } // 滚动回顶部 window.scrollTo({ top: 0, behavior: 'smooth' }); await this.delay(500); }, async setupMessageObserver(hrKey) { // ⚠️ 用原版的容器选择器(.chat-message .im-list = 右侧聊天消息列表)。 // 之前改成 this.getChatContainer() 优先,它的候选里有裸的 ".im-list", // 一旦命中的不是聊天消息列表(比如左侧列表),新消息的 DOM 变动就 // 根本不会触发监听 —— 表现就是「新消息来了完全没有检测到」。 let chatContainer = await this.waitForElement(".chat-message .im-list", 2500); if (!chatContainer) chatContainer = this.getChatContainer(); if (!chatContainer) return; if (this.messageObserver) { this.messageObserver.disconnect(); this.messageObserver = null; } this.messageObserver = new MutationObserver(async (mutations) => { if (!state.isRunning) return; if (this.getChatMode() === "ai-auto-reply") { // AI 回复:只「叫醒」串行 worker,回复动作统一由 worker 串行完成 this.kickAiCycle(); return; } // 打招呼:保持原来的框架 —— 只有「对方(HR)来了新消息」才处理 let hasNewFriendMessage = false; for (const mutation of mutations) { if (mutation.type === "childList" && mutation.addedNodes.length > 0) { hasNewFriendMessage = Array.from(mutation.addedNodes).some((node) => node.classList?.contains("item-friend") ); if (hasNewFriendMessage) break; } } if (hasNewFriendMessage) { await this.handleNewMessage(hrKey); } }); this.messageObserver.observe(chatContainer, { childList: true, subtree: true, }); }, async handleNewMessage(hrKey) { if (!state.isRunning) return; if (this.processingMessage) return; this.processingMessage = true; try { await this.delay(CONFIG.OPERATION_INTERVAL); const lastMessage = await this.getLastFriendMessageText(); if (!lastMessage) return; const cleanedMessage = this.cleanMessage(lastMessage); const shouldSendResumeOnly = cleanedMessage.includes("简历"); if (cleanedMessage === this.lastProcessedMessage) return; this.lastProcessedMessage = cleanedMessage; this.log(`已同意交换,对方: ${lastMessage}`); await this.delay(CONFIG.DELAYS.MEDIUM_SHORT); const updatedMessage = await this.getLastFriendMessageText(); if ( updatedMessage && this.cleanMessage(updatedMessage) !== cleanedMessage ) { await this.handleNewMessage(hrKey); return; } const autoSendResume = settings.useAutoSendResume; // 只有「AI全自动回复消息」模式才触发 AI 回复 const autoReplyEnabled = Core.getChatMode() === "ai-auto-reply"; if (shouldSendResumeOnly && autoSendResume) { this.log('对方提到"简历",正在发送简历'); const sent = await HRInteractionManager.sendResume(); if (sent) { state.hrInteractions.sentResumeHRs.add(hrKey); StatePersistence.saveState(); this.log(`已向 ${hrKey} 发送简历`); } } else if (autoReplyEnabled) { await HRInteractionManager.handleHRInteraction(hrKey); } await this.delay(CONFIG.DELAYS.MEDIUM_SHORT); } catch (error) { this.log(`处理消息出错: ${error.message}`); } finally { this.processingMessage = false; } }, cleanMessage(message) { if (!message) return ""; let clean = message.replace(/<[^>]*>/g, ""); clean = clean .trim() .replace(/\s+/g, " ") .replace(/[\u200B-\u200D\uFEFF]/g, ""); return clean; }, getLatestChatLi(targetName = "") { // 优先找当前激活的聊天(用户点击选中的) const activeSelectors = [ 'ul[role="group"] li[role="listitem"].active:has(.friend-content-warp)', 'ul[role="group"] li[role="listitem"].current:has(.friend-content-warp)', 'ul[role="group"] li[role="listitem"][aria-current="true"]:has(.friend-content-warp)', 'ul[role="group"] li[role="listitem"][aria-selected="true"]:has(.friend-content-warp)', 'ul[role="group"] li[role="listitem"].is-active:has(.friend-content-warp)', ]; for (const sel of activeSelectors) { const el = document.querySelector(sel); if (el) return el; } // 如果指定了姓名,尝试从列表中匹配 if (targetName) { const allItems = document.querySelectorAll( 'ul[role="group"] li[role="listitem"][class]:has(.friend-content-warp)' ); for (const item of allItems) { const nameEl = item.querySelector(".name-text"); if (nameEl && nameEl.textContent.trim() === targetName) { return item; } } } // 兜底:取第一个 return document.querySelector( 'ul[role="group"] li[role="listitem"][class]:has(.friend-content-warp)' ); }, getPositionName() { try { const positionNameElement = Core.getCachedElement(".position-name", true) || Core.getCachedElement(".job-name", true) || Core.getCachedElement( '[class*="position-content"] .left-content .position-name', true ) || document.querySelector(".position-name") || document.querySelector(".job-name"); if (positionNameElement) { return positionNameElement.textContent.trim(); } else { // Silent failure is better here as we might check multiple times return ""; } } catch (e) { Core.log(`获取岗位名称出错: ${e.message}`); return ""; } }, async suggestReply() { try { const lastMessage = await this.getLastFriendMessageText(); if (!lastMessage) return false; // 检测HR是否明确拒绝(去掉"谢谢""再见"等易误判词,避免把正常对话当成拒绝) const rejectionPatterns = [ "不合适", "不符合", "不需要", "暂时不招", "已招满", "不符合条件", "不符合要求", "我们要求", "您的条件", "经验不足", "学历不够", "暂不匹配", "不太合适", "不是很合适", "暂无需求", "没有需求", "不需要人", "已经招到", "已经招满", "编制已满", "坑位已满", "已结束", "已截止", "职位已下", "岗位已撤" ]; const isRejection = rejectionPatterns.some(pattern => lastMessage.includes(pattern) ); if (isRejection) { this.log(`检测到HR拒绝,不生成建议: "${lastMessage.slice(0, 30)}..."`); return false; } const replyText = await this.generatePersonalizedReply(lastMessage); if (!replyText) return false; this.log(`AI建议回复: ${replyText.slice(0, 30)}...`); const inputBox = await this.waitForElement("#chat-input"); if (!inputBox) return false; inputBox.textContent = ""; inputBox.focus(); document.execCommand("insertText", false, replyText); this.log('建议回复已填入输入框,请审核后发送'); return true; } catch (error) { this.log(`生成建议回复出错: ${error.message}`); return false; } }, async _suggestDeclineForMismatch(positionName, includeKeywords) { try { const resumeText = state.settings.resumeText || ''; const declinePrompt = `你是一位求职者,收到了一个HR的打招呼消息,但这个岗位和你的专业方向不匹配。 岗位名称: ${positionName} 你的求职方向: ${includeKeywords.join('、')} 简历摘要: ${resumeText.slice(0, 500) || '未提供'} 请生成一句礼貌的婉拒回复(20-40字),表达: 1. 感谢对方的关注 2. 说明岗位和自己的专业方向不太匹配 3. 祝愿对方早日找到合适的人选 语气要真诚、礼貌,不要生硬。直接给出回复内容,不需要引号或前缀。`; const replyText = await this.requestAi(declinePrompt, '你是一位求职者,需要礼貌地婉拒不匹配的岗位。回复要真诚礼貌、简洁。'); if (!replyText) return false; this.log(`AI婉拒建议: ${replyText.slice(0, 30)}...`); const inputBox = await this.waitForElement("#chat-input"); if (!inputBox) return false; inputBox.textContent = ""; inputBox.focus(); document.execCommand("insertText", false, replyText); this.log('婉拒建议已填入输入框,请审核后发送'); return true; } catch (error) { this.log(`生成婉拒建议出错: ${error.message}`); return false; } }, // ============ AI 智能回复增强(多轮上下文 + 结构化决策 + 全会话监听) ============ // 参考开源项目 ghost-job 的实现思路: // 1) 带最近 N 条完整聊天记录生成回复,避免答非所问、重复寒暄 // 2) 让 AI 输出结构化决策 JSON(continue / hr_rejected / declined + send_resume + interview), // 替代原来的关键词拒绝匹配,识别更准、不误伤 // 3) 轮询 BOSS 会话接口,监听所有 HR 的回复,而不是只盯当前打开的聊天 // 4) 逐字输入 + 随机延迟 + 定期休息,模拟真人节奏 AI_CHAT: { HISTORY_LIMIT: 20, // 带给模型的历史消息条数 WATCH_MIN: 15, // 全会话轮询最小间隔(秒)——新消息到达时会被事件立刻唤醒,这里只是兜底 WATCH_MAX: 30, // 全会话轮询最大间隔(秒) REPLY_DELAY: [800, 2000], // 回复前随机延迟(毫秒),保留一点真人节奏 TYPING: [40, 90], // 逐字输入每个字的间隔(毫秒) REST_EVERY: 15, // 每回复 N 条休息一次 REST_MINUTES: [1, 2], // 休息时长范围(分钟) MAX_PER_CYCLE: 8, // 每轮最多处理的会话数,防止回复风暴 }, repliedCount: 0, handledMsgIds: new Set(), watcherRunning: false, // 会话列表「最后一条消息预览」快照,用于 DOM 兜底判断 HR 是否发了新消息 lastSeenPreview: new Map(), // 我方在各会话最后发出的内容,用于区分预览里的消息是不是自己发的 lastSentText: new Map(), // 会话级去重:会话名 -> 已处理过的 HR 最后一条消息指纹(参考 ghost-job 的 _handled) handledConversations: new Map(), // 互斥锁:同一时刻只允许一个 AI 回复流程运行(防止多入口抢窗口导致回复串人) aiCycleRunning: false, // 有事件唤醒时置位,worker 的长间隔等待会被提前打断 _needCycle: false, _lastKick: 0, _lastCycleEnd: 0, rand(min, max) { return min + Math.random() * (max - min); }, // 聊天记录容器:BOSS 不同版本的类名不一样,逐个尝试,避免读不到消息就静默不回复 getChatContainer() { const candidates = [ ".chat-message .im-list", ".chat-record .im-list", ".chat-message ul.im-list", ".chat-record", ".chat-message", "ul.im-list", ".im-list", ]; for (const sel of candidates) { const el = document.querySelector(sel); if (el) return el; } // 兜底:以任意一条消息节点反推容器 const anyMsg = document.querySelector(".message-item"); if (anyMsg && anyMsg.parentElement) return anyMsg.parentElement; return null; }, // 取一条消息的正文(多套选择器兜底) getMessageText(li) { const selectors = [ ".message-content .text", ".text-content", ".text span", ".text", ".message-content", ]; for (const sel of selectors) { const el = li.querySelector(sel); const t = (el?.innerText || el?.textContent || "").trim(); if (t) return t; } return this.readCardText(li); }, // 读取当前聊天窗口的完整消息记录(含双方,从早到晚) async getChatHistory() { try { const chatContainer = this.getChatContainer(); if (!chatContainer) { // 打开会话时会高频轮询本函数,这里做 30 秒节流,避免日志刷屏 const now = Date.now(); if (now - (this._lastNoChatContainerLog || 0) > 30000) { this._lastNoChatContainerLog = now; this.log("⚠️ 未找到聊天记录容器,请确认聊天窗口已打开"); } return []; } let items = Array.from( chatContainer.querySelectorAll("li.message-item") ); if (!items.length) { items = Array.from(chatContainer.querySelectorAll(".message-item")); } const history = []; for (const li of items) { const fromHr = li.classList.contains("item-friend"); const fromMe = li.classList.contains("item-myself"); if (!fromHr && !fromMe) continue; const text = this.getMessageText(li); if (!text) continue; // 纯系统卡片没有文字,跳过 history.push({ mid: li.dataset.mid || li.getAttribute("data-mid") || "", fromHr, text: this.cleanMessage(text), }); } return history; } catch (error) { this.log(`读取聊天记录出错: ${error.message}`); return []; } }, // 提取卡片类消息里的文字(如附件简历请求卡片),剔除按钮和时间戳 readCardText(li) { try { const NOISE = '.message-time, .time, .figure, .avatar, img, button, [class*="btn"], [class*="status"], [class*="operate"]'; const BUTTONS = new Set(["拒绝", "同意", "接受", "查看", "确定", "取消"]); const parts = []; const noisy = (el) => { const hit = el.closest && el.closest(NOISE); return hit && hit !== li && li.contains(hit); }; const walker = document.createTreeWalker(li, NodeFilter.SHOW_TEXT); for (let node; (node = walker.nextNode()); ) { const text = node.textContent.trim(); if ( text && !noisy(node.parentElement) && !BUTTONS.has(text) && !/^\d{1,2}:\d{2}$/.test(text) ) { parts.push(text); } } return parts.join(" ").trim(); } catch (e) { return ""; } }, buildChatSystemRole() { const userRole = (state.settings.ai && state.settings.ai.role) || ""; return ( userRole || "你是一个在BOSS直聘上和HR聊天的求职者,回复口语化、像真人微信聊天。" ); }, // 组装决策提示词:简历 + 简历分析 + 岗位 + 完整聊天记录 + 固定策略 buildChatDecisionPrompt(history, positionName) { const resume = this.truncateResumeText( state.settings.resumeText || "", 3000 ); const analysis = state.settings.resumeAnalysis || ""; const lines = history .slice(-this.AI_CHAT.HISTORY_LIMIT) .map((m) => `${m.fromHr ? "HR" : "我"}:${m.text}`) .join("\n"); const parts = []; if (resume) parts.push(`【我的简历】\n${resume}`); if (analysis) parts.push(`【简历亮点分析】\n${analysis}`); if (positionName) parts.push(`【沟通的岗位】${positionName}`); parts.push( `【聊天记录(从早到晚,最后一条是 HR 刚发的消息)】\n${lines}` ); parts.push( [ "【任务】阅读以上聊天记录,针对 HR 的最新消息作出判断并回复。只输出一个 JSON 对象,禁止输出任何其他文字、解释或代码块标记,格式:", '{"outcome":"continue","reply":"回复正文","send_resume":false,"interview":false}', "", "字段说明:", '1. outcome 取值:"hr_rejected"=HR 明确拒绝(说不合适/已招满/暂不考虑等,此时 reply 留空字符串);"declined"=岗位与我的方向明显不符,礼貌婉拒(reply 里写婉拒的话);"continue"=其他一切情况。', "2. send_resume:HR 索要简历或发来简历请求卡片时为 true,回复发出后系统会自动发送附件简历。", "3. interview:HR 主动邀请面试,或正在和我确认面试时间/地点/方式时为 true;只是我方提到面试不算。", "", "回复正文(reply)的固定策略:", "- 像真人手机聊天:口语化、15-40 字、一次一句话、不换行;不用「您好」「很高兴」等套话,绝不承认或暗示自己是 AI。", "- 严格衔接聊天记录的上下文:HR 已经说过的事不要重复问,我方已经回答过的问题不要重复回答,不要每次都重新自我介绍。", "- 薪资问题:优先用简历里的期望薪资回答;没有就表达开放态度(如「期望 10-15K,具体可以聊」),不要说死。", "- 到岗时间:表达诚意但不承诺具体日期(如「随时可以到岗」或「需要一周左右交接」)。", "- HR 问技术/经历问题:结合简历里的真实项目和经历简短作答,展示匹配度。", "- HR 最新一条是系统提示、表情或无实质内容时,reply 留空字符串。", ].join("\n") ); return parts.join("\n\n"); }, // 解析 AI 返回的结构化决策;模型不按格式输出时降级为普通回复 parseAiDecision(raw) { if (!raw) return null; let text = String(raw).trim(); const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/i); if (fence) text = fence[1].trim(); const start = text.indexOf("{"); const end = text.lastIndexOf("}"); if (start === -1 || end === -1 || end <= start) { const fallback = text .replace(/^["'「]|["'」]$/g, "") .replace(/\s+/g, " ") .trim(); if (!fallback) return null; this.log("AI 未按 JSON 格式输出,已降级为普通回复"); return { outcome: "continue", reply: fallback.slice(0, 100), send_resume: false, interview: false, }; } try { const obj = JSON.parse(text.slice(start, end + 1)); const outcome = ["continue", "hr_rejected", "declined"].includes( obj.outcome ) ? obj.outcome : "continue"; return { outcome, reply: typeof obj.reply === "string" ? obj.reply.replace(/\s+/g, " ").trim() : "", send_resume: !!obj.send_resume, interview: !!obj.interview, }; } catch (e) { this.log(`解析 AI 决策失败: ${e.message},已降级为普通回复`); const fallback = text.replace(/\s+/g, " ").trim(); return { outcome: "continue", reply: fallback.slice(0, 100), send_resume: false, interview: false, }; } }, async requestAiDecision(prompt) { const raw = await this.requestAi(prompt, this.buildChatSystemRole()); return this.parseAiDecision(raw); }, // 逐字输入并点击发送,模拟真人打字节奏 async sendReplyLikeHuman(replyText) { // 输入框多套选择器兜底(#chat-input / .chat-input / contenteditable) const inputSelectors = [ "#chat-input", ".chat-input", ".chat-editor [contenteditable='true']", "[contenteditable='true']", ]; let inputBox = null; for (const sel of inputSelectors) { inputBox = await this.waitForElement(sel, 1200); if (inputBox) { this.log(`已定位聊天输入框: ${sel}`); break; } } if (!inputBox) { this.log("❌ 未找到聊天输入框,回复发送失败"); return false; } inputBox.textContent = ""; inputBox.focus(); for (const ch of replyText) { document.execCommand("insertText", false, ch); await this.delay( this.rand(this.AI_CHAT.TYPING[0], this.AI_CHAT.TYPING[1]) ); } await this.delay(this.rand(200, 500)); // 发送按钮多套选择器兜底 let sendButton = null; for (const sel of [".btn-send", ".btn-sure", ".send-btn", "[class*='btn-send']"]) { sendButton = DOMCache.get(sel) || document.querySelector(sel); if (sendButton) break; } if (sendButton) { await this.simulateClick(sendButton); } else { this.log("未找到发送按钮,改用回车发送"); inputBox.dispatchEvent( new KeyboardEvent("keydown", { key: "Enter", keyCode: 13, code: "Enter", which: 13, bubbles: true, }) ); } return true; }, // 把沟通结果同步到投递追踪看板(按公司名匹配记录) syncChatStatusToTracker(companyName, status) { try { if (!companyName) return false; const norm = (s) => String(s || "") .replace(/(.*?)/g, "") .replace(/\(.*?\)/g, "") .trim(); const key = norm(companyName); if (!key) return false; const records = state.applicationRecords || []; const record = records.find((r) => { const rn = norm(r.companyName); return rn && (rn.includes(key) || key.includes(rn)); }); if (!record || record.status === status) return false; record.status = status; state.applicationRecords = records; StorageManager.setItem("applicationRecords", records); try { GM_setValue("applicationRecords", JSON.stringify(records)); } catch (e) {} this.log(`已同步投递看板状态: ${companyName} → ${status}`); return true; } catch (e) { return false; } }, // 当前沟通模式:greeting=打招呼(发送自我介绍) / ai-auto-reply=AI全自动回复消息 getChatMode() { const saved = localStorage.getItem("communicationMode") || (typeof settings !== "undefined" && settings.communicationMode) || ""; return saved === "greeting" ? "greeting" : "ai-auto-reply"; }, // ---- 全量会话监听:轮询 BOSS 会话接口,找「最后一条是 HR 发的」会话并逐个回复 ---- // ---- AI 自动回复:单一串行 worker(对齐 ghost-job 的 ChatResponder 架构)---- // 核心原则:同一时刻只允许一个回复流程(aiCycleRunning 互斥); // 每次回复都在「刚打开、且校验过身份的」那个聊天窗口里完成; // 处理前先占位标记,失败也不重复。这样从根本上杜绝多入口抢窗口导致回复串人。 async startAiReplyWorker() { if (this.getChatMode() !== "ai-auto-reply") return; if (this.watcherRunning) return; this.watcherRunning = true; this._needCycle = false; // 先装好「打开会话」的确认信号:之后点击会话时才能等到 getBossData 请求 this.installChatApiSniffer(); this.log( `AI 自动回复已启动:串行检查所有 HR 会话(每 ${this.AI_CHAT.WATCH_MIN}-${this.AI_CHAT.WATCH_MAX} 秒一轮,有新消息会立刻插队)` ); (async () => { while (this.watcherRunning && state.isRunning) { try { if (this.getChatMode() !== "ai-auto-reply") break; await this.runAiReplyCycle(); } catch (error) { this.log(`AI 回复循环出错: ${error.message}`); } // 分片等待:期间被打断可提前进入下一轮,但两轮之间至少间隔 5 秒,防止空转 const wait = this.rand(this.AI_CHAT.WATCH_MIN, this.AI_CHAT.WATCH_MAX); for (let i = 0; i < wait && this.watcherRunning && state.isRunning; i++) { if (this._needCycle && Date.now() - this._lastCycleEnd > 1500) break; await this.delay(1000); } this._needCycle = false; } this.watcherRunning = false; this.log("AI 自动回复已停止"); })(); }, // 事件唤醒:聊天列表 / 当前窗口来新消息时调用。只「叫醒」worker,不直接回复。 kickAiCycle() { if (!state.isRunning) return; if (this.getChatMode() !== "ai-auto-reply") return; if (this.aiCycleRunning) { // 已有流程在跑:标记一下,跑完自然续上 this._needCycle = true; return; } const now = Date.now(); if (now - this._lastKick < 1200) return; // 1.2 秒防抖(原 3 秒,影响新消息响应速度) this._lastKick = now; this.runAiReplyCycle(); }, stopConversationWatcher() { this.watcherRunning = false; this._needCycle = false; }, // 一键自检:把 AI 回复链路的每个环节都检查一遍并输出到日志 async diagnoseAiReply() { this.log("========== AI 回复自检开始 =========="); try { const mode = this.getChatMode(); this.log( `1. 沟通模式:${mode === "greeting" ? "打招呼(不会自动回消息)" : "AI全自动回复消息"}` ); this.log( `2. 运行状态:${state.isRunning ? "已启动" : "未启动(请先点按钮启动)"}` ); const apiKey = localStorage.getItem("aiApiKey"); const apiUrl = localStorage.getItem("aiApiUrl"); const model = localStorage.getItem("aiModel"); this.log( `3. AI 配置:${apiKey ? "Key 已填" : "❌ Key 缺失"} / ${ apiUrl || "❌ 地址缺失" } / ${model || "❌ 模型缺失"}` ); this.log( `4. 简历:${ state.settings && state.settings.resumeText ? `已配置(${state.settings.resumeText.length} 字)` : "未配置(建议上传,回复质量更好)" }` ); const container = this.getChatContainer(); this.log( `5. 聊天记录容器:${container ? container.className || container.tagName : "❌ 未找到"}` ); const history = await this.getChatHistory(); const hrMsgs = history.filter((m) => m.fromHr && m.text); this.log( `6. 聊天记录:共 ${history.length} 条,其中 HR 的 ${hrMsgs.length} 条` ); if (hrMsgs.length) { this.log(` 最新一条 HR 消息:${hrMsgs[hrMsgs.length - 1].text.slice(0, 40)}`); } const inputOk = document.querySelector( "#chat-input, .chat-input, [contenteditable='true']" ); this.log(`7. 聊天输入框:${inputOk ? "✅ 已找到" : "❌ 未找到(窗口未打开?)"}`); const btnOk = document.querySelector( ".btn-send, .btn-sure, .send-btn, [class*='btn-send']" ); this.log(`8. 发送按钮:${btnOk ? "✅ 已找到" : "未找到(将用回车发送)"}`); const list = await this.fetchConversationList(); const domItems = document.querySelectorAll( '.user-list-content li, .user-list li, ul[role="group"] li[role="listitem"]' ).length; // 有多少个会话在等我回复(与 worker 的判定口径一致) let waiting = 0; try { const info = await this.getActiveChatFromHeader(); const pending = await this.collectWaitingConversations(); waiting = pending.length; this.log( `9. 会话列表:接口/扫描共 ${list.length} 个,页面会话节点 ${domItems} 个,其中待回复 ${waiting} 个` ); this.log( ` 当前打开的会话:${ info && info.name ? info.name + " · " + (info.company || "") : "(窗口标题未识别到有效姓名,将用「点击的列表项 + 聊天内容」双重校验)" }` ); const bogus = this.findBogusHeaderText(); if (bogus) { this.log( ` ⚠️ 页面存在常驻 AI 节点「${bogus.slice(0, 20)}」——已自动忽略,不会再被当成当前会话` ); } if (this.lastClickedConversationText) { this.log( ` 最近点击的列表项:${this.lastClickedConversationText.slice(0, 26)}` ); } const domScan = this.scanConversationsFromDom(); this.log( ` 左侧列表可识别会话 ${domScan.length} 个${ domScan.length ? ":" + domScan .slice(0, 3) .map((d) => d.name + (d.__unread ? "(未读)" : "")) .join(" / ") : "(若为 0,说明列表节点结构已变,请把这行发我)" }` ); if (waiting) { this.log( ` 待回复示例:${pending .slice(0, 3) .map((p) => p.name) .join(" / ")}` ); } } catch (e) { this.log(`9. 会话列表:接口/扫描共 ${list.length} 个,页面会话节点 ${domItems} 个`); } this.log( `10. 监听状态:事件监听 ${this.chatListObserver ? "✅" : "❌"} / 串行回复${ this.watcherRunning ? "已启动 ✅" : "未启动 ❌(请先点启动按钮)" } / 本轮运行中 ${this.aiCycleRunning ? "是" : "否"} / 已处理会话 ${ this.handledConversations.size } 个` ); this.log("========== AI 回复自检结束 =========="); } catch (e) { this.log(`自检出错: ${e.message}`); } }, // 调 BOSS 自己的会话接口拿最近会话(同源请求,自动带登录态) // ① ghost-job 同款两步接口(先取会话 ID,再批量取详情,含 lastMessageInfo/uid) // ② 单接口兜底 ③ 都不可用则扫左侧会话列表 DOM async fetchConversationList() { const viaIds = await this.fetchConversationsViaIds(); if (viaIds && viaIds.length) return viaIds; const endpoints = [ "/wapi/zprelation/friend/getGeekFriendList.json?page=1", "/wapi/zpgeek/friend/getGeekFriendList.json?page=1", ]; for (const url of endpoints) { try { const res = await fetch(url, { credentials: "include" }).then((r) => r.json() ); if (res && res.code === 0 && res.zpData) { const list = res.zpData.result || res.zpData.friendList || []; if (Array.isArray(list) && list.length) { if (!this._listApiOk) { this._listApiOk = true; this.log(`会话列表接口可用(${url.split("?")[0]})`); } return list.map((f) => Object.assign({}, f, { __dom: false })); } } else if (res && res.code !== 0) { this.log( `会话列表接口返回异常: code=${res.code} ${res.message || ""}` ); } } catch (e) { this.log(`会话列表接口请求失败: ${e.message}`); } } // 接口都不可用 → DOM 兜底 const domList = this.scanConversationsFromDom(); if (domList.length) { if (!this._listDomWarned) { this._listDomWarned = true; this.log( `会话接口不可用,已降级为页面扫描(读到 ${domList.length} 个会话)` ); } } else if (!this._listEmptyWarned) { this._listEmptyWarned = true; this.log("⚠️ 既没读到会话接口,也没扫到会话列表,请确认在聊天页(/web/geek/chat)"); } return domList; }, // ghost-job 同款:先按更新时间拿会话 ID,再批量取详情(详情里才有 uid / lastMessageInfo) async fetchConversationsViaIds() { try { const ids = await fetch( "/wapi/zprelation/friend/geekFilterByLabel?labelId=0", { credentials: "include" } ).then((r) => r.json()); if (!ids || ids.code !== 0) return null; const top = ((ids.zpData && ids.zpData.friendList) || []) .slice(0, 20) .map((f) => f.friendId) .filter(Boolean); if (!top.length) return null; const res = await fetch( "/wapi/zprelation/friend/getGeekFriendList.json", { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: "friendIds=" + encodeURIComponent(top.join(",")), credentials: "include", } ).then((r) => r.json()); if (!res || res.code !== 0) return null; const list = (res.zpData && res.zpData.result) || []; if (!list.length) return null; if (!this._listApiOk) { this._listApiOk = true; this.log("会话列表接口可用(geekFilterByLabel + getGeekFriendList)"); } return list.map((f) => Object.assign({}, f, { __dom: false })); } catch (e) { return null; } }, // DOM 兜底:从左侧会话列表直接提取会话(含未读标记和最后一条消息预览) // 同样不依赖特定 class:节点由 collectConversationNodes 统一过滤,姓名从整项文本里解析 scanConversationsFromDom() { try { let items = this.collectConversationNodes({ requireAvatar: true }); if (!items.length) items = this.collectConversationNodes({ requireAvatar: false }); const isVisible = (el) => !!el && (el.getClientRects().length > 0 || (el.offsetWidth || el.offsetHeight || el.offsetParent)); return items .map((li) => { const full = this.chatTextOf(li); // 姓名:优先取名字节点,取不到就从整项文本解析 const nameEl = li.querySelector(".name-text, .name, .friend-name"); const name = this.chatTextOf(nameEl) || this.parseNameFromText(full); const companyEl = li.querySelector( ".company-name, .company, .brand-name, .name-box span:nth-child(2)" ); const company = this.chatTextOf(companyEl) || this.parseCompanyFromText(full); const previewEl = li.querySelector( ".friend-content-warp .text, .last-msg, .gray, .message-text, .text" ); // 未读标记:必须是「可见的」小圆点/数字,避免隐藏占位误判 const unreadEl = li.querySelector( ".notice-badge, .badge, .red-dot, .unread-count, [class*='badge'], [class*='unread']" ); let preview = this.chatTextOf(previewEl); if (!preview) { // 没有预览节点时,用整项文本里去掉姓名/公司的剩余部分兜底 preview = full .replace(name, "") .replace(company, "") .replace(/\[.*?\]/g, "") .trim() .slice(0, 60); } return { name, brandName: company, uid: "", __dom: true, __unread: isVisible(unreadEl), lastMessageInfo: { fromId: "", msgId: "", text: preview }, }; }) .filter((f) => f.name && !this.isPseudoConversation(f.name)); } catch (e) { return []; } }, // 一轮检查:找出「最后一条是 HR 发的」会话,逐个串行回复(互斥,防止抢窗口) async runAiReplyCycle() { if (this.aiCycleRunning) return; // 互斥:同一时刻只有一个回复流程 if (!state.isRunning) return; if (this.getChatMode() !== "ai-auto-reply") return; this.aiCycleRunning = true; this._needCycle = false; try { const pending = await this.collectWaitingConversations(); if (!pending.length) return; this.log(`发现 ${pending.length} 个会话在等我回复`); const batch = pending.slice(0, this.AI_CHAT.MAX_PER_CYCLE); for (const friend of batch) { if (!state.isRunning || !this.watcherRunning) break; if (this.getChatMode() !== "ai-auto-reply") break; const done = await this.handleOneConversation(friend); // 只有「确实处理过」(已回复 / 无需回复 / 已判定拒绝)才标记; // 打开失败等异常不标记,下一轮会自动重试(旧版在这里占位,失败一次就永久跳过) if (done) { this.markConversationHandled(friend.name, friend.fingerprint); } else { this.log(`「${friend.name}」本轮未完成,下一轮会重试`); } await this.delay(this.rand(1000, 2200)); } } finally { this.aiCycleRunning = false; this._lastCycleEnd = Date.now(); } }, // BOSS 自带的「AI/智能聊天/系统消息/AI筛选提交」等伪会话:接口和页面列表里都会出现,但不是真人 HR isPseudoConversation(name) { const t = this.normText(name).replace(/\s+/g, ""); if (!t) return true; return /Boss智能聊天|BOSS智能聊天|智能聊天|智能对话|智能助手|求职助手|简历助手|系统消息|官方账号|AI筛选|^AI$|^系统$/.test( t ); }, // 收集等待回复的会话:接口数据优先,并把 DOM 扫描结果按名字并入,避免漏掉 async collectWaitingConversations() { const list = await this.fetchConversationList(); const byName = new Map(); for (const f of list) { const name = String(f.name || f.bossName || "").trim(); if (!name) continue; if (this.isPseudoConversation(name)) continue; const company = String(f.brandName || f.company || "").trim(); const last = f.lastMessageInfo || {}; if (f.__dom) { // —— 页面扫描数据:未读标记 或 「预览变化且最后一条不是我发的」—— const preview = String(last.text || "").trim(); const prev = this.lastSeenPreview.get(name); const mine = this.lastSentText.get(name); const looksMine = !!mine && !!preview && preview.indexOf(String(mine).slice(0, 8)) === 0; if (preview) this.lastSeenPreview.set(name, preview); const changed = prev !== undefined && prev !== preview; if (looksMine) continue; if (!(f.__unread || changed)) continue; if (!preview) continue; // 没有预览文字,无法判断内容,等待下一轮 if (this.isConversationHandled(name, preview)) continue; byName.set(name, { name, company, fingerprint: preview, dom: true, }); continue; } // —— 接口数据:最后一条消息是 HR 发的 → 在等我回复 —— const hrUid = String(f.uid || ""); const lastFrom = String(last.fromId || ""); const mid = String(last.msgId || ""); const unread = Number(f.unreadCount || f.unreadMsgCount || 0) > 0 || !!(last && last.unread); const hrSentLast = !!hrUid && !!lastFrom && lastFrom === hrUid; if (!mid) continue; if (!hrSentLast && !unread) continue; if (this.isConversationHandled(name, mid)) continue; byName.set(name, { name, company, fingerprint: mid, dom: false, }); } // DOM 并集:接口没覆盖到的会话,用页面扫描结果补上 if (list.some((f) => !f.__dom)) { for (const d of this.scanConversationsFromDom()) { const name = String(d.name || "").trim(); if (!name || byName.has(name)) continue; if (this.isPseudoConversation(name)) continue; const preview = String((d.lastMessageInfo || {}).text || "").trim(); const mine = this.lastSentText.get(name); const looksMine = !!mine && !!preview && preview.indexOf(String(mine).slice(0, 8)) === 0; if (looksMine || !preview) continue; if (!d.__unread) continue; if (this.isConversationHandled(name, preview)) continue; byName.set(name, { name, company: d.brandName || "", fingerprint: preview, dom: true }); } } return Array.from(byName.values()); }, // 会话级去重:同一会话的同一条 HR 消息只处理一次 isConversationHandled(name, fingerprint) { if (!name || !fingerprint) return false; return this.handledConversations.get(name) === String(fingerprint); }, markConversationHandled(name, fingerprint) { if (!name || !fingerprint) return; this.handledConversations.set(name, String(fingerprint)); if (this.handledConversations.size > 500) { const oldest = this.handledConversations.keys().next().value; this.handledConversations.delete(oldest); } }, // ── ghost-job 的「打开会话确认」方式(核心)────────────────────────── // ghost-job 点开会话时用 page.expect_response(getBossData) 等这个接口回来, // 以此确认「窗口真的切过去了」——它从不读页面标题,所以不会被 // 「AI · Boss智能聊天」常驻节点 / 列表预览被裁剪这类问题坑到。 // 这里没有 Playwright,用「包一层 fetch/XHR」达到同样效果: // 记录每次 getBossData 响应的「时间 + 内容」,点击后只要它回来了就算切成功, // 而且响应里带着 HR 姓名(zpData.data.name),相当于 ghost-job 拿到的 uid。 installChatApiSniffer({ silent = false } = {}) { if (this._snifferInstalled) return; this._snifferInstalled = true; this._chatApiHits = []; const HIT = /getBossData/i; const record = (url, data) => { try { if (!HIT.test(String(url || ""))) return; this._chatApiHits.push({ url: String(url), at: Date.now(), data }); if (this._chatApiHits.length > 30) this._chatApiHits.shift(); } catch (e) { /* 记录失败不影响页面本身的请求 */ } }; const targets = []; try { if (typeof unsafeWindow !== "undefined" && unsafeWindow) { targets.push(unsafeWindow); } } catch (e) { /* 沙箱不支持 unsafeWindow 时忽略 */ } try { targets.push(window); } catch (e) { /* 忽略 */ } // ① fetch:只旁听,原样返回响应(res.clone() 读取,不消耗原响应体) for (const w of targets) { try { const orig = w.fetch; if (typeof orig !== "function" || orig.__seekWrapped) continue; const wrapped = function (...args) { const url = args[0] && args[0].url ? args[0].url : args[0]; return orig.apply(this, args).then((res) => { try { const clone = res.clone(); clone .json() .then((d) => record(url, d)) .catch(() => {}); } catch (e) { /* 忽略 */ } return res; }); }; wrapped.__seekWrapped = true; w.fetch = wrapped; } catch (e) { /* fetch 不可写时忽略 */ } } // ② XHR:BOSS 部分请求走 XHR for (const w of targets) { try { const XP = w.XMLHttpRequest && w.XMLHttpRequest.prototype; if (!XP || (XP.open && XP.open.__seekWrapped)) continue; const origOpen = XP.open; const origSend = XP.send; XP.open = function (method, url, ...rest) { try { this.__seekUrl = url; } catch (e) { /* 忽略 */ } return origOpen.call(this, method, url, ...rest); }; XP.send = function (...args) { try { this.addEventListener("load", () => { try { const u = this.responseURL || this.__seekUrl; if (HIT.test(String(u || ""))) { record(u, JSON.parse(this.responseText)); } } catch (e) { /* 忽略 */ } }); } catch (e) { /* 忽略 */ } return origSend.apply(this, args); }; XP.open.__seekWrapped = true; } catch (e) { /* XHR 不可写时忽略 */ } } if (!silent) { this.log( "已启用「打开会话」确认信号:以 getBossData 请求为准(与 ghost-job 同一做法)" ); } }, // 取「本次点击之后」回来的首个 getBossData 响应 chatApiHitAfter(t0) { const hits = this._chatApiHits || []; for (let i = hits.length - 1; i >= 0; i--) { if (hits[i].at >= t0) return hits[i]; } return null; }, // 从 getBossData 响应里取 HR 姓名(= ghost-job 的 zpData.data.name) hrFromChatApi(hit) { try { const zp = (hit && hit.data && (hit.data.zpData || hit.data)) || {}; const data = zp.data || {}; return String(data.name || zp.hrName || "").trim(); } catch (e) { return ""; } }, // 从 getBossData 响应里取岗位名(= ghost-job 的 job_from_boss_data) positionFromChatApi(hit) { try { const zp = (hit && hit.data && (hit.data.zpData || hit.data)) || {}; const job = zp.job || {}; const data = zp.data || {}; return String(job.jobName || data.jobName || zp.jobName || "").trim(); } catch (e) { return ""; } }, // 打开指定会话:点击左侧列表项,并用「多个真实信号」确认右侧确实切到了目标 HR // 信号优先级(从强到弱): // ① getBossData 请求回来了(ghost-job 的做法)—— 最强,且能顺带拿到 HR 姓名核对 // ② 窗口标题读到的就是目标姓名(已排除「AI · Boss智能聊天」这类常驻节点) // ③ 聊天窗口里的消息,和「我点的那个列表项」的预览对得上 // ④ 该列表项被标记为选中态 async openConversationByName(name, company) { let item = this.findConversationItem(name, company); if (!item) { // 列表可能是虚拟滚动,目标没渲染出来 → 在列表内滚动查找 item = await this.scrollConversationListToFind(name, company); } if (!item) { this.log( `未找到会话「${name}」(左侧列表没有匹配项,请确认它在当前列表里且没有被折叠)` ); return false; } // 记录点击项文本 + 它的最后一条消息预览,供后面双重校验使用 const itemText = this.chatTextOf(item); const preview = this.parsePreviewFromItemText(itemText, name, company); this.lastClickedConversationText = itemText; this.lastClickedConversationPreview = preview; this.lastClickedConversationAt = Date.now(); this.log(`打开会话「${name}」→ 点击列表项「${itemText.slice(0, 24)}」`); // 依次尝试两种点击目标:整行 li(最标准)→ 头像区(有些改版只把事件绑在头像上) const inner = item.querySelector("[class*='figure']") || item.querySelector("img"); const targets = [item]; if (inner) targets.push(inner); let lastSeen = ""; // 页面上真实读到的窗口姓名(不含兜底) let sawMismatch = ""; // getBossData 明确告诉我们「打开的不是目标」时的对方姓名 this._lastChatApiJob = ""; // 本次会话对应的岗位名(由 getBossData 提供) for (let t = 0; t < targets.length; t++) { try { item.scrollIntoView({ block: "center" }); } catch (e) { /* 忽略 */ } const clickAt = Date.now() - 30; await this.simulateClick(targets[t]); // 窗口切换通常 300-900ms 内完成,轮询 6 次(1.8 秒)足够;失败还有下面的兜底,不会漏回 for (let i = 0; i < 6; i++) { await this.delay(300); // 信号①(最强,ghost-job 的做法):点击后 BOSS 请求了 getBossData const hit = this.chatApiHitAfter(clickAt); if (hit) { const hitName = this.hrFromChatApi(hit); // 接口明确返回了别的 HR → 说明点错了,换下一个点击目标(绝不放行,防止回复串人) if (hitName && !this.isSameHr(hitName, name)) { sawMismatch = hitName; break; } const hist0 = await this.getChatHistory(); if (hist0.length || hitName) { this._lastChatApiJob = this.positionFromChatApi(hit); this.log( `✅ 已打开「${name}」(getBossData 请求确认${ hitName ? ":" + hitName : "" })` ); return true; } } // 信号②:窗口标题就是目标姓名 const info = await this.getActiveChatFromHeader({ allowClickedFallback: false, }); const seen = (info && info.name) || ""; if (seen) { lastSeen = seen; if (this.isSameHr(seen, name)) { this.log(`✅ 已打开「${name}」(窗口标题确认)`); return true; } } // 信号③:聊天内容与「我点的那个列表项」对得上 const hist = await this.getChatHistory(); if (this.historyMatchesPreview(hist, preview)) { this.log(`✅ 已打开「${name}」(聊天内容与列表项一致确认)`); return true; } // 信号④:该列表项被标记为选中态(且窗口里确实有消息) if (hist.length && this.isConversationItemActive(item)) { this.log(`✅ 已打开「${name}」(列表选中态确认)`); return true; } } } // ⚠️ 有明确的反证(getBossData 报的是别人的名字)时,绝不走下面的「兜底放行」, // 否则就会变成「知道点错了还硬着头皮回复」——这正是回复串人的成因。 if (sawMismatch) { this.log( `⚠️ getBossData 返回的是「${sawMismatch}」,与目标「${name}」不一致,本轮放弃(防止回复串人)` ); return false; } // 兜底:页面读不到有效标题(例如被「AI · Boss智能聊天」这类常驻节点挡住)时, // 只要聊天窗口有内容就按「已打开」继续 —— 后面发送前还有一次内容一致性校验兜底, // 不会因此串人;而如果在这里直接判失败,就会像之前那样一条都不回复。 const hist2 = await this.getChatHistory(); if (hist2.length) { this.log( `⚠️ 读不到有效的窗口标题${ lastSeen ? `(当前读到的是「${lastSeen}」)` : "(页面上可能存在常驻的 AI 节点)" },改用聊天内容判断「${name}」已打开(发送前会再校验一次)` ); return true; } this.log( `「${name}」点击后聊天窗口没有内容${ lastSeen ? `(窗口仍是「${lastSeen}」)` : "" },本轮跳过(下一轮会自动重试)` ); return false; }, // 左侧会话列表节点收集(唯一入口,三个地方共用,避免口径不一致) // 严格模式要求:最内层 li + 有头像 img + 文本够长 + 不是筛选标签 collectConversationNodes({ requireAvatar = true } = {}) { try { const raw = Array.from( document.querySelectorAll( 'ul[role="group"] li[role="listitem"], .user-list-content li, .user-list li, .chat-user li, .friend-list li, [class*="user-list"] li, [class*="friend-list"] li, [class*="chat-list"] li, [class*="user-item"], [class*="conversation-item"]' ) ); // 顶部/侧边筛选标签("全部/未读/新招呼"等)不是会话,必须排除 const FILTER_LABEL = /^(全部|未读|新招呼|沟通过|已投递|已交换|牛人|新消息|我的|推荐|筛选|更多|置顶|收藏|不合适|已结束|打招呼|AI筛选|AI筛选提交|AI助手|智能助手)$/; return raw.filter((li) => { const t = this.normText(li.textContent).replace(/\s+/g, ""); if (t.length < 3) return false; if (li.querySelectorAll("li").length) return false; // 只取最内层会话项 if (FILTER_LABEL.test(t)) return false; if (requireAvatar && !li.querySelector("img")) return false; return true; }); } catch (e) { return []; } }, // 列表虚拟滚动兜底:在左侧会话列表容器里分步滚动,逐步查找目标会话项 async scrollConversationListToFind(name, company) { try { const cands = Array.from( document.querySelectorAll( '.user-list-content, .user-list, [class*="user-list"], [class*="chat-list"], [class*="friend-list"], ul[role="group"]' ) ); const scroller = cands.find( (el) => el && el.scrollHeight > el.clientHeight + 20 ); if (!scroller) return null; for (let i = 0; i < 6; i++) { scroller.scrollTop = i === 0 ? 0 : Math.min( scroller.scrollHeight, scroller.scrollTop + scroller.clientHeight * 0.8 ); await this.delay(400); const hit = this.findConversationItem(name, company); if (hit) return hit; } } catch (e) { /* 忽略,交由上层按未找到处理 */ } return null; }, // 按「姓名 + 公司」在左侧会话列表里找到对应的列表项 // 说明:不再依赖 .name-text/.friend-content 这些可能已改名的 class,直接匹配整项文本 findConversationItem(name, company) { try { // 先用严格口径(要求有头像),拿不到再放宽,保证既准确又不至于找不到 let items = this.collectConversationNodes({ requireAvatar: true }); if (!items.length) items = this.collectConversationNodes({ requireAvatar: false }); if (!items.length) return null; const norm = (s) => String(s || "").replace(/\s+/g, "").trim(); const nName = norm(name); const nComp = norm(company); const textOf = (li) => norm(li.textContent); if (nName) { if (nComp) { const byBoth = items.find( (li) => textOf(li).includes(nName) && textOf(li).includes(nComp) ); if (byBoth) return byBoth; } const byName = items.find((li) => textOf(li).includes(nName)); if (byName) return byName; // 姓名写法不同(如「陈露」vs「陈女士-长沙蓝博」)时,按解析出的姓名再试一次。 // ⚠️ 必须要求解析结果非空:isSameHr 在「任一方为空」时会返回 true, // 若这里传空串进去,就会无条件命中列表里的第一项 → 点错会话。 const byParsed = items.find((li) => { const parsed = this.parseNameFromText(textOf(li)); return !!parsed && this.isSameHr(parsed, nName); }); if (byParsed) return byParsed; } if (nComp) { const byComp = items.find((li) => textOf(li).includes(nComp)); if (byComp) return byComp; } return null; } catch (e) { return null; } }, // 处理一个会话:打开 → 读记录 → 确认「确实在等我回复」→ AI 决策 → 原窗口内发送 // 返回 true 表示「已处理完(无需再管)」,false 表示本轮未完成、下一轮重试 async handleOneConversation(friend) { const { name, company } = friend; try { const opened = await this.openConversationByName(name, company); if (!opened) return false; // 打开后以实际窗口内容为准(左侧预览可能是系统消息或已过时) const history = await this.getChatHistory(); const unanswered = this.getUnansweredMessages(history); if (!unanswered.length) { this.log(`「${name}」没有待回复的消息(最后一条是我发的或系统消息),跳过`); return true; } const lastMsg = unanswered[unanswered.length - 1]; // DOM 兜底来源用「最后一条 HR 消息文字」做二次去重 if (friend.dom && this.isConversationHandled(name, lastMsg.text)) return true; const ok = await this.replyToCurrentChat(name, company, history, lastMsg); if (ok) return true; // replyToCurrentChat 内部已判定「无需再回」的情况(如 HR 已拒绝)也算处理完成 return ( this.isConversationHandled(name, lastMsg.mid || lastMsg.text) || this.isConversationHandled(name, friend.fingerprint) ); } catch (error) { this.log(`处理会话「${name}」出错: ${error.message}`); return false; } }, // 「我最后一条消息之后 HR 发来的消息」——与 ghost-job 的 unanswered() 完全一致 getUnansweredMessages(history) { const tail = []; for (let i = history.length - 1; i >= 0; i--) { const m = history[i]; if (!m.fromHr) break; tail.push(m); } return tail.reverse().filter((m) => m.text); }, // 在「当前打开的」会话里完成一次 AI 回复 // ⚠️ 关键:模型返回后必须重新校验窗口身份,对不上就放弃发送(这是之前回复串人的根因) async replyToCurrentChat(name, company, history, lastMsg) { const tag = name || this.currentMonitoredHR || "当前会话"; if (!state.isRunning) { this.log("AI回复跳过:未处于运行状态(请先点「开始智能回复」)"); return false; } if (this.getChatMode() !== "ai-auto-reply") return false; const msgKey = (lastMsg.mid || "t:" + lastMsg.text) + "@" + tag; if (this.handledMsgIds.has(msgKey)) return false; // 已回复过,正常去重 this.log(`检测到「${tag}」消息: ${lastMsg.text.slice(0, 30)}`); // 回复前随机等待,模拟真人节奏 await this.delay( this.rand(this.AI_CHAT.REPLY_DELAY[0], this.AI_CHAT.REPLY_DELAY[1]) ); if (!state.isRunning) return false; // 让 AI 结合简历 + 岗位 + 聊天记录做结构化决策。 // 岗位名优先用 ghost-job 那条路径(getBossData 里的 jobName),拿不到再退回页面读取 const positionName = this._lastChatApiJob || this.getPositionName() || ""; const decision = await this.requestAiDecision( this.buildChatDecisionPrompt(history, positionName) ); if (!decision) { this.log("AI回复跳过:模型没有返回可用内容(请检查 API Key / 模型 / 网络)"); return false; } // —— 发送前二次校验(这是之前「回复串人」的根治手段)—— // ① 姓名核对:窗口标题 → 最近点击的会话项;读到伪会话名(AI/系统)时一律忽略 // ② 当前会话的最后一条消息是否仍是刚才那条 // ③ 当前窗口的内容是否与「我点击的列表项」对得上(防止窗口其实没切过去) const hrInfo = await this.getActiveChatFromHeader(); let openedName = (hrInfo && hrInfo.name) || ""; if (openedName && this.isPseudoConversation(openedName)) openedName = ""; let nameSource = openedName ? "窗口标题" : ""; // 信号②:窗口标题读不到时,用「左侧列表里处于选中态的会话项」判身份 if (!openedName) { const activeName = this.getActiveConversationName(); if (activeName) { openedName = activeName; nameSource = "列表选中项"; } } // ⚠️ 这里故意**不再**用「最近点击的会话项」兜底判身份: // 它永远等于我们刚点开的目标名,拿它比对等于没校验(恒成立), // 还会把真正有效的内容校验挤掉,造成「看着校验了其实没校验」。 if (openedName && name) { if (!this.isSameHr(openedName, name)) { this.log( `⚠️ 当前窗口的${nameSource}是「${openedName}」,与本次要回复的「${name}」不一致,放弃发送(防止回复串人)` ); return false; } this.log(`✅ 发送前校验通过:「${name}」(依据:${nameSource})`); } else { // 两个真实信号都拿不到时才退回内容比对(已做成「极难判冲突」,不会误伤) this.log("⚠️ 读不到当前聊天窗口的 HR 姓名,改用「消息内容一致性」校验"); const previewRef = Date.now() - (this.lastClickedConversationAt || 0) < 120000 ? this.lastClickedConversationPreview || "" : ""; if (previewRef && this.historyContradictsPreview(history, previewRef)) { this.log( `⚠️ 当前窗口内容与刚点击的会话项「${(this.lastClickedConversationText || "").slice( 0, 20 )}」对不上,放弃发送(防止回复串人),下一轮重试` ); return false; } } const verifiedName = openedName || name || tag; const nowHistory = await this.getChatHistory(); const nowLast = this.getUnansweredMessages(nowHistory).pop(); const wantedMid = (lastMsg && lastMsg.mid) || ""; const wantedText = (lastMsg && lastMsg.text) || ""; const nowMid = (nowLast && nowLast.mid) || ""; const nowText = (nowLast && nowLast.text) || ""; const sameMsg = wantedMid && nowMid ? wantedMid === nowMid : !!nowText && nowText === wantedText; if (!nowLast || !sameMsg) { this.log( `⚠️ 「${verifiedName}」会话内容已变化(可能在等模型期间又来了新消息),本次放弃发送,下一轮再处理` ); return false; } // 去重用的键统一取「会话名」(而不是可能被解析得很长的 verifiedName), // 否则下一轮用 friend.name 去查会查不到,导致同一会话被反复排队 const chatKeyName = name || verifiedName; this.handledMsgIds.add(msgKey); this.markConversationHandled(chatKeyName, lastMsg.mid || lastMsg.text); const companyName = (hrInfo && hrInfo.company) || company || ""; const hrKey = `${verifiedName}-${companyName}`.toLowerCase(); // HR 已拒绝:只记录状态,不再回复(也不再自动关窗,避免干扰其它会话) if (decision.outcome === "hr_rejected") { this.log( `AI 判断 HR 已拒绝,结束对话: "${lastMsg.text.slice(0, 30)}..."` ); this.syncChatStatusToTracker(companyName, "rejected"); return false; } if (!decision.reply) { this.log(`AI 判断本条无需回复(outcome=${decision.outcome})`); return false; } // 发送回复(逐字输入,模拟真人) const sent = await this.sendReplyLikeHuman(decision.reply); if (!sent) { this.log("发送回复失败(未找到输入框或发送按钮)"); return false; } this.log(`AI 已回复「${tag}」: ${decision.reply.slice(0, 40)}`); // 记录我方最后发出的内容,供会话预览比对(区分"最后一条是不是我发的") this.lastSentText.set(chatKeyName, decision.reply); // 记录处理状态 state.hrInteractions.processedHRs.add(hrKey); if (!state.hrInteractions.lastMessageTime) { state.hrInteractions.lastMessageTime = {}; } state.hrInteractions.lastMessageTime[hrKey] = Date.now(); StatePersistence.saveState(); // 同步沟通结果到投递追踪看板 if (decision.interview) { this.syncChatStatusToTracker(companyName, "interview"); } else { this.syncChatStatusToTracker(companyName, "replied"); } if (decision.outcome === "declined") { this.syncChatStatusToTracker(companyName, "rejected"); } // 固定策略:HR 要简历 → 自动同意简历请求卡片或发送附件简历 if (decision.send_resume) { await this.delay(this.rand(600, 1200)); const agreed = await HRInteractionManager.handleCardMessage(); if (agreed) { this.log("已同意 HR 的附件简历请求"); } else { const sentResume = await HRInteractionManager.sendResume(); if (sentResume) { this.log("已自动向 HR 发送附件简历"); } else { this.log("自动发送简历失败,请手动发送"); } } } // 休息节奏:连续回复 N 条后休息一会,降低风控风险 this.repliedCount++; if ( this.AI_CHAT.REST_EVERY && this.repliedCount % this.AI_CHAT.REST_EVERY === 0 ) { const restMin = this.rand( this.AI_CHAT.REST_MINUTES[0], this.AI_CHAT.REST_MINUTES[1] ); this.log( `已连续回复 ${this.repliedCount} 条,休息 ${Math.round(restMin)} 分钟` ); await this.delay(restMin * 60 * 1000); } return true; }, // 兼容旧调用入口:对「当前打开的会话」做一次回复(内部同样走串行 + 发送前校验) async aiReply() { if (this.aiCycleRunning) return false; if (this.getChatMode() !== "ai-auto-reply") return false; if (!state.isRunning) return false; const history = await this.getChatHistory(); const unanswered = this.getUnansweredMessages(history); if (!unanswered.length) { this.log("AI回复跳过:当前会话没有待回复的 HR 消息"); return false; } const info = await this.getActiveChatFromHeader(); return this.replyToCurrentChat( (info && info.name) || "", (info && info.company) || "", history, unanswered[unanswered.length - 1] ); }, async requestAi(message, systemRoleOverride = null) { // 获取用户自定义配置 const customApiKey = localStorage.getItem("aiApiKey"); const customApiUrl = localStorage.getItem("aiApiUrl"); const customModel = localStorage.getItem("aiModel"); const customRole = localStorage.getItem("aiRole"); // 使用用户自定义配置 const authToken = customApiKey; const apiUrl = customApiUrl; const model = customModel; // 默认系统角色(仅在无自定义角色且无override时使用) const defaultSystemRole = "你是一个正在BOSS直聘上与HR聊天的求职者。回复要求:简短口语化(30字内),像朋友微信聊天;有经验说经验,没经验说能力;工资说范围、到岗给时间、离职说成长;不说您好、不模板化、不写小作文。"; const systemRole = systemRoleOverride || customRole || defaultSystemRole; // 检查是否配置了AI if (!authToken || !apiUrl || !model) { this.log("⚠️ 未配置AI API,请先点击AI配置按钮设置API Key"); return null; } // 构建请求体,兼容不同API const requestBody = { model: model, messages: [ { role: "user", content: message } ], max_tokens: 512 }; // 只有非硅基流动API才添加system role和额外参数 // 硅基流动的某些模型可能不支持system role if (!apiUrl.includes("siliconflow.cn")) { requestBody.messages.unshift({ role: "system", content: systemRole, }); requestBody.temperature = 0.9; requestBody.top_p = 0.8; } // 单次请求 const doRequest = () => new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { reject(new Error("AI请求超时(60秒)")); }, 60000); GM_xmlhttpRequest({ method: "POST", url: apiUrl, headers: { "Content-Type": "application/json", Authorization: "Bearer " + authToken, }, data: JSON.stringify(requestBody), timeout: 60000, onload: (response) => { clearTimeout(timeoutId); try { const result = JSON.parse(response.responseText); // 处理不同API的响应格式 if (result.choices && result.choices[0] && result.choices[0].message) { // OpenAI格式 resolve(result.choices[0].message.content.trim()); } else if (result.code !== undefined && result.code !== 0) { // 讯飞格式错误 throw new Error( "API错误: " + result.message + "(Code: " + result.code + ")" ); } else if (result.choices && result.choices[0]) { // 其他格式 resolve(result.choices[0].message?.content?.trim() || result.choices[0].text?.trim() || ""); } else { throw new Error("无法解析API响应格式"); } } catch (error) { reject( new Error( "响应解析失败: " + error.message + "\n原始响应: " + response.responseText ) ); } }, onerror: (error) => { clearTimeout(timeoutId); reject(new Error("网络请求失败: " + error)); }, ontimeout: () => { clearTimeout(timeoutId); reject(new Error("请求超时")); }, }); }); // 重试逻辑:失败后等3秒再试1次 try { return await doRequest(); } catch (firstErr) { this.log(`AI首次请求失败: ${firstErr.message},3秒后重试...`); await this.delay(3000); try { return await doRequest(); } catch (secondErr) { throw new Error(`AI请求失败(已重试): ${secondErr.message}`); } } }, // 读取简历文件内容 async readResumeFile(file) { return new Promise(async (resolve, reject) => { const reader = new FileReader(); reader.onload = async (e) => { try { const content = e.target.result; // 对于PDF文件,尝试提取文本 if (file.type === 'application/pdf' || file.name.endsWith('.pdf')) { // PDF文本提取需要额外处理(使用PDF.js) const text = await this.extractTextFromPDF(content); // 检查提取结果 if (!text || text.length < 100) { // 提取失败,返回提示信息 resolve({ success: false, text: "", message: "【PDF提取失败】\n\n浏览器无法直接读取此PDF的文本内容。\n\n可能原因:\n1. PDF是扫描件(图片格式)\n2. PDF使用了特殊字体编码\n3. PDF有密码保护\n\n【解决方案】\n请直接打开PDF文件,按 Ctrl+A 全选,Ctrl+C 复制,\n然后粘贴到下方的简历内容文本框中。\n\n这是最简单可靠的方法!" }); } else { resolve({ success: true, text: text }); } } else if (file.name.endsWith('.doc') || file.name.endsWith('.docx')) { // Word文件尝试读取 const text = this.extractTextFromWord(content); if (!text || text.length < 50) { resolve({ success: false, text: "", message: "【Word提取失败】\n\n浏览器无法直接读取此Word文件的文本内容。\n\n【解决方案】\n请直接打开Word文件,按 Ctrl+A 全选,Ctrl+C 复制,\n然后粘贴到下方的简历内容文本框中。" }); } else { resolve({ success: true, text: text }); } } else { // 文本文件直接读取 resolve({ success: true, text: content }); } } catch (error) { reject(new Error("文件解析失败: " + error.message)); } }; reader.onerror = () => { reject(new Error("文件读取失败")); }; // 根据文件类型选择读取方式 if (file.type === 'application/pdf' || file.name.endsWith('.pdf')) { reader.readAsArrayBuffer(file); } else { reader.readAsText(file, 'UTF-8'); } }); }, // 从Word文件提取文本(简单实现) extractTextFromWord(arrayBuffer) { try { // 将ArrayBuffer转换为Uint8Array const uint8Array = new Uint8Array(arrayBuffer); // 尝试解码为文本 const decoder = new TextDecoder('utf-8'); let text = decoder.decode(uint8Array); // 清理Word文档中的XML标签和特殊字符 text = text .replace(/<[^>]+>/g, ' ') // 移除XML标签 .replace(/\{[^}]+\}/g, ' ') // 移除Word特殊标记 .replace(/[^\u4e00-\u9fa5a-zA-Z0-9\s\n\.,;:!?,。;:!?]/g, ' ') // 保留常用字符 .replace(/\s+/g, ' ') // 合并多个空格 .trim(); return text; } catch (error) { console.error("Word解析错误:", error); return ""; } }, // 使用PDF.js提取PDF文本 async extractTextFromPDF(arrayBuffer) { try { // 检查PDF.js是否可用 if (typeof pdfjsLib === 'undefined') { console.error('PDF.js库未加载'); return ''; } // 设置PDF.js worker(使用CDN) pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js'; // 加载PDF文档 const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise; console.log(`PDF加载成功,共 ${pdf.numPages} 页`); let fullText = ''; // 遍历所有页面提取文本 for (let i = 1; i <= pdf.numPages; i++) { try { const page = await pdf.getPage(i); const textContent = await page.getTextContent(); // 提取文本项 const pageText = textContent.items .map(item => item.str) .filter(str => str && str.trim().length > 0) .join(' '); if (pageText) { fullText += pageText + '\n'; } // 释放页面资源 page.cleanup(); } catch (pageError) { console.error(`提取第 ${i} 页文本失败:`, pageError); } } // 清理提取的文本 fullText = fullText .replace(/\s+/g, ' ') .replace(/\n+/g, '\n') .trim(); console.log(`PDF文本提取完成,共 ${fullText.length} 字符`); // 检查提取结果 if (fullText.length < 50) { console.log("PDF文本提取结果太短,可能是扫描件或图片PDF"); return ''; } return fullText; } catch (error) { console.error('PDF.js解析错误:', error); return ''; } }, // AI分析简历 // 截断简历文本到指定长度(避免超过AI token限制) truncateResumeText(resumeText, maxLength = 8000) { if (!resumeText || resumeText.length <= maxLength) { return resumeText; } // 尝试在句子边界截断 let truncated = resumeText.substring(0, maxLength); const lastPeriod = truncated.lastIndexOf('。'); const lastNewline = truncated.lastIndexOf('\n'); const lastSpace = truncated.lastIndexOf(' '); // 找到最后一个合适的截断点 const cutPoint = Math.max(lastPeriod, lastNewline, lastSpace); if (cutPoint > maxLength * 0.8) { truncated = truncated.substring(0, cutPoint + 1); } return truncated + "\n[简历内容已截断,仅显示前" + truncated.length + "字符]"; }, async analyzeResumeWithAI(resumeText) { try { this.log("正在使用AI分析简历..."); // 检查简历内容是否为空 if (!resumeText || resumeText.trim().length < 50) { throw new Error("简历内容太短或为空,请检查是否正确上传了简历文件"); } // 截断简历文本,避免超过token限制 const truncatedResume = this.truncateResumeText(resumeText, 6000); if (truncatedResume.length < resumeText.length) { this.log(`简历内容已截断: ${resumeText.length} -> ${truncatedResume.length} 字符`); } // 记录简历前200字符用于调试 this.log(`简历内容前200字符: ${truncatedResume.substring(0, 200)}...`); const analysisPrompt = `【重要】请基于以下提供的真实简历内容进行分析,不要生成示例或模板内容: ===简历开始=== ${truncatedResume} ===简历结束=== 请严格基于上述简历中的真实信息,分析并输出以下内容: 1. 核心技能(从简历中提取的具体技能,不要编造) 2. 工作经验亮点(基于简历中的工作经历) 3. 教育背景(简历中的真实教育信息) 4. 个人优势(基于简历内容总结) 5. 适合岗位类型(根据简历技能匹配) 【警告】如果简历内容为空或不清晰,请直接回复"简历内容无法识别,请检查上传的文件"。 不要生成示例数据!必须基于真实简历内容分析。`; const analysis = await this.requestAi(analysisPrompt); // 检查AI是否返回了示例内容(包含"某知名大学"、"某顶尖大学"等模板词汇) const templateKeywords = ['某知名大学', '某顶尖大学', '某大学', '示例', '模板', '某某']; const isTemplate = templateKeywords.some(keyword => analysis.includes(keyword)); if (isTemplate) { this.log("警告:AI返回了模板内容,可能未正确读取简历"); return "【警告】AI未能正确识别简历内容,请检查:\n1. 文件是否正确上传\n2. 简历内容是否可提取(PDF可能是扫描件)\n3. 尝试直接粘贴简历文本到文本框中\n\n原始返回内容:\n" + analysis; } this.log("简历分析完成"); return analysis; } catch (error) { this.log(`简历分析失败: ${error.message}`); throw error; } }, // 根据简历生成自我介绍 async generateGreetingsFromResume(resumeText, analysis) { try { this.log("正在生成自我介绍..."); // 截断简历文本 const truncatedResume = this.truncateResumeText(resumeText, 4000); const greetingPrompt = `基于以下简历信息,生成4条简洁的自我介绍,用于求职时首次沟通: 简历内容: ${truncatedResume} 简历分析: ${analysis} 要求: 1. 每条30-50字 2. 第一条:基本信息+求职意向 3. 第二条:核心技能 4. 第三条:项目/工作经验亮点 5. 第四条:个人优势+期待回复 6. 语气真诚、专业但不过于正式 7. 不要出现"您好"开头,直接说内容 请直接输出4条自我介绍内容,每条一行,用数字编号。`; const response = await this.requestAi(greetingPrompt); // 解析生成的自我介绍 const greetings = response.split('\n') .filter(line => line.trim()) .map((line, index) => { // 移除数字编号 const content = line.replace(/^\d+[\.、]\s*/, '').trim(); return { id: String(index + 1), content }; }) .filter(g => g.content.length > 10); // 过滤掉太短的 if (greetings.length >= 2) { state.settings.greetingsList = greetings.slice(0, 4); localStorage.setItem("greetingsList", JSON.stringify(state.settings.greetingsList)); this.log(`已生成 ${greetings.length} 条自我介绍`); // 刷新UI setTimeout(() => { if (typeof renderGreetingsList === 'function') { renderGreetingsList(); } }, 100); return greetings; } else { this.log("生成的自我介绍数量不足,使用默认内容"); return null; } } catch (error) { this.log(`生成自我介绍失败: ${error.message}`); return null; } }, // 根据简历信息生成个性化回复 async generatePersonalizedReply(hrMessage) { try { const resumeText = state.settings.resumeText || ""; const resumeAnalysis = state.settings.resumeAnalysis || ""; // 读取最近聊天记录,让回复衔接上下文(参考 ghost-job 多轮对话思路) let historyLines = ""; try { const history = await this.getChatHistory(); const lines = history .slice(-(this.AI_CHAT ? this.AI_CHAT.HISTORY_LIMIT : 20)) .map((m) => `${m.fromHr ? "HR" : "我"}:${m.text}`) .join("\n"); if (lines) historyLines = `【聊天记录(从早到晚,最后一条是HR刚发的)】\n${lines}\n`; } catch (e) {} // 自动生成匹配的角色设定 const autoRole = await this.generateAutoRole(resumeText, resumeAnalysis); if (!resumeText && !resumeAnalysis) { // 没有简历信息,使用普通AI回复(但应用自动角色) const prompt = `${historyLines ? historyLines + "\n" : ""}【HR的消息】"${hrMessage}"\n\n请结合聊天记录的上下文回复HR的最新消息。直接给出回复内容,不要任何前缀说明。`; return await this.requestAi(prompt, autoRole); } // 截断简历文本 const truncatedResume = this.truncateResumeText(resumeText, 3000); const replyPrompt = `【角色设定】 ${autoRole} 【简历信息】 ${truncatedResume} ${resumeAnalysis ? `【简历亮点分析】\n${resumeAnalysis}\n` : ''} ${historyLines} 【HR的消息】"${hrMessage}" 【回复要求】 1. 回复要自然、口语化,像真人聊天,像朋友间交流 2. 不要用"您好""很高兴""我是"这种正式开场白 3. 结合简历中的实际经历和技能来回答,展示真实能力 4. 如果HR问技术问题,展示你的专业能力,用具体项目或经历佐证 5. 如果HR问薪资,给出一个合理范围,不要太死板 6. 如果HR问到岗时间,可以表达诚意但不要承诺太死 7. 回复控制在30字以内,要简洁有力 8. 可以用适当的语气词和标点来增加真实感 9. 不要暴露你是AI,措辞要像真人自然交流 10. 严格衔接上面的聊天记录:HR已经说过的事不要重复问,我方已经回答过的问题不要重复回答,不要每次都重新自我介绍 直接给出回复内容,不需要任何前缀说明。`; const reply = await this.requestAi(replyPrompt); return reply; } catch (error) { this.log(`生成个性化回复失败: ${error.message}`); // 失败时使用普通AI回复 return await this.requestAi(hrMessage); } }, // 根据简历自动生成匹配的角色设定 async generateAutoRole(resumeText, resumeAnalysis) { // 基础角色设定 let baseRole = "你是一个在BOSS直聘上和HR聊天的求职者,回复简短口语化(30字内),像朋友微信聊天,不说套话不写小作文"; if (!resumeText && !resumeAnalysis) { return baseRole; } // 从简历分析中提取关键信息来判断角色类型 const textToAnalyze = (resumeText + " " + (resumeAnalysis || "")).toLowerCase(); // 检测是否为应届生 const isFreshGraduate = /应届|毕业|实习经历|无工作经历/.test(textToAnalyze) && !/1-3年|3-5年|5年|10年/.test(textToAnalyze); // 检测是否有工作经验 const yearsMatch = textToAnalyze.match(/(\d+)年/i); const years = yearsMatch ? parseInt(yearsMatch[1]) : 0; // 检测技术背景 const isTech = /开发|工程|技术|编程|代码|前端|后端|java|python|js|javascript|sql|数据库|算法/.test(textToAnalyze); // 检测管理背景 const isManagement = /管理|带领|团队|负责|主管|经理|总监/.test(textToAnalyze); // 检测设计背景 const isDesign = /设计|ui|ux|创意|审美|视觉/.test(textToAnalyze); // 根据检测结果生成角色 let roleDescription = ""; if (isFreshGraduate) { roleDescription = "你是一个刚毕业或即将毕业的应届生,对职场充满热情但不世故,说话真诚直接,有点青涩但很努力,会主动表达学习意愿"; } else if (years > 0 && years <= 3) { roleDescription = "你是一个有" + years + "年工作经验的职场新人,跳槽原因是追求更好的发展机会,说话比较实在,会主动展示自己的项目经验"; } else if (years > 3 && years <= 5) { roleDescription = "你是一个有" + years + "年工作经验的职场人,跳槽是为了职业发展,说话成熟稳重,会从专业角度回答问题"; } else if (years > 5) { roleDescription = "你是一个资深的职场专业人士,有" + years + "年丰富经验,说话专业自信,跳槽是为了更好的平台或转型,会强调自己的核心竞争力"; } else { roleDescription = "你是一个正在求职的职场人,说话自然诚恳,会主动展示自己的优势,坦诚回答薪资到岗等问题"; } // 根据技术背景添加特点 if (isTech) { roleDescription += "。对技术问题会用通俗易懂的方式解释"; } // 根据管理背景添加特点 if (isManagement) { roleDescription += "。有团队管理经验,会强调自己的协作和领导能力"; } // 根据设计背景添加特点 if (isDesign) { roleDescription += "。有设计思维,说话有审美感和创意"; } return baseRole + "。" + roleDescription + "。回复要简短,30字以内,像真人手机聊天一样自然,不要模板套话。"; }, async getLastFriendMessageText() { try { const chatContainer = DOMCache.get(".chat-message .im-list"); if (!chatContainer) return null; const friendMessages = Array.from( chatContainer.querySelectorAll("li.message-item.item-friend") ); if (friendMessages.length === 0) return null; const lastMessageEl = friendMessages[friendMessages.length - 1]; const textEl = lastMessageEl.querySelector(".text span"); return textEl?.textContent?.trim() || null; } catch (error) { ErrorHandler.handle(error, 'Core.getLastFriendMessageText'); this.log(`获取消息出错: ${error.message}`); return null; } }, async simulateClick(element) { if (!element) return; const rect = element.getBoundingClientRect(); const x = rect.left + rect.width / 2; const y = rect.top + rect.height / 2; const dispatchMouseEvent = (type, options = {}) => { const event = new MouseEvent(type, { bubbles: true, cancelable: true, view: document.defaultView, clientX: x, clientY: y, ...options, }); element.dispatchEvent(event); }; dispatchMouseEvent("mouseover"); await this.delay(CONFIG.DELAYS.SHORT); dispatchMouseEvent("mousemove"); await this.delay(CONFIG.DELAYS.SHORT); dispatchMouseEvent("mousedown", { button: 0 }); await this.delay(CONFIG.DELAYS.SHORT); dispatchMouseEvent("mouseup", { button: 0 }); await this.delay(CONFIG.DELAYS.SHORT); dispatchMouseEvent("click", { button: 0 }); }, async waitForElement(selectorOrFunction, timeout = 5000) { return new Promise((resolve) => { let element; if (typeof selectorOrFunction === "function") element = selectorOrFunction(); else element = document.querySelector(selectorOrFunction); if (element) return resolve(element); const timeoutId = setTimeout(() => { observer.disconnect(); resolve(null); }, timeout); const observer = new MutationObserver(() => { if (typeof selectorOrFunction === "function") element = selectorOrFunction(); else element = document.querySelector(selectorOrFunction); if (element) { clearTimeout(timeoutId); observer.disconnect(); resolve(element); } }); observer.observe(document.body, { childList: true, subtree: true }); }); }, getContextMultiplier(context) { const multipliers = { dict_load: 1.0, click: 0.8, selection: 0.8, default: 1.0, }; return multipliers[context] || multipliers["default"]; }, async smartDelay(baseTime, context = "default") { const multiplier = this.getContextMultiplier(context); const adjustedTime = baseTime * multiplier; return this.delay(adjustedTime); }, async delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }, async handleGreetSettingsPage() { try { localStorage.setItem(STORAGE.VISITED_GREET_SET, "true"); await this.delay(1000); const titleElement = document.querySelector("h3.title-wrap"); if (titleElement) { titleElement.textContent = "请务必打开 打招呼语功能"; titleElement.style.color = "red"; titleElement.style.fontWeight = "bold"; titleElement.style.fontSize = "18px"; } const possibleSelectors = [ "h4 .ui-switch", ".ui-switch", "span.ui-switch", "[class*='ui-switch']" ]; let switchElement = null; for (const selector of possibleSelectors) { switchElement = document.querySelector(selector); if (switchElement) { break; } } if (switchElement) { const isChecked = switchElement.classList.contains("ui-switch-checked"); if (!isChecked) { await this.simulateClick(switchElement); await this.delay(800); const newSwitchElement = document.querySelector(possibleSelectors.find(s => document.querySelector(s))); if (newSwitchElement && newSwitchElement.classList.contains("ui-switch-checked")) { UI.notify("招呼语功能已启用", "success"); } else { await this.simulateClick(switchElement); await this.delay(500); const finalSwitchElement = document.querySelector(possibleSelectors.find(s => document.querySelector(s))); if (finalSwitchElement && finalSwitchElement.classList.contains("ui-switch-checked")) { UI.notify("招呼语功能已启用", "success"); } else { UI.notify("请手动启用招呼语功能", "warning"); } } } else { UI.notify("招呼语功能已启用", "success"); } } else { const allSwitches = document.querySelectorAll("[class*='switch']"); allSwitches.forEach((el, index) => { this.log(`开关 ${index + 1}: ${el.className}, 文本: ${el.textContent?.trim()}`); }); } } catch (error) { ErrorHandler.handle(error, 'Core.handleGreetSettingsPage'); } }, extractTwoCharKeywords(text) { const keywords = []; const cleanedText = text.replace(/[\s,,.。::;;""''\[\]\(\)\{\}]/g, ""); for (let i = 0; i < cleanedText.length - 1; i++) { keywords.push(cleanedText.substring(i, i + 2)); } return keywords; }, async resetCycle() { // 检查是否配置了多个城市 const cities = state.locationKeywords.filter(kw => kw.trim() !== ''); if (cities.length > 1 && state.currentCityIndex < cities.length - 1) { // 切换到下一个城市 state.currentCityIndex = (state.currentCityIndex || 0) + 1; const nextCity = cities[state.currentCityIndex]; this.log(`当前城市职位投递完成,准备切换到: ${nextCity}`); // 切换城市 const switched = await this.switchCity(nextCity); if (switched) { // 重置状态,继续投递 state.currentIndex = 0; state.jobList = []; this.log(`已切换到 ${nextCity},继续投递...`); // 延迟后重新开始 await this.delay(3000); toggleProcess(); // 停止当前循环 await this.delay(1000); toggleProcess(); // 重新开始 return; } } // 所有城市都投递完成 toggleProcess(); this.log("所有城市岗位沟通完成,恭喜您即将找到理想工作!"); state.currentIndex = 0; state.currentCityIndex = 0; }, async switchCity(cityName) { try { // 1. 点击城市选择器 const citySelector = document.querySelector('.city-label') || document.querySelector('[class*="city"]') || document.querySelector('.filter-city'); if (!citySelector) { this.log("未找到城市选择器,尝试直接修改搜索框"); return await this.switchCityBySearch(cityName); } citySelector.click(); await this.delay(1000); // 2. 在弹出的城市列表中查找目标城市 const cityInput = document.querySelector('.city-search input') || document.querySelector('.filter-city-search input'); if (cityInput) { // 输入城市名搜索 cityInput.value = cityName; cityInput.dispatchEvent(new Event('input', { bubbles: true })); await this.delay(1000); } // 3. 点击目标城市 const cityItems = document.querySelectorAll('.city-item, .filter-city-item, [class*="city-list"] li'); for (const item of cityItems) { if (item.textContent.includes(cityName)) { item.click(); this.log(`已选择城市: ${cityName}`); await this.delay(2000); // 等待页面刷新 return true; } } // 如果没找到,尝试直接搜索 return await this.switchCityBySearch(cityName); } catch (error) { this.log(`切换城市失败: ${error.message}`); return false; } }, async switchCityBySearch(cityName) { try { // 通过搜索框切换城市 const searchInput = document.querySelector('.search-input') || document.querySelector('input[placeholder*="搜索"]') || document.querySelector('.ipt-search'); if (!searchInput) { this.log("未找到搜索框,无法切换城市"); return false; } // 清空并输入新的搜索条件 searchInput.value = `${state.includeKeywords[0] || ''} ${cityName}`; searchInput.dispatchEvent(new Event('input', { bubbles: true })); await this.delay(500); // 触发搜索 const searchBtn = document.querySelector('.search-btn') || document.querySelector('.btn-search') || document.querySelector('button[type="submit"]'); if (searchBtn) { searchBtn.click(); } else { // 按回车键搜索 searchInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', keyCode: 13 })); } this.log(`已通过搜索切换到: ${cityName}`); await this.delay(3000); // 等待搜索结果加载 return true; } catch (error) { this.log(`通过搜索切换城市失败: ${error.message}`); return false; } }, log(message) { const logEntry = `[${new Date().toLocaleTimeString()}] ${message}`; const logPanel = document.querySelector("#pro-log"); if (logPanel) { if (state.comments.isCommentMode) { return; } const logItem = document.createElement("div"); logItem.className = "log-item"; logItem.style.padding = "0px 8px"; logItem.textContent = logEntry; logPanel.appendChild(logItem); logPanel.scrollTop = logPanel.scrollHeight; } }, async getCurrentCompanyName() { try { let companyName = ""; let retries = 0; const maxRetries = 10; while (retries < maxRetries && !companyName) { const bossInfoAttr = document.querySelector(".boss-info-attr"); if (bossInfoAttr) { const text = bossInfoAttr.textContent.trim(); if (text) { const parts = text.split("·"); if (parts.length >= 1) { companyName = parts[0].trim(); if (companyName) { return companyName; } } } } retries++; if (retries < maxRetries) { await this.delay(200); } } return companyName; } catch (error) { console.log(`获取公司名失败: ${error.message}`); return ""; } }, async fetchCompanyComments(companyName, page = 1, size = 10) { return new Promise((resolve, reject) => { if (!companyName) { resolve({ success: false, data: null, message: "公司名不能为空" }); return; } const apiUrl = `https://jasun.xyz/api/public/boss-reviews?company_name=${encodeURIComponent(companyName)}&page=${page}&size=${size}`; GM_xmlhttpRequest({ method: "GET", url: apiUrl, headers: { "Content-Type": "application/json", }, timeout: 10000, onload: (response) => { try { const data = JSON.parse(response.responseText); if (data.code === 200) { resolve({ success: true, data: data.data, message: data.message }); } else { resolve({ success: false, data: null, message: data.message || "获取评论失败" }); } } catch (error) { console.log(`解析评论数据失败: ${error.message}`); resolve({ success: false, data: null, message: "响应解析失败" }); } }, onerror: (error) => { console.log(`获取评论失败: ${error.message}`); resolve({ success: false, data: null, message: "网络请求失败" }); }, ontimeout: () => { console.log("获取评论超时"); resolve({ success: false, data: null, message: "请求超时" }); }, }); }); }, async submitCompanyComment(companyName, comment) { return new Promise((resolve, reject) => { if (!companyName || !comment) { resolve({ success: false, message: "公司名和评论不能为空" }); return; } const apiUrl = `https://jasun.xyz/api/public/boss-reviews`; GM_xmlhttpRequest({ method: "POST", url: apiUrl, headers: { "Content-Type": "application/json", }, data: JSON.stringify({ company_name: companyName, content: comment, }), timeout: 10000, onload: (response) => { try { const data = JSON.parse(response.responseText); if (data.code === 200) { resolve({ success: true, message: data.message || "评论发布成功" }); } else { resolve({ success: false, message: data.message || "评论提交失败" }); } } catch (error) { resolve({ success: false, message: "响应解析失败" }); } }, onerror: (error) => { resolve({ success: false, message: "网络请求失败" }); }, ontimeout: () => { resolve({ success: false, message: "请求超时" }); }, }); }); }, displayComments(comments, companyName) { const logPanel = document.querySelector("#pro-log"); if (!logPanel) return; logPanel.innerHTML = ""; logPanel.style.position = "relative"; logPanel.style.padding = "0"; logPanel.style.height = "260px"; logPanel.style.display = "flex"; logPanel.style.flexDirection = "column"; if (!companyName) { const noCompanyItem = document.createElement("div"); noCompanyItem.className = "comment-item"; noCompanyItem.style.cssText = "padding: 0px; border-bottom: 1px solid #e5e7eb; color: #6b7280; text-align: center;"; noCompanyItem.textContent = "未找到公司信息"; logPanel.appendChild(noCompanyItem); return; } const contentContainer = document.createElement("div"); contentContainer.className = "comment-content-container"; contentContainer.style.cssText = "flex: 1; overflow-y: auto; padding: 12px; scrollbar-width: thin; scrollbar-color: var(--primary-color) var(--secondary-color);"; const headerItem = document.createElement("div"); headerItem.className = "comment-header"; headerItem.style.cssText = "padding: 0px; border-radius: 0px; margin-bottom: 0px;"; headerItem.innerHTML = `
${companyName}
`; contentContainer.appendChild(headerItem); if (!comments || comments.length === 0) { const noCommentsItem = document.createElement("div"); noCommentsItem.className = "comment-item"; noCommentsItem.style.cssText = "padding: 12px; border-bottom: 1px solid #e5e7eb; color: #6b7280; text-align: center;"; noCommentsItem.textContent = "这家公司还没有评论哦,来评论一下吧!"; contentContainer.appendChild(noCommentsItem); } else { comments.forEach((comment, index) => { const commentItem = document.createElement("div"); commentItem.className = "comment-item"; commentItem.style.cssText = "padding: 12px; border-bottom: 1px solid #e5e7eb; margin-bottom: 8px; background: #ffffff; border-radius: 8px;"; const contentDiv = document.createElement("div"); contentDiv.style.cssText = "color: #374151; font-size: 13px; line-height: 1.6; margin-bottom: 6px; word-break: break-word;"; contentDiv.textContent = comment.content || comment.comment || comment; const metaDiv = document.createElement("div"); metaDiv.style.cssText = "font-size: 11px; color: #9ca3af; display: flex; justify-content: space-between;"; const timeText = comment.createdAt || comment.time || new Date().toLocaleString(); metaDiv.innerHTML = `${timeText}`; commentItem.appendChild(contentDiv); commentItem.appendChild(metaDiv); contentContainer.appendChild(commentItem); }); } logPanel.appendChild(contentContainer); const inputContainer = document.createElement("div"); inputContainer.className = "comment-input-container"; inputContainer.style.cssText = "flex-shrink: 0; padding: 12px; background: var(--secondary-color); border-top: 1px solid #e5e7eb; display: flex; gap: 8px; align-items: center;"; const input = document.createElement("input"); input.type = "text"; input.id = "comment-input"; input.placeholder = "说点什么呢..."; input.style.cssText = "flex: 1; padding: 8px 12px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 13px; font-family: inherit; box-sizing: border-box; outline: none;"; input.onfocus = () => { input.style.borderColor = "var(--primary-color)"; }; input.onblur = () => { input.style.borderColor = "#d1d5db"; }; const submitBtn = document.createElement("button"); submitBtn.textContent = "发送"; submitBtn.style.cssText = "padding: 8px 16px; background: var(--primary-color); color: white; border: none; border-radius: 6px; font-size: 12px; font-weight: 500; cursor: pointer; white-space: nowrap; transition: all 0.2s ease;"; submitBtn.onmouseenter = () => { submitBtn.style.opacity = "0.9"; }; submitBtn.onmouseleave = () => { submitBtn.style.opacity = "1"; }; submitBtn.onclick = async () => { const commentText = input.value.trim(); if (!commentText) { alert("请输入评论内容"); return; } submitBtn.disabled = true; submitBtn.textContent = "提交中..."; const result = await this.submitCompanyComment(companyName, commentText); if (result.success) { alert("评论提交成功!"); input.value = ""; // 评论功能已移除 } else { alert(result.message || "评论提交失败"); } submitBtn.disabled = false; submitBtn.textContent = "发送"; }; inputContainer.appendChild(input); inputContainer.appendChild(submitBtn); logPanel.appendChild(inputContainer); contentContainer.scrollTop = contentContainer.scrollHeight; }, async loadAndDisplayComments() { const companyName = await this.getCurrentCompanyName(); state.comments.currentCompanyName = companyName; state.comments.isCommentMode = true; if (state.comments.isLoading) return; state.comments.isLoading = true; const logPanel = document.querySelector("#pro-log"); if (logPanel) { logPanel.innerHTML = '
加载评论中...
'; } const result = await this.fetchCompanyComments(companyName); state.comments.isLoading = false; const comments = result.success && result.data ? result.data.records : []; state.comments.commentsList = comments; this.displayComments(comments, companyName); }, }; async function toggleProcess() { state.isRunning = !state.isRunning; if (state.isRunning) { state.comments.isCommentMode = false; state.jobList = []; state.currentCityIndex = 0; state.includeKeywords = elements.includeInput.value .trim() .toLowerCase() .split(/[,,]/) .filter((keyword) => keyword.trim() !== ""); state.locationKeywords = (elements.locationInput?.value || "") .trim() .toLowerCase() .split(/[,,]/) .filter((keyword) => keyword.trim() !== ""); elements.controlBtn.textContent = "停止海投"; elements.controlBtn.style.background = "#4285f4"; const logPanel = document.querySelector("#pro-log"); if (logPanel) { logPanel.innerHTML = ""; } const startTime = new Date(); Core.log(`开始自动海投,时间:${startTime.toLocaleTimeString()}`); Core.log( `筛选条件:职位名包含【${state.includeKeywords.join("、") || "无" }】,工作地包含【${state.locationKeywords.join("、") || "无"}】` ); // 如果有多个城市,先切换到第一个城市 const cities = state.locationKeywords.filter(kw => kw.trim() !== ''); if (cities.length > 0) { const firstCity = cities[0]; Core.log(`准备切换到第一个城市: ${firstCity}`); const switched = await Core.switchCity(firstCity); if (switched) { Core.log(`已切换到 ${firstCity},等待页面加载...`); await Core.delay(3000); } else { Core.log(`切换城市失败,使用当前页面`); } } Core.startProcessing(); } else { elements.controlBtn.textContent = "启动海投"; elements.controlBtn.style.background = "#4285f4"; state.isRunning = false; state.currentIndex = 0; // 评论功能已移除 } } function toggleChatProcess() { state.isRunning = !state.isRunning; const isGreetingMode = Core.getChatMode() === "greeting"; const startLabel = isGreetingMode ? "开始打招呼" : "开始智能回复"; const stopLabel = isGreetingMode ? "停止打招呼" : "停止智能回复"; if (state.isRunning) { elements.controlBtn.textContent = stopLabel; elements.controlBtn.style.background = "#34a853"; const startTime = new Date(); Core.log( `${isGreetingMode ? "开始打招呼(发送自我介绍)" : "开始 AI 全自动回复"},时间:${startTime.toLocaleTimeString()}` ); Promise.resolve(Core.startProcessing()).catch((e) => Core.log(`启动流程出错: ${e.message}`) ); } else { elements.controlBtn.textContent = startLabel; elements.controlBtn.style.background = "#34a853"; state.isRunning = false; if (Core.messageObserver) { Core.messageObserver.disconnect(); Core.messageObserver = null; } if (Core.chatListObserver) { Core.chatListObserver.disconnect(); Core.chatListObserver = null; } // 停止全量会话监听 Core.stopConversationWatcher(); const stopTime = new Date(); Core.log( `${isGreetingMode ? "停止打招呼" : "停止 AI 全自动回复"},时间:${stopTime.toLocaleTimeString()}` ); } } const STORAGE = { LETTER: "letterLastShown", GUIDE: "shouldShowGuide", AI_COUNT: "aiReplyCount", AI_DATE: "lastAiDate", VISITED_GREET_SET: "hasVisitedGreetSet", }; const letter = { showLetterToUser: function () { const COLORS = { primary: "#4285f4", text: "#333", textLight: "#666", background: "#f8f9fa", }; const overlay = document.createElement("div"); overlay.id = "letter-overlay"; overlay.style.cssText = ` position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); display: flex; justify-content: center; align-items: center; z-index: 9999; backdrop-filter: blur(5px); animation: fadeIn 0.3s ease-out; `; const envelopeContainer = document.createElement("div"); envelopeContainer.id = "envelope-container"; envelopeContainer.style.cssText = ` position: relative; width: 90%; max-width: 650px; height: 400px; perspective: 1000px; `; const envelope = document.createElement("div"); envelope.id = "envelope"; envelope.style.cssText = ` position: absolute; width: 100%; height: 100%; transform-style: preserve-3d; transition: transform 0.6s ease; `; const envelopeBack = document.createElement("div"); envelopeBack.id = "envelope-back"; envelopeBack.style.cssText = ` position: absolute; width: 100%; height: 100%; background: ${COLORS.background}; border-radius: 10px; box-shadow: 0 15px 35px rgba(0,0,0,0.2); backface-visibility: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 30px; cursor: pointer; transition: all 0.3s; `; envelopeBack.innerHTML = `
小臣版AI-boss海投助手
点击开启高效求职之旅
© 2026 小臣版AI-Boss海投助手
`; envelopeBack.addEventListener("click", () => { envelope.style.transform = "rotateY(180deg)"; setTimeout(() => { const content = document.getElementById("letter-content"); if (content) { content.style.display = "block"; content.style.animation = "fadeInUp 0.5s ease-out forwards"; } }, 300); }); const envelopeFront = document.createElement("div"); envelopeFront.id = "envelope-front"; envelopeFront.style.cssText = ` position: absolute; width: 100%; height: 100%; background: #fff; border-radius: 10px; box-shadow: 0 15px 35px rgba(0,0,0,0.2); transform: rotateY(180deg); backface-visibility: hidden; display: flex; flex-direction: column; `; const titleBar = document.createElement("div"); titleBar.style.cssText = ` padding: 20px 30px; background: #4285f4; color: white; font-size: clamp(1.2rem, 2.5vw, 1.4rem); font-weight: 600; border-radius: 10px 10px 0 0; display: flex; align-items: center; `; titleBar.innerHTML = `致AI-Boss海投助手用户:`; const letterContent = document.createElement("div"); letterContent.id = "letter-content"; letterContent.style.cssText = ` flex: 1; padding: 25px 30px; overflow-y: auto; font-size: clamp(0.95rem, 2vw, 1.05rem); line-height: 1.8; color: ${COLORS.text}; background-blend-mode: overlay; background-color: rgba(255,255,255,0.95); display: none; `; letterContent.innerHTML = `

你好,未来的成功人士:

  展信如晤。

  本工具旨在帮助求职者提高投递效率,实现智能化求职。

  我是小臣,在原作者的基础上,我进一步完善和优化了功能, 让这个工具更加强大和易用:

  •   智能岗位筛选,精准投递不盲目
  •   自动批量沟通,一键打招呼省时省力
  •   AI智能回复,24小时在线不错过任何机会
  •   简历自动发送,支持多份简历图片
  •   自定义AI配置,支持硅基流动、火山引擎等平台
  •   个性化沟通策略,大幅提升面试邀约率
  •   自动城市切换,扩大求职范围

  工具只是辅助,你的能力才是核心竞争力。 愿它成为你求职路上的得力助手,助你斩获Offer!

  冀以尘雾之微补益山海,荧烛末光增辉日月。

小臣
2025年4月
`; const buttonArea = document.createElement("div"); buttonArea.style.cssText = ` padding: 15px 30px; display: flex; justify-content: center; border-top: 1px solid #eee; background: ${COLORS.background}; border-radius: 0 0 10px 10px; `; const startButton = document.createElement("button"); startButton.style.cssText = ` background: #4285f4; color: white; border: none; border-radius: 8px; padding: 12px 30px; font-size: clamp(1rem, 2vw, 1.1rem); font-weight: 500; cursor: pointer; transition: all 0.3s; box-shadow: 0 6px 16px rgba(66, 133, 244, 0.3); outline: none; display: flex; align-items: center; `; startButton.innerHTML = `开始使用`; startButton.addEventListener("click", () => { const hasVisitedGreetSet = localStorage.getItem(STORAGE.VISITED_GREET_SET); if (!hasVisitedGreetSet) { localStorage.setItem(STORAGE.VISITED_GREET_SET, "true"); window.open( "https://www.zhipin.com/web/geek/notify-set?type=greetSet", "_blank" ); } envelopeContainer.style.animation = "scaleOut 0.3s ease-in forwards"; overlay.style.animation = "fadeOut 0.3s ease-in forwards"; setTimeout(() => { if (overlay.parentNode === document.body) { document.body.removeChild(overlay); } }, 300); }); buttonArea.appendChild(startButton); envelopeFront.appendChild(titleBar); envelopeFront.appendChild(letterContent); envelopeFront.appendChild(buttonArea); envelope.appendChild(envelopeBack); envelope.appendChild(envelopeFront); envelopeContainer.appendChild(envelope); overlay.appendChild(envelopeContainer); document.body.appendChild(overlay); const style = document.createElement("style"); style.textContent = ` @keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } } @keyframes fadeOut { from { opacity: 1 } to { opacity: 0 } } @keyframes scaleOut { from { transform: scale(1); opacity: 1 } to { transform: scale(.9); opacity: 0 } } @keyframes fadeInUp { from { opacity: 0; transform: translateY(20px) } to { opacity: 1; transform: translateY(0) } } #envelope-back:hover { transform: scale(1.02); box-shadow: 0 20px 40px rgba(0,0,0,0.25); } #envelope-front button:hover { transform: scale(1.05); box-shadow: 0 8px 20px rgba(66, 133, 244, 0.4); } #envelope-front button:active { transform: scale(0.98); } @media (max-width: 480px) { #envelope-container { height: 350px; } #letter-content { font-size: 0.9rem; padding: 15px; } } `; document.head.appendChild(style); }, }; const guide = { steps: [ { target: "div.city-label.active", content: '海投前,先在BOSS筛选出岗位!\n\n助手会先滚动收集界面上显示的岗位,\n随后依次进行沟通~', arrowPosition: "bottom", defaultPosition: { left: "50%", top: "20%", transform: "translateX(-50%)", }, }, { target: 'a[ka="header-jobs"]', content: '职位页操作流程:\n\n1. 扫描职位卡片\n2. 点击"立即沟通"(需开启"自动打招呼")\n3. 留在当前页,继续沟通下一个职位\n\n全程无需手动干预,高效投递!', arrowPosition: "bottom", defaultPosition: { left: "25%", top: "80px" }, }, { target: 'a[ka="header-message"]', content: '海投建议!\n\n• HR与您沟通,HR需要付费给平台\n因此您尽可能先自我介绍以提高效率 \n\n• HR查看附件简历,HR也要付费给平台\n所以尽量先发送`图片简历`给HR', arrowPosition: "left", defaultPosition: { right: "150px", top: "100px" }, }, { target: "div.logo", content: '您需要打开两个浏览器窗口:\n\n左侧窗口自动打招呼发起沟通\n右侧发送自我介绍和图片简历\n\n您只需专注于挑选offer!', arrowPosition: "right", defaultPosition: { left: "200px", top: "20px" }, }, { target: "div.logo", content: '特别注意:\n\n1. BOSS直聘每日打招呼上限为150次\n2. 聊天页仅处理最上方的最新对话\n3. 打招呼后对方会显示在聊天页\n4. 投递操作过于频繁有封号风险!', arrowPosition: "bottom", defaultPosition: { left: "50px", top: "80px" }, }, ], currentStep: 0, guideElement: null, overlay: null, highlightElements: [], showGuideToUser() { this.overlay = document.createElement("div"); this.overlay.id = "guide-overlay"; this.overlay.style.cssText = ` position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.5); backdrop-filter: blur(2px); z-index: 99997; pointer-events: none; opacity: 0; transition: opacity 0.3s ease; `; document.body.appendChild(this.overlay); this.guideElement = document.createElement("div"); this.guideElement.id = "guide-tooltip"; this.guideElement.style.cssText = ` position: fixed; z-index: 99999; width: 320px; background: white; border-radius: 12px; box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; overflow: hidden; opacity: 0; transform: translateY(10px); transition: opacity 0.3s ease, transform 0.3s ease; `; document.body.appendChild(this.guideElement); setTimeout(() => { this.overlay.style.opacity = "1"; setTimeout(() => { this.showStep(0); }, 300); }, 100); }, showStep(stepIndex) { const step = this.steps[stepIndex]; if (!step) return; this.clearHighlights(); const target = document.querySelector(step.target); if (target) { const rect = target.getBoundingClientRect(); const highlight = document.createElement("div"); highlight.className = "guide-highlight"; highlight.style.cssText = ` position: fixed; top: ${rect.top}px; left: ${rect.left}px; width: ${rect.width}px; height: ${rect.height}px; background: ${step.highlightColor || "#4285f4"}; opacity: 0.2; border-radius: 4px; z-index: 99998; box-shadow: 0 0 0 4px ${step.highlightColor || "#4285f4"}; animation: guide-pulse 2s infinite; `; document.body.appendChild(highlight); this.highlightElements.push(highlight); this.setGuidePositionFromTarget(step, rect); } else { console.warn("引导目标元素未找到,使用默认位置:", step.target); this.setGuidePositionFromDefault(step); } let buttonsHtml = ""; if (stepIndex === this.steps.length - 1) { buttonsHtml = `
`; } else { buttonsHtml = `
`; } this.guideElement.innerHTML = `
AI-Boss海投助手引导
步骤 ${stepIndex + 1 }/${this.steps.length}
${step.content }
${buttonsHtml} `; if (stepIndex === this.steps.length - 1) { document .getElementById("guide-finish-btn") .addEventListener("click", () => this.endGuide(true)); } else { document .getElementById("guide-next-btn") .addEventListener("click", () => this.nextStep()); document .getElementById("guide-skip-btn") .addEventListener("click", () => this.endGuide()); } if (stepIndex === this.steps.length - 1) { const finishBtn = document.getElementById("guide-finish-btn"); finishBtn.addEventListener("mouseenter", () => { finishBtn.style.background = this.darkenColor( step.highlightColor || "#4285f4", 15 ); finishBtn.style.boxShadow = "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)"; }); finishBtn.addEventListener("mouseleave", () => { finishBtn.style.background = step.highlightColor || "#4285f4"; finishBtn.style.boxShadow = "0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)"; }); } else { const nextBtn = document.getElementById("guide-next-btn"); const skipBtn = document.getElementById("guide-skip-btn"); nextBtn.addEventListener("mouseenter", () => { nextBtn.style.background = this.darkenColor( step.highlightColor || "#4285f4", 15 ); nextBtn.style.boxShadow = "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)"; }); nextBtn.addEventListener("mouseleave", () => { nextBtn.style.background = step.highlightColor || "#4285f4"; nextBtn.style.boxShadow = "0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)"; }); skipBtn.addEventListener("mouseenter", () => { skipBtn.style.background = "#f3f4f6"; }); skipBtn.addEventListener("mouseleave", () => { skipBtn.style.background = "white"; }); } this.guideElement.style.opacity = "1"; this.guideElement.style.transform = "translateY(0)"; }, setGuidePositionFromTarget(step, rect) { let left, top; const guideWidth = 320; const guideHeight = 240; switch (step.arrowPosition) { case "top": left = rect.left + rect.width / 2 - guideWidth / 2; top = rect.top - guideHeight - 20; break; case "bottom": left = rect.left + rect.width / 2 - guideWidth / 2; top = rect.bottom + 20; break; case "left": left = rect.left - guideWidth - 20; top = rect.top + rect.height / 2 - guideHeight / 2; break; case "right": left = rect.right + 20; top = rect.top + rect.height / 2 - guideHeight / 2; break; default: left = rect.right + 20; top = rect.top; } left = Math.max(10, Math.min(left, window.innerWidth - guideWidth - 10)); top = Math.max(10, Math.min(top, window.innerHeight - guideHeight - 10)); this.guideElement.style.left = `${left}px`; this.guideElement.style.top = `${top}px`; this.guideElement.style.transform = "translateY(0)"; }, setGuidePositionFromDefault(step) { const position = step.defaultPosition || { left: "50%", top: "50%", transform: "translate(-50%, -50%)", }; Object.assign(this.guideElement.style, { left: position.left, top: position.top, right: position.right || "auto", bottom: position.bottom || "auto", transform: position.transform || "none", }); }, nextStep() { const currentStep = this.steps[this.currentStep]; if (currentStep) { const target = document.querySelector(currentStep.target); if (target) { target.removeEventListener("click", this.nextStep); } } this.currentStep++; if (this.currentStep < this.steps.length) { this.guideElement.style.opacity = "0"; this.guideElement.style.transform = "translateY(10px)"; setTimeout(() => { this.showStep(this.currentStep); }, 300); } }, clearHighlights() { this.highlightElements.forEach((el) => el.remove()); this.highlightElements = []; }, endGuide(isCompleted = false) { this.clearHighlights(); this.guideElement.style.opacity = "0"; this.guideElement.style.transform = "translateY(10px)"; this.overlay.style.opacity = "0"; setTimeout(() => { if (this.overlay && this.overlay.parentNode) { this.overlay.parentNode.removeChild(this.overlay); } if (this.guideElement && this.guideElement.parentNode) { this.guideElement.parentNode.removeChild(this.guideElement); } if (isCompleted && this.chatUrl) { window.open(this.chatUrl, "_blank"); } }, 300); document.dispatchEvent(new Event("guideEnd")); }, darkenColor(color, percent) { let R = parseInt(color.substring(1, 3), 16); let G = parseInt(color.substring(3, 5), 16); let B = parseInt(color.substring(5, 7), 16); R = parseInt((R * (100 - percent)) / 100); G = parseInt((G * (100 - percent)) / 100); B = parseInt((B * (100 - percent)) / 100); R = R < 255 ? R : 255; G = G < 255 ? G : 255; B = B < 255 ? B : 255; R = Math.round(R); G = Math.round(G); B = Math.round(B); const RR = R.toString(16).length === 1 ? "0" + R.toString(16) : R.toString(16); const GG = G.toString(16).length === 1 ? "0" + G.toString(16) : G.toString(16); const BB = B.toString(16).length === 1 ? "0" + B.toString(16) : B.toString(16); return `#${RR}${GG}${BB}`; }, }; const style = document.createElement("style"); style.textContent = ` @keyframes guide-pulse { 0% { transform: scale(1); box-shadow: 0 0 0 0 rgba(66, 133, 244, 0.4); } 70% { transform: scale(1); box-shadow: 0 0 0 10px rgba(66, 133, 244, 0); } 100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(66, 133, 244, 0); } } .guide-content .highlight { font-weight: 700; color: #1a73e8; } .guide-content .warning { font-weight: 700; color: #d93025; } `; document.head.appendChild(style); function getToday() { return new Date().toISOString().split("T")[0]; } async function init() { try { const midnight = new Date(); midnight.setDate(midnight.getDate() + 1); midnight.setHours(0, 0, 0, 0); setTimeout(() => { localStorage.removeItem(STORAGE.AI_COUNT); localStorage.removeItem(STORAGE.AI_DATE); localStorage.removeItem(STORAGE.LETTER); }, midnight - Date.now()); UI.init(); document.body.style.position = "relative"; const today = getToday(); if (location.pathname.includes("/jobs")) { if (localStorage.getItem(STORAGE.LETTER) !== today) { letter.showLetterToUser(); localStorage.setItem(STORAGE.LETTER, today); } else if (localStorage.getItem(STORAGE.GUIDE) !== "true") { guide.showGuideToUser(); localStorage.setItem(STORAGE.GUIDE, "true"); } Core.log("欢迎使用AI-Boss海投助手,我将自动投递岗位!"); } else if (location.pathname.includes("/chat")) { Core.log("欢迎使用AI-Boss海投助手,我将自动发送简历!"); // 尽早装上「打开会话」的确认信号(只旁听 getBossData,不改动页面本身的任何请求; // 装早一点是为了避免页面自己已经把 fetch 引用缓存起来)。 try { Core.installChatApiSniffer({ silent: true }); } catch (e) { /* 忽略,不影响其它功能 */ } } else if (location.pathname.includes("/notify-set")) { Core.log("欢迎使用AI-Boss海投助手,我将自动启用招呼语功能!"); Core.handleGreetSettingsPage(); } else { Core.log("当前页面暂不支持,请移步至职位页面!"); } } catch (error) { console.error("初始化失败:", error); if (UI.notify) UI.notify("初始化失败", "error"); } } window.addEventListener("load", init); let lastUrl = location.href; new MutationObserver(() => { const currentUrl = location.href; if (currentUrl !== lastUrl) { lastUrl = currentUrl; // 评论功能已移除 } }).observe(document, { subtree: true, childList: true }); function addGreetingItem() { if (!state.settings.greetingsList) { state.settings.greetingsList = []; } const hasEmpty = state.settings.greetingsList.some( (greeting) => !greeting.content.trim() ); if (hasEmpty) { return; } const newGreeting = { id: Date.now().toString(), content: "", }; state.settings.greetingsList.push(newGreeting); StatePersistence.saveState(); renderGreetingsList(); } function renderGreetingsList() { const greetingsList = document.getElementById("greetings-list"); if (!greetingsList) return; greetingsList.innerHTML = ""; if ( !state.settings.greetingsList || state.settings.greetingsList.length === 0 ) { greetingsList.innerHTML = '
暂无自我介绍内容
'; return; } state.settings.greetingsList.forEach((greeting, index) => { const greetingElement = document.createElement("div"); greetingElement.className = "greeting-item"; greetingElement.style.cssText = ` border: 1px solid #e5e7eb; border-radius: 6px; padding: 6px; margin-bottom: 6px; background: #f9fafb; `; greetingElement.innerHTML = `
${index + 1}.
`; greetingsList.appendChild(greetingElement); }); attachGreetingEventListeners(); } function attachGreetingEventListeners() { document.querySelectorAll(".delete-greeting-btn").forEach((btn) => { btn.addEventListener("click", (e) => { const greetingId = e.target.dataset.id; state.settings.greetingsList = state.settings.greetingsList.filter( (g) => g.id !== greetingId ); StatePersistence.saveState(); renderGreetingsList(); }); }); document.querySelectorAll(".greeting-input").forEach((input) => { input.addEventListener("input", (e) => { const greetingId = e.target.dataset.id; const greeting = state.settings.greetingsList.find( (g) => g.id === greetingId ); if (greeting) { greeting.content = e.target.value; StatePersistence.saveState(); } }); }); } function loadGreetings() { if (!state.settings.greetingsList) { state.settings.greetingsList = []; } renderGreetingsList(); } function loadSettingsIntoUI() { const aiRoleInput = document.getElementById("ai-role-input"); if (aiRoleInput) { aiRoleInput.value = settings.ai.role; } const autoReplyInput = document.querySelector( "#toggle-auto-reply-mode input" ); if (autoReplyInput) { autoReplyInput.checked = settings.autoReply; } const autoSendResumeInput = document.querySelector( "#toggle-auto-send-resume input" ); if (autoSendResumeInput) { autoSendResumeInput.checked = settings.useAutoSendResume; } const excludeHeadhuntersInput = document.querySelector( "#toggle-exclude-headhunters input" ); if (excludeHeadhuntersInput) { excludeHeadhuntersInput.checked = settings.excludeHeadhunters; } const autoSendImageResumeInput = document.querySelector( "#toggle-auto-send-image-resume input" ); if (autoSendImageResumeInput) { autoSendImageResumeInput.checked = settings.useAutoSendImageResume; } loadGreetings(); } // 数据看板 - 存储读写 function syncRecordsToStorage(records) { try { GM_setValue('applicationRecords', JSON.stringify(records)); state.applicationRecords = records; } catch(e) { StorageManager.setItem("applicationRecords", records); } } function getRecordsFromStorage() { try { var v = GM_getValue('applicationRecords'); return v ? JSON.parse(v) : (StorageManager.getParsedItem("applicationRecords", [])); } catch(e) { return StorageManager.getParsedItem("applicationRecords", []); } } // ==================== 数据看板 - 全屏页面模式 ==================== // 在当前标签页全屏显示看板(@require Chart.js 和 GM_getValue 直接可用) function toggleInlineDashboard() { showDashInPage(); } // ==================== 全屏看板状态 ==================== var dashState = { panel: null, visible: false, charts: {}, allRecords: [], filteredRecords: [], currentPage: 1, pageSize: 20, selectedPositions: [] }; var statMap = { applied: { text: '已投递', cls: '#60a5fa' }, replied: { text: '已回复', cls: '#34d399' }, interview:{ text: '面试中', cls: '#fbbf24' }, rejected: { text: '已拒绝', cls: '#f87171' }, pending: { text: '待回复', cls: '#d1d5db' } }; function showDashInPage() { if (dashState.panel) { dashState.panel.style.display = 'flex'; dashState.visible = true; refreshDash(); return; } var p = document.createElement('div'); p.id = 'ss-dash-full'; p.style.cssText = 'display:flex;position:fixed;top:0;left:0;width:100%;height:100%;z-index:2147483647;background:#111827;color:#e5e7eb;font-family:-apple-system,BlinkMacSystemFont,\"Segoe UI\",\"PingFang SC\",\"Microsoft YaHei\",sans-serif;overflow-y:auto;'; p.innerHTML = `

海投面板

小臣AI海投助手 - 数据看板

投递趋势(近7天)

岗位 TOP8

公司 TOP8

地区分布

投递记录

公司 岗位 地区 经验 学历 HR 状态 时间 操作
`; document.body.appendChild(p); dashState.panel = p; dashState.visible = true; document.getElementById('ss-back').addEventListener('click', function() { dashState.panel.style.display = 'none'; dashState.visible = false; destroyDashCharts(); }); document.getElementById('ss-export').addEventListener('click', exportDashCSV); document.getElementById('ss-clear').addEventListener('click', clearDashData); document.getElementById('ss-search').addEventListener('input', function() { applyDashFilter(); renderDashTable(); }); document.getElementById('ss-filter').addEventListener('change', function() { applyDashFilter(); renderDashTable(); }); document.getElementById('ss-tbody').addEventListener('click', function(e) { var btn = e.target.closest('[data-act="del"]'); if (btn) deleteDashRecord(btn.dataset.id); }); document.getElementById('ss-page').addEventListener('click', function(e) { var btn = e.target.closest('[data-p]'); if (btn && !btn.disabled) { dashState.currentPage = parseInt(btn.dataset.p, 10); renderDashTable(); } }); refreshDash(); } function destroyDashCharts() { Object.values(dashState.charts).forEach(function(c) { try { c.destroy(); } catch(e) {} }); dashState.charts = {}; } function renderPositionFilters() { // 从所有记录中提取唯一的岗位名称,渲染为可点击的筛选标签 var el = document.getElementById('ss-pos-filter'); if (!el) return; el.innerHTML = ''; // 收集所有岗位名(去重并按出现次数排序) var cnt = {}; dashState.allRecords.forEach(function(r) { var n = r.jobName || ''; if (n) cnt[n] = (cnt[n] || 0) + 1; }); var sorted = Object.keys(cnt).sort(function(a, b) { return cnt[b] - cnt[a]; }); var sel = dashState.selectedPositions || []; sorted.forEach(function(name) { var chip = document.createElement('span'); chip.className = 'ss-pos-chip'; var active = sel.indexOf(name) >= 0; chip.textContent = name + ' (' + cnt[name] + ')'; chip.style.cssText = 'display:inline-flex;align-items:center;gap:4px;padding:4px 12px;border-radius:14px;font-size:12px;cursor:pointer;border:1px solid ' + (active ? '#3b82f6' : '#374151') + ';background:' + (active ? 'rgba(59,130,246,0.2)' : 'transparent') + ';color:' + (active ? '#60a5fa' : '#d1d5db') + ';transition:all .15s ease;'; chip.addEventListener('click', function() { var idx = dashState.selectedPositions.indexOf(name); if (idx >= 0) { dashState.selectedPositions.splice(idx, 1); } else { dashState.selectedPositions.push(name); } renderPositionFilters(); applyDashFilter(); renderDashTable(); }); el.appendChild(chip); }); if (sel.length > 0) { var clearBtn = document.createElement('span'); clearBtn.textContent = '✕ 清除筛选'; clearBtn.style.cssText = 'display:inline-flex;align-items:center;gap:4px;padding:4px 12px;border-radius:14px;font-size:12px;cursor:pointer;border:1px solid #ef4444;background:rgba(239,68,68,0.1);color:#f87171;transition:all .15s ease;'; clearBtn.addEventListener('click', function() { dashState.selectedPositions = []; renderPositionFilters(); applyDashFilter(); renderDashTable(); }); el.appendChild(clearBtn); } } function refreshDash() { dashState.allRecords = getRecordsFromStorage(); renderPositionFilters(); applyDashFilter(); renderDashStats(); try { renderDashCharts(); } catch(e) { console.error('图表渲染失败:', e); } renderDashTable(); } function applyDashFilter() { var kw = (document.getElementById('ss-search')?.value || '').trim().toLowerCase(); var st = document.getElementById('ss-filter')?.value || ''; var r = dashState.allRecords.slice(); if (kw) r = r.filter(function(x) { return (x.companyName||'').toLowerCase().indexOf(kw)>=0 || (x.jobName||'').toLowerCase().indexOf(kw)>=0; }); if (st) r = r.filter(function(x) { return x.status === st; }); // 多选岗位筛选 if (dashState.selectedPositions && dashState.selectedPositions.length > 0) { r = r.filter(function(x) { return dashState.selectedPositions.indexOf(x.jobName) >= 0; }); } dashState.filteredRecords = r; dashState.currentPage = 1; } function renderDashStats() { var total = dashState.allRecords.length; var t = new Date(); t.setHours(0,0,0,0); var today = dashState.allRecords.filter(function(r) { return r.applyTime >= t.getTime(); }).length; document.getElementById('ss-stats').innerHTML = '
📨
'+total+'
总投递数
' + '
📅
'+today+'
今日投递
'; } function renderDashCharts() { destroyDashCharts(); if (!dashState.allRecords.length || typeof Chart === 'undefined') return; var dg = { color: 'rgba(55,65,81,0.6)' }, dt = { color: '#9ca3af', font: { size: 11 } }; var tc = document.getElementById('ss-trend'); if (tc) { var days=[],cnts=[]; for(var i=6;i>=0;i--){var d=new Date();d.setDate(d.getDate()-i);var ds=new Date(d);ds.setHours(0,0,0,0);var de=new Date(d);de.setHours(23,59,59,999);days.push((d.getMonth()+1)+'/'+d.getDate());cnts.push(dashState.allRecords.filter(function(r){return r.applyTime>=ds.getTime()&&r.applyTime<=de.getTime()}).length);} dashState.charts.trend = new Chart(tc, {type:'line',data:{labels:days,datasets:[{data:cnts,borderColor:'#60a5fa',backgroundColor:'rgba(96,165,250,0.12)',fill:true,tension:0.4,pointRadius:3,pointBackgroundColor:'#60a5fa',borderWidth:2}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{display:false}},scales:{x:{ticks:dt,grid:dg},y:{ticks:Object.assign({},dt,{stepSize:1}),grid:dg,beginAtZero:true}}}}); } var jc = document.getElementById('ss-job'); if (jc) { var jcnt={};dashState.allRecords.forEach(function(r){var n=r.jobName||'未知';jcnt[n]=(jcnt[n]||0)+1;}); var jd=Object.entries(jcnt).sort(function(a,b){return b[1]-a[1]}).slice(0,8); dashState.charts.job = new Chart(jc, {type:'bar',data:{labels:jd.map(function(d){return d[0]}),datasets:[{data:jd.map(function(d){return d[1]}),backgroundColor:jd.map(function(_,i){return 'hsla('+(210+i*15)+',70%,60%,0.85)'}),borderRadius:6}]},options:{indexAxis:'y',responsive:true,maintainAspectRatio:false,plugins:{legend:{display:false}},scales:{x:{ticks:Object.assign({},dt,{stepSize:1}),grid:dg,beginAtZero:true},y:{ticks:Object.assign({},dt,{font:{size:10}}),grid:{display:false}}}}}); } var cc = document.getElementById('ss-comp'); if (cc) { var ccnt={};dashState.allRecords.forEach(function(r){var n=(r.companyName||'未知').replace(/(.*)/g,'').replace(/\(.*\)/g,'');ccnt[n]=(ccnt[n]||0)+1;}); var cd=Object.entries(ccnt).sort(function(a,b){return b[1]-a[1]}).slice(0,8); dashState.charts.company = new Chart(cc, {type:'bar',data:{labels:cd.map(function(d){return d[0]}),datasets:[{data:cd.map(function(d){return d[1]}),backgroundColor:cd.map(function(_,i){return 'hsla('+(280+i*20)+',55%,55%,0.85)'}),borderRadius:6}]},options:{indexAxis:'y',responsive:true,maintainAspectRatio:false,plugins:{legend:{display:false}},scales:{x:{ticks:Object.assign({},dt,{stepSize:1}),grid:dg,beginAtZero:true},y:{ticks:Object.assign({},dt,{font:{size:10}}),grid:{display:false}}}}}); } var lc = document.getElementById('ss-loc'); if (lc) { var lcnt={};dashState.allRecords.forEach(function(r){if(r.location){var l=r.location.split('·')[0]||r.location;lcnt[l]=(lcnt[l]||0)+1;}}); var ld=Object.entries(lcnt).sort(function(a,b){return b[1]-a[1]}).slice(0,8); var pc=['#60a5fa','#34d399','#fbbf24','#f87171','#a78bfa','#fb923c','#38bdf8','#a3e635']; dashState.charts.location = new Chart(lc, {type:'doughnut',data:{labels:ld.map(function(d){return d[0]}),datasets:[{data:ld.map(function(d){return d[1]}),backgroundColor:ld.map(function(_,i){return pc[i%pc.length]}),borderColor:'#1f2937',borderWidth:2}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{position:'right',labels:{color:'#d1d5db',padding:12,font:{size:11},usePointStyle:true}}}}}); } } function esc(s) { var d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; } function ft(ts) { if(!ts)return'-';var d=new Date(ts);var p=function(n){return String(n).padStart(2,'0')};return d.getFullYear()+'-'+p(d.getMonth()+1)+'-'+p(d.getDate())+' '+p(d.getHours())+':'+p(d.getMinutes()); } function stClr(hex) { return 'rgba('+parseInt(hex.slice(1,3),16)+','+parseInt(hex.slice(3,5),16)+','+parseInt(hex.slice(5,7),16)+',0.2)'; } function renderDashTable() { // 不调用 applyDashFilter(),避免翻页时重置 currentPage var tp = Math.ceil(dashState.filteredRecords.length / dashState.pageSize) || 1; if (dashState.currentPage > tp) dashState.currentPage = tp; var s = (dashState.currentPage - 1) * dashState.pageSize; var pg = dashState.filteredRecords.slice(s, s + dashState.pageSize); document.getElementById('ss-count').textContent = '(' + dashState.filteredRecords.length + ' 条)'; var tb = document.getElementById('ss-tbody'); if (!pg.length) { tb.innerHTML = '' + (dashState.allRecords.length === 0 ? '暂无投递数据' : '未找到匹配的记录') + ''; } else { tb.innerHTML = pg.map(function(r) { var sm = statMap[r.status] || statMap.applied; return ''+(esc(r.companyName)||'-')+''+(esc(r.jobName)||'-')+'' + ''+esc(r.location)+'' + ''+(esc(r.experience)||'不限')+''+(esc(r.education)||'不限')+'' + ''+(esc(r.hrPosition)||'-')+'' + ''+sm.text+'' + ''+ft(r.applyTime)+''; }).join(''); } renderDashPage(); } function renderDashPage() { var tp = Math.ceil(dashState.filteredRecords.length / dashState.pageSize) || 1; var el = document.getElementById('ss-page'); if (tp <= 1) { el.innerHTML = '共 '+dashState.filteredRecords.length+' 条'; return; } var h = ''; for (var i=1;i<=tp;i++) { if (tp>7&&i>2&&i1) { if(i===3)h+=''; continue; } h += ''; } h += ''; h += ' 共 '+dashState.filteredRecords.length+' 条/'+tp+' 页'; el.innerHTML = h; } function exportDashCSV() { var recs = dashState.filteredRecords.length ? dashState.filteredRecords : dashState.allRecords; var hd = ['公司','岗位','地区','经验','学历','HR','状态','时间']; var rows = recs.map(function(r) { return [r.companyName||'',r.jobName||'',r.location||'',r.experience||'',r.education||'',r.hrPosition||'',(statMap[r.status]||statMap.applied).text,ft(r.applyTime)]; }); var csv = [hd].concat(rows).map(function(r) { return r.map(function(c) { return '"'+String(c).replace(/"/g,'""')+'"'; }).join(','); }).join('\n'); var b = new Blob(['\ufeff'+csv],{type:'text/csv;charset=utf-8'}); var a = document.createElement('a'); a.href=URL.createObjectURL(b); a.download='投递记录_'+new Date().toISOString().slice(0,10)+'.csv'; a.click(); } function clearDashData() { if (!confirm('确定清空所有投递记录?此操作不可恢复。')) return; try { GM_setValue('applicationRecords', JSON.stringify([])); GM_setValue('lastClearedAt', JSON.stringify(Date.now())); } catch(e) { StorageManager.setItem("applicationRecords", []); } dashState.allRecords = []; dashState.filteredRecords = []; dashState.currentPage = 1; destroyDashCharts(); refreshDash(); } function deleteDashRecord(id) { if (!confirm('确定删除这条记录?')) return; var r = getRecordsFromStorage(); r = r.filter(function(x) { return x.id !== id; }); try { GM_setValue('applicationRecords', JSON.stringify(r)); } catch(e) { StorageManager.setItem("applicationRecords", r); } state.applicationRecords = r; dashState.allRecords = r; refreshDash(); } })();