From 84dcdd3dcd3b5ea7443d4700be268c0e16277433 Mon Sep 17 00:00:00 2001 From: GaoXiang233 <1679562189@qq.com> Date: Sun, 3 May 2026 09:40:31 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=A6=96=E6=AC=A1=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E6=88=91=E7=9A=84=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- background.js | 1 - background/background.js | 894 ++++++++++++++++++++++++++ content/content.css | 693 ++++++++++++++++++++ content/content.js | 1316 ++++++++++++++++++++++++++++++++++++++ lib/elementWaiter.js | 487 ++++++++++++++ lib/errorHandler.js | 408 ++++++++++++ lib/logger.js | 291 +++++++++ lib/platformRules.js | 1272 ++++++++++++++++++++++++++++++++++++ lib/storage.js | 519 +++++++++++++++ manifest.json | 113 +++- popup/popup.css | 1214 ++++++++++++++++++++++++++++++----- popup/popup.html | 498 ++++++++++++++- popup/popup.js | 1206 +++++++++++++++++++++++++++++++--- scripts/content.js | 84 --- 14 files changed, 8612 insertions(+), 384 deletions(-) delete mode 100644 background.js create mode 100644 background/background.js create mode 100644 content/content.css create mode 100644 content/content.js create mode 100644 lib/elementWaiter.js create mode 100644 lib/errorHandler.js create mode 100644 lib/logger.js create mode 100644 lib/platformRules.js create mode 100644 lib/storage.js delete mode 100644 scripts/content.js diff --git a/background.js b/background.js deleted file mode 100644 index 464c9b5..0000000 --- a/background.js +++ /dev/null @@ -1 +0,0 @@ -console.log("好运来"); diff --git a/background/background.js b/background/background.js new file mode 100644 index 0000000..14f0f03 --- /dev/null +++ b/background/background.js @@ -0,0 +1,894 @@ +const LogLevel = { + DEBUG: 0, + INFO: 1, + WARN: 2, + ERROR: 3, + NONE: 4 +}; + +const LogLevelNames = { + 0: 'DEBUG', + 1: 'INFO', + 2: 'WARN', + 3: 'ERROR', + 4: 'NONE' +}; + +class SimpleLogger { + constructor(options = {}) { + this.config = { + minLevel: LogLevel.INFO, + maxLogs: 1000, + enableConsole: true, + enableStorage: true, + storageKey: 'jobapp_logs', + ...options + }; + this.logs = []; + this._initialized = false; + } + + async init() { + if (this._initialized) return; + + try { + if (this.config.enableStorage && chrome.storage) { + const result = await chrome.storage.local.get(this.config.storageKey); + if (result[this.config.storageKey]) { + this.logs = result[this.config.storageKey]; + } + } + } catch (error) { + console.warn('[Logger] Failed to load logs:', error); + } + + this._initialized = true; + } + + _formatLog(level, ...args) { + const timestamp = new Date().toISOString(); + const levelName = LogLevelNames[level] || 'UNKNOWN'; + const message = args.map(arg => { + if (typeof arg === 'object') { + try { + return JSON.stringify(arg); + } catch (e) { + return String(arg); + } + } + return String(arg); + }).join(' '); + + return { + timestamp, + level: levelName, + levelCode: level, + message + }; + } + + async _addLog(logEntry) { + if (this.config.enableConsole) { + const prefix = `[${logEntry.level}] ${logEntry.timestamp}`; + switch (logEntry.levelCode) { + case LogLevel.DEBUG: console.log(prefix, logEntry.message); break; + case LogLevel.INFO: console.info(prefix, logEntry.message); break; + case LogLevel.WARN: console.warn(prefix, logEntry.message); break; + case LogLevel.ERROR: console.error(prefix, logEntry.message); break; + default: console.log(prefix, logEntry.message); + } + } + + if (this._initialized) { + this.logs.push(logEntry); + if (this.logs.length > this.config.maxLogs) { + this.logs = this.logs.slice(-this.config.maxLogs); + } + await this._saveToStorage(); + } + } + + async _saveToStorage() { + if (!this.config.enableStorage || !chrome.storage) return; + + try { + await chrome.storage.local.set({ + [this.config.storageKey]: this.logs + }); + } catch (error) { + console.warn('[Logger] Failed to save logs:', error); + } + } + + async debug(...args) { + if (this.config.minLevel > LogLevel.DEBUG) return; + await this._addLog(this._formatLog(LogLevel.DEBUG, ...args)); + } + + async info(...args) { + if (this.config.minLevel > LogLevel.INFO) return; + await this._addLog(this._formatLog(LogLevel.INFO, ...args)); + } + + async warn(...args) { + if (this.config.minLevel > LogLevel.WARN) return; + await this._addLog(this._formatLog(LogLevel.WARN, ...args)); + } + + async error(...args) { + if (this.config.minLevel > LogLevel.ERROR) return; + await this._addLog(this._formatLog(LogLevel.ERROR, ...args)); + } + + getLogs(filter = {}) { + let result = [...this.logs]; + + if (filter.level) { + result = result.filter(l => l.level === filter.level); + } + + if (filter.since) { + result = result.filter(l => l.timestamp >= filter.since); + } + + if (filter.limit) { + result = result.slice(-filter.limit); + } + + return result; + } + + async clear() { + this.logs = []; + await this._saveToStorage(); + } +} + +const STORAGE_KEYS = { + SETTINGS: 'jobapp_settings', + STATISTICS: 'jobapp_statistics', + HISTORY: 'jobapp_history', + BLACKLIST: 'jobapp_blacklist', + CUSTOM_RULES: 'jobapp_custom_rules', + PLATFORM_CONFIGS: 'jobapp_platform_configs' +}; + +const DEFAULT_SETTINGS = { + version: '2.0.0', + enabled: true, + autoApplyEnabled: true, + maxApplicationsPerSession: 50, + applyDelay: 2000, + elementWaitTimeout: 10000, + maxRetryCount: 3, + retryDelay: 1000, + logLevel: 'INFO', + enableNotifications: true, + enableSound: false, + autoCloseModal: true, + confirmBeforeApply: false, + platforms: { + zhipin: { enabled: true, autoScroll: true }, + zhaopin: { enabled: true, autoScroll: true }, + job51: { enabled: true, autoScroll: true }, + lagou: { enabled: true, autoScroll: true }, + liepin: { enabled: true, autoScroll: true } + } +}; + +const DEFAULT_STATISTICS = { + totalApplications: 0, + successfulApplications: 0, + failedApplications: 0, + byPlatform: {}, + byDate: {}, + lastApplicationAt: null, + firstApplicationAt: null, + currentStreak: 0, + bestStreak: 0 +}; + +const DEFAULT_HISTORY = { + items: [], + maxItems: 1000 +}; + +const DEFAULT_BLACKLIST = { + domains: [], + keywords: [], + companies: [] +}; + +const DEFAULT_CUSTOM_RULES = { + rules: [], + enabled: true +}; + +const MessageActions = { + PING: 'ping', + GET_STATUS: 'getStatus', + GET_PLATFORM: 'getPlatform', + EXECUTE_APPLY: 'executeApply', + CANCEL_APPLY: 'cancelApply', + GET_HISTORY: 'getHistory', + GET_STATISTICS: 'getStatistics', + FIND_APPLY_BUTTON: 'findApplyButton', + GET_LOGS: 'getLogs', + CLEAR_LOGS: 'clearLogs', + + GET_SETTINGS: 'getSettings', + SET_SETTINGS: 'setSettings', + GET_BLACKLIST: 'getBlacklist', + ADD_TO_BLACKLIST: 'addToBlacklist', + REMOVE_FROM_BLACKLIST: 'removeFromBlacklist', + GET_CUSTOM_RULES: 'getCustomRules', + ADD_CUSTOM_RULE: 'addCustomRule', + UPDATE_CUSTOM_RULE: 'updateCustomRule', + DELETE_CUSTOM_RULE: 'deleteCustomRule', + RESET_ALL: 'resetAll', + GET_ALL_STATISTICS: 'getAllStatistics', + GET_ALL_HISTORY: 'getAllHistory', + CLEAR_HISTORY: 'clearHistory', + STATUS_CHANGE: 'statusChange', + APPLY_COMPLETE: 'applyComplete', + SCROLL: 'scroll', + SET_COUNT: 'setCount', + SEND_JOB_TITLES: 'sendJobTitles', + NOTIFY: 'notify' +}; + +const ApplyStatus = { + IDLE: 'idle', + INITIALIZING: 'initializing', + DETECTING_PLATFORM: 'detecting_platform', + FINDING_BUTTON: 'finding_button', + CLICKING_BUTTON: 'clicking_button', + WAITING_POPUP: 'waiting_popup', + CONFIRMING_APPLY: 'confirming_apply', + CHECKING_RESULT: 'checking_result', + SUCCESS: 'success', + FAILED: 'failed', + CANCELLED: 'cancelled', + SKIPPED: 'skipped' +}; + +class BackgroundManager { + constructor() { + this.logger = new SimpleLogger({ minLevel: LogLevel.INFO }); + this._state = { + currentStatus: ApplyStatus.IDLE, + activeTabId: null, + applyCount: 0, + maxApplyCount: 10, + isApplying: false, + jobTitles: [], + lastError: null + }; + this._initialized = false; + } + + async init() { + if (this._initialized) return; + + await this.logger.init(); + await this._initStorage(); + + this._setupMessageListeners(); + this._setupCommandListeners(); + this._setupTabListeners(); + + this._initialized = true; + this.logger.info('快投简历 Background Service Worker 已初始化'); + } + + async _initStorage() { + try { + const settings = await this.getSettings(); + const logLevel = LogLevel[settings.logLevel] || LogLevel.INFO; + this.logger.config.minLevel = logLevel; + } catch (error) { + this.logger.error('初始化存储失败:', error); + } + } + + _setupMessageListeners() { + chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + this.logger.debug(`收到消息: ${message.action}`, { from: sender.tab?.id }); + + this._handleMessage(message, sender) + .then(response => { + sendResponse(response); + }) + .catch(error => { + this.logger.error('消息处理失败:', error); + sendResponse({ success: false, error: error.message }); + }); + + return true; + }); + } + + _setupCommandListeners() { + chrome.commands.onCommand.addListener(async (command) => { + this.logger.info(`收到快捷键命令: ${command}`); + + try { + switch (command) { + case 'start_auto_apply': + await this._startAutoApply(); + break; + case 'stop_auto_apply': + await this._stopAutoApply(); + break; + } + } catch (error) { + this.logger.error(`命令执行失败: ${command}`, error); + } + }); + } + + _setupTabListeners() { + chrome.tabs.onRemoved.addListener((tabId) => { + if (this._state.activeTabId === tabId) { + this._state.isApplying = false; + this._state.currentStatus = ApplyStatus.IDLE; + this.logger.info('活动标签页已关闭,停止投递'); + } + }); + + chrome.tabs.onUpdated.addListener((tabId, changeInfo) => { + if (changeInfo.status === 'complete' && this._state.activeTabId === tabId) { + this.logger.debug('活动标签页加载完成'); + } + }); + } + + async _handleMessage(message, sender) { + const { action, ...data } = message; + + switch (action) { + case MessageActions.PING: + return { success: true, data: { timestamp: Date.now() } }; + + case MessageActions.GET_STATUS: + return { + success: true, + data: { + status: this._state.currentStatus, + isApplying: this._state.isApplying, + applyCount: this._state.applyCount, + maxApplyCount: this._state.maxApplyCount, + lastError: this._state.lastError + } + }; + + case MessageActions.GET_SETTINGS: + return { success: true, data: await this.getSettings() }; + + case MessageActions.SET_SETTINGS: + return { success: true, data: await this.setSettings(data.settings) }; + + case MessageActions.GET_STATISTICS: + return { success: true, data: await this.getStatistics() }; + + case MessageActions.GET_ALL_STATISTICS: + return { success: true, data: await this.getStatistics() }; + + case MessageActions.GET_HISTORY: + return { success: true, data: await this.getHistory() }; + + case MessageActions.GET_ALL_HISTORY: + return { success: true, data: await this.getHistory() }; + + case MessageActions.CLEAR_HISTORY: + return { success: true, data: await this.clearHistory() }; + + case MessageActions.GET_LOGS: + return { success: true, data: this.logger.getLogs(data.filter || {}) }; + + case MessageActions.CLEAR_LOGS: + await this.logger.clear(); + return { success: true, data: { cleared: true } }; + + case MessageActions.GET_BLACKLIST: + return { success: true, data: await this.getBlacklist() }; + + case MessageActions.ADD_TO_BLACKLIST: + return { + success: true, + data: await this.addToBlacklist(data.type, data.value) + }; + + case MessageActions.REMOVE_FROM_BLACKLIST: + return { + success: true, + data: await this.removeFromBlacklist(data.type, data.value) + }; + + case MessageActions.GET_CUSTOM_RULES: + return { success: true, data: await this.getCustomRules() }; + + case MessageActions.ADD_CUSTOM_RULE: + return { success: true, data: await this.addCustomRule(data.rule) }; + + case MessageActions.UPDATE_CUSTOM_RULE: + return { + success: true, + data: await this.updateCustomRule(data.ruleId, data.updates) + }; + + case MessageActions.DELETE_CUSTOM_RULE: + return { + success: true, + data: await this.deleteCustomRule(data.ruleId) + }; + + case MessageActions.RESET_ALL: + return { success: true, data: await this.resetAll() }; + + case MessageActions.SET_COUNT: + this._state.maxApplyCount = (Math.round((data.count_value / 100) * 24) + 1) * 10; + return { success: true, data: { maxApplyCount: this._state.maxApplyCount } }; + + case MessageActions.SCROLL: + return this._handleScrollAction(sender); + + case MessageActions.SEND_JOB_TITLES: + this._state.jobTitles = data.jobTitles || []; + this._state.currentJobCount = data.numJobs || 0; + return { success: true }; + + case MessageActions.STATUS_CHANGE: + return this._handleStatusChange(data.data, sender); + + case MessageActions.APPLY_COMPLETE: + return this._handleApplyComplete(data.data, sender); + + case MessageActions.NOTIFY: + await this._showNotification(data.title, data.message, data.type); + return { success: true }; + + default: + if (sender.tab) { + return this._forwardToContentScript(sender.tab.id, message); + } + return { success: false, error: `未知的消息类型: ${action}` }; + } + } + + async _handleScrollAction(sender) { + if (!sender.tab) { + return { success: false, error: '无效的发送者' }; + } + + this._state.activeTabId = sender.tab.id; + this._state.isApplying = true; + this._state.applyCount = 0; + this._state.currentStatus = ApplyStatus.INITIALIZING; + + this.logger.info(`开始投递,目标数量: ${this._state.maxApplyCount}`); + await this._showNotification( + '快投简历', + `开始投递,目标数量: ${this._state.maxApplyCount}`, + 'info' + ); + + return { success: true, data: { maxApplyCount: this._state.maxApplyCount } }; + } + + async _handleStatusChange(data, sender) { + this._state.currentStatus = data.newStatus; + this.logger.debug(`状态变化: ${data.oldStatus} -> ${data.newStatus}`); + + this._broadcastToPopup({ + action: MessageActions.STATUS_CHANGE, + data: { + ...data, + applyCount: this._state.applyCount, + maxApplyCount: this._state.maxApplyCount + } + }); + + return { success: true }; + } + + async _handleApplyComplete(data, sender) { + this._state.applyCount++; + + const platform = data.platform || 'unknown'; + const success = data.success; + + await this._updateStatistics(platform, success); + await this._addHistoryItem({ + ...data, + timestamp: new Date().toISOString() + }); + + this.logger.info(`投递 ${success ? '成功' : '失败'},累计: ${this._state.applyCount}/${this._state.maxApplyCount}`); + + this._broadcastToPopup({ + action: MessageActions.APPLY_COMPLETE, + data: { + ...data, + applyCount: this._state.applyCount, + maxApplyCount: this._state.maxApplyCount + } + }); + + if (success) { + await this._showNotification( + '投递成功', + `已投递 ${this._state.applyCount}/${this._state.maxApplyCount} 份简历`, + 'success' + ); + } + + if (this._state.applyCount >= this._state.maxApplyCount) { + this._state.isApplying = false; + this._state.currentStatus = ApplyStatus.SUCCESS; + + this.logger.info(`完成投递,共投递 ${this._state.applyCount} 份`); + await this._showNotification( + '投递完成', + `已完成 ${this._state.applyCount} 份简历投递`, + 'success' + ); + } + + return { success: true }; + } + + async _forwardToContentScript(tabId, message) { + try { + const response = await chrome.tabs.sendMessage(tabId, message); + return response || { success: true }; + } catch (error) { + this.logger.error(`转发消息到 Content Script 失败:`, error); + return { success: false, error: error.message }; + } + } + + _broadcastToPopup(message) { + chrome.runtime.sendMessage(message).catch(() => { + }); + } + + async _showNotification(title, message, type = 'info') { + try { + const settings = await this.getSettings(); + if (!settings.enableNotifications) return; + + const iconUrl = chrome.runtime.getURL('images/Icon48.png'); + + await chrome.notifications.create({ + type: 'basic', + iconUrl: iconUrl, + title: title, + message: message, + priority: type === 'error' ? 2 : 0 + }); + } catch (error) { + this.logger.warn('通知创建失败:', error); + } + } + + async _startAutoApply() { + const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!activeTab) return; + + await this._forwardToContentScript(activeTab.id, { action: 'scroll' }); + } + + async _stopAutoApply() { + if (!this._state.activeTabId) return; + + await this._forwardToContentScript(this._state.activeTabId, { + action: MessageActions.CANCEL_APPLY + }); + + this._state.isApplying = false; + this._state.currentStatus = ApplyStatus.CANCELLED; + + this.logger.info('投递已取消'); + await this._showNotification('投递已取消', `已投递 ${this._state.applyCount} 份`, 'info'); + } + + async _updateStatistics(platform, success) { + const stats = await this.getStatistics(); + const today = new Date().toISOString().split('T')[0]; + + stats.totalApplications++; + if (success) { + stats.successfulApplications++; + } else { + stats.failedApplications++; + } + + if (!stats.byPlatform[platform]) { + stats.byPlatform[platform] = { total: 0, successful: 0, failed: 0 }; + } + stats.byPlatform[platform].total++; + if (success) { + stats.byPlatform[platform].successful++; + } else { + stats.byPlatform[platform].failed++; + } + + if (!stats.byDate[today]) { + stats.byDate[today] = { total: 0, successful: 0, failed: 0 }; + } + stats.byDate[today].total++; + if (success) { + stats.byDate[today].successful++; + } else { + stats.byDate[today].failed++; + } + + stats.lastApplicationAt = new Date().toISOString(); + if (!stats.firstApplicationAt) { + stats.firstApplicationAt = stats.lastApplicationAt; + } + + await this.setStatistics(stats); + } + + async _addHistoryItem(item) { + const history = await this.getHistory(); + const historyItem = { + id: this._generateId(), + timestamp: new Date().toISOString(), + ...item + }; + + history.items.unshift(historyItem); + + if (history.items.length > history.maxItems) { + history.items = history.items.slice(0, history.maxItems); + } + + await this.setHistory(history); + } + + async getSettings() { + try { + const result = await chrome.storage.local.get(STORAGE_KEYS.SETTINGS); + return this._mergeDefaults(result[STORAGE_KEYS.SETTINGS], DEFAULT_SETTINGS); + } catch (error) { + this.logger.error('获取设置失败:', error); + return { ...DEFAULT_SETTINGS }; + } + } + + async setSettings(settings) { + try { + const currentSettings = await this.getSettings(); + const updatedSettings = { ...currentSettings, ...settings }; + await chrome.storage.local.set({ [STORAGE_KEYS.SETTINGS]: updatedSettings }); + return updatedSettings; + } catch (error) { + this.logger.error('保存设置失败:', error); + throw error; + } + } + + async getStatistics() { + try { + const result = await chrome.storage.local.get(STORAGE_KEYS.STATISTICS); + return result[STORAGE_KEYS.STATISTICS] || { ...DEFAULT_STATISTICS }; + } catch (error) { + this.logger.error('获取统计失败:', error); + return { ...DEFAULT_STATISTICS }; + } + } + + async setStatistics(stats) { + try { + await chrome.storage.local.set({ [STORAGE_KEYS.STATISTICS]: stats }); + return stats; + } catch (error) { + this.logger.error('保存统计失败:', error); + throw error; + } + } + + async getHistory() { + try { + const result = await chrome.storage.local.get(STORAGE_KEYS.HISTORY); + return result[STORAGE_KEYS.HISTORY] || { ...DEFAULT_HISTORY }; + } catch (error) { + this.logger.error('获取历史失败:', error); + return { ...DEFAULT_HISTORY }; + } + } + + async setHistory(history) { + try { + await chrome.storage.local.set({ [STORAGE_KEYS.HISTORY]: history }); + return history; + } catch (error) { + this.logger.error('保存历史失败:', error); + throw error; + } + } + + async clearHistory() { + try { + await chrome.storage.local.set({ [STORAGE_KEYS.HISTORY]: { ...DEFAULT_HISTORY } }); + return { cleared: true }; + } catch (error) { + this.logger.error('清除历史失败:', error); + throw error; + } + } + + async getBlacklist() { + try { + const result = await chrome.storage.local.get(STORAGE_KEYS.BLACKLIST); + return result[STORAGE_KEYS.BLACKLIST] || { ...DEFAULT_BLACKLIST }; + } catch (error) { + this.logger.error('获取黑名单失败:', error); + return { ...DEFAULT_BLACKLIST }; + } + } + + async setBlacklist(blacklist) { + try { + await chrome.storage.local.set({ [STORAGE_KEYS.BLACKLIST]: blacklist }); + return blacklist; + } catch (error) { + this.logger.error('保存黑名单失败:', error); + throw error; + } + } + + async addToBlacklist(type, value) { + const blacklist = await this.getBlacklist(); + + if (!blacklist[type]) { + blacklist[type] = []; + } + + if (!blacklist[type].includes(value)) { + blacklist[type].push(value); + await this.setBlacklist(blacklist); + this.logger.info(`添加到黑名单: ${type} - ${value}`); + return { added: true }; + } + + return { added: false, reason: '已存在' }; + } + + async removeFromBlacklist(type, value) { + const blacklist = await this.getBlacklist(); + + if (blacklist[type] && blacklist[type].includes(value)) { + blacklist[type] = blacklist[type].filter(v => v !== value); + await this.setBlacklist(blacklist); + this.logger.info(`从黑名单移除: ${type} - ${value}`); + return { removed: true }; + } + + return { removed: false, reason: '不存在' }; + } + + async getCustomRules() { + try { + const result = await chrome.storage.local.get(STORAGE_KEYS.CUSTOM_RULES); + return result[STORAGE_KEYS.CUSTOM_RULES] || { ...DEFAULT_CUSTOM_RULES }; + } catch (error) { + this.logger.error('获取自定义规则失败:', error); + return { ...DEFAULT_CUSTOM_RULES }; + } + } + + async setCustomRules(rules) { + try { + await chrome.storage.local.set({ [STORAGE_KEYS.CUSTOM_RULES]: rules }); + return rules; + } catch (error) { + this.logger.error('保存自定义规则失败:', error); + throw error; + } + } + + async addCustomRule(rule) { + const customRules = await this.getCustomRules(); + const ruleWithId = { + id: this._generateId(), + createdAt: new Date().toISOString(), + enabled: true, + ...rule + }; + + customRules.rules.push(ruleWithId); + await this.setCustomRules(customRules); + this.logger.info('添加自定义规则:', ruleWithId); + + return ruleWithId; + } + + async updateCustomRule(ruleId, updates) { + const customRules = await this.getCustomRules(); + const index = customRules.rules.findIndex(r => r.id === ruleId); + + if (index > -1) { + customRules.rules[index] = { + ...customRules.rules[index], + ...updates, + updatedAt: new Date().toISOString() + }; + await this.setCustomRules(customRules); + this.logger.info(`更新自定义规则: ${ruleId}`); + return { updated: true }; + } + + return { updated: false, reason: '规则不存在' }; + } + + async deleteCustomRule(ruleId) { + const customRules = await this.getCustomRules(); + customRules.rules = customRules.rules.filter(r => r.id !== ruleId); + await this.setCustomRules(customRules); + this.logger.info(`删除自定义规则: ${ruleId}`); + return { deleted: true }; + } + + async resetAll() { + try { + await chrome.storage.local.set({ + [STORAGE_KEYS.SETTINGS]: { ...DEFAULT_SETTINGS }, + [STORAGE_KEYS.STATISTICS]: { ...DEFAULT_STATISTICS }, + [STORAGE_KEYS.HISTORY]: { ...DEFAULT_HISTORY }, + [STORAGE_KEYS.BLACKLIST]: { ...DEFAULT_BLACKLIST }, + [STORAGE_KEYS.CUSTOM_RULES]: { ...DEFAULT_CUSTOM_RULES }, + [STORAGE_KEYS.PLATFORM_CONFIGS]: {} + }); + + this._state = { + currentStatus: ApplyStatus.IDLE, + activeTabId: null, + applyCount: 0, + maxApplyCount: 10, + isApplying: false, + jobTitles: [], + lastError: null + }; + + this.logger.info('所有数据已重置'); + await this._showNotification('数据已重置', '所有设置和数据已恢复默认值', 'info'); + + return { reset: true }; + } catch (error) { + this.logger.error('重置数据失败:', error); + throw error; + } + } + + _mergeDefaults(obj, defaults) { + if (!obj) return { ...defaults }; + + const result = { ...defaults }; + for (const key in obj) { + if (obj.hasOwnProperty(key)) { + if (typeof obj[key] === 'object' && obj[key] !== null && + typeof defaults[key] === 'object' && defaults[key] !== null && + !Array.isArray(obj[key])) { + result[key] = this._mergeDefaults(obj[key], defaults[key]); + } else { + result[key] = obj[key]; + } + } + } + return result; + } + + _generateId() { + return 'id_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); + } +} + +const backgroundManager = new BackgroundManager(); + +backgroundManager.init().then(() => { + console.log('快投简历 Background Service Worker 启动完成'); +}); diff --git a/content/content.css b/content/content.css new file mode 100644 index 0000000..b72b4c4 --- /dev/null +++ b/content/content.css @@ -0,0 +1,693 @@ +:root { + --jobapp-primary: #3b82f6; + --jobapp-primary-dark: #2563eb; + --jobapp-success: #10b981; + --jobapp-warning: #f59e0b; + --jobapp-danger: #ef4444; + --jobapp-purple: #8b5cf6; + --jobapp-bg: #ffffff; + --jobapp-text: #1e293b; + --jobapp-text-muted: #64748b; + --jobapp-border: #e2e8f0; + --jobapp-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + --jobapp-shadow-lg: 0 10px 25px rgba(0, 0, 0, 0.2); +} + +.jobapp-container, +.jobapp-container * { + all: initial; + box-sizing: border-box; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; +} + +.jobapp-container { + position: fixed; + z-index: 2147483647; + pointer-events: none; +} + +.jobapp-container > * { + pointer-events: auto; +} + +.jobapp-status-panel { + position: fixed; + top: 20px; + right: 20px; + width: 280px; + background: var(--jobapp-bg); + border-radius: 12px; + box-shadow: var(--jobapp-shadow-lg); + border: 1px solid var(--jobapp-border); + overflow: hidden; + animation: jobapp-slideIn 0.3s ease-out; +} + +@keyframes jobapp-slideIn { + from { + opacity: 0; + transform: translateX(100px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +.jobapp-status-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: linear-gradient(135deg, var(--jobapp-primary) 0%, var(--jobapp-purple) 100%); + color: white; +} + +.jobapp-status-header .jobapp-title { + font-size: 14px; + font-weight: 600; + display: flex; + align-items: center; + gap: 8px; +} + +.jobapp-status-header .jobapp-icon { + width: 20px; + height: 20px; +} + +.jobapp-status-header .jobapp-close-btn { + width: 24px; + height: 24px; + border: none; + background: rgba(255, 255, 255, 0.2); + border-radius: 6px; + color: white; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s ease; +} + +.jobapp-status-header .jobapp-close-btn:hover { + background: rgba(255, 255, 255, 0.3); +} + +.jobapp-status-body { + padding: 16px; +} + +.jobapp-status-info { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; +} + +.jobapp-status-info .jobapp-indicator { + width: 40px; + height: 40px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.jobapp-status-info .jobapp-indicator.running { + background: #fef3c7; + color: #d97706; + animation: jobapp-pulse 1.5s ease-in-out infinite; +} + +.jobapp-status-info .jobapp-indicator.success { + background: #d1fae5; + color: #059669; +} + +.jobapp-status-info .jobapp-indicator.error { + background: #fee2e2; + color: #dc2626; +} + +@keyframes jobapp-pulse { + 0%, 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.7; + transform: scale(1.05); + } +} + +.jobapp-status-info .jobapp-text { + flex: 1; +} + +.jobapp-status-info .jobapp-text .jobapp-status { + font-size: 15px; + font-weight: 600; + color: var(--jobapp-text); + margin-bottom: 2px; +} + +.jobapp-status-info .jobapp-text .jobapp-detail { + font-size: 12px; + color: var(--jobapp-text-muted); +} + +.jobapp-progress-section { + margin-bottom: 12px; +} + +.jobapp-progress-section .jobapp-progress-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.jobapp-progress-section .jobapp-progress-label { + font-size: 12px; + font-weight: 500; + color: var(--jobapp-text-muted); +} + +.jobapp-progress-section .jobapp-progress-count { + font-size: 13px; + font-weight: 600; + color: var(--jobapp-primary); +} + +.jobapp-progress-section .jobapp-progress-bar { + width: 100%; + height: 8px; + background: #e2e8f0; + border-radius: 4px; + overflow: hidden; +} + +.jobapp-progress-section .jobapp-progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--jobapp-primary) 0%, var(--jobapp-purple) 100%); + border-radius: 4px; + transition: width 0.3s ease; +} + +.jobapp-current-job { + padding: 12px; + background: #f8fafc; + border-radius: 8px; + border: 1px solid var(--jobapp-border); +} + +.jobapp-current-job .jobapp-job-label { + font-size: 11px; + font-weight: 500; + color: var(--jobapp-text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 6px; +} + +.jobapp-current-job .jobapp-job-title { + font-size: 14px; + font-weight: 600; + color: var(--jobapp-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.jobapp-current-job .jobapp-job-company { + font-size: 12px; + color: var(--jobapp-text-muted); + margin-top: 2px; +} + +.jobapp-log-panel { + position: fixed; + bottom: 20px; + right: 20px; + width: 350px; + max-height: 200px; + background: rgba(30, 41, 59, 0.95); + border-radius: 10px; + box-shadow: var(--jobapp-shadow-lg); + overflow: hidden; + backdrop-filter: blur(10px); +} + +.jobapp-log-panel .jobapp-log-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: rgba(0, 0, 0, 0.2); + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.jobapp-log-panel .jobapp-log-title { + font-size: 12px; + font-weight: 600; + color: #94a3b8; +} + +.jobapp-log-panel .jobapp-log-toggle { + width: 20px; + height: 20px; + border: none; + background: transparent; + color: #94a3b8; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.jobapp-log-panel .jobapp-log-content { + max-height: 160px; + overflow-y: auto; + padding: 8px; +} + +.jobapp-log-panel .jobapp-log-entry { + font-size: 11px; + font-family: 'Consolas', 'Monaco', monospace; + padding: 4px 8px; + margin-bottom: 2px; + border-radius: 4px; + display: flex; + align-items: flex-start; + gap: 8px; +} + +.jobapp-log-panel .jobapp-log-entry .jobapp-log-time { + color: #64748b; + flex-shrink: 0; +} + +.jobapp-log-panel .jobapp-log-entry .jobapp-log-level { + font-size: 10px; + font-weight: 600; + padding: 1px 4px; + border-radius: 3px; + flex-shrink: 0; +} + +.jobapp-log-panel .jobapp-log-entry.info .jobapp-log-level { + background: rgba(59, 130, 246, 0.2); + color: #60a5fa; +} + +.jobapp-log-panel .jobapp-log-entry.success .jobapp-log-level { + background: rgba(16, 185, 129, 0.2); + color: #34d399; +} + +.jobapp-log-panel .jobapp-log-entry.warn .jobapp-log-level { + background: rgba(245, 158, 11, 0.2); + color: #fbbf24; +} + +.jobapp-log-panel .jobapp-log-entry.error .jobapp-log-level { + background: rgba(239, 68, 68, 0.2); + color: #f87171; +} + +.jobapp-log-panel .jobapp-log-entry .jobapp-log-message { + color: #e2e8f0; + flex: 1; + word-break: break-word; +} + +.jobapp-toast { + position: fixed; + top: 20px; + left: 50%; + transform: translateX(-50%); + padding: 12px 20px; + border-radius: 8px; + box-shadow: var(--jobapp-shadow-lg); + font-size: 14px; + font-weight: 500; + z-index: 2147483647; + animation: jobapp-toastIn 0.3s ease-out; +} + +@keyframes jobapp-toastIn { + from { + opacity: 0; + transform: translateX(-50%) translateY(-20px); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0); + } +} + +.jobapp-toast.success { + background: #d1fae5; + color: #065f46; + border: 1px solid #a7f3d0; +} + +.jobapp-toast.error { + background: #fee2e2; + color: #991b1b; + border: 1px solid #fecaca; +} + +.jobapp-toast.warning { + background: #fef3c7; + color: #92400e; + border: 1px solid #fde68a; +} + +.jobapp-toast.info { + background: #dbeafe; + color: #1e40af; + border: 1px solid #bfdbfe; +} + +.jobapp-highlight-button { + outline: 2px solid var(--jobapp-primary) !important; + outline-offset: 2px !important; + animation: jobapp-highlight 1s ease-in-out infinite; +} + +@keyframes jobapp-highlight { + 0%, 100% { + outline-color: var(--jobapp-primary); + } + 50% { + outline-color: var(--jobapp-purple); + } +} + +.jobapp-applied-card { + position: relative; + opacity: 0.6; +} + +.jobapp-applied-card::after { + content: '已投递'; + position: absolute; + top: 8px; + right: 8px; + padding: 4px 8px; + background: var(--jobapp-success); + color: white; + font-size: 10px; + font-weight: 600; + border-radius: 4px; + z-index: 10; +} + +.jobapp-skip-badge { + position: absolute; + top: 8px; + right: 8px; + padding: 4px 8px; + background: var(--jobapp-warning); + color: white; + font-size: 10px; + font-weight: 600; + border-radius: 4px; + z-index: 10; +} + +.jobapp-mini-indicator { + position: fixed; + bottom: 20px; + left: 20px; + width: 48px; + height: 48px; + background: linear-gradient(135deg, var(--jobapp-primary) 0%, var(--jobapp-purple) 100%); + border-radius: 12px; + box-shadow: var(--jobapp-shadow-lg); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + z-index: 2147483646; + transition: transform 0.2s ease; +} + +.jobapp-mini-indicator:hover { + transform: scale(1.1); +} + +.jobapp-mini-indicator.running { + animation: jobapp-pulse 1.5s ease-in-out infinite; +} + +.jobapp-mini-indicator .jobapp-count { + color: white; + font-size: 12px; + font-weight: 700; +} + +.jobapp-mini-indicator .jobapp-count-wrapper { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +} + +.jobapp-mini-indicator .jobapp-count-label { + color: rgba(255, 255, 255, 0.8); + font-size: 9px; + font-weight: 500; +} + +.jobapp-float-panel { + position: fixed; + bottom: 80px; + left: 20px; + background: var(--jobapp-bg); + border-radius: 12px; + box-shadow: var(--jobapp-shadow-lg); + border: 1px solid var(--jobapp-border); + padding: 12px 16px; + min-width: 200px; + z-index: 2147483646; + animation: jobapp-floatIn 0.2s ease-out; +} + +@keyframes jobapp-floatIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.jobapp-float-panel .jobapp-float-title { + font-size: 13px; + font-weight: 600; + color: var(--jobapp-text); + margin-bottom: 8px; +} + +.jobapp-float-panel .jobapp-float-info { + font-size: 12px; + color: var(--jobapp-text-muted); + margin-bottom: 12px; +} + +.jobapp-float-panel .jobapp-float-actions { + display: flex; + gap: 8px; +} + +.jobapp-float-panel .jobapp-btn { + flex: 1; + padding: 8px 12px; + font-size: 12px; + font-weight: 500; + border: none; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; +} + +.jobapp-float-panel .jobapp-btn.primary { + background: var(--jobapp-primary); + color: white; +} + +.jobapp-float-panel .jobapp-btn.primary:hover { + background: var(--jobapp-primary-dark); +} + +.jobapp-float-panel .jobapp-btn.secondary { + background: var(--jobapp-border); + color: var(--jobapp-text); +} + +.jobapp-float-panel .jobapp-btn.secondary:hover { + background: #cbd5e1; +} + +.jobapp-hidden { + display: none !important; +} + +.jobapp-log-panel::-webkit-scrollbar, +.jobapp-log-content::-webkit-scrollbar { + width: 4px; +} + +.jobapp-log-panel::-webkit-scrollbar-track, +.jobapp-log-content::-webkit-scrollbar-track { + background: transparent; +} + +.jobapp-log-panel::-webkit-scrollbar-thumb, +.jobapp-log-content::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); + border-radius: 2px; +} + +.jobapp-confirm-dialog { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 2147483647; + backdrop-filter: blur(4px); +} + +.jobapp-confirm-dialog .jobapp-dialog-box { + background: var(--jobapp-bg); + border-radius: 16px; + box-shadow: var(--jobapp-shadow-lg); + max-width: 360px; + width: 90%; + overflow: hidden; + animation: jobapp-dialogIn 0.3s ease-out; +} + +@keyframes jobapp-dialogIn { + from { + opacity: 0; + transform: scale(0.9); + } + to { + opacity: 1; + transform: scale(1); + } +} + +.jobapp-confirm-dialog .jobapp-dialog-header { + padding: 16px 20px; + background: linear-gradient(135deg, var(--jobapp-warning) 0%, #f97316 100%); + color: white; +} + +.jobapp-confirm-dialog .jobapp-dialog-title { + font-size: 16px; + font-weight: 600; +} + +.jobapp-confirm-dialog .jobapp-dialog-body { + padding: 20px; +} + +.jobapp-confirm-dialog .jobapp-dialog-message { + font-size: 14px; + color: var(--jobapp-text); + line-height: 1.6; +} + +.jobapp-confirm-dialog .jobapp-dialog-detail { + font-size: 12px; + color: var(--jobapp-text-muted); + margin-top: 8px; + padding: 8px; + background: #f8fafc; + border-radius: 6px; + font-family: 'Consolas', 'Monaco', monospace; +} + +.jobapp-confirm-dialog .jobapp-dialog-footer { + padding: 12px 20px 20px; + display: flex; + gap: 12px; +} + +.jobapp-confirm-dialog .jobapp-dialog-btn { + flex: 1; + padding: 12px; + font-size: 14px; + font-weight: 500; + border: none; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; +} + +.jobapp-confirm-dialog .jobapp-dialog-btn.cancel { + background: var(--jobapp-border); + color: var(--jobapp-text); +} + +.jobapp-confirm-dialog .jobapp-dialog-btn.cancel:hover { + background: #cbd5e1; +} + +.jobapp-confirm-dialog .jobapp-dialog-btn.confirm { + background: var(--jobapp-primary); + color: white; +} + +.jobapp-confirm-dialog .jobapp-dialog-btn.confirm:hover { + background: var(--jobapp-primary-dark); +} + +.jobapp-confirm-dialog .jobapp-dialog-btn.danger { + background: var(--jobapp-danger); + color: white; +} + +.jobapp-confirm-dialog .jobapp-dialog-btn.danger:hover { + background: #dc2626; +} + +.jobapp-banner { + position: fixed; + top: 0; + left: 0; + right: 0; + padding: 8px 16px; + background: linear-gradient(135deg, var(--jobapp-primary) 0%, var(--jobapp-purple) 100%); + color: white; + font-size: 14px; + font-weight: 500; + text-align: center; + z-index: 2147483645; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); +} + +.jobapp-banner.success { + background: linear-gradient(135deg, var(--jobapp-success) 0%, #059669 100%); +} + +.jobapp-banner.warning { + background: linear-gradient(135deg, var(--jobapp-warning) 0%, #d97706 100%); +} + +.jobapp-banner.error { + background: linear-gradient(135deg, var(--jobapp-danger) 0%, #dc2626 100%); +} diff --git a/content/content.js b/content/content.js new file mode 100644 index 0000000..030ffd9 --- /dev/null +++ b/content/content.js @@ -0,0 +1,1316 @@ +const LogLevel = { + DEBUG: 0, + INFO: 1, + WARN: 2, + ERROR: 3 +}; + +const WaitStatus = { + PENDING: 'pending', + WAITING: 'waiting', + FOUND: 'found', + TIMEOUT: 'timeout', + CANCELLED: 'cancelled' +}; + +const PlatformType = { + ZHIPIN: 'zhipin', + ZHAOPIN: 'zhaopin', + JOB51: 'job51', + LAGOU: 'lagou', + LIEPIN: 'liepin', + UNKNOWN: 'unknown' +}; + +const PageType = { + JOB_LIST: 'job_list', + JOB_DETAIL: 'job_detail', + COMPANY_DETAIL: 'company_detail', + SEARCH_RESULT: 'search_result', + OTHER: 'other' +}; + +const ApplyStatus = { + IDLE: 'idle', + INITIALIZING: 'initializing', + DETECTING_PLATFORM: 'detecting_platform', + FINDING_BUTTON: 'finding_button', + CLICKING_BUTTON: 'clicking_button', + WAITING_POPUP: 'waiting_popup', + CONFIRMING_APPLY: 'confirming_apply', + CHECKING_RESULT: 'checking_result', + SUCCESS: 'success', + FAILED: 'failed', + CANCELLED: 'cancelled', + SKIPPED: 'skipped' +}; + +class SimpleLogger { + constructor() { + this.logs = []; + this.maxLogs = 500; + this.minLevel = LogLevel.INFO; + } + + _log(level, message, ...args) { + if (level < this.minLevel) return; + + const timestamp = new Date().toISOString(); + const levelNames = ['DEBUG', 'INFO', 'WARN', 'ERROR']; + const levelName = levelNames[level] || 'UNKNOWN'; + + const logEntry = { + timestamp, + level: levelName, + levelCode: level, + message, + args: args.length > 0 ? args : undefined + }; + + this.logs.push(logEntry); + if (this.logs.length > this.maxLogs) { + this.logs = this.logs.slice(-this.maxLogs); + } + + const consoleMethod = level === LogLevel.DEBUG ? 'log' : + level === LogLevel.INFO ? 'info' : + level === LogLevel.WARN ? 'warn' : 'error'; + + console[consoleMethod](`[JobApp][${levelName}] ${timestamp}`, message, ...args); + } + + debug(message, ...args) { + this._log(LogLevel.DEBUG, message, ...args); + } + + info(message, ...args) { + this._log(LogLevel.INFO, message, ...args); + } + + warn(message, ...args) { + this._log(LogLevel.WARN, message, ...args); + } + + error(message, ...args) { + this._log(LogLevel.ERROR, message, ...args); + } + + getLogs() { + return [...this.logs]; + } + + clearLogs() { + this.logs = []; + } +} + +class SimpleWaiter { + constructor(logger = null) { + this.logger = logger; + this._cancelled = false; + } + + cancel() { + this._cancelled = true; + } + + reset() { + this._cancelled = false; + } + + _log(level, message, ...args) { + if (this.logger && this.logger[level]) { + this.logger[level](`[Waiter] ${message}`, ...args); + } + } + + async waitForElement(selector, options = {}) { + const { + timeout = 10000, + interval = 100, + visible = true, + enabled = true + } = options; + + const startTime = Date.now(); + + while (Date.now() - startTime < timeout) { + if (this._cancelled) { + this._log('info', `等待已取消: ${selector}`); + return { status: WaitStatus.CANCELLED, element: null }; + } + + const element = this._findElement(selector, { visible, enabled }); + if (element) { + this._log('debug', `元素已找到: ${selector}`); + return { status: WaitStatus.FOUND, element }; + } + + await this._delay(interval); + } + + this._log('warn', `等待元素超时: ${selector}`); + return { status: WaitStatus.TIMEOUT, element: null }; + } + + async waitForElements(selector, options = {}) { + const { + timeout = 10000, + interval = 100, + visible = true + } = options; + + const startTime = Date.now(); + + while (Date.now() - startTime < timeout) { + if (this._cancelled) { + return { status: WaitStatus.CANCELLED, elements: [] }; + } + + const elements = this._findElements(selector, { visible }); + if (elements && elements.length > 0) { + this._log('debug', `找到 ${elements.length} 个元素: ${selector}`); + return { status: WaitStatus.FOUND, elements }; + } + + await this._delay(interval); + } + + this._log('warn', `等待多个元素超时: ${selector}`); + return { status: WaitStatus.TIMEOUT, elements: [] }; + } + + async waitForElementClickable(selector, options = {}) { + const { + timeout = 10000, + interval = 100 + } = options; + + const startTime = Date.now(); + + while (Date.now() - startTime < timeout) { + if (this._cancelled) { + return { status: WaitStatus.CANCELLED, element: null }; + } + + const result = await this.waitForElement(selector, { + timeout: Math.min(2000, timeout - (Date.now() - startTime)), + interval, + visible: true + }); + + if (result.status === WaitStatus.FOUND && result.element) { + if (this._isClickable(result.element)) { + this._log('debug', `元素已可点击: ${selector}`); + return { status: WaitStatus.FOUND, element: result.element }; + } + } + + if (result.status === WaitStatus.CANCELLED) { + return result; + } + + await this._delay(interval); + } + + this._log('warn', `等待元素可点击超时: ${selector}`); + return { status: WaitStatus.TIMEOUT, element: null }; + } + + async waitForPopup(selectors, options = {}) { + const { + timeout = 5000, + interval = 200 + } = options; + + const startTime = Date.now(); + + while (Date.now() - startTime < timeout) { + if (this._cancelled) { + return { status: WaitStatus.CANCELLED, popup: null }; + } + + for (const selector of selectors) { + const result = await this.waitForElement(selector, { + timeout: Math.min(500, timeout - (Date.now() - startTime)), + interval: 50, + visible: true + }); + + if (result.status === WaitStatus.FOUND && result.element) { + this._log('debug', `弹窗已出现: ${selector}`); + return { + status: WaitStatus.FOUND, + popup: result.element, + selector + }; + } + + if (result.status === WaitStatus.CANCELLED) { + return { status: WaitStatus.CANCELLED, popup: null }; + } + } + + await this._delay(interval); + } + + this._log('warn', `等待弹窗超时`); + return { status: WaitStatus.TIMEOUT, popup: null }; + } + + async waitForCondition(condition, options = {}) { + const { + timeout = 10000, + interval = 100 + } = options; + + const startTime = Date.now(); + + while (Date.now() - startTime < timeout) { + if (this._cancelled) { + return { status: WaitStatus.CANCELLED, result: null }; + } + + try { + const result = await condition(); + if (result) { + this._log('debug', `条件已满足`); + return { status: WaitStatus.FOUND, result }; + } + } catch (error) { + this._log('debug', `条件检查出错:`, error); + } + + await this._delay(interval); + } + + this._log('warn', `等待条件满足超时`); + return { status: WaitStatus.TIMEOUT, result: null }; + } + + async retry(operation, options = {}) { + const { + maxRetries = 3, + retryDelay = 1000, + onRetry = null + } = options; + + let lastError = null; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + this._log('debug', `执行操作尝试 ${attempt + 1}/${maxRetries + 1}`); + const result = await operation(attempt); + this._log('debug', `操作成功,尝试次数: ${attempt + 1}`); + return { success: true, result, attempt }; + } catch (error) { + lastError = error; + this._log('warn', `操作失败,尝试 ${attempt + 1}/${maxRetries + 1}:`, error); + + if (attempt < maxRetries) { + if (onRetry) { + onRetry(attempt, error); + } + + const delay = retryDelay * (attempt + 1); + this._log('debug', `等待 ${delay}ms 后重试...`); + await this._delay(delay); + } + } + } + + this._log('error', `操作最终失败,已重试 ${maxRetries} 次:`, lastError); + return { success: false, error: lastError, maxRetries }; + } + + _findElement(selector, options = {}) { + const { visible = true, enabled = true } = options; + + try { + if (typeof selector === 'function') { + return selector(); + } + + let element = null; + + if (visible) { + const elements = document.querySelectorAll(selector); + for (const el of elements) { + if (this._isVisible(el)) { + element = el; + break; + } + } + } else { + element = document.querySelector(selector); + } + + return element; + } catch (error) { + this._log('debug', `选择器无效: ${selector}`, error); + return null; + } + } + + _findElements(selector, options = {}) { + const { visible = true } = options; + + try { + if (typeof selector === 'function') { + const result = selector(); + return Array.isArray(result) ? result : [result]; + } + + let elements = Array.from(document.querySelectorAll(selector)); + + if (visible) { + elements = elements.filter(el => this._isVisible(el)); + } + + return elements; + } catch (error) { + this._log('debug', `选择器无效: ${selector}`, error); + return []; + } + } + + _isVisible(element) { + if (!element) return false; + + const style = window.getComputedStyle(element); + + if (style.display === 'none') return false; + if (style.visibility === 'hidden') return false; + if (style.opacity === '0') return false; + + const rect = element.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return false; + + return true; + } + + _isClickable(element) { + if (!this._isVisible(element)) return false; + + const style = window.getComputedStyle(element); + + if (style.pointerEvents === 'none') return false; + + if (element.disabled) return false; + + return true; + } + + _delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } +} + +const PLATFORM_CONFIGS = { + [PlatformType.ZHIPIN]: { + name: 'BOSS直聘', + domainPatterns: ['zhipin.com', 'www.zhipin.com', 'm.zhipin.com'], + + selectors: { + applyButton: [ + '.btn-start-chat', + '[class*="btn-start"]', + '[class*="chat-btn"]', + '.resume-btn', + '[class*="apply-btn"]' + ], + popup: { + modal: [ + '.dialog-wrapper', + '.modal-wrapper', + '[class*="popup"]', + '[class*="modal"]', + '[class*="dialog"]' + ], + confirm: [ + '.dialog-footer .btn-primary', + '.modal-footer .btn-primary', + '[class*="confirm-btn"]', + '[class*="sure-btn"]' + ], + close: [ + '.dialog-close', + '.modal-close', + '[class*="close-btn"]', + '.icon-close' + ] + }, + jobCards: [ + '.job-card-wrapper', + '.job-card-body', + '.job-list-item', + '[class*="job-card"]' + ] + }, + + textPatterns: { + applyButtons: ['立即沟通', '沟通', '投简历', '投递', '申请职位', '立即申请', '一键投递', '聊一聊'], + successMessages: ['投递成功', '申请成功', '已发送', '沟通成功', '已投递'], + errorMessages: ['投递失败', '申请失败', '请先完善简历', '请登录', '验证码'] + }, + + delays: { + afterClick: 1000, + popupWait: 3000, + resultCheck: 2000 + } + }, + + [PlatformType.ZHAOPIN]: { + name: '智联招聘', + domainPatterns: ['zhaopin.com', 'www.zhaopin.com', 'm.zhaopin.com', 'xiaoyuan.zhaopin.com'], + + selectors: { + applyButton: [ + '.btn-apply', + '[class*="apply-btn"]', + '[class*="投递"]', + '.resume-apply-btn', + '#applyJobBtn' + ], + popup: { + modal: ['.popup-box', '.pop-box', '[class*="popup"]', '[class*="modal"]'], + confirm: ['.pop-sure', '[class*="sure-btn"]', '[class*="confirm-btn"]'], + close: ['.pop-close', '[class*="close-btn"]'] + }, + jobCards: ['.job-item', '.joblist-box__item', '[class*="job-item"]'] + }, + + textPatterns: { + applyButtons: ['立即投递', '投递', '申请职位', '一键投递', '投简历'], + successMessages: ['投递成功', '申请成功', '已投递', '投递完成'], + errorMessages: ['投递失败', '请完善简历', '请先登录'] + }, + + delays: { + afterClick: 1500, + popupWait: 3500, + resultCheck: 2500 + } + }, + + [PlatformType.JOB51]: { + name: '前程无忧', + domainPatterns: ['51job.com', 'www.51job.com', 'm.51job.com', 'jobs.51job.com'], + + selectors: { + applyButton: [ + '.p_but', + '.btn_apply', + '[class*="apply"]', + '[onclick*="apply"]' + ], + popup: { + modal: ['.div_apply', '[class*="popup"]', '[class*="modal"]'], + confirm: ['.sure', '[class*="confirm"]', '[class*="sure"]'], + close: ['.close', '[class*="close-btn"]'] + }, + jobCards: ['.e', '.j_joblist div', '.joblist-item'] + }, + + textPatterns: { + applyButtons: ['申请职位', '立即申请', '投递', '一键投递', '投简历'], + successMessages: ['申请成功', '投递成功', '已申请'], + errorMessages: ['申请失败', '请完善简历', '请先登录'] + }, + + delays: { + afterClick: 1500, + popupWait: 4000, + resultCheck: 2500 + } + }, + + [PlatformType.LAGOU]: { + name: '拉勾网', + domainPatterns: ['lagou.com', 'www.lagou.com', 'm.lagou.com'], + + selectors: { + applyButton: [ + '.s_position', + '[class*="apply"]', + '[class*="投递"]', + '.btn-apply' + ], + popup: { + modal: ['.body-container', '[class*="dialog"]', '[class*="modal"]'], + confirm: ['.dialog-footer .btn-primary', '[class*="confirm-btn"]'], + close: ['.icon-close', '[class*="close-btn"]'] + }, + jobCards: ['.con_list_item', '.list_item_top', '.position_item'] + }, + + textPatterns: { + applyButtons: ['投递简历', '立即投递', '申请', '投简历'], + successMessages: ['投递成功', '申请成功', '已投递'], + errorMessages: ['投递失败', '请完善简历', '请登录'] + }, + + delays: { + afterClick: 1200, + popupWait: 3500, + resultCheck: 2000 + } + }, + + [PlatformType.LIEPIN]: { + name: '猎聘网', + domainPatterns: ['liepin.com', 'www.liepin.com', 'm.liepin.com'], + + selectors: { + applyButton: [ + '.btn-apply', + '[class*="apply-btn"]', + '[class*="投递"]', + '.resume-apply' + ], + popup: { + modal: ['.modal', '[class*="modal"]', '[class*="dialog"]'], + confirm: ['.modal-footer .btn-primary', '[class*="confirm-btn"]'], + close: ['.modal-close', '[class*="close-btn"]'] + }, + jobCards: ['.job-card-pc', '.job-card-item', '.job-item'] + }, + + textPatterns: { + applyButtons: ['立即投递', '投递简历', '申请职位', '一键投递'], + successMessages: ['投递成功', '申请成功', '已投递'], + errorMessages: ['投递失败', '请完善简历', '请登录'] + }, + + delays: { + afterClick: 1200, + popupWait: 3500, + resultCheck: 2000 + } + } +}; + +class PlatformDetector { + detectPlatform(url) { + if (!url) return PlatformType.UNKNOWN; + + try { + const urlObj = new URL(url); + const hostname = urlObj.hostname; + + for (const [platformId, config] of Object.entries(PLATFORM_CONFIGS)) { + for (const pattern of config.domainPatterns) { + if (hostname.includes(pattern) || pattern.includes(hostname)) { + return platformId; + } + } + } + + return PlatformType.UNKNOWN; + } catch (e) { + return PlatformType.UNKNOWN; + } + } + + getPlatformConfig(platformId) { + return PLATFORM_CONFIGS[platformId] || null; + } + + getPlatformName(platformId) { + return PLATFORM_CONFIGS[platformId]?.name || '未知平台'; + } + + matchButtonText(platformId, buttonText) { + if (!buttonText) return null; + + const config = this.getPlatformConfig(platformId); + if (!config) return null; + + const text = buttonText.trim().toLowerCase(); + + for (const pattern of config.textPatterns.applyButtons) { + if (text.includes(pattern.toLowerCase())) { + return { type: 'apply', match: pattern }; + } + } + + return null; + } + + isSuccessMessage(platformId, message) { + if (!message) return false; + + const config = this.getPlatformConfig(platformId); + if (!config) return false; + + const msg = message.toLowerCase(); + return config.textPatterns.successMessages.some(pattern => + msg.includes(pattern.toLowerCase()) + ); + } + + isErrorMessage(platformId, message) { + if (!message) return false; + + const config = this.getPlatformConfig(platformId); + if (!config) return false; + + const msg = message.toLowerCase(); + return config.textPatterns.errorMessages.some(pattern => + msg.includes(pattern.toLowerCase()) + ); + } +} + +class ApplyManager { + constructor(logger, waiter, detector) { + this.logger = logger; + this.waiter = waiter; + this.detector = detector; + + this._status = ApplyStatus.IDLE; + this._currentJobInfo = null; + this._applyHistory = []; + this._listeners = new Map(); + this._platformId = null; + this._platformConfig = null; + } + + get status() { + return this._status; + } + + get currentJobInfo() { + return this._currentJobInfo; + } + + onStatusChange(listener) { + if (!this._listeners.has('statusChange')) { + this._listeners.set('statusChange', []); + } + this._listeners.get('statusChange').push(listener); + + return () => { + const listeners = this._listeners.get('statusChange'); + if (listeners) { + const index = listeners.indexOf(listener); + if (index > -1) { + listeners.splice(index, 1); + } + } + }; + } + + _notifyStatusChange(oldStatus, newStatus, data = {}) { + const listeners = this._listeners.get('statusChange'); + if (listeners) { + listeners.forEach(listener => { + try { + listener({ + oldStatus, + newStatus, + jobInfo: this._currentJobInfo, + ...data + }); + } catch (error) { + this.logger.error('状态监听器出错:', error); + } + }); + } + } + + _updateStatus(newStatus, data = {}) { + const oldStatus = this._status; + this._status = newStatus; + this.logger.info(`状态更新: ${oldStatus} -> ${newStatus}`, data); + this._notifyStatusChange(oldStatus, newStatus, data); + } + + init() { + this._platformId = this.detector.detectPlatform(window.location.href); + this._platformConfig = this.detector.getPlatformConfig(this._platformId); + + if (this._platformId !== PlatformType.UNKNOWN) { + this.logger.info(`检测到平台: ${this._platformConfig.name} (${this._platformId})`); + } else { + this.logger.warn('未检测到已知的招聘平台'); + } + } + + async findApplyButton() { + this._updateStatus(ApplyStatus.FINDING_BUTTON); + + if (!this._platformConfig) { + this.logger.warn('未配置平台规则,尝试通用查找'); + const result = await this._findApplyButtonGeneric(); + return result; + } + + const selectors = this._platformConfig.selectors.applyButton; + const textPatterns = this._platformConfig.textPatterns.applyButtons; + + this.logger.debug(`开始查找投递按钮,选择器数量: ${selectors.length}`); + + for (const selector of selectors) { + const result = await this.waiter.waitForElementClickable(selector, { + timeout: 5000, + interval: 200 + }); + + if (result.status === WaitStatus.FOUND && result.element) { + const buttonText = this._getButtonText(result.element); + const match = this.detector.matchButtonText(this._platformId, buttonText); + + if (match) { + this.logger.info(`找到投递按钮: ${selector},文本: "${buttonText}"`); + return { + found: true, + element: result.element, + selector, + buttonText, + match + }; + } + + this.logger.debug(`按钮文本不匹配: "${buttonText}",继续查找...`); + } + } + + this.logger.warn('未找到明确的投递按钮,尝试文本匹配查找...'); + const textMatchResult = await this._findButtonByText(textPatterns); + if (textMatchResult.found) { + return textMatchResult; + } + + this._updateStatus(ApplyStatus.FAILED, { reason: '未找到投递按钮' }); + return { found: false, reason: '未找到投递按钮' }; + } + + async _findApplyButtonGeneric() { + const genericSelectors = [ + 'button[type="submit"]', + 'input[type="submit"]', + '[class*="apply"]', + '[class*="投递"]', + '[class*="申请"]', + 'button' + ]; + + const genericTexts = ['投递', '申请', '立即', '沟通', '聊一聊']; + + for (const selector of genericSelectors) { + const result = await this.waiter.waitForElementClickable(selector, { + timeout: 3000, + interval: 150 + }); + + if (result.status === WaitStatus.FOUND && result.element) { + const buttonText = this._getButtonText(result.element); + for (const text of genericTexts) { + if (buttonText.includes(text)) { + this.logger.info(`通用查找找到按钮: "${buttonText}"`); + return { + found: true, + element: result.element, + selector, + buttonText, + match: { type: 'apply', match: text } + }; + } + } + } + } + + return { found: false, reason: '通用查找未找到合适按钮' }; + } + + async _findButtonByText(textPatterns) { + const buttons = document.querySelectorAll('button, a, input[type="button"], input[type="submit"]'); + + for (const button of buttons) { + if (!this.waiter._isClickable(button)) continue; + + const buttonText = this._getButtonText(button); + for (const pattern of textPatterns) { + if (buttonText.includes(pattern)) { + this.logger.info(`通过文本匹配找到按钮: "${buttonText}"`); + return { + found: true, + element: button, + selector: 'text_match', + buttonText, + match: { type: 'apply', match: pattern } + }; + } + } + } + + return { found: false, reason: '文本匹配未找到' }; + } + + _getButtonText(element) { + if (!element) return ''; + + let text = ''; + + if (element.value) { + text = element.value; + } else if (element.textContent) { + text = element.textContent; + } else if (element.innerText) { + text = element.innerText; + } + + const ariaLabel = element.getAttribute('aria-label'); + if (ariaLabel && !text.includes(ariaLabel)) { + text += ' ' + ariaLabel; + } + + return text.trim().replace(/\s+/g, ' '); + } + + async clickApplyButton(element) { + this._updateStatus(ApplyStatus.CLICKING_BUTTON); + + if (!element) { + this._updateStatus(ApplyStatus.FAILED, { reason: '按钮元素为空' }); + return { success: false, reason: '按钮元素为空' }; + } + + try { + this.logger.info('点击投递按钮...'); + + const clickEvent = new MouseEvent('click', { + bubbles: true, + cancelable: true, + view: window + }); + + const wasCancelled = !element.dispatchEvent(clickEvent); + + if (!wasCancelled && typeof element.click === 'function') { + element.click(); + } + + const delays = this._platformConfig?.delays || { afterClick: 1000 }; + await this.waiter._delay(delays.afterClick); + + this.logger.info('按钮点击完成'); + return { success: true }; + + } catch (error) { + this.logger.error('点击按钮出错:', error); + this._updateStatus(ApplyStatus.FAILED, { reason: '点击按钮出错', error }); + return { success: false, reason: '点击按钮出错', error }; + } + } + + async waitForPopup() { + this._updateStatus(ApplyStatus.WAITING_POPUP); + + const delays = this._platformConfig?.delays || { popupWait: 3000 }; + const popupSelectors = this._platformConfig?.selectors?.popup?.modal || [ + '[class*="popup"]', + '[class*="modal"]', + '[class*="dialog"]' + ]; + + this.logger.debug(`等待弹窗,超时: ${delays.popupWait}ms`); + + const result = await this.waiter.waitForPopup(popupSelectors, { + timeout: delays.popupWait, + interval: 200 + }); + + if (result.status === WaitStatus.FOUND) { + this.logger.info('检测到弹窗'); + return { found: true, popup: result.popup, selector: result.selector }; + } + + this.logger.warn('未检测到弹窗,可能直接投递成功'); + return { found: false, reason: '未检测到弹窗' }; + } + + async findConfirmButton(popupElement) { + this._updateStatus(ApplyStatus.CONFIRMING_APPLY); + + const confirmSelectors = this._platformConfig?.selectors?.popup?.confirm || [ + '[class*="confirm"]', + '[class*="sure"]', + '[class*="确定"]', + '.btn-primary' + ]; + + this.logger.debug('查找确认按钮...'); + + if (popupElement) { + for (const selector of confirmSelectors) { + try { + const element = popupElement.querySelector(selector); + if (element && this.waiter._isClickable(element)) { + const buttonText = this._getButtonText(element); + this.logger.info(`在弹窗中找到确认按钮: "${buttonText}"`); + return { found: true, element, selector, buttonText }; + } + } catch (error) { + this.logger.debug(`选择器无效: ${selector}`, error); + } + } + } + + for (const selector of confirmSelectors) { + const result = await this.waiter.waitForElementClickable(selector, { + timeout: 2000, + interval: 150 + }); + + if (result.status === WaitStatus.FOUND && result.element) { + const buttonText = this._getButtonText(result.element); + this.logger.info(`找到确认按钮: "${buttonText}"`); + return { found: true, element: result.element, selector, buttonText }; + } + } + + this.logger.warn('未找到确认按钮'); + return { found: false, reason: '未找到确认按钮' }; + } + + async clickConfirmButton(element) { + if (!element) { + this.logger.warn('确认按钮元素为空,跳过确认步骤'); + return { success: true, skipped: true }; + } + + try { + this.logger.info('点击确认按钮...'); + + const clickEvent = new MouseEvent('click', { + bubbles: true, + cancelable: true, + view: window + }); + + const wasCancelled = !element.dispatchEvent(clickEvent); + + if (!wasCancelled && typeof element.click === 'function') { + element.click(); + } + + const delays = this._platformConfig?.delays || { afterClick: 1000 }; + await this.waiter._delay(delays.afterClick); + + this.logger.info('确认按钮点击完成'); + return { success: true }; + + } catch (error) { + this.logger.error('点击确认按钮出错:', error); + return { success: false, error }; + } + } + + async checkApplyResult() { + this._updateStatus(ApplyStatus.CHECKING_RESULT); + + const delays = this._platformConfig?.delays || { resultCheck: 2000 }; + + this.logger.debug('检查投递结果...'); + await this.waiter._delay(delays.resultCheck); + + const successMessages = document.querySelectorAll('[class*="success"], [class*="成功"], [class*="已"], [role="alert"]'); + + for (const el of successMessages) { + const text = el.textContent || el.innerText || ''; + if (this.detector.isSuccessMessage(this._platformId, text)) { + this.logger.info(`检测到成功消息: "${text}"`); + this._updateStatus(ApplyStatus.SUCCESS, { message: text }); + return { success: true, message: text }; + } + } + + const errorMessages = document.querySelectorAll('[class*="error"], [class*="失败"], [class*="错误"], [role="alert"]'); + + for (const el of errorMessages) { + const text = el.textContent || el.innerText || ''; + if (this.detector.isErrorMessage(this._platformId, text)) { + this.logger.warn(`检测到失败消息: "${text}"`); + this._updateStatus(ApplyStatus.FAILED, { message: text }); + return { success: false, message: text }; + } + } + + this.logger.info('未检测到明确的结果消息,假设投递成功'); + this._updateStatus(ApplyStatus.SUCCESS, { message: '投递完成(无明确反馈)' }); + return { success: true, message: '投递完成(无明确反馈)' }; + } + + async executeApplyFlow() { + this.init(); + this._updateStatus(ApplyStatus.INITIALIZING); + + const historyItem = { + timestamp: new Date().toISOString(), + platform: this._platformId, + platformName: this._platformConfig?.name || '未知', + url: window.location.href, + status: ApplyStatus.IDLE, + steps: [] + }; + + try { + const buttonResult = await this.findApplyButton(); + historyItem.steps.push({ + step: 'find_apply_button', + success: buttonResult.found, + detail: buttonResult.found ? `找到按钮: ${buttonResult.buttonText}` : buttonResult.reason + }); + + if (!buttonResult.found) { + historyItem.status = ApplyStatus.FAILED; + this._addHistoryItem(historyItem); + return { success: false, reason: buttonResult.reason }; + } + + const clickResult = await this.clickApplyButton(buttonResult.element); + historyItem.steps.push({ + step: 'click_apply_button', + success: clickResult.success, + detail: clickResult.success ? '按钮点击成功' : clickResult.reason + }); + + if (!clickResult.success) { + historyItem.status = ApplyStatus.FAILED; + this._addHistoryItem(historyItem); + return { success: false, reason: clickResult.reason, error: clickResult.error }; + } + + const popupResult = await this.waitForPopup(); + historyItem.steps.push({ + step: 'wait_for_popup', + success: popupResult.found, + detail: popupResult.found ? '检测到弹窗' : popupResult.reason + }); + + if (popupResult.found) { + const confirmResult = await this.findConfirmButton(popupResult.popup); + historyItem.steps.push({ + step: 'find_confirm_button', + success: confirmResult.found, + detail: confirmResult.found ? `找到确认按钮: ${confirmResult.buttonText}` : confirmResult.reason + }); + + if (confirmResult.found) { + const confirmClickResult = await this.clickConfirmButton(confirmResult.element); + historyItem.steps.push({ + step: 'click_confirm_button', + success: confirmClickResult.success, + detail: confirmClickResult.skipped ? '跳过确认' : (confirmClickResult.success ? '确认点击成功' : '确认点击失败') + }); + } + } + + const result = await this.checkApplyResult(); + historyItem.status = result.success ? ApplyStatus.SUCCESS : ApplyStatus.FAILED; + historyItem.result = result; + + this._addHistoryItem(historyItem); + + return { + success: result.success, + message: result.message, + history: historyItem + }; + + } catch (error) { + this.logger.error('投递流程执行出错:', error); + historyItem.status = ApplyStatus.FAILED; + historyItem.error = error.message; + this._addHistoryItem(historyItem); + this._updateStatus(ApplyStatus.FAILED, { error }); + + return { + success: false, + reason: '投递流程异常', + error: error.message + }; + } + } + + _addHistoryItem(item) { + this._applyHistory.push(item); + if (this._applyHistory.length > 100) { + this._applyHistory = this._applyHistory.slice(-100); + } + } + + getHistory() { + return [...this._applyHistory]; + } + + getStatistics() { + const total = this._applyHistory.length; + const success = this._applyHistory.filter(h => h.status === ApplyStatus.SUCCESS).length; + const failed = this._applyHistory.filter(h => h.status === ApplyStatus.FAILED).length; + const byPlatform = {}; + + this._applyHistory.forEach(h => { + if (!byPlatform[h.platform]) { + byPlatform[h.platform] = { total: 0, success: 0, failed: 0 }; + } + byPlatform[h.platform].total++; + if (h.status === ApplyStatus.SUCCESS) { + byPlatform[h.platform].success++; + } else if (h.status === ApplyStatus.FAILED) { + byPlatform[h.platform].failed++; + } + }); + + return { + total, + success, + failed, + successRate: total > 0 ? (success / total * 100).toFixed(1) + '%' : '0%', + byPlatform + }; + } + + cancel() { + this.waiter.cancel(); + this._updateStatus(ApplyStatus.CANCELLED); + } + + reset() { + this.waiter.reset(); + this._status = ApplyStatus.IDLE; + this._currentJobInfo = null; + } +} + +const logger = new SimpleLogger(); +const waiter = new SimpleWaiter(logger); +const detector = new PlatformDetector(); +const applyManager = new ApplyManager(logger, waiter, detector); + +const MessageActions = { + PING: 'ping', + GET_STATUS: 'getStatus', + GET_PLATFORM: 'getPlatform', + EXECUTE_APPLY: 'executeApply', + CANCEL_APPLY: 'cancelApply', + GET_HISTORY: 'getHistory', + GET_STATISTICS: 'getStatistics', + FIND_APPLY_BUTTON: 'findApplyButton', + GET_LOGS: 'getLogs', + CLEAR_LOGS: 'clearLogs' +}; + +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + logger.debug(`收到消息: ${message.action}`, message); + + const handleMessage = async () => { + try { + switch (message.action) { + case MessageActions.PING: + return { success: true, data: { timestamp: Date.now() } }; + + case MessageActions.GET_STATUS: + return { + success: true, + data: { + status: applyManager.status, + jobInfo: applyManager.currentJobInfo + } + }; + + case MessageActions.GET_PLATFORM: + const platformId = detector.detectPlatform(window.location.href); + return { + success: true, + data: { + platformId, + platformName: detector.getPlatformName(platformId), + isSupported: platformId !== PlatformType.UNKNOWN + } + }; + + case MessageActions.EXECUTE_APPLY: + applyManager.reset(); + const result = await applyManager.executeApplyFlow(); + return { + success: result.success, + data: result + }; + + case MessageActions.CANCEL_APPLY: + applyManager.cancel(); + return { success: true, data: { cancelled: true } }; + + case MessageActions.GET_HISTORY: + return { success: true, data: applyManager.getHistory() }; + + case MessageActions.GET_STATISTICS: + return { success: true, data: applyManager.getStatistics() }; + + case MessageActions.FIND_APPLY_BUTTON: + applyManager.init(); + const buttonResult = await applyManager.findApplyButton(); + return { + success: buttonResult.found, + data: { + found: buttonResult.found, + buttonText: buttonResult.buttonText, + selector: buttonResult.selector + } + }; + + case MessageActions.GET_LOGS: + return { success: true, data: logger.getLogs() }; + + case MessageActions.CLEAR_LOGS: + logger.clearLogs(); + return { success: true, data: { cleared: true } }; + + default: + return { success: false, error: `未知的消息类型: ${message.action}` }; + } + } catch (error) { + logger.error('消息处理出错:', error); + return { success: false, error: error.message }; + } + }; + + handleMessage() + .then(response => { + sendResponse(response); + }) + .catch(error => { + sendResponse({ success: false, error: error.message }); + }); + + return true; +}); + +applyManager.onStatusChange(({ oldStatus, newStatus, jobInfo, ...data }) => { + chrome.runtime.sendMessage({ + action: 'statusChange', + data: { + oldStatus, + newStatus, + jobInfo, + ...data + } + }).catch(error => { + logger.debug('发送状态变化消息失败:', error); + }); +}); + +(function init() { + logger.info('快投简历 Content Script 已加载'); + logger.info(`当前URL: ${window.location.href}`); + + applyManager.init(); + + if (applyManager['_platformId'] !== PlatformType.UNKNOWN) { + logger.info(`已在支持的平台上运行: ${applyManager['_platformConfig']?.name}`); + } +})(); diff --git a/lib/elementWaiter.js b/lib/elementWaiter.js new file mode 100644 index 0000000..78bddbb --- /dev/null +++ b/lib/elementWaiter.js @@ -0,0 +1,487 @@ +const WaitStatus = { + PENDING: 'pending', + WAITING: 'waiting', + FOUND: 'found', + TIMEOUT: 'timeout', + CANCELLED: 'cancelled' +}; + +const DEFAULT_OPTIONS = { + timeout: 10000, + interval: 100, + maxRetries: 3, + retryDelay: 1000, + visible: true, + enabled: true, + stable: false, + stableDelay: 500 +}; + +class WaitController { + constructor() { + this._cancelled = false; + } + + cancel() { + this._cancelled = true; + } + + isCancelled() { + return this._cancelled; + } +} + +class ElementWaiter { + constructor(logger = null) { + this.logger = logger; + this._activeWaits = new Map(); + } + + _log(level, message, ...args) { + if (this.logger && this.logger[level]) { + this.logger[level](`[ElementWaiter] ${message}`, ...args); + } + } + + async waitForElement(selector, options = {}, controller = null) { + const opts = { ...DEFAULT_OPTIONS, ...options }; + const startTime = Date.now(); + const waitId = 'wait_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); + + this._activeWaits.set(waitId, { + selector, + startTime, + timeout: opts.timeout, + status: WaitStatus.WAITING + }); + + this._log('debug', `开始等待元素: ${selector}`, { timeout: opts.timeout, interval: opts.interval }); + + try { + while (Date.now() - startTime < opts.timeout) { + if (controller && controller.isCancelled()) { + this._log('info', `等待已取消: ${selector}`); + this._updateWaitStatus(waitId, WaitStatus.CANCELLED); + return { status: WaitStatus.CANCELLED, element: null }; + } + + const element = this._findElement(selector, opts); + + if (element) { + if (opts.stable) { + await this._waitForStable(element, opts.stableDelay); + } + + this._log('debug', `元素已找到: ${selector}`, { elapsed: Date.now() - startTime }); + this._updateWaitStatus(waitId, WaitStatus.FOUND); + return { status: WaitStatus.FOUND, element }; + } + + await this._delay(opts.interval); + } + + this._log('warn', `等待元素超时: ${selector}`, { timeout: opts.timeout }); + this._updateWaitStatus(waitId, WaitStatus.TIMEOUT); + return { status: WaitStatus.TIMEOUT, element: null }; + + } catch (error) { + this._log('error', `等待元素时出错: ${selector}`, error); + this._updateWaitStatus(waitId, WaitStatus.TIMEOUT); + return { status: WaitStatus.TIMEOUT, element: null, error }; + } finally { + this._activeWaits.delete(waitId); + } + } + + async waitForElements(selector, options = {}, controller = null) { + const opts = { ...DEFAULT_OPTIONS, ...options }; + const startTime = Date.now(); + const waitId = 'waitAll_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); + + this._activeWaits.set(waitId, { + selector, + startTime, + timeout: opts.timeout, + status: WaitStatus.WAITING + }); + + this._log('debug', `开始等待多个元素: ${selector}`); + + try { + while (Date.now() - startTime < opts.timeout) { + if (controller && controller.isCancelled()) { + this._log('info', `等待已取消: ${selector}`); + this._updateWaitStatus(waitId, WaitStatus.CANCELLED); + return { status: WaitStatus.CANCELLED, elements: [] }; + } + + const elements = this._findElements(selector, opts); + + if (elements && elements.length > 0) { + this._log('debug', `找到 ${elements.length} 个元素: ${selector}`); + this._updateWaitStatus(waitId, WaitStatus.FOUND); + return { status: WaitStatus.FOUND, elements }; + } + + await this._delay(opts.interval); + } + + this._log('warn', `等待多个元素超时: ${selector}`); + this._updateWaitStatus(waitId, WaitStatus.TIMEOUT); + return { status: WaitStatus.TIMEOUT, elements: [] }; + + } catch (error) { + this._log('error', `等待多个元素时出错: ${selector}`, error); + this._updateWaitStatus(waitId, WaitStatus.TIMEOUT); + return { status: WaitStatus.TIMEOUT, elements: [], error }; + } finally { + this._activeWaits.delete(waitId); + } + } + + async waitForElementClickable(selector, options = {}, controller = null) { + const opts = { ...DEFAULT_OPTIONS, ...options }; + const startTime = Date.now(); + + this._log('debug', `开始等待元素可点击: ${selector}`); + + while (Date.now() - startTime < opts.timeout) { + if (controller && controller.isCancelled()) { + return { status: WaitStatus.CANCELLED, element: null }; + } + + const result = await this.waitForElement(selector, { + ...opts, + timeout: Math.min(2000, opts.timeout - (Date.now() - startTime)) + }, controller); + + if (result.status === WaitStatus.FOUND && result.element) { + if (this._isClickable(result.element)) { + this._log('debug', `元素已可点击: ${selector}`); + return { status: WaitStatus.FOUND, element: result.element }; + } + } + + if (result.status === WaitStatus.CANCELLED) { + return result; + } + + await this._delay(opts.interval); + } + + this._log('warn', `等待元素可点击超时: ${selector}`); + return { status: WaitStatus.TIMEOUT, element: null }; + } + + async waitForPopup(selectors, options = {}, controller = null) { + const opts = { ...DEFAULT_OPTIONS, ...options }; + const startTime = Date.now(); + + this._log('debug', `开始等待弹窗:`, selectors); + + while (Date.now() - startTime < opts.timeout) { + if (controller && controller.isCancelled()) { + return { status: WaitStatus.CANCELLED, popup: null }; + } + + for (const selector of selectors) { + const result = await this.waitForElement(selector, { + ...opts, + timeout: Math.min(500, opts.timeout - (Date.now() - startTime)), + visible: true + }, controller); + + if (result.status === WaitStatus.FOUND && result.element) { + this._log('debug', `弹窗已出现: ${selector}`); + return { + status: WaitStatus.FOUND, + popup: result.element, + selector + }; + } + + if (result.status === WaitStatus.CANCELLED) { + return { status: WaitStatus.CANCELLED, popup: null }; + } + } + + await this._delay(opts.interval); + } + + this._log('warn', `等待弹窗超时`); + return { status: WaitStatus.TIMEOUT, popup: null }; + } + + async waitForUrlChange(initialUrl, options = {}, controller = null) { + const opts = { ...DEFAULT_OPTIONS, ...options }; + const startTime = Date.now(); + + this._log('debug', `开始等待URL变化,初始URL: ${initialUrl}`); + + while (Date.now() - startTime < opts.timeout) { + if (controller && controller.isCancelled()) { + return { status: WaitStatus.CANCELLED, url: window.location.href }; + } + + const currentUrl = window.location.href; + if (currentUrl !== initialUrl) { + this._log('debug', `URL已变化: ${initialUrl} -> ${currentUrl}`); + return { status: WaitStatus.FOUND, url: currentUrl }; + } + + await this._delay(opts.interval); + } + + this._log('warn', `等待URL变化超时`); + return { status: WaitStatus.TIMEOUT, url: window.location.href }; + } + + async waitForCondition(condition, options = {}, controller = null) { + const opts = { ...DEFAULT_OPTIONS, ...options }; + const startTime = Date.now(); + + this._log('debug', `开始等待条件满足`); + + while (Date.now() - startTime < opts.timeout) { + if (controller && controller.isCancelled()) { + return { status: WaitStatus.CANCELLED, result: null }; + } + + try { + const result = await condition(); + if (result) { + this._log('debug', `条件已满足`); + return { status: WaitStatus.FOUND, result }; + } + } catch (error) { + this._log('debug', `条件检查出错:`, error); + } + + await this._delay(opts.interval); + } + + this._log('warn', `等待条件满足超时`); + return { status: WaitStatus.TIMEOUT, result: null }; + } + + async retry(operation, options = {}) { + const opts = { + maxRetries: DEFAULT_OPTIONS.maxRetries, + retryDelay: DEFAULT_OPTIONS.retryDelay, + onRetry: null, + ...options + }; + + let lastError = null; + + for (let attempt = 0; attempt <= opts.maxRetries; attempt++) { + try { + this._log('debug', `执行操作尝试 ${attempt + 1}/${opts.maxRetries + 1}`); + const result = await operation(attempt); + this._log('debug', `操作成功,尝试次数: ${attempt + 1}`); + return { success: true, result, attempt }; + } catch (error) { + lastError = error; + this._log('warn', `操作失败,尝试 ${attempt + 1}/${opts.maxRetries + 1}:`, error); + + if (attempt < opts.maxRetries) { + if (opts.onRetry) { + opts.onRetry(attempt, error); + } + + const delay = typeof opts.retryDelay === 'function' + ? opts.retryDelay(attempt) + : opts.retryDelay * (attempt + 1); + + this._log('debug', `等待 ${delay}ms 后重试...`); + await this._delay(delay); + } + } + } + + this._log('error', `操作最终失败,已重试 ${opts.maxRetries} 次:`, lastError); + return { success: false, error: lastError, maxRetries: opts.maxRetries }; + } + + async retryWithBackoff(operation, options = {}) { + const opts = { + maxRetries: 3, + initialDelay: 1000, + maxDelay: 10000, + multiplier: 2, + ...options + }; + + const getDelay = (attempt) => { + const delay = opts.initialDelay * Math.pow(opts.multiplier, attempt); + return Math.min(delay, opts.maxDelay); + }; + + return this.retry(operation, { + maxRetries: opts.maxRetries, + retryDelay: getDelay, + onRetry: opts.onRetry + }); + } + + _findElement(selector, options) { + if (typeof selector === 'function') { + return selector(); + } + + let element = null; + + try { + if (options.visible) { + const elements = document.querySelectorAll(selector); + for (const el of elements) { + if (this._isVisible(el)) { + element = el; + break; + } + } + } else { + element = document.querySelector(selector); + } + } catch (error) { + this._log('debug', `选择器无效: ${selector}`, error); + return null; + } + + return element; + } + + _findElements(selector, options) { + if (typeof selector === 'function') { + const result = selector(); + return Array.isArray(result) ? result : [result]; + } + + let elements = []; + + try { + elements = Array.from(document.querySelectorAll(selector)); + + if (options.visible) { + elements = elements.filter(el => this._isVisible(el)); + } + } catch (error) { + this._log('debug', `选择器无效: ${selector}`, error); + return []; + } + + return elements; + } + + _isVisible(element) { + if (!element) return false; + + const style = window.getComputedStyle(element); + + if (style.display === 'none') return false; + if (style.visibility === 'hidden') return false; + if (style.opacity === '0') return false; + + const rect = element.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return false; + + return true; + } + + _isClickable(element) { + if (!this._isVisible(element)) return false; + + const style = window.getComputedStyle(element); + + if (style.pointerEvents === 'none') return false; + + if (element.disabled) return false; + + return true; + } + + async _waitForStable(element, delay) { + const initialRect = element.getBoundingClientRect(); + await this._delay(delay); + const currentRect = element.getBoundingClientRect(); + + return ( + initialRect.top === currentRect.top && + initialRect.left === currentRect.left && + initialRect.width === currentRect.width && + initialRect.height === currentRect.height + ); + } + + _delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + _updateWaitStatus(waitId, status) { + const wait = this._activeWaits.get(waitId); + if (wait) { + wait.status = status; + } + } + + cancelAllWaits() { + this._log('info', `取消所有等待操作`); + this._activeWaits.forEach((wait, id) => { + wait.status = WaitStatus.CANCELLED; + }); + } + + getActiveWaits() { + return Array.from(this._activeWaits.entries()).map(([id, wait]) => ({ + id, + ...wait + })); + } + + createController() { + return new WaitController(); + } +} + +const globalWaiter = new ElementWaiter(); + +const ElementWaiterWrapper = { + WaitStatus, + WaitController, + DEFAULT_OPTIONS, + ElementWaiter, + + init: (logger) => { + globalWaiter.logger = logger; + }, + + waitForElement: (selector, options, controller) => + globalWaiter.waitForElement(selector, options, controller), + + waitForElements: (selector, options, controller) => + globalWaiter.waitForElements(selector, options, controller), + + waitForElementClickable: (selector, options, controller) => + globalWaiter.waitForElementClickable(selector, options, controller), + + waitForPopup: (selectors, options, controller) => + globalWaiter.waitForPopup(selectors, options, controller), + + waitForUrlChange: (initialUrl, options, controller) => + globalWaiter.waitForUrlChange(initialUrl, options, controller), + + waitForCondition: (condition, options, controller) => + globalWaiter.waitForCondition(condition, options, controller), + + retry: (operation, options) => + globalWaiter.retry(operation, options), + + retryWithBackoff: (operation, options) => + globalWaiter.retryWithBackoff(operation, options), + + cancelAllWaits: () => globalWaiter.cancelAllWaits(), + getActiveWaits: () => globalWaiter.getActiveWaits(), + createController: () => globalWaiter.createController() +}; + +export default ElementWaiterWrapper; diff --git a/lib/errorHandler.js b/lib/errorHandler.js new file mode 100644 index 0000000..b062d3c --- /dev/null +++ b/lib/errorHandler.js @@ -0,0 +1,408 @@ +const ErrorType = { + UNKNOWN_ERROR: 'unknown_error', + NETWORK_ERROR: 'network_error', + TIMEOUT_ERROR: 'timeout_error', + STORAGE_ERROR: 'storage_error', + PERMISSION_ERROR: 'permission_error', + DOM_ERROR: 'dom_error', + VALIDATION_ERROR: 'validation_error', + CONFIG_ERROR: 'config_error', + PLATFORM_ERROR: 'platform_error', + EXTENSION_ERROR: 'extension_error', + RUNTIME_ERROR: 'runtime_error' +}; + +const ErrorSeverity = { + LOW: 'low', + MEDIUM: 'medium', + HIGH: 'high', + CRITICAL: 'critical' +}; + +const ERROR_MESSAGES = { + [ErrorType.UNKNOWN_ERROR]: '发生未知错误', + [ErrorType.NETWORK_ERROR]: '网络连接失败,请检查网络连接', + [ErrorType.TIMEOUT_ERROR]: '操作超时,请重试', + [ErrorType.STORAGE_ERROR]: '数据存储失败', + [ErrorType.PERMISSION_ERROR]: '权限不足,请检查扩展权限设置', + [ErrorType.DOM_ERROR]: '页面元素操作失败', + [ErrorType.VALIDATION_ERROR]: '数据验证失败', + [ErrorType.CONFIG_ERROR]: '配置错误', + [ErrorType.PLATFORM_ERROR]: '平台不支持或平台操作失败', + [ErrorType.EXTENSION_ERROR]: '扩展内部错误', + [ErrorType.RUNTIME_ERROR]: '运行时错误' +}; + +class JobAppError extends Error { + constructor(message, type = ErrorType.UNKNOWN_ERROR, options = {}) { + super(message); + this.name = 'JobAppError'; + this.type = type; + this.severity = options.severity || ErrorSeverity.HIGH; + this.timestamp = new Date().toISOString(); + this.context = options.context || {}; + this.cause = options.cause || null; + this.code = options.code || null; + + if (Error.captureStackTrace) { + Error.captureStackTrace(this, JobAppError); + } + + if (!message) { + this.message = ERROR_MESSAGES[type] || ERROR_MESSAGES[ErrorType.UNKNOWN_ERROR]; + } + } + + toJSON() { + return { + name: this.name, + message: this.message, + type: this.type, + severity: this.severity, + timestamp: this.timestamp, + context: this.context, + code: this.code, + stack: this.stack + }; + } + + toString() { + return `[${this.type}] ${this.message}${this.code ? ` (Code: ${this.code})` : ''}`; + } +} + +class ErrorHandler { + constructor(logger = null) { + this.logger = logger; + this._initialized = false; + this._errorListeners = []; + this._lastErrors = []; + this._maxLastErrors = 50; + } + + init() { + if (this._initialized) return; + + this._setupGlobalErrorListeners(); + this._setupUnhandledRejectionListeners(); + this._initialized = true; + + if (this.logger) { + this.logger.info('[ErrorHandler] Error handler initialized'); + } + } + + _setupGlobalErrorListeners() { + const originalOnError = window.onerror; + + window.onerror = (message, source, lineno, colno, error) => { + const errorInfo = { + message: String(message), + source: source, + lineno: lineno, + colno: colno, + stack: error?.stack + }; + + this.handleError(error || new Error(String(message)), { + context: errorInfo, + severity: ErrorSeverity.HIGH + }); + + if (originalOnError) { + return originalOnError(message, source, lineno, colno, error); + } + + return false; + }; + } + + _setupUnhandledRejectionListeners() { + window.addEventListener('unhandledrejection', (event) => { + event.preventDefault(); + + const reason = event.reason; + let error; + + if (reason instanceof Error) { + error = reason; + } else if (typeof reason === 'object' && reason !== null) { + error = new Error(JSON.stringify(reason)); + } else { + error = new Error(String(reason)); + } + + this.handleError(error, { + type: ErrorType.RUNTIME_ERROR, + severity: ErrorSeverity.HIGH, + context: { isPromiseRejection: true } + }); + }); + } + + handleError(error, options = {}) { + const type = options.type || this._classifyError(error); + const severity = options.severity || ErrorSeverity.MEDIUM; + const context = options.context || {}; + + let jobAppError; + + if (error instanceof JobAppError) { + jobAppError = error; + } else if (error instanceof Error) { + jobAppError = new JobAppError(error.message, type, { + severity, + context: { + ...context, + originalError: { + name: error.name, + message: error.message, + stack: error.stack + } + }, + cause: error + }); + } else { + jobAppError = new JobAppError(String(error), type, { + severity, + context + }); + } + + this._recordError(jobAppError); + this._notifyListeners(jobAppError); + + if (this.logger) { + const logMethod = severity === ErrorSeverity.CRITICAL || severity === ErrorSeverity.HIGH + ? 'error' + : severity === ErrorSeverity.MEDIUM + ? 'warn' + : 'info'; + this.logger[logMethod](`[Error:${type}] ${jobAppError.message}`, jobAppError.context); + } + + return jobAppError; + } + + _classifyError(error) { + if (!error) return ErrorType.UNKNOWN_ERROR; + + if (error instanceof TypeError) { + if (error.message.includes('Cannot read property') || + error.message.includes('Cannot set property')) { + return ErrorType.DOM_ERROR; + } + return ErrorType.RUNTIME_ERROR; + } + + if (error instanceof ReferenceError) { + return ErrorType.RUNTIME_ERROR; + } + + if (error instanceof SyntaxError) { + return ErrorType.CONFIG_ERROR; + } + + if (error.message) { + const msg = error.message.toLowerCase(); + + if (msg.includes('network') || + msg.includes('fetch') || + msg.includes('xhr') || + msg.includes('connection')) { + return ErrorType.NETWORK_ERROR; + } + + if (msg.includes('timeout') || msg.includes('timed out')) { + return ErrorType.TIMEOUT_ERROR; + } + + if (msg.includes('storage') || + msg.includes('quota') || + msg.includes('chrome.storage')) { + return ErrorType.STORAGE_ERROR; + } + + if (msg.includes('permission') || + msg.includes('access denied') || + msg.includes('not allowed')) { + return ErrorType.PERMISSION_ERROR; + } + + if (msg.includes('dom') || + msg.includes('element') || + msg.includes('node')) { + return ErrorType.DOM_ERROR; + } + + if (msg.includes('validation') || + msg.includes('invalid') || + msg.includes('required')) { + return ErrorType.VALIDATION_ERROR; + } + } + + if (error.message?.includes('extension') || error.name?.includes('Extension')) { + return ErrorType.EXTENSION_ERROR; + } + + return ErrorType.UNKNOWN_ERROR; + } + + _recordError(error) { + this._lastErrors.push({ + error: error.toJSON(), + timestamp: Date.now() + }); + + if (this._lastErrors.length > this._maxLastErrors) { + this._lastErrors = this._lastErrors.slice(-this._maxLastErrors); + } + } + + _notifyListeners(error) { + this._errorListeners.forEach(listener => { + try { + listener(error); + } catch (e) { + console.error('[ErrorHandler] Error in listener:', e); + } + }); + } + + onError(listener) { + this._errorListeners.push(listener); + return () => { + const index = this._errorListeners.indexOf(listener); + if (index > -1) { + this._errorListeners.splice(index, 1); + } + }; + } + + getLastErrors(count = 10) { + return this._lastErrors + .slice(-count) + .reverse(); + } + + getErrorStats() { + const stats = { + total: this._lastErrors.length, + byType: {}, + bySeverity: {}, + lastHour: 0, + last24Hours: 0 + }; + + const now = Date.now(); + const oneHour = 60 * 60 * 1000; + const oneDay = 24 * oneHour; + + this._lastErrors.forEach(record => { + const { error, timestamp } = record; + + if (!stats.byType[error.type]) { + stats.byType[error.type] = 0; + } + stats.byType[error.type]++; + + if (!stats.bySeverity[error.severity]) { + stats.bySeverity[error.severity] = 0; + } + stats.bySeverity[error.severity]++; + + if (timestamp > now - oneHour) { + stats.lastHour++; + } + if (timestamp > now - oneDay) { + stats.last24Hours++; + } + }); + + return stats; + } + + clearErrors() { + this._lastErrors = []; + } + + createError(message, type = ErrorType.UNKNOWN_ERROR, options = {}) { + return new JobAppError(message, type, options); + } + + wrapAsync(fn, options = {}) { + return async (...args) => { + try { + return await fn(...args); + } catch (error) { + return this.handleError(error, options); + } + }; + } + + wrapSync(fn, options = {}) { + return (...args) => { + try { + return fn(...args); + } catch (error) { + return this.handleError(error, options); + } + }; + } + + getUserFriendlyMessage(error) { + if (error instanceof JobAppError) { + return ERROR_MESSAGES[error.type] || error.message; + } + + if (error instanceof Error) { + const type = this._classifyError(error); + return ERROR_MESSAGES[type] || error.message; + } + + return ERROR_MESSAGES[ErrorType.UNKNOWN_ERROR]; + } + + getErrorTypeInfo(type) { + return { + type, + defaultMessage: ERROR_MESSAGES[type] || ERROR_MESSAGES[ErrorType.UNKNOWN_ERROR] + }; + } +} + +const globalErrorHandler = new ErrorHandler(); + +const ErrorHandlerWrapper = { + ErrorType, + ErrorSeverity, + JobAppError, + ErrorHandler, + + init: (logger) => { + if (logger) { + globalErrorHandler.logger = logger; + } + globalErrorHandler.init(); + }, + + handle: (error, options) => globalErrorHandler.handleError(error, options), + onError: (listener) => globalErrorHandler.onError(listener), + getLastErrors: (count) => globalErrorHandler.getLastErrors(count), + getErrorStats: () => globalErrorHandler.getErrorStats(), + clearErrors: () => globalErrorHandler.clearErrors(), + + createError: (message, type, options) => + globalErrorHandler.createError(message, type, options), + + wrapAsync: (fn, options) => globalErrorHandler.wrapAsync(fn, options), + wrapSync: (fn, options) => globalErrorHandler.wrapSync(fn, options), + + getUserFriendlyMessage: (error) => + globalErrorHandler.getUserFriendlyMessage(error), + + getErrorTypeInfo: (type) => globalErrorHandler.getErrorTypeInfo(type) +}; + +export default ErrorHandlerWrapper; diff --git a/lib/logger.js b/lib/logger.js new file mode 100644 index 0000000..417ba2c --- /dev/null +++ b/lib/logger.js @@ -0,0 +1,291 @@ +const LogLevel = { + DEBUG: 0, + INFO: 1, + WARN: 2, + ERROR: 3, + NONE: 4 +}; + +const LogLevelNames = { + 0: 'DEBUG', + 1: 'INFO', + 2: 'WARN', + 3: 'ERROR', + 4: 'NONE' +}; + +const DEFAULT_CONFIG = { + minLevel: LogLevel.INFO, + maxLogs: 1000, + enableConsole: true, + enableStorage: true, + storageKey: 'jobapp_logs', + enableTimestamp: true, + enableLevel: true +}; + +class Logger { + constructor(options = {}) { + this.config = { ...DEFAULT_CONFIG, ...options }; + this.logs = []; + this._initialized = false; + this._pendingLogs = []; + } + + async init() { + if (this._initialized) return; + + try { + if (this.config.enableStorage && typeof chrome !== 'undefined' && chrome.storage) { + const result = await chrome.storage.local.get(this.config.storageKey); + if (result[this.config.storageKey]) { + this.logs = result[this.config.storageKey]; + } + } + } catch (error) { + console.warn('[Logger] Failed to load logs from storage:', error); + } + + if (this._pendingLogs.length > 0) { + this.logs.push(...this._pendingLogs); + this._pendingLogs = []; + await this._saveToStorage(); + } + + this._initialized = true; + } + + _formatLog(level, ...args) { + const timestamp = new Date().toISOString(); + const levelName = LogLevelNames[level] || 'UNKNOWN'; + const message = args.map(arg => { + if (typeof arg === 'object') { + try { + return JSON.stringify(arg); + } catch (e) { + return String(arg); + } + } + return String(arg); + }).join(' '); + + return { + timestamp, + level: levelName, + levelCode: level, + message, + rawArgs: args + }; + } + + _shouldLog(level) { + return level >= this.config.minLevel && level < LogLevel.NONE; + } + + async _addLog(logEntry) { + if (this.config.enableConsole) { + this._logToConsole(logEntry); + } + + if (this._initialized) { + this.logs.push(logEntry); + if (this.logs.length > this.config.maxLogs) { + this.logs = this.logs.slice(-this.config.maxLogs); + } + await this._saveToStorage(); + } else { + this._pendingLogs.push(logEntry); + } + } + + _logToConsole(logEntry) { + const { levelCode, timestamp, level, message } = logEntry; + const prefix = `[${level}] ${timestamp}`; + + switch (levelCode) { + case LogLevel.DEBUG: + console.log(prefix, message); + break; + case LogLevel.INFO: + console.info(prefix, message); + break; + case LogLevel.WARN: + console.warn(prefix, message); + break; + case LogLevel.ERROR: + console.error(prefix, message); + break; + default: + console.log(prefix, message); + } + } + + async _saveToStorage() { + if (!this.config.enableStorage) return; + if (typeof chrome === 'undefined' || !chrome.storage) return; + + try { + await chrome.storage.local.set({ + [this.config.storageKey]: this.logs + }); + } catch (error) { + console.warn('[Logger] Failed to save logs to storage:', error); + } + } + + async debug(...args) { + if (!this._shouldLog(LogLevel.DEBUG)) return; + const log = this._formatLog(LogLevel.DEBUG, ...args); + await this._addLog(log); + } + + async info(...args) { + if (!this._shouldLog(LogLevel.INFO)) return; + const log = this._formatLog(LogLevel.INFO, ...args); + await this._addLog(log); + } + + async warn(...args) { + if (!this._shouldLog(LogLevel.WARN)) return; + const log = this._formatLog(LogLevel.WARN, ...args); + await this._addLog(log); + } + + async error(...args) { + if (!this._shouldLog(LogLevel.ERROR)) return; + const log = this._formatLog(LogLevel.ERROR, ...args); + await this._addLog(log); + } + + getLogs(filter = {}) { + let result = [...this.logs]; + + if (filter.level) { + result = result.filter(l => l.level === filter.level); + } + + if (filter.since) { + result = result.filter(l => l.timestamp >= filter.since); + } + + if (filter.until) { + result = result.filter(l => l.timestamp <= filter.until); + } + + if (filter.limit) { + result = result.slice(-filter.limit); + } + + if (filter.contains) { + const keyword = filter.contains.toLowerCase(); + result = result.filter(l => l.message.toLowerCase().includes(keyword)); + } + + return result; + } + + getStats() { + const stats = { + total: this.logs.length, + byLevel: { + DEBUG: 0, + INFO: 0, + WARN: 0, + ERROR: 0 + } + }; + + this.logs.forEach(log => { + if (stats.byLevel[log.level] !== undefined) { + stats.byLevel[log.level]++; + } + }); + + return stats; + } + + async clear() { + this.logs = []; + this._pendingLogs = []; + await this._saveToStorage(); + } + + exportLogs(format = 'json') { + if (format === 'json') { + return JSON.stringify(this.logs, null, 2); + } + + if (format === 'text') { + return this.logs.map(log => + `[${log.level}] ${log.timestamp} ${log.message}` + ).join('\n'); + } + + if (format === 'csv') { + const header = 'Timestamp,Level,Message\n'; + const rows = this.logs.map(log => + `"${log.timestamp}","${log.level}","${log.message.replace(/"/g, '""')}"` + ); + return header + rows.join('\n'); + } + + return ''; + } + + setLevel(level) { + if (typeof level === 'string') { + const levelKey = level.toUpperCase(); + if (LogLevel[levelKey] !== undefined) { + this.config.minLevel = LogLevel[levelKey]; + } + } else if (typeof level === 'number' && level >= LogLevel.DEBUG && level <= LogLevel.NONE) { + this.config.minLevel = level; + } + } + + getLevel() { + return this.config.minLevel; + } + + getLevelName() { + return LogLevelNames[this.config.minLevel]; + } + + async setConfig(newConfig) { + this.config = { ...this.config, ...newConfig }; + await this._saveToStorage(); + } + + getConfig() { + return { ...this.config }; + } +} + +const globalLogger = new Logger(); + +const LoggerWrapper = { + LogLevel, + LogLevelNames, + + init: () => globalLogger.init(), + debug: (...args) => globalLogger.debug(...args), + info: (...args) => globalLogger.info(...args), + warn: (...args) => globalLogger.warn(...args), + error: (...args) => globalLogger.error(...args), + + getLogs: (filter) => globalLogger.getLogs(filter), + getStats: () => globalLogger.getStats(), + clear: () => globalLogger.clear(), + exportLogs: (format) => globalLogger.exportLogs(format), + + setLevel: (level) => globalLogger.setLevel(level), + getLevel: () => globalLogger.getLevel(), + getLevelName: () => globalLogger.getLevelName(), + + setConfig: (config) => globalLogger.setConfig(config), + getConfig: () => globalLogger.getConfig(), + + createLogger: (options) => new Logger(options) +}; + +export default LoggerWrapper; diff --git a/lib/platformRules.js b/lib/platformRules.js new file mode 100644 index 0000000..51eacf3 --- /dev/null +++ b/lib/platformRules.js @@ -0,0 +1,1272 @@ +const PlatformType = { + ZHIPIN: 'zhipin', + ZHAOPIN: 'zhaopin', + JOB51: 'job51', + LAGOU: 'lagou', + LIEPIN: 'liepin', + UNKNOWN: 'unknown' +}; + +const PlatformNames = { + [PlatformType.ZHIPIN]: 'BOSS直聘', + [PlatformType.ZHAOPIN]: '智联招聘', + [PlatformType.JOB51]: '前程无忧', + [PlatformType.LAGOU]: '拉勾网', + [PlatformType.LIEPIN]: '猎聘网', + [PlatformType.UNKNOWN]: '未知平台' +}; + +const PageType = { + JOB_LIST: 'job_list', + JOB_DETAIL: 'job_detail', + COMPANY_DETAIL: 'company_detail', + SEARCH_RESULT: 'search_result', + OTHER: 'other' +}; + +const PLATFORM_RULES = { + [PlatformType.ZHIPIN]: { + id: PlatformType.ZHIPIN, + name: PlatformNames[PlatformType.ZHIPIN], + enabled: true, + version: '2.0', + lastUpdated: '2024-01-01', + + domainPatterns: [ + 'zhipin.com', + 'www.zhipin.com', + 'm.zhipin.com' + ], + + pageTypeDetectors: { + [PageType.JOB_LIST]: { + urlPatterns: [ + /^https?:\/\/(www\.)?zhipin\.com\/.*\/?\??/, + /^https?:\/\/(www\.)?zhipin\.com\/c\d+\/.*/ + ], + selectors: [ + '.job-list', + '.job-card-wrapper', + '.job-card-body' + ] + }, + [PageType.JOB_DETAIL]: { + urlPatterns: [ + /^https?:\/\/(www\.)?zhipin\.com\/job_detail\//, + /^https?:\/\/(www\.)?zhipin\.com\/.*\/job_detail\// + ], + selectors: [ + '.job-detail-body', + '.job-detail-section', + '[class*="job-detail"]' + ] + } + }, + + selectors: { + jobCards: { + listPage: [ + '.job-card-wrapper', + '.job-card-body', + '.job-list-item', + '[class*="job-card"]' + ], + searchPage: [ + '.search-job-card', + '.job-card-wrapper' + ] + }, + + applyButton: { + primary: [ + '.btn-start-chat', + '[class*="btn-start"]', + '[class*="chat-btn"]', + '.resume-btn', + '[class*="apply-btn"]' + ], + secondary: [ + '.btn-add-list', + '[class*="collect-btn"]', + '[class*="favorite-btn"]' + ], + popup: [ + '.dialog-footer .btn-primary', + '.modal-footer .btn-primary', + '[class*="confirm-btn"]' + ] + }, + + jobInfo: { + jobTitle: [ + '.job-title', + '[class*="job-title"]', + '.name', + 'h1' + ], + salary: [ + '.salary', + '[class*="salary"]', + '.text-red' + ], + location: [ + '.location', + '[class*="location"]', + '[class*="area"]' + ], + experience: [ + '.experience', + '[class*="experience"]', + '[class*="exp-"]' + ], + education: [ + '.education', + '[class*="education"]', + '[class*="edu-"]' + ] + }, + + companyInfo: { + companyName: [ + '.company-name', + '[class*="company-name"]', + '.company-text h3', + '.info-name' + ], + companySize: [ + '.company-size', + '[class*="company-size"]', + '[class*="scale"]' + ], + companyType: [ + '.company-type', + '[class*="company-type"]', + '[class*="industry"]' + ] + }, + + popupElements: { + modal: [ + '.dialog-wrapper', + '.modal-wrapper', + '[class*="popup"]', + '[class*="modal"]', + '[class*="dialog"]' + ], + closeButton: [ + '.dialog-close', + '.modal-close', + '[class*="close-btn"]', + '.icon-close' + ], + confirmButton: [ + '.btn-primary', + '[class*="confirm"]', + '[class*="sure"]', + '.dialog-footer button:first-child' + ], + cancelButton: [ + '.btn-default', + '[class*="cancel"]', + '.dialog-footer button:last-child' + ] + }, + + formElements: { + resumeSelect: [ + '.resume-select', + '[class*="resume"] select', + '#resumeSelect' + ], + inputFields: [ + 'input[type="text"]', + 'input[type="hidden"]', + 'textarea' + ], + checkbox: [ + 'input[type="checkbox"]', + '[class*="checkbox"]' + ] + } + }, + + behaviors: { + applyFlow: [ + { + step: 'find_apply_button', + action: 'click', + waitFor: 'popup' + }, + { + step: 'wait_for_popup', + action: 'wait', + timeout: 5000 + }, + { + step: 'confirm_apply', + action: 'click', + selectorType: 'confirmButton' + } + ], + + autoScroll: { + enabled: true, + scrollDelay: 800, + maxScrolls: 50, + stableThreshold: 2000 + }, + + delays: { + afterClick: 1000, + afterScroll: 500, + waitForElement: 100, + popupWait: 3000 + } + }, + + textPatterns: { + applyButtons: [ + '立即沟通', + '沟通', + '投简历', + '投递', + '申请职位', + '立即申请', + '一键投递', + '聊一聊' + ], + collectButtons: [ + '收藏', + '加入收藏', + '收藏职位', + '关注' + ], + successMessages: [ + '投递成功', + '申请成功', + '已发送', + '沟通成功', + '已投递' + ], + errorMessages: [ + '投递失败', + '申请失败', + '请先完善简历', + '请登录', + '验证码' + ] + } + }, + + [PlatformType.ZHAOPIN]: { + id: PlatformType.ZHAOPIN, + name: PlatformNames[PlatformType.ZHAOPIN], + enabled: true, + version: '2.0', + lastUpdated: '2024-01-01', + + domainPatterns: [ + 'zhaopin.com', + 'www.zhaopin.com', + 'm.zhaopin.com', + 'xiaoyuan.zhaopin.com' + ], + + pageTypeDetectors: { + [PageType.JOB_LIST]: { + urlPatterns: [ + /^https?:\/\/(www\.)?zhaopin\.com\/\//, + /^https?:\/\/(www\.)?zhaopin\.com\/sou\//, + /^https?:\/\/xiaoyuan\.zhaopin\.com\/\// + ], + selectors: [ + '.joblist-box', + '.job-item', + '[class*="job-item"]' + ] + }, + [PageType.JOB_DETAIL]: { + urlPatterns: [ + /^https?:\/\/(www\.)?zhaopin\.com\/job_detail\//, + /^https?:\/\/xiaoyuan\.zhaopin\.com\/job_detail\// + ], + selectors: [ + '.job-detail-box', + '.job-detail-header', + '[class*="job-detail"]' + ] + } + }, + + selectors: { + jobCards: { + listPage: [ + '.job-item', + '.joblist-box__item', + '[class*="job-item"]' + ] + }, + + applyButton: { + primary: [ + '.btn-apply', + '[class*="apply-btn"]', + '[class*="投递"]', + '.resume-apply-btn', + '#applyJobBtn' + ], + popup: [ + '.pop-sure', + '[class*="sure-btn"]', + '[class*="confirm-btn"]' + ] + }, + + jobInfo: { + jobTitle: [ + '.job-title', + '.info-h3 h1', + '[class*="job-title"]' + ], + salary: [ + '.salary', + '[class*="salary"]', + '.money' + ], + location: [ + '.address', + '[class*="address"]', + '[class*="location"]' + ], + experience: [ + '.experience', + '[class*="experience"]' + ], + education: [ + '.education', + '[class*="education"]' + ] + }, + + companyInfo: { + companyName: [ + '.company-name', + '.company h3', + '[class*="company-name"]' + ], + companySize: [ + '.company-size', + '[class*="company-size"]' + ], + companyType: [ + '.company-type', + '[class*="company-type"]' + ] + }, + + popupElements: { + modal: [ + '.popup-box', + '.pop-box', + '[class*="popup"]', + '[class*="modal"]' + ], + closeButton: [ + '.pop-close', + '[class*="close-btn"]' + ], + confirmButton: [ + '.pop-sure', + '[class*="sure"]', + '[class*="confirm"]' + ], + cancelButton: [ + '.pop-cancel', + '[class*="cancel"]' + ] + } + }, + + behaviors: { + applyFlow: [ + { + step: 'find_apply_button', + action: 'click', + waitFor: 'popup' + }, + { + step: 'wait_for_popup', + action: 'wait', + timeout: 5000 + }, + { + step: 'confirm_apply', + action: 'click', + selectorType: 'confirmButton' + } + ], + + autoScroll: { + enabled: true, + scrollDelay: 1000, + maxScrolls: 50, + stableThreshold: 2500 + }, + + delays: { + afterClick: 1500, + afterScroll: 800, + waitForElement: 150, + popupWait: 3500 + } + }, + + textPatterns: { + applyButtons: [ + '立即投递', + '投递', + '申请职位', + '一键投递', + '投简历' + ], + collectButtons: [ + '收藏', + '关注', + '加入收藏' + ], + successMessages: [ + '投递成功', + '申请成功', + '已投递', + '投递完成' + ], + errorMessages: [ + '投递失败', + '请完善简历', + '请先登录' + ] + } + }, + + [PlatformType.JOB51]: { + id: PlatformType.JOB51, + name: PlatformNames[PlatformType.JOB51], + enabled: true, + version: '2.0', + lastUpdated: '2024-01-01', + + domainPatterns: [ + '51job.com', + 'www.51job.com', + 'm.51job.com', + 'jobs.51job.com' + ], + + pageTypeDetectors: { + [PageType.JOB_LIST]: { + urlPatterns: [ + /^https?:\/\/(www\.)?51job\.com\/sc\//, + /^https?:\/\/jobs\.51job\.com\//, + /^https?:\/\/(www\.)?51job\.com\/all\// + ], + selectors: [ + '.j_joblist', + '.joblist', + '.resultlist' + ] + }, + [PageType.JOB_DETAIL]: { + urlPatterns: [ + /^https?:\/\/jobs\.51job\.com\/.*\/, + /^https?:\/\/(www\.)?51job\.com\/job\/.*/ + ], + selectors: [ + '.tCompany', + '.tHeader', + '.j_tag' + ] + } + }, + + selectors: { + jobCards: { + listPage: [ + '.e', + '.j_joblist div', + '.joblist-item' + ] + }, + + applyButton: { + primary: [ + '.p_but', + '.btn_apply', + '[class*="apply"]', + '[onclick*="apply"]' + ], + popup: [ + '.sure', + '[class*="confirm"]' + ] + }, + + jobInfo: { + jobTitle: [ + '.tHeader h1', + '.tHeader_top h1', + '[class*="job-title"]' + ], + salary: [ + '.tHeader .c_red', + '[class*="salary"]', + '.welfare-tab-box span:first-child' + ], + location: [ + '.tHeader_top .fp', + '[class*="location"]' + ], + experience: [ + '[class*="experience"]', + '.msg ltype' + ], + education: [ + '[class*="education"]', + '.msg ltype' + ] + }, + + companyInfo: { + companyName: [ + '.tCompany .com_name', + '.tHeader .cname', + '[class*="company-name"]' + ], + companySize: [ + '.com_tag', + '[class*="company-size"]' + ], + companyType: [ + '.com_type', + '[class*="company-type"]' + ] + }, + + popupElements: { + modal: [ + '.div_apply', + '[class*="popup"]', + '[class*="modal"]' + ], + closeButton: [ + '.close', + '[class*="close-btn"]' + ], + confirmButton: [ + '.sure', + '[class*="confirm"]', + '[class*="sure"]' + ], + cancelButton: [ + '.cancel', + '[class*="cancel"]' + ] + } + }, + + behaviors: { + applyFlow: [ + { + step: 'find_apply_button', + action: 'click', + waitFor: 'popup' + }, + { + step: 'wait_for_popup', + action: 'wait', + timeout: 6000 + }, + { + step: 'confirm_apply', + action: 'click', + selectorType: 'confirmButton' + } + ], + + autoScroll: { + enabled: true, + scrollDelay: 1200, + maxScrolls: 50, + stableThreshold: 3000 + }, + + delays: { + afterClick: 1500, + afterScroll: 1000, + waitForElement: 200, + popupWait: 4000 + } + }, + + textPatterns: { + applyButtons: [ + '申请职位', + '立即申请', + '投递', + '一键投递', + '投简历' + ], + collectButtons: [ + '收藏', + '关注' + ], + successMessages: [ + '申请成功', + '投递成功', + '已申请' + ], + errorMessages: [ + '申请失败', + '请完善简历', + '请先登录' + ] + } + }, + + [PlatformType.LAGOU]: { + id: PlatformType.LAGOU, + name: PlatformNames[PlatformType.LAGOU], + enabled: true, + version: '2.0', + lastUpdated: '2024-01-01', + + domainPatterns: [ + 'lagou.com', + 'www.lagou.com', + 'm.lagou.com' + ], + + pageTypeDetectors: { + [PageType.JOB_LIST]: { + urlPatterns: [ + /^https?:\/\/(www\.)?lagou\.com\/zhaopin\//, + /^https?:\/\/(www\.)?lagou\.com\/gongsi\// + ], + selectors: [ + '.item_con_list', + '.list_item_top', + '.position_list' + ] + }, + [PageType.JOB_DETAIL]: { + urlPatterns: [ + /^https?:\/\/(www\.)?lagou\.com\/jobs\/.*\.html/, + /^https?:\/\/(www\.)?lagou\.com\/jobs\/\d+\/.*/ + ], + selectors: [ + '.position-content-l', + '.job-detail', + '.company-content' + ] + } + }, + + selectors: { + jobCards: { + listPage: [ + '.con_list_item', + '.list_item_top', + '.position_item' + ] + }, + + applyButton: { + primary: [ + '.s_position', + '[class*="apply"]', + '[class*="投递"]', + '.btn-apply' + ], + popup: [ + '.dialog-footer .btn-primary', + '[class*="confirm-btn"]' + ] + }, + + jobInfo: { + jobTitle: [ + '.job-name', + '.position-head-wrap h1', + '[class*="job-name"]' + ], + salary: [ + '.salary', + '[class*="salary"]', + '.money' + ], + location: [ + '.work_addr', + '[class*="location"]', + '[class*="addr"]' + ], + experience: [ + '[class*="experience"]', + '.job_request span' + ], + education: [ + '[class*="education"]', + '.job_request span' + ] + }, + + companyInfo: { + companyName: [ + '.company', + '.company_name', + '[class*="company-name"]' + ], + companySize: [ + '.industry', + '[class*="company-size"]' + ], + companyType: [ + '.company-label', + '[class*="company-type"]' + ] + }, + + popupElements: { + modal: [ + '.body-container', + '[class*="dialog"]', + '[class*="modal"]' + ], + closeButton: [ + '.icon-close', + '[class*="close-btn"]' + ], + confirmButton: [ + '.btn-primary', + '[class*="confirm"]' + ], + cancelButton: [ + '.btn-default', + '[class*="cancel"]' + ] + } + }, + + behaviors: { + applyFlow: [ + { + step: 'find_apply_button', + action: 'click', + waitFor: 'popup' + }, + { + step: 'wait_for_popup', + action: 'wait', + timeout: 5000 + }, + { + step: 'confirm_apply', + action: 'click', + selectorType: 'confirmButton' + } + ], + + autoScroll: { + enabled: true, + scrollDelay: 1000, + maxScrolls: 50, + stableThreshold: 2500 + }, + + delays: { + afterClick: 1200, + afterScroll: 800, + waitForElement: 150, + popupWait: 3500 + } + }, + + textPatterns: { + applyButtons: [ + '投递简历', + '立即投递', + '申请', + '投简历' + ], + collectButtons: [ + '收藏', + '关注', + '收藏职位' + ], + successMessages: [ + '投递成功', + '申请成功', + '已投递' + ], + errorMessages: [ + '投递失败', + '请完善简历', + '请登录' + ] + } + }, + + [PlatformType.LIEPIN]: { + id: PlatformType.LIEPIN, + name: PlatformNames[PlatformType.LIEPIN], + enabled: true, + version: '2.0', + lastUpdated: '2024-01-01', + + domainPatterns: [ + 'liepin.com', + 'www.liepin.com', + 'm.liepin.com' + ], + + pageTypeDetectors: { + [PageType.JOB_LIST]: { + urlPatterns: [ + /^https?:\/\/(www\.)?liepin\.com\/zhaopin\//, + /^https?:\/\/(www\.)?liepin\.com\/\w+\/\// + ], + selectors: [ + '.job-list-box', + '.job-card-list', + '.so-job-result' + ] + }, + [PageType.JOB_DETAIL]: { + urlPatterns: [ + /^https?:\/\/(www\.)?liepin\.com\/job\/.*\.shtml/, + /^https?:\/\/(www\.)?liepin\.com\/job\/\d+\// + ], + selectors: [ + '.job-detail-title', + '.job-detail-info', + '.company-info' + ] + } + }, + + selectors: { + jobCards: { + listPage: [ + '.job-card-pc', + '.job-card-item', + '.job-item' + ] + }, + + applyButton: { + primary: [ + '.btn-apply', + '[class*="apply-btn"]', + '[class*="投递"]', + '.resume-apply' + ], + popup: [ + '.modal-footer .btn-primary', + '[class*="confirm-btn"]' + ] + }, + + jobInfo: { + jobTitle: [ + '.job-title', + '.job-detail-title h1', + '[class*="job-title"]' + ], + salary: [ + '.job-salary', + '[class*="salary"]', + '.salary' + ], + location: [ + '.job-addr', + '[class*="location"]', + '[class*="addr"]' + ], + experience: [ + '[class*="experience"]', + '.job-qualifications span' + ], + education: [ + '[class*="education"]', + '.job-qualifications span' + ] + }, + + companyInfo: { + companyName: [ + '.company-name', + '.job-company-info h3', + '[class*="company-name"]' + ], + companySize: [ + '.company-size', + '[class*="company-size"]' + ], + companyType: [ + '.company-industry', + '[class*="company-type"]' + ] + }, + + popupElements: { + modal: [ + '.modal', + '[class*="modal"]', + '[class*="dialog"]' + ], + closeButton: [ + '.modal-close', + '[class*="close-btn"]' + ], + confirmButton: [ + '.btn-primary', + '[class*="confirm"]' + ], + cancelButton: [ + '.btn-default', + '[class*="cancel"]' + ] + } + }, + + behaviors: { + applyFlow: [ + { + step: 'find_apply_button', + action: 'click', + waitFor: 'popup' + }, + { + step: 'wait_for_popup', + action: 'wait', + timeout: 5000 + }, + { + step: 'confirm_apply', + action: 'click', + selectorType: 'confirmButton' + } + ], + + autoScroll: { + enabled: true, + scrollDelay: 1000, + maxScrolls: 50, + stableThreshold: 2500 + }, + + delays: { + afterClick: 1200, + afterScroll: 800, + waitForElement: 150, + popupWait: 3500 + } + }, + + textPatterns: { + applyButtons: [ + '立即投递', + '投递简历', + '申请职位', + '一键投递' + ], + collectButtons: [ + '收藏', + '关注', + '收藏职位' + ], + successMessages: [ + '投递成功', + '申请成功', + '已投递' + ], + errorMessages: [ + '投递失败', + '请完善简历', + '请登录' + ] + } + } +}; + +class PlatformRuleManager { + constructor() { + this._customRules = new Map(); + this._initialized = false; + } + + async init() { + if (this._initialized) return; + this._initialized = true; + } + + detectPlatform(url) { + if (!url) return PlatformType.UNKNOWN; + + try { + const urlObj = new URL(url); + const hostname = urlObj.hostname; + + for (const [platformId, rules] of Object.entries(PLATFORM_RULES)) { + for (const pattern of rules.domainPatterns) { + if (hostname.includes(pattern) || pattern.includes(hostname)) { + return platformId; + } + } + } + + return PlatformType.UNKNOWN; + } catch (e) { + return PlatformType.UNKNOWN; + } + } + + getPlatformRules(platformId) { + return PLATFORM_RULES[platformId] || null; + } + + getPlatformName(platformId) { + return PlatformNames[platformId] || PlatformNames[PlatformType.UNKNOWN]; + } + + getAllPlatforms() { + return Object.values(PLATFORM_RULES); + } + + getEnabledPlatforms() { + return Object.values(PLATFORM_RULES).filter(p => p.enabled); + } + + detectPageType(url, document = null) { + const platformId = this.detectPlatform(url); + if (platformId === PlatformType.UNKNOWN) { + return PageType.OTHER; + } + + const rules = this.getPlatformRules(platformId); + if (!rules || !rules.pageTypeDetectors) { + return PageType.OTHER; + } + + for (const [pageType, detector] of Object.entries(rules.pageTypeDetectors)) { + if (detector.urlPatterns) { + for (const pattern of detector.urlPatterns) { + if (pattern.test(url)) { + return pageType; + } + } + } + + if (document && detector.selectors) { + for (const selector of detector.selectors) { + if (document.querySelector(selector)) { + return pageType; + } + } + } + } + + return PageType.OTHER; + } + + getSelectors(platformId, selectorType, subType = null) { + const rules = this.getPlatformRules(platformId); + if (!rules) return []; + + if (!rules.selectors) return []; + + const selectorGroup = rules.selectors[selectorType]; + if (!selectorGroup) return []; + + if (subType && typeof selectorGroup === 'object' && !Array.isArray(selectorGroup)) { + return selectorGroup[subType] || []; + } + + if (Array.isArray(selectorGroup)) { + return selectorGroup; + } + + return []; + } + + getApplyButtonSelectors(platformId) { + return this.getSelectors(platformId, 'applyButton', 'primary'); + } + + getPopupConfirmSelectors(platformId) { + return this.getSelectors(platformId, 'applyButton', 'popup'); + } + + getJobCardSelectors(platformId, pageType = PageType.JOB_LIST) { + const subType = pageType === PageType.SEARCH_RESULT ? 'searchPage' : 'listPage'; + return this.getSelectors(platformId, 'jobCards', subType); + } + + getPopupSelectors(platformId) { + const modalSelectors = this.getSelectors(platformId, 'popupElements', 'modal'); + const closeSelectors = this.getSelectors(platformId, 'popupElements', 'closeButton'); + const confirmSelectors = this.getSelectors(platformId, 'popupElements', 'confirmButton'); + const cancelSelectors = this.getSelectors(platformId, 'popupElements', 'cancelButton'); + + return { + modal: modalSelectors, + close: closeSelectors, + confirm: confirmSelectors, + cancel: cancelSelectors + }; + } + + getTextPatterns(platformId, patternType) { + const rules = this.getPlatformRules(platformId); + if (!rules || !rules.textPatterns) return []; + return rules.textPatterns[patternType] || []; + } + + getBehaviors(platformId) { + const rules = this.getPlatformRules(platformId); + return rules?.behaviors || null; + } + + getDelays(platformId) { + const behaviors = this.getBehaviors(platformId); + return behaviors?.delays || { + afterClick: 1000, + afterScroll: 500, + waitForElement: 100, + popupWait: 3000 + }; + } + + getApplyFlow(platformId) { + const behaviors = this.getBehaviors(platformId); + return behaviors?.applyFlow || [ + { step: 'find_apply_button', action: 'click', waitFor: 'popup' }, + { step: 'wait_for_popup', action: 'wait', timeout: 5000 }, + { step: 'confirm_apply', action: 'click', selectorType: 'confirmButton' } + ]; + } + + getAutoScrollConfig(platformId) { + const behaviors = this.getBehaviors(platformId); + return behaviors?.autoScroll || { + enabled: true, + scrollDelay: 800, + maxScrolls: 50, + stableThreshold: 2000 + }; + } + + addCustomRule(platformId, rule) { + if (!this._customRules.has(platformId)) { + this._customRules.set(platformId, []); + } + this._customRules.get(platformId).push({ + id: 'custom_' + Date.now(), + ...rule, + createdAt: new Date().toISOString() + }); + } + + getCustomRules(platformId) { + return this._customRules.get(platformId) || []; + } + + matchButtonText(platformId, buttonText) { + if (!buttonText) return false; + + const applyPatterns = this.getTextPatterns(platformId, 'applyButtons'); + const collectPatterns = this.getTextPatterns(platformId, 'collectButtons'); + + const text = buttonText.trim().toLowerCase(); + + for (const pattern of applyPatterns) { + if (text.includes(pattern.toLowerCase())) { + return { type: 'apply', match: pattern }; + } + } + + for (const pattern of collectPatterns) { + if (text.includes(pattern.toLowerCase())) { + return { type: 'collect', match: pattern }; + } + } + + return null; + } + + isSuccessMessage(platformId, message) { + if (!message) return false; + + const successPatterns = this.getTextPatterns(platformId, 'successMessages'); + const msg = message.toLowerCase(); + + return successPatterns.some(pattern => msg.includes(pattern.toLowerCase())); + } + + isErrorMessage(platformId, message) { + if (!message) return false; + + const errorPatterns = this.getTextPatterns(platformId, 'errorMessages'); + const msg = message.toLowerCase(); + + return errorPatterns.some(pattern => msg.includes(pattern.toLowerCase())); + } +} + +const globalRuleManager = new PlatformRuleManager(); + +const PlatformRulesWrapper = { + PlatformType, + PlatformNames, + PageType, + PLATFORM_RULES, + PlatformRuleManager, + + init: () => globalRuleManager.init(), + detectPlatform: (url) => globalRuleManager.detectPlatform(url), + getPlatformRules: (platformId) => globalRuleManager.getPlatformRules(platformId), + getPlatformName: (platformId) => globalRuleManager.getPlatformName(platformId), + getAllPlatforms: () => globalRuleManager.getAllPlatforms(), + getEnabledPlatforms: () => globalRuleManager.getEnabledPlatforms(), + detectPageType: (url, document) => globalRuleManager.detectPageType(url, document), + getSelectors: (platformId, selectorType, subType) => + globalRuleManager.getSelectors(platformId, selectorType, subType), + getApplyButtonSelectors: (platformId) => + globalRuleManager.getApplyButtonSelectors(platformId), + getPopupConfirmSelectors: (platformId) => + globalRuleManager.getPopupConfirmSelectors(platformId), + getJobCardSelectors: (platformId, pageType) => + globalRuleManager.getJobCardSelectors(platformId, pageType), + getPopupSelectors: (platformId) => + globalRuleManager.getPopupSelectors(platformId), + getTextPatterns: (platformId, patternType) => + globalRuleManager.getTextPatterns(platformId, patternType), + getBehaviors: (platformId) => + globalRuleManager.getBehaviors(platformId), + getDelays: (platformId) => + globalRuleManager.getDelays(platformId), + getApplyFlow: (platformId) => + globalRuleManager.getApplyFlow(platformId), + getAutoScrollConfig: (platformId) => + globalRuleManager.getAutoScrollConfig(platformId), + addCustomRule: (platformId, rule) => + globalRuleManager.addCustomRule(platformId, rule), + getCustomRules: (platformId) => + globalRuleManager.getCustomRules(platformId), + matchButtonText: (platformId, buttonText) => + globalRuleManager.matchButtonText(platformId, buttonText), + isSuccessMessage: (platformId, message) => + globalRuleManager.isSuccessMessage(platformId, message), + isErrorMessage: (platformId, message) => + globalRuleManager.isErrorMessage(platformId, message) +}; + +export default PlatformRulesWrapper; diff --git a/lib/storage.js b/lib/storage.js new file mode 100644 index 0000000..0ce2cbc --- /dev/null +++ b/lib/storage.js @@ -0,0 +1,519 @@ +const STORAGE_KEYS = { + SETTINGS: 'jobapp_settings', + STATISTICS: 'jobapp_statistics', + HISTORY: 'jobapp_history', + BLACKLIST: 'jobapp_blacklist', + CUSTOM_RULES: 'jobapp_custom_rules', + PLATFORM_CONFIGS: 'jobapp_platform_configs' +}; + +const DEFAULT_SETTINGS = { + version: '2.0.0', + enabled: true, + autoApplyEnabled: true, + maxApplicationsPerSession: 50, + applyDelay: 2000, + elementWaitTimeout: 10000, + maxRetryCount: 3, + retryDelay: 1000, + logLevel: 'INFO', + enableNotifications: true, + enableSound: false, + autoCloseModal: true, + confirmBeforeApply: false, + platforms: { + zhipin: { enabled: true, autoScroll: true }, + zhaopin: { enabled: true, autoScroll: true }, + job51: { enabled: true, autoScroll: true }, + lagou: { enabled: true, autoScroll: true }, + liepin: { enabled: true, autoScroll: true } + } +}; + +const DEFAULT_STATISTICS = { + totalApplications: 0, + successfulApplications: 0, + failedApplications: 0, + byPlatform: {}, + byDate: {}, + lastApplicationAt: null, + firstApplicationAt: null, + currentStreak: 0, + bestStreak: 0 +}; + +const DEFAULT_HISTORY = { + items: [], + maxItems: 1000 +}; + +const DEFAULT_BLACKLIST = { + domains: [], + keywords: [], + companies: [] +}; + +const DEFAULT_CUSTOM_RULES = { + rules: [], + enabled: true +}; + +class StorageManager { + constructor() { + this._initialized = false; + this._listeners = new Map(); + } + + async init() { + if (this._initialized) return; + + this._setupStorageListener(); + this._initialized = true; + } + + _setupStorageListener() { + if (typeof chrome !== 'undefined' && chrome.storage) { + chrome.storage.onChanged.addListener((changes, namespace) => { + this._notifyListeners(changes, namespace); + }); + } + } + + _notifyListeners(changes, namespace) { + this._listeners.forEach((listeners, key) => { + if (changes[key]) { + listeners.forEach(listener => { + try { + listener(changes[key].newValue, changes[key].oldValue, namespace); + } catch (e) { + console.error('[StorageManager] Listener error:', e); + } + }); + } + }); + } + + onChanged(key, listener) { + if (!this._listeners.has(key)) { + this._listeners.set(key, []); + } + this._listeners.get(key).push(listener); + + return () => { + const listeners = this._listeners.get(key); + if (listeners) { + const index = listeners.indexOf(listener); + if (index > -1) { + listeners.splice(index, 1); + } + } + }; + } + + async get(key, defaultValue = null) { + if (typeof chrome === 'undefined' || !chrome.storage) { + console.warn('[StorageManager] chrome.storage not available'); + return defaultValue; + } + + try { + const result = await chrome.storage.local.get(key); + return result[key] !== undefined ? result[key] : defaultValue; + } catch (error) { + console.error('[StorageManager] Get error:', error); + return defaultValue; + } + } + + async set(key, value) { + if (typeof chrome === 'undefined' || !chrome.storage) { + console.warn('[StorageManager] chrome.storage not available'); + return false; + } + + try { + await chrome.storage.local.set({ [key]: value }); + return true; + } catch (error) { + console.error('[StorageManager] Set error:', error); + return false; + } + } + + async remove(key) { + if (typeof chrome === 'undefined' || !chrome.storage) { + console.warn('[StorageManager] chrome.storage not available'); + return false; + } + + try { + await chrome.storage.local.remove(key); + return true; + } catch (error) { + console.error('[StorageManager] Remove error:', error); + return false; + } + } + + async clear() { + if (typeof chrome === 'undefined' || !chrome.storage) { + console.warn('[StorageManager] chrome.storage not available'); + return false; + } + + try { + await chrome.storage.local.clear(); + return true; + } catch (error) { + console.error('[StorageManager] Clear error:', error); + return false; + } + } + + async getSettings() { + const settings = await this.get(STORAGE_KEYS.SETTINGS, DEFAULT_SETTINGS); + return this._mergeDefaults(settings, DEFAULT_SETTINGS); + } + + async setSettings(settings) { + const currentSettings = await this.getSettings(); + const updatedSettings = { ...currentSettings, ...settings }; + return this.set(STORAGE_KEYS.SETTINGS, updatedSettings); + } + + async getStatistics() { + return this.get(STORAGE_KEYS.STATISTICS, DEFAULT_STATISTICS); + } + + async setStatistics(statistics) { + return this.set(STORAGE_KEYS.STATISTICS, statistics); + } + + async updateStatistics(updates) { + const stats = await this.getStatistics(); + Object.assign(stats, updates); + return this.setStatistics(stats); + } + + async incrementApplication(platform, success = true) { + const stats = await this.getStatistics(); + const today = new Date().toISOString().split('T')[0]; + + stats.totalApplications++; + if (success) { + stats.successfulApplications++; + } else { + stats.failedApplications++; + } + + if (!stats.byPlatform[platform]) { + stats.byPlatform[platform] = { + total: 0, + successful: 0, + failed: 0 + }; + } + stats.byPlatform[platform].total++; + if (success) { + stats.byPlatform[platform].successful++; + } else { + stats.byPlatform[platform].failed++; + } + + if (!stats.byDate[today]) { + stats.byDate[today] = { + total: 0, + successful: 0, + failed: 0 + }; + } + stats.byDate[today].total++; + if (success) { + stats.byDate[today].successful++; + } else { + stats.byDate[today].failed++; + } + + const lastDate = stats.lastApplicationAt ? + new Date(stats.lastApplicationAt).toISOString().split('T')[0] : null; + + if (success) { + if (lastDate === today) { + stats.currentStreak++; + } else { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + const yesterdayStr = yesterday.toISOString().split('T')[0]; + + if (lastDate === yesterdayStr) { + stats.currentStreak++; + } else { + stats.currentStreak = 1; + } + } + + if (stats.currentStreak > stats.bestStreak) { + stats.bestStreak = stats.currentStreak; + } + } + + stats.lastApplicationAt = new Date().toISOString(); + if (!stats.firstApplicationAt) { + stats.firstApplicationAt = stats.lastApplicationAt; + } + + return this.setStatistics(stats); + } + + async getHistory() { + return this.get(STORAGE_KEYS.HISTORY, DEFAULT_HISTORY); + } + + async addHistoryItem(item) { + const history = await this.getHistory(); + const historyItem = { + id: this._generateId(), + timestamp: new Date().toISOString(), + ...item + }; + + history.items.unshift(historyItem); + + if (history.items.length > history.maxItems) { + history.items = history.items.slice(0, history.maxItems); + } + + return this.set(STORAGE_KEYS.HISTORY, history); + } + + async clearHistory() { + return this.set(STORAGE_KEYS.HISTORY, DEFAULT_HISTORY); + } + + async getBlacklist() { + return this.get(STORAGE_KEYS.BLACKLIST, DEFAULT_BLACKLIST); + } + + async setBlacklist(blacklist) { + return this.set(STORAGE_KEYS.BLACKLIST, blacklist); + } + + async isBlacklisted(url, companyName = '') { + const blacklist = await this.getBlacklist(); + + if (!blacklist.domains && !blacklist.keywords && !blacklist.companies) { + return false; + } + + if (blacklist.domains && blacklist.domains.length > 0) { + try { + const urlObj = new URL(url); + const domain = urlObj.hostname; + for (const blackDomain of blacklist.domains) { + if (domain.includes(blackDomain) || blackDomain.includes(domain)) { + return true; + } + } + } catch (e) { + } + } + + if (blacklist.keywords && blacklist.keywords.length > 0) { + const urlLower = url.toLowerCase(); + for (const keyword of blacklist.keywords) { + if (urlLower.includes(keyword.toLowerCase())) { + return true; + } + } + } + + if (blacklist.companies && blacklist.companies.length > 0 && companyName) { + const companyLower = companyName.toLowerCase(); + for (const blackCompany of blacklist.companies) { + if (companyLower.includes(blackCompany.toLowerCase())) { + return true; + } + } + } + + return false; + } + + async addToBlacklist(type, value) { + const blacklist = await this.getBlacklist(); + + if (!blacklist[type]) { + blacklist[type] = []; + } + + if (!blacklist[type].includes(value)) { + blacklist[type].push(value); + return this.setBlacklist(blacklist); + } + + return false; + } + + async removeFromBlacklist(type, value) { + const blacklist = await this.getBlacklist(); + + if (blacklist[type] && blacklist[type].includes(value)) { + blacklist[type] = blacklist[type].filter(v => v !== value); + return this.setBlacklist(blacklist); + } + + return false; + } + + async getCustomRules() { + return this.get(STORAGE_KEYS.CUSTOM_RULES, DEFAULT_CUSTOM_RULES); + } + + async setCustomRules(rules) { + return this.set(STORAGE_KEYS.CUSTOM_RULES, rules); + } + + async addCustomRule(rule) { + const customRules = await this.getCustomRules(); + const ruleWithId = { + id: this._generateId(), + createdAt: new Date().toISOString(), + enabled: true, + ...rule + }; + + customRules.rules.push(ruleWithId); + return this.setCustomRules(customRules); + } + + async updateCustomRule(ruleId, updates) { + const customRules = await this.getCustomRules(); + const index = customRules.rules.findIndex(r => r.id === ruleId); + + if (index > -1) { + customRules.rules[index] = { + ...customRules.rules[index], + ...updates, + updatedAt: new Date().toISOString() + }; + return this.setCustomRules(customRules); + } + + return false; + } + + async deleteCustomRule(ruleId) { + const customRules = await this.getCustomRules(); + customRules.rules = customRules.rules.filter(r => r.id !== ruleId); + return this.setCustomRules(customRules); + } + + async getPlatformConfigs() { + return this.get(STORAGE_KEYS.PLATFORM_CONFIGS, {}); + } + + async setPlatformConfigs(configs) { + return this.set(STORAGE_KEYS.PLATFORM_CONFIGS, configs); + } + + async getPlatformConfig(platform) { + const configs = await this.getPlatformConfigs(); + return configs[platform] || null; + } + + async setPlatformConfig(platform, config) { + const configs = await this.getPlatformConfigs(); + configs[platform] = { + ...configs[platform], + ...config, + updatedAt: new Date().toISOString() + }; + return this.setPlatformConfigs(configs); + } + + async resetAll() { + await this.set(STORAGE_KEYS.SETTINGS, DEFAULT_SETTINGS); + await this.set(STORAGE_KEYS.STATISTICS, DEFAULT_STATISTICS); + await this.set(STORAGE_KEYS.HISTORY, DEFAULT_HISTORY); + await this.set(STORAGE_KEYS.BLACKLIST, DEFAULT_BLACKLIST); + await this.set(STORAGE_KEYS.CUSTOM_RULES, DEFAULT_CUSTOM_RULES); + await this.set(STORAGE_KEYS.PLATFORM_CONFIGS, {}); + return true; + } + + _mergeDefaults(obj, defaults) { + if (!obj) return { ...defaults }; + + const result = { ...defaults }; + for (const key in obj) { + if (obj.hasOwnProperty(key)) { + if (typeof obj[key] === 'object' && obj[key] !== null && + typeof defaults[key] === 'object' && defaults[key] !== null && + !Array.isArray(obj[key])) { + result[key] = this._mergeDefaults(obj[key], defaults[key]); + } else { + result[key] = obj[key]; + } + } + } + return result; + } + + _generateId() { + return 'id_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); + } +} + +const globalStorage = new StorageManager(); + +const StorageWrapper = { + STORAGE_KEYS, + DEFAULT_SETTINGS, + DEFAULT_STATISTICS, + DEFAULT_HISTORY, + DEFAULT_BLACKLIST, + DEFAULT_CUSTOM_RULES, + StorageManager, + + init: () => globalStorage.init(), + get: (key, defaultValue) => globalStorage.get(key, defaultValue), + set: (key, value) => globalStorage.set(key, value), + remove: (key) => globalStorage.remove(key), + clear: () => globalStorage.clear(), + onChanged: (key, listener) => globalStorage.onChanged(key, listener), + + getSettings: () => globalStorage.getSettings(), + setSettings: (settings) => globalStorage.setSettings(settings), + + getStatistics: () => globalStorage.getStatistics(), + setStatistics: (stats) => globalStorage.setStatistics(stats), + updateStatistics: (updates) => globalStorage.updateStatistics(updates), + incrementApplication: (platform, success) => + globalStorage.incrementApplication(platform, success), + + getHistory: () => globalStorage.getHistory(), + addHistoryItem: (item) => globalStorage.addHistoryItem(item), + clearHistory: () => globalStorage.clearHistory(), + + getBlacklist: () => globalStorage.getBlacklist(), + setBlacklist: (blacklist) => globalStorage.setBlacklist(blacklist), + isBlacklisted: (url, companyName) => + globalStorage.isBlacklisted(url, companyName), + addToBlacklist: (type, value) => globalStorage.addToBlacklist(type, value), + removeFromBlacklist: (type, value) => globalStorage.removeFromBlacklist(type, value), + + getCustomRules: () => globalStorage.getCustomRules(), + setCustomRules: (rules) => globalStorage.setCustomRules(rules), + addCustomRule: (rule) => globalStorage.addCustomRule(rule), + updateCustomRule: (ruleId, updates) => globalStorage.updateCustomRule(ruleId, updates), + deleteCustomRule: (ruleId) => globalStorage.deleteCustomRule(ruleId), + + getPlatformConfigs: () => globalStorage.getPlatformConfigs(), + setPlatformConfigs: (configs) => globalStorage.setPlatformConfigs(configs), + getPlatformConfig: (platform) => globalStorage.getPlatformConfig(platform), + setPlatformConfig: (platform, config) => globalStorage.setPlatformConfig(platform, config), + + resetAll: () => globalStorage.resetAll() +}; + +export default StorageWrapper; diff --git a/manifest.json b/manifest.json index 408b4ee..9998b04 100644 --- a/manifest.json +++ b/manifest.json @@ -1,47 +1,100 @@ { "manifest_version": 3, - - "name": "__MSG_extensionName__", - "description": "__MSG_extensionDescription__", - "version": "1.0", - "author": "Kun Chen", - "version_name": "1.0.1", - "default_locale": "zh_CN", - "background": { - "service_worker": "background.js" - }, - "host_permissions": ["https://www.zhipin.com/*"], - "permissions": [ - "activeTab", - "scripting", - "storage", - "webRequest", - "declarativeNetRequestWithHostAccess" - ], + "name": "快投简历 - 一键海投助手", + "version": "2.0.0", + "description": "支持多招聘平台的智能简历投递助手,自动识别投递按钮,支持自定义规则和统计功能", + "author": "快投简历开发团队", "icons": { "16": "images/Icon16.png", "32": "images/Icon32.png", "48": "images/Icon48.png", "128": "images/Icon128.png" }, - "action": { "default_popup": "popup/popup.html", - "default_icon": "images/Icon128.png" + "default_icon": { + "16": "images/Icon16.png", + "32": "images/Icon32.png", + "48": "images/Icon48.png", + "128": "images/Icon128.png" + }, + "default_title": "快投简历" + }, + "permissions": [ + "activeTab", + "storage", + "scripting", + "tabs", + "notifications", + "webNavigation" + ], + "host_permissions": [ + "https://*.zhipin.com/*", + "https://*.zhaopin.com/*", + "https://*.51job.com/*", + "https://*.lagou.com/*", + "https://*.liepin.com/*", + "https://*.jobui.com/*", + "https://*.jobui.net/*", + "" + ], + "background": { + "service_worker": "background/background.js" }, "content_scripts": [ { - "js": ["scripts/content.js"], - "matches": ["https://www.zhipin.com/*", "https://www.51job.com/*"] + "matches": [ + "https://*.zhipin.com/*", + "https://*.zhaopin.com/*", + "https://*.51job.com/*", + "https://*.lagou.com/*", + "https://*.liepin.com/*", + "https://*.jobui.com/*", + "https://*.jobui.net/*" + ], + "js": [ + "content/content.js" + ], + "css": [ + "content/content.css" + ], + "run_at": "document_idle" } ], - "declarative_net_request": { - "rule_resources": [ - { - "id": "ruleset_1", - "enabled": true, - "path": "rules.json" - } - ] + "web_accessible_resources": [ + { + "resources": [ + "images/*.png", + "content/*.css", + "content/*.js" + ], + "matches": [ + "" + ] + } + ], + "default_locale": "zh_CN", + "commands": { + "_execute_action": { + "suggested_key": { + "default": "Ctrl+Shift+J", + "mac": "Command+Shift+J" + }, + "description": "打开快投简历控制面板" + }, + "start_auto_apply": { + "suggested_key": { + "default": "Ctrl+Shift+A", + "mac": "Command+Shift+A" + }, + "description": "开始自动投递" + }, + "stop_auto_apply": { + "suggested_key": { + "default": "Ctrl+Shift+S", + "mac": "Command+Shift+S" + }, + "description": "停止自动投递" + } } } diff --git a/popup/popup.css b/popup/popup.css index 909f1f6..02a21af 100644 --- a/popup/popup.css +++ b/popup/popup.css @@ -1,182 +1,1098 @@ -.main { - width: 25rem; - margin: 0 auto; - background-color: #35b9b3; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - gap: 1rem; - padding-top: 1rem; +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --primary-color: #3b82f6; + --primary-hover: #2563eb; + --primary-light: #dbeafe; + --success-color: #10b981; + --success-light: #d1fae5; + --danger-color: #ef4444; + --danger-light: #fee2e2; + --warning-color: #f59e0b; + --warning-light: #fef3c7; + --purple-color: #8b5cf6; + --purple-light: #ede9fe; + + --bg-primary: #ffffff; + --bg-secondary: #f8fafc; + --bg-tertiary: #f1f5f9; + --bg-hover: #e2e8f0; + + --text-primary: #1e293b; + --text-secondary: #64748b; + --text-muted: #94a3b8; + + --border-color: #e2e8f0; + --border-light: #f1f5f9; + + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1); + + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 16px; + + --transition-fast: 0.15s ease; + --transition-normal: 0.25s ease; } body { - width: 25rem; - margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + font-size: 14px; + line-height: 1.5; + color: var(--text-primary); + background-color: var(--bg-secondary); + width: 380px; + min-height: 500px; + max-height: 600px; + overflow: hidden; } -.container { - width: 23rem; - display: flex; /* 设置容器为flex布局 */ - align-items: center; /* 设置子元素在垂直方向上居中对齐 */ - background-color: #115e59; - border-radius: 2rem; - justify-content: space-between; - margin: 0 auto; +.app-container { + display: flex; + flex-direction: column; + height: 100vh; + max-height: 600px; + background-color: var(--bg-primary); } -.list-group { - width: 23rem; - background-color: #35b9b3; - padding-bottom: 1rem; +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: linear-gradient(135deg, var(--primary-color) 0%, var(--purple-color) 100%); + color: white; } -#name { - margin-right: 10px; /* 设置名字和图片之间的间距 */ +.header-content { + display: flex; + align-items: center; + gap: 12px; } -#image { - width: 3.4rem; - border-radius: 2rem; +.logo { + width: 40px; + height: 40px; + border-radius: var(--radius-md); + background-color: rgba(255, 255, 255, 0.2); + padding: 4px; } -#job-list { - background-color: #115e59; - margin: 0 auto 0 auto; - border-radius: 1.5rem; - padding-bottom: 1rem; + +.header-text { + display: flex; + flex-direction: column; + gap: 2px; } -/* 给按钮添加基本的样式 */ -.btn { - display: inline-flex; - align-items: center; - justify-content: center; - border: none; - padding: 0.5rem 1rem; - font-size: 1rem; - font-weight: 600; - cursor: pointer; - border-radius: 1rem; -} - -/* 给按钮添加成功的颜色 */ -.btn-success { - color: white; - background-color: #377cfb; +.header-text h1 { + font-size: 16px; + font-weight: 600; +} + +.header-text .version { + font-size: 11px; + opacity: 0.8; +} + +.header-actions { + display: flex; + gap: 8px; +} + +.icon-btn { + width: 32px; + height: 32px; + border: none; + border-radius: var(--radius-md); + background-color: rgba(255, 255, 255, 0.15); + color: white; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all var(--transition-fast); +} + +.icon-btn:hover { + background-color: rgba(255, 255, 255, 0.25); +} + +.icon-btn:active { + transform: scale(0.95); +} + +.nav-tabs { + display: flex; + background-color: var(--bg-tertiary); + border-bottom: 1px solid var(--border-color); + padding: 0 8px; +} + +.nav-tab { + flex: 1; + padding: 10px 12px; + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); + background: none; + border: none; + cursor: pointer; + position: relative; + transition: all var(--transition-fast); } -/* 给按钮添加轮廓的样式 */ -.btn-outline { - color: #eeaf3a; - background-color: transparent; - border: 2px solid #1fd65f; +.nav-tab:hover { + color: var(--text-primary); + background-color: var(--bg-hover); } -.btn:hover { - background-color: #377cfb; +.nav-tab.active { + color: var(--primary-color); + background-color: var(--bg-primary); } -#job-count { - font-size: 1rem; /* 16px */ - font-weight: 800; - line-height: 1.5rem; /* 24px */ - color: rgb(96, 165, 250); /* sky-400 */ - opacity: 1; /* 100% */ - margin: 0 5rem 0 auto; +.nav-tab.active::after { + content: ''; + position: absolute; + bottom: 0; + left: 50%; + transform: translateX(-50%); + width: 40px; + height: 2px; + background-color: var(--primary-color); + border-radius: 1px; } -/* 给段落添加文字颜色 */ -li { - padding-top: 1rem; - font-size: 1.2rem; /* 16px */ - line-height: 1.5rem; /* 24px */ - font-weight: 400; - color: rgb(96, 165, 250); /* sky-400 */ +.content { + flex: 1; + overflow-y: auto; + background-color: var(--bg-secondary); } -/* 给段落添加鼠标悬停时的文字颜色 */ -li:hover { - color: #e2e8f0; +.tab-content { + display: none; + padding: 16px; } -.main2 { - width: 60%; - margin: 11% auto 1% auto; - position: relative; +.tab-content.active { + display: block; +} + +.status-section { + margin-bottom: 16px; +} + +.status-card { + display: flex; + align-items: center; + gap: 12px; + padding: 16px; + background-color: var(--bg-primary); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); +} + +.status-icon { + width: 48px; + height: 48px; + border-radius: var(--radius-md); + background-color: var(--primary-light); + color: var(--primary-color); + display: flex; + align-items: center; + justify-content: center; +} + +.status-icon.success { + background-color: var(--success-light); + color: var(--success-color); +} + +.status-icon.running { + background-color: var(--warning-light); + color: var(--warning-color); + animation: pulse 1.5s ease-in-out infinite; +} + +.status-icon.error { + background-color: var(--danger-light); + color: var(--danger-color); +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.6; } +} + +.status-info { + display: flex; + flex-direction: column; + gap: 2px; +} + +.status-text { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); +} + +.status-detail { + font-size: 12px; + color: var(--text-secondary); +} + +.control-section { + margin-bottom: 16px; + padding: 16px; + background-color: var(--bg-primary); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); +} + +.control-group { + margin-bottom: 16px; +} + +.control-label { + display: block; + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); + margin-bottom: 8px; +} + +.slider-container { + position: relative; } .slider { - -webkit-appearance: none; - width: 100%; - height: 7px; - border-radius: 3px; + width: 100%; + height: 6px; + -webkit-appearance: none; + appearance: none; + background: var(--border-color); + border-radius: 3px; + outline: none; } -/* Design slider button */ .slider::-webkit-slider-thumb { - -webkit-appearance: none; - width: 48px; - height: 48px; - cursor: pointer; - position: relative; - z-index: 3; -} - -#selector { - height: 104px; - width: 48px; - position: absolute; - bottom: -20px; - left: 50%; - transform: translateX(-50%); - z-index: 2; -} -.selectBtn { - height: 30px; - width: 30px; - background-image: url(https://fadzrinmadu.github.io/hosted-assets/range-slider-using-html-and-css/icon.png); - background-size: cover; - background-position: center; - border-radius: 50%; - position: absolute; - bottom: 13px; -} -#selectValue { - width: 30px; - height: 30px; - position: absolute; - top: 18px; - background: #ffd200; - border-radius: 4px; - text-align: center; - line-height: 35px; - font-size: 16px; - font-weight: bold; -} -#selectValue::after { - content: ""; - border-top: 16px solid #ffd200; - border-left: 15.3px solid #35b9b3; - border-right: 15.3px solid #35b9b3; - position: absolute; - bottom: -12px; - left: 0px; -} -#progressBar { - width: 50%; - height: 7px; - background: #ffd200; - border-radius: 3px; - position: absolute; - bottom: 4px; - left: 0; -} - -.last { - font-size: 1.5rem; /* 16px */ - font-weight: 800; - line-height: 1.5rem; /* 24px */ - color: rgb(96, 165, 250); /* sky-400 */ - opacity: 1; /* 100% */ - margin: 1rem 5rem 1rem 1rem; + -webkit-appearance: none; + appearance: none; + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--primary-color); + cursor: pointer; + box-shadow: 0 2px 4px rgba(59, 130, 246, 0.3); + transition: all var(--transition-fast); +} + +.slider::-webkit-slider-thumb:hover { + transform: scale(1.1); + box-shadow: 0 3px 6px rgba(59, 130, 246, 0.4); +} + +.slider-labels { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 6px; + font-size: 11px; + color: var(--text-muted); +} + +.slider-value { + font-size: 13px; + font-weight: 600; + color: var(--primary-color); + position: relative; + background-color: var(--primary-light); + padding: 2px 8px; + border-radius: var(--radius-sm); +} + +.action-buttons { + display: flex; + gap: 12px; +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 10px 16px; + font-size: 14px; + font-weight: 500; + border: none; + border-radius: var(--radius-md); + cursor: pointer; + transition: all var(--transition-fast); +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-large { + flex: 1; + padding: 12px 20px; + font-size: 15px; +} + +.btn-small { + padding: 6px 12px; + font-size: 12px; +} + +.btn-primary { + background-color: var(--primary-color); + color: white; +} + +.btn-primary:hover:not(:disabled) { + background-color: var(--primary-hover); +} + +.btn-secondary { + background-color: var(--bg-tertiary); + color: var(--text-primary); +} + +.btn-secondary:hover:not(:disabled) { + background-color: var(--bg-hover); +} + +.btn-danger { + background-color: var(--danger-color); + color: white; +} + +.btn-danger:hover:not(:disabled) { + background-color: #dc2626; +} + +.btn-success { + background-color: var(--success-color); + color: white; +} + +.btn-success:hover:not(:disabled) { + background-color: #059669; +} + +.btn-text { + background: none; + color: var(--text-secondary); + padding: 6px 10px; + font-size: 12px; +} + +.btn-text:hover:not(:disabled) { + color: var(--primary-color); + background-color: var(--primary-light); +} + +.progress-section { + margin-bottom: 16px; + padding: 16px; + background-color: var(--bg-primary); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); +} + +.progress-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.progress-label { + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); +} + +.progress-count { + font-size: 13px; + font-weight: 600; + color: var(--primary-color); +} + +.progress-bar { + width: 100%; + height: 8px; + background-color: var(--border-color); + border-radius: 4px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--primary-color) 0%, var(--purple-color) 100%); + border-radius: 4px; + transition: width var(--transition-normal); +} + +.job-list-section { + margin-bottom: 16px; + padding: 16px; + background-color: var(--bg-primary); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); +} + +.section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.section-title { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); +} + +.job-list { + max-height: 120px; + overflow-y: auto; +} + +.job-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0; + border-bottom: 1px solid var(--border-light); +} + +.job-item:last-child { + border-bottom: none; +} + +.job-title { + font-size: 13px; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 220px; +} + +.job-status { + font-size: 11px; + padding: 2px 8px; + border-radius: var(--radius-sm); +} + +.job-status.success { + background-color: var(--success-light); + color: var(--success-color); +} + +.job-status.failed { + background-color: var(--danger-light); + color: var(--danger-color); +} + +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 24px; + color: var(--text-muted); +} + +.empty-state.small { + padding: 16px; +} + +.empty-state svg { + margin-bottom: 8px; + opacity: 0.5; +} + +.empty-state p { + font-size: 13px; +} + +.quick-settings { + display: flex; + gap: 16px; + padding: 12px 16px; + background-color: var(--bg-primary); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); +} + +.setting-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.setting-item > div { + display: flex; + flex-direction: column; + gap: 2px; +} + +.setting-label { + font-size: 13px; + color: var(--text-primary); +} + +.setting-desc { + font-size: 11px; + color: var(--text-muted); +} + +.toggle-switch { + position: relative; + display: inline-block; + width: 44px; + height: 24px; +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} + +.toggle-slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--border-color); + transition: var(--transition-fast); + border-radius: 24px; +} + +.toggle-slider:before { + position: absolute; + content: ""; + height: 18px; + width: 18px; + left: 3px; + bottom: 3px; + background-color: white; + transition: var(--transition-fast); + border-radius: 50%; + box-shadow: var(--shadow-sm); +} + +.toggle-switch input:checked + .toggle-slider { + background-color: var(--primary-color); +} + +.toggle-switch input:checked + .toggle-slider:before { + transform: translateX(20px); +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; + margin-bottom: 16px; +} + +.stat-card { + display: flex; + align-items: center; + gap: 12px; + padding: 16px; + background-color: var(--bg-primary); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); +} + +.stat-icon { + width: 40px; + height: 40px; + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; +} + +.stat-icon.blue { + background-color: var(--primary-light); + color: var(--primary-color); +} + +.stat-icon.green { + background-color: var(--success-light); + color: var(--success-color); +} + +.stat-icon.red { + background-color: var(--danger-light); + color: var(--danger-color); +} + +.stat-icon.purple { + background-color: var(--purple-light); + color: var(--purple-color); +} + +.stat-info { + display: flex; + flex-direction: column; + gap: 2px; +} + +.stat-value { + font-size: 20px; + font-weight: 700; + color: var(--text-primary); +} + +.stat-label { + font-size: 12px; + color: var(--text-secondary); +} + +.platform-stats, +.date-stats { + margin-bottom: 16px; + padding: 16px; + background-color: var(--bg-primary); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); +} + +.platform-list, +.date-list { + margin-top: 12px; +} + +.platform-stat-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0; + border-bottom: 1px solid var(--border-light); +} + +.platform-stat-item:last-child { + border-bottom: none; +} + +.platform-name { + font-size: 13px; + color: var(--text-primary); +} + +.platform-counts { + font-size: 12px; + color: var(--text-secondary); +} + +.blacklist-section { + background-color: var(--bg-primary); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + overflow: hidden; +} + +.blacklist-tabs { + display: flex; + background-color: var(--bg-tertiary); + border-bottom: 1px solid var(--border-color); +} + +.blacklist-tab { + flex: 1; + padding: 10px 12px; + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); + background: none; + border: none; + cursor: pointer; + transition: all var(--transition-fast); +} + +.blacklist-tab:hover { + color: var(--text-primary); + background-color: var(--bg-hover); +} + +.blacklist-tab.active { + color: var(--primary-color); + background-color: var(--bg-primary); +} + +.blacklist-content { + padding: 16px; +} + +.add-item-form { + display: flex; + gap: 8px; + margin-bottom: 16px; +} + +.form-input { + flex: 1; + padding: 8px 12px; + font-size: 13px; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + outline: none; + transition: all var(--transition-fast); +} + +.form-input:focus { + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.form-textarea { + width: 100%; + min-height: 80px; + padding: 8px 12px; + font-size: 13px; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + outline: none; + resize: vertical; + font-family: inherit; + transition: all var(--transition-fast); +} + +.form-textarea:focus { + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.select-input { + padding: 8px 12px; + font-size: 13px; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + background-color: var(--bg-primary); + outline: none; + cursor: pointer; + transition: all var(--transition-fast); +} + +.select-input:focus { + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.blacklist-items { + max-height: 200px; + overflow-y: auto; +} + +.blacklist-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + background-color: var(--bg-tertiary); + border-radius: var(--radius-md); + margin-bottom: 8px; +} + +.blacklist-item:last-child { + margin-bottom: 0; +} + +.blacklist-item-value { + font-size: 13px; + color: var(--text-primary); + word-break: break-all; +} + +.delete-btn { + width: 24px; + height: 24px; + border: none; + border-radius: var(--radius-sm); + background-color: transparent; + color: var(--text-muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all var(--transition-fast); +} + +.delete-btn:hover { + background-color: var(--danger-light); + color: var(--danger-color); +} + +.rules-section { + background-color: var(--bg-primary); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + overflow: hidden; +} + +.rules-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px; + border-bottom: 1px solid var(--border-light); +} + +.rules-list { + padding: 16px; +} + +.rule-item { + padding: 12px; + background-color: var(--bg-tertiary); + border-radius: var(--radius-md); + margin-bottom: 12px; +} + +.rule-item:last-child { + margin-bottom: 0; +} + +.rule-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.rule-name { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); +} + +.rule-type { + font-size: 11px; + padding: 2px 8px; + background-color: var(--primary-light); + color: var(--primary-color); + border-radius: var(--radius-sm); +} + +.rule-content { + font-size: 12px; + color: var(--text-secondary); + word-break: break-all; + margin-bottom: 8px; +} + +.rule-footer { + display: flex; + justify-content: space-between; + align-items: center; +} + +.rule-actions { + display: flex; + gap: 4px; +} + +.footer { + padding: 12px 16px; + background-color: var(--bg-primary); + border-top: 1px solid var(--border-color); +} + +.footer-actions { + display: flex; + justify-content: space-between; + margin-bottom: 8px; +} + +.footer-info { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + font-size: 11px; + color: var(--text-muted); +} + +.footer-info .divider { + color: var(--border-color); +} + +.modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; +} + +.modal.hidden { + display: none; +} + +.modal-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); +} + +.modal-content { + position: relative; + width: 340px; + max-height: 80vh; + background-color: var(--bg-primary); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-lg); + overflow: hidden; + display: flex; + flex-direction: column; +} + +.modal-content.modal-small { + width: 280px; +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px; + border-bottom: 1px solid var(--border-light); +} + +.modal-header h3 { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); +} + +.modal-body { + padding: 16px; + overflow-y: auto; + max-height: 400px; +} + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 12px 16px; + border-top: 1px solid var(--border-light); +} + +.setting-group { + margin-bottom: 20px; +} + +.setting-group:last-child { + margin-bottom: 0; +} + +.setting-group h4 { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 12px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border-light); +} + +.setting-group .setting-item { + margin-bottom: 12px; +} + +.setting-group .setting-item:last-child { + margin-bottom: 0; +} + +.form-group { + margin-bottom: 16px; +} + +.form-group:last-child { + margin-bottom: 0; +} + +.form-label { + display: block; + font-size: 13px; + font-weight: 500; + color: var(--text-primary); + margin-bottom: 6px; +} + +::-webkit-scrollbar { + width: 6px; +} + +::-webkit-scrollbar-track { + background: var(--bg-tertiary); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); +} + +.toast { + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + padding: 10px 20px; + background-color: var(--text-primary); + color: white; + font-size: 13px; + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + z-index: 2000; + opacity: 0; + transition: opacity var(--transition-fast); +} + +.toast.show { + opacity: 1; +} + +.toast.success { + background-color: var(--success-color); +} + +.toast.error { + background-color: var(--danger-color); +} + +.toast.warning { + background-color: var(--warning-color); } diff --git a/popup/popup.html b/popup/popup.html index 4101296..16ccccf 100644 --- a/popup/popup.html +++ b/popup/popup.html @@ -1,36 +1,480 @@ - - + + + 快投简历 - 一键海投助手 - - -
-
-

请登陆Boss直聘

- 图片 -
-
- -
-
-
+ + +
+
+
+ +
+

快投简历

+ v2.0.0 +
+
+
+ + +
+
+ + + +
+
+
+
+
+ + + +
+
+
就绪
+
请选择招聘平台
+
+
+
+ +
+
+ +
+ +
+ 10份 + 50份 + 250份 +
+
+
+ +
+ + +
+
+ +
+
+ 投递进度 + 0 / 50 +
+
+
+
+
+ +
+
+ 最近投递 + +
+
+
+ + + +

暂无投递记录

+
+
+
+ +
+
+ 开启通知 + +
+
+ 确认前弹窗 + +
+
+
+ +
+
+
+
+ + + +
+
+
0
+
总投递数
+
+
+
+
+ + + +
+
+
0
+
成功投递
+
+
+
+
+ + + +
+
+
0
+
失败投递
+
+
+
+
+ + + +
+
+
0%
+
成功率
+
+
+
+ +
+

按平台统计

+
+
+

暂无平台数据

+
+
+
+ +
+

每日投递趋势

+
+
+

暂无趋势数据

+
+
+
+
+ +
+
+
+ + + +
+ +
+
+ + +
+ +
+
+

黑名单为空

+
+
+
+
+
+ +
+
+
+

自定义规则

+ +
+ +
+
+ + + +

暂无自定义规则

+
+
+
+
+
+ +
+ + +
+
+ + + + + + - -
- -
-
-
    -
    - - + + diff --git a/popup/popup.js b/popup/popup.js index c723486..76fb633 100644 --- a/popup/popup.js +++ b/popup/popup.js @@ -1,104 +1,1124 @@ -let slider = document.querySelector(".slider"); -let selector = document.getElementById("selector"); -let selectValue = document.getElementById("selectValue"); -let progressBar = document.getElementById("progressBar"); -var jobCount = document.getElementById("job-count"); -// selectValue.innerHTML = slider.value; -selectValue.innerHTML = "空"; -// ((value/100) * 240) + 10 -slider.oninput = function () { - selectValue.innerHTML = (Math.round((this.value / 100) * 24) + 1) * 10; - selector.style.left = this.value + "%"; - progressBar.style.width = this.value + "%"; +const MessageActions = { + PING: 'ping', + GET_STATUS: 'getStatus', + GET_PLATFORM: 'getPlatform', + EXECUTE_APPLY: 'executeApply', + CANCEL_APPLY: 'cancelApply', + GET_HISTORY: 'getHistory', + GET_STATISTICS: 'getStatistics', + FIND_APPLY_BUTTON: 'findApplyButton', + GET_LOGS: 'getLogs', + CLEAR_LOGS: 'clearLogs', + + GET_SETTINGS: 'getSettings', + SET_SETTINGS: 'setSettings', + GET_BLACKLIST: 'getBlacklist', + ADD_TO_BLACKLIST: 'addToBlacklist', + REMOVE_FROM_BLACKLIST: 'removeFromBlacklist', + GET_CUSTOM_RULES: 'getCustomRules', + ADD_CUSTOM_RULE: 'addCustomRule', + UPDATE_CUSTOM_RULE: 'updateCustomRule', + DELETE_CUSTOM_RULE: 'deleteCustomRule', + RESET_ALL: 'resetAll', + GET_ALL_STATISTICS: 'getAllStatistics', + GET_ALL_HISTORY: 'getAllHistory', + CLEAR_HISTORY: 'clearHistory', + STATUS_CHANGE: 'statusChange', + APPLY_COMPLETE: 'applyComplete', + SCROLL: 'scroll', + SET_COUNT: 'setCount', + SEND_JOB_TITLES: 'sendJobTitles', + NOTIFY: 'notify' }; -// popup.js // 获取按钮元素 - -window.onload = function () { - // listen for messages from background.js or content.js - chrome.runtime.onMessage.addListener(function ( - message, - sender, - sendResponse - ) { - // check if the message has imgUrl and my_name properties - if (message.imgUrl) { - // do something with imgUrl and my_name - console.log(message.imgUrl); - // get the elements in popup.html - // var name = document.getElementById("name"); - var image = document.getElementById("image"); - // modify the elements' content or attributes - // name.textContent = message.my_name; - image.setAttribute("src", message.imgUrl); - } - }); - - chrome.storage.local.get(["imgUrl"], function (result) { - // check if imgUrl and my_name exist in local storage - if (result.imgUrl) { - // get the elements in popup.html - // var name = document.getElementById("name"); - var image = document.getElementById("image"); - jobCount.textContent = "可投递岗位:10"; - - image.setAttribute("src", result.imgUrl); - } else { - // show default values - // name.textContent = "欢迎使用1"; - image.setAttribute("src", "/images/Icon512.png"); - } - }); - - // 获取其他元素和事件监听器... +const ApplyStatus = { + IDLE: 'idle', + INITIALIZING: 'initializing', + DETECTING_PLATFORM: 'detecting_platform', + FINDING_BUTTON: 'finding_button', + CLICKING_BUTTON: 'clicking_button', + WAITING_POPUP: 'waiting_popup', + CONFIRMING_APPLY: 'confirming_apply', + CHECKING_RESULT: 'checking_result', + SUCCESS: 'success', + FAILED: 'failed', + CANCELLED: 'cancelled', + SKIPPED: 'skipped' }; -var applyBtn = document.querySelector(".apply-btn"); -const rangeInput = document.querySelector(".slider"); -rangeInput.addEventListener("input", function () { - const value = rangeInput.value; - applyBtn.textContent = `投${(Math.round((value / 100) * 24) + 1) * 10}份简历`; - // 发送消息到 content.js - // 发送消息到 content.js - chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) { - chrome.tabs.sendMessage(tabs[0].id, { - action: "setCount", - count_value: value, - }); - }); -}); -applyBtn.addEventListener("click", function () { - // execute content.js in the current tab - chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) { - // send a message to the active tab - chrome.tabs.sendMessage(tabs[0].id, { action: "scroll" }); - }); -}); +const PlatformNames = { + zhipin: 'BOSS直聘', + zhaopin: '智联招聘', + job51: '前程无忧', + lagou: '拉勾网', + liepin: '猎聘网', + unknown: '未知平台' +}; + +class PopupManager { + constructor() { + this._state = { + currentTab: 'main', + currentBlacklistType: 'domains', + settings: {}, + statistics: {}, + blacklist: { domains: [], keywords: [], companies: [] }, + customRules: { rules: [] }, + history: [], + editingRuleId: null, + confirmCallback: null + }; + + this._init(); + } + + async _init() { + this._setupEventListeners(); + this._setupMessageListeners(); + + await this._loadSettings(); + await this._loadStatistics(); + await this._loadBlacklist(); + await this._loadCustomRules(); + await this._loadHistory(); + + this._updateSliderValue(); + this._renderBlacklist(); + this._renderCustomRules(); + this._updateStatisticsUI(); + this._updateHistoryUI(); + + console.log('快投简历 Popup 已初始化'); + } + + _setupEventListeners() { + document.querySelectorAll('.nav-tab').forEach(tab => { + tab.addEventListener('click', (e) => { + this._switchTab(e.target.dataset.tab); + }); + }); + + document.querySelectorAll('.blacklist-tab').forEach(tab => { + tab.addEventListener('click', (e) => { + this._switchBlacklistTab(e.target.dataset.type); + }); + }); + + const slider = document.getElementById('apply-count-slider'); + if (slider) { + slider.addEventListener('input', (e) => { + this._updateSliderValue(e.target.value); + }); + } + + const btnStart = document.getElementById('btn-start-apply'); + if (btnStart) { + btnStart.addEventListener('click', () => this._startApply()); + } + + const btnStop = document.getElementById('btn-stop-apply'); + if (btnStop) { + btnStop.addEventListener('click', () => this._stopApply()); + } + + const btnSettings = document.getElementById('btn-settings'); + if (btnSettings) { + btnSettings.addEventListener('click', () => this._openSettingsModal()); + } + + const btnCloseSettings = document.getElementById('btn-close-settings'); + if (btnCloseSettings) { + btnCloseSettings.addEventListener('click', () => this._closeSettingsModal()); + } + + const btnCancelSettings = document.getElementById('btn-cancel-settings'); + if (btnCancelSettings) { + btnCancelSettings.addEventListener('click', () => this._closeSettingsModal()); + } + + const btnSaveSettings = document.getElementById('btn-save-settings'); + if (btnSaveSettings) { + btnSaveSettings.addEventListener('click', () => this._saveSettings()); + } + + const btnAddBlacklist = document.getElementById('btn-add-blacklist'); + if (btnAddBlacklist) { + btnAddBlacklist.addEventListener('click', () => this._addToBlacklist()); + } + + const blacklistInput = document.getElementById('blacklist-input'); + if (blacklistInput) { + blacklistInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + this._addToBlacklist(); + } + }); + } + + const btnAddRule = document.getElementById('btn-add-rule'); + if (btnAddRule) { + btnAddRule.addEventListener('click', () => this._openRuleModal()); + } + + const btnCloseRuleModal = document.getElementById('btn-close-rule-modal'); + if (btnCloseRuleModal) { + btnCloseRuleModal.addEventListener('click', () => this._closeRuleModal()); + } + + const btnCancelRule = document.getElementById('btn-cancel-rule'); + if (btnCancelRule) { + btnCancelRule.addEventListener('click', () => this._closeRuleModal()); + } + + const btnSaveRule = document.getElementById('btn-save-rule'); + if (btnSaveRule) { + btnSaveRule.addEventListener('click', () => this._saveRule()); + } + + const btnClearHistory = document.getElementById('btn-clear-history'); + if (btnClearHistory) { + btnClearHistory.addEventListener('click', () => this._clearHistory()); + } + + const btnReset = document.getElementById('btn-reset'); + if (btnReset) { + btnReset.addEventListener('click', () => this._confirmResetAll()); + } + + const btnExportLogs = document.getElementById('btn-export-logs'); + if (btnExportLogs) { + btnExportLogs.addEventListener('click', () => this._exportLogs()); + } + + const btnConfirmNo = document.getElementById('btn-confirm-no'); + if (btnConfirmNo) { + btnConfirmNo.addEventListener('click', () => this._hideConfirmModal()); + } + + const btnConfirmYes = document.getElementById('btn-confirm-yes'); + if (btnConfirmYes) { + btnConfirmYes.addEventListener('click', () => this._executeConfirmAction()); + } + + const toggleNotifications = document.getElementById('toggle-notifications'); + if (toggleNotifications) { + toggleNotifications.addEventListener('change', (e) => { + this._updateQuickSetting('enableNotifications', e.target.checked); + }); + } + + const toggleConfirm = document.getElementById('toggle-confirm'); + if (toggleConfirm) { + toggleConfirm.addEventListener('change', (e) => { + this._updateQuickSetting('confirmBeforeApply', e.target.checked); + }); + } + } + + _setupMessageListeners() { + chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + this._handleMessage(message, sender); + return true; + }); + } + + async _handleMessage(message, sender) { + const { action, data } = message; + + switch (action) { + case MessageActions.STATUS_CHANGE: + this._handleStatusChange(data); + break; + case MessageActions.APPLY_COMPLETE: + this._handleApplyComplete(data); + break; + case MessageActions.SEND_JOB_TITLES: + this._handleJobTitles(data); + break; + } + } + + _handleStatusChange(data) { + const { newStatus, applyCount, maxApplyCount } = data; + this._updateStatusUI(newStatus); + + if (applyCount !== undefined && maxApplyCount !== undefined) { + this._updateProgressUI(applyCount, maxApplyCount); + } + } + + _handleApplyComplete(data) { + const { success, applyCount, maxApplyCount } = data; + + if (applyCount !== undefined && maxApplyCount !== undefined) { + this._updateProgressUI(applyCount, maxApplyCount); + } + + this._loadHistory(); + this._loadStatistics(); + } + + _handleJobTitles(data) { + const { jobTitles, numJobs } = data; + const jobCountEl = document.querySelector('.status-detail'); + if (jobCountEl && numJobs !== undefined) { + jobCountEl.textContent = `可投递岗位: ${numJobs}`; + } + } + + _switchTab(tabName) { + document.querySelectorAll('.nav-tab').forEach(tab => { + tab.classList.toggle('active', tab.dataset.tab === tabName); + }); + + document.querySelectorAll('.tab-content').forEach(content => { + content.classList.toggle('active', content.id === `tab-${tabName}`); + }); + + this._state.currentTab = tabName; + } + + _switchBlacklistTab(type) { + document.querySelectorAll('.blacklist-tab').forEach(tab => { + tab.classList.toggle('active', tab.dataset.type === type); + }); + + this._state.currentBlacklistType = type; + this._renderBlacklist(); + } + + _updateSliderValue(value) { + const slider = document.getElementById('apply-count-slider'); + const valueEl = document.getElementById('apply-count-value'); + + const val = value || slider.value; + const count = (Math.round((val / 100) * 24) + 1) * 10; + + if (valueEl) { + valueEl.textContent = `${count}份`; + } + + this._sendToBackground({ + action: MessageActions.SET_COUNT, + count_value: parseInt(val) + }); + } + + async _startApply() { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + + if (!tab) { + this._showToast('无法获取当前标签页', 'error'); + return; + } + + try { + const response = await chrome.tabs.sendMessage(tab.id, { + action: MessageActions.SCROLL + }); + + if (response && response.success) { + this._updateStatusUI('running'); + this._toggleApplyButtons(true); + this._showToast('开始投递', 'success'); + } else { + this._showToast(response?.error || '启动失败', 'error'); + } + } catch (error) { + console.error('启动投递失败:', error); + this._showToast('请在招聘平台页面使用', 'warning'); + } + } + + async _stopApply() { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + + if (!tab) return; + + try { + await chrome.tabs.sendMessage(tab.id, { + action: MessageActions.CANCEL_APPLY + }); + + this._updateStatusUI('idle'); + this._toggleApplyButtons(false); + this._showToast('投递已暂停', 'warning'); + } catch (error) { + console.error('停止投递失败:', error); + this._showToast('停止失败', 'error'); + } + } + + _toggleApplyButtons(isApplying) { + const btnStart = document.getElementById('btn-start-apply'); + const btnStop = document.getElementById('btn-stop-apply'); + + if (btnStart) { + btnStart.disabled = isApplying; + } + if (btnStop) { + btnStop.disabled = !isApplying; + } + } + + _updateStatusUI(status) { + const statusIcon = document.getElementById('status-icon'); + const statusText = document.getElementById('status-text'); + const statusDetail = document.getElementById('status-detail'); + + if (!statusIcon || !statusText) return; + + const statusMap = { + idle: { text: '就绪', detail: '请选择招聘平台', class: '' }, + running: { text: '投递中', detail: '正在执行投递流程...', class: 'running' }, + finding_button: { text: '查找按钮', detail: '正在查找投递按钮...', class: 'running' }, + clicking_button: { text: '点击按钮', detail: '正在点击投递按钮...', class: 'running' }, + waiting_popup: { text: '等待弹窗', detail: '等待弹窗出现...', class: 'running' }, + confirming_apply: { text: '确认投递', detail: '正在确认投递...', class: 'running' }, + checking_result: { text: '检查结果', detail: '正在检查投递结果...', class: 'running' }, + success: { text: '投递成功', detail: '投递已完成', class: 'success' }, + failed: { text: '投递失败', detail: '投递过程中出现错误', class: 'error' }, + cancelled: { text: '已取消', detail: '投递已取消', class: '' } + }; + + const statusInfo = statusMap[status] || statusMap.idle; + statusText.textContent = statusInfo.text; + if (statusDetail) { + statusDetail.textContent = statusInfo.detail; + } + + statusIcon.className = 'status-icon'; + if (statusInfo.class) { + statusIcon.classList.add(statusInfo.class); + } + } + + _updateProgressUI(applyCount, maxApplyCount) { + const progressCount = document.getElementById('progress-count'); + const progressFill = document.getElementById('progress-fill'); + + if (progressCount) { + progressCount.textContent = `${applyCount} / ${maxApplyCount}`; + } + + if (progressFill) { + const percent = maxApplyCount > 0 ? (applyCount / maxApplyCount) * 100 : 0; + progressFill.style.width = `${percent}%`; + } + } + + async _loadSettings() { + try { + const response = await this._sendToBackground({ + action: MessageActions.GET_SETTINGS + }); + + if (response && response.success) { + this._state.settings = response.data; + this._populateSettingsForm(); + this._updateQuickSettingsUI(); + } + } catch (error) { + console.error('加载设置失败:', error); + } + } + + _populateSettingsForm() { + const settings = this._state.settings; + + const enabled = document.getElementById('setting-enabled'); + if (enabled) enabled.checked = settings.enabled !== false; + + const notifications = document.getElementById('setting-notifications'); + if (notifications) notifications.checked = settings.enableNotifications !== false; + + const confirm = document.getElementById('setting-confirm'); + if (confirm) confirm.checked = settings.confirmBeforeApply === true; + + const maxApps = document.getElementById('setting-max-applications'); + if (maxApps) maxApps.value = settings.maxApplicationsPerSession || 50; + + const applyDelay = document.getElementById('setting-apply-delay'); + if (applyDelay) applyDelay.value = settings.applyDelay || 2000; + + const waitTimeout = document.getElementById('setting-wait-timeout'); + if (waitTimeout) waitTimeout.value = settings.elementWaitTimeout || 10000; + + const maxRetries = document.getElementById('setting-max-retries'); + if (maxRetries) maxRetries.value = settings.maxRetryCount || 3; + + const logLevel = document.getElementById('setting-log-level'); + if (logLevel) logLevel.value = settings.logLevel || 'INFO'; + + const platforms = settings.platforms || {}; + document.querySelectorAll('#platform-settings input[type="checkbox"]').forEach(input => { + const platform = input.dataset.platform; + if (platforms[platform]) { + input.checked = platforms[platform].enabled !== false; + } + }); + } + + _updateQuickSettingsUI() { + const settings = this._state.settings; + + const toggleNotifications = document.getElementById('toggle-notifications'); + if (toggleNotifications) { + toggleNotifications.checked = settings.enableNotifications !== false; + } + + const toggleConfirm = document.getElementById('toggle-confirm'); + if (toggleConfirm) { + toggleConfirm.checked = settings.confirmBeforeApply === true; + } + } + + async _updateQuickSetting(key, value) { + const settings = { ...this._state.settings, [key]: value }; + + try { + await this._sendToBackground({ + action: MessageActions.SET_SETTINGS, + settings: { [key]: value } + }); + + this._state.settings = settings; + this._showToast('设置已更新', 'success'); + } catch (error) { + console.error('更新设置失败:', error); + this._showToast('更新失败', 'error'); + } + } + + async _saveSettings() { + const settings = { + enabled: document.getElementById('setting-enabled')?.checked, + enableNotifications: document.getElementById('setting-notifications')?.checked, + confirmBeforeApply: document.getElementById('setting-confirm')?.checked, + maxApplicationsPerSession: parseInt(document.getElementById('setting-max-applications')?.value) || 50, + applyDelay: parseInt(document.getElementById('setting-apply-delay')?.value) || 2000, + elementWaitTimeout: parseInt(document.getElementById('setting-wait-timeout')?.value) || 10000, + maxRetryCount: parseInt(document.getElementById('setting-max-retries')?.value) || 3, + logLevel: document.getElementById('setting-log-level')?.value || 'INFO', + platforms: {} + }; + + document.querySelectorAll('#platform-settings input[type="checkbox"]').forEach(input => { + const platform = input.dataset.platform; + settings.platforms[platform] = { enabled: input.checked }; + }); + + try { + const response = await this._sendToBackground({ + action: MessageActions.SET_SETTINGS, + settings + }); + + if (response && response.success) { + this._state.settings = settings; + this._updateQuickSettingsUI(); + this._closeSettingsModal(); + this._showToast('设置已保存', 'success'); + } + } catch (error) { + console.error('保存设置失败:', error); + this._showToast('保存失败', 'error'); + } + } + + _openSettingsModal() { + this._populateSettingsForm(); + document.getElementById('settings-modal')?.classList.remove('hidden'); + } + + _closeSettingsModal() { + document.getElementById('settings-modal')?.classList.add('hidden'); + } + + async _loadStatistics() { + try { + const response = await this._sendToBackground({ + action: MessageActions.GET_ALL_STATISTICS + }); + + if (response && response.success) { + this._state.statistics = response.data; + this._updateStatisticsUI(); + } + } catch (error) { + console.error('加载统计失败:', error); + } + } + + _updateStatisticsUI() { + const stats = this._state.statistics; -// get imgUrl and my_name from local storage + const statTotal = document.getElementById('stat-total'); + if (statTotal) statTotal.textContent = stats.totalApplications || 0; -// listen for messages from content.js -chrome.runtime.onMessage.addListener(function (request, sender, sendResponse) { - // check if the action is "sendJobTitles" - if (request.action === "sendJobTitles") { - // get the job titles and number of jobs - var jobTitles = request.jobTitles; - var numJobs = request.numJobs; + const statSuccess = document.getElementById('stat-success'); + if (statSuccess) statSuccess.textContent = stats.successfulApplications || 0; - // update the popup.html with the job titles and number of jobs - var jobList = document.getElementById("job-list"); - jobList.innerHTML = ""; + const statFailed = document.getElementById('stat-failed'); + if (statFailed) statFailed.textContent = stats.failedApplications || 0; + + const statRate = document.getElementById('stat-rate'); + if (statRate) { + const total = stats.totalApplications || 0; + const success = stats.successfulApplications || 0; + const rate = total > 0 ? ((success / total) * 100).toFixed(1) : 0; + statRate.textContent = `${rate}%`; + } + + this._renderPlatformStats(); + this._renderDateStats(); + } + + _renderPlatformStats() { + const container = document.getElementById('platform-stats-list'); + if (!container) return; + + const byPlatform = this._state.statistics.byPlatform || {}; + const platforms = Object.keys(byPlatform); + + if (platforms.length === 0) { + container.innerHTML = '

    暂无平台数据

    '; + return; + } + + let html = ''; + platforms.forEach(platform => { + const data = byPlatform[platform]; + const name = PlatformNames[platform] || platform; + html += ` +
    + ${name} + + 成功: ${data.successful || 0} / 总计: ${data.total || 0} + +
    + `; + }); + + container.innerHTML = html; + } + + _renderDateStats() { + const container = document.getElementById('date-stats-list'); + if (!container) return; + + const byDate = this._state.statistics.byDate || {}; + const dates = Object.keys(byDate).sort().reverse().slice(0, 7); + + if (dates.length === 0) { + container.innerHTML = '

    暂无趋势数据

    '; + return; + } + + let html = ''; + dates.forEach(date => { + const data = byDate[date]; + html += ` +
    + ${date} + + 成功: ${data.successful || 0} / 总计: ${data.total || 0} + +
    + `; + }); + + container.innerHTML = html; + } + + async _loadBlacklist() { + try { + const response = await this._sendToBackground({ + action: MessageActions.GET_BLACKLIST + }); + + if (response && response.success) { + this._state.blacklist = response.data; + this._renderBlacklist(); + } + } catch (error) { + console.error('加载黑名单失败:', error); + } + } + + _renderBlacklist() { + const container = document.getElementById('blacklist-items'); + if (!container) return; + + const type = this._state.currentBlacklistType; + const items = this._state.blacklist[type] || []; + + if (items.length === 0) { + container.innerHTML = '

    黑名单为空

    '; + return; + } + + let html = ''; + items.forEach((item, index) => { + html += ` +
    + ${this._escapeHtml(item)} + +
    + `; + }); + + container.innerHTML = html; + + container.querySelectorAll('.delete-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + const type = e.currentTarget.dataset.type; + const index = parseInt(e.currentTarget.dataset.index); + this._removeFromBlacklist(type, index); + }); + }); + } + + async _addToBlacklist() { + const input = document.getElementById('blacklist-input'); + if (!input) return; + + const value = input.value.trim(); + if (!value) { + this._showToast('请输入内容', 'warning'); + return; + } + + const type = this._state.currentBlacklistType; + + try { + const response = await this._sendToBackground({ + action: MessageActions.ADD_TO_BLACKLIST, + type, + value + }); + + if (response && response.success && response.data.added) { + this._state.blacklist[type].push(value); + this._renderBlacklist(); + input.value = ''; + this._showToast('添加成功', 'success'); + } else { + this._showToast('已存在或添加失败', 'warning'); + } + } catch (error) { + console.error('添加到黑名单失败:', error); + this._showToast('添加失败', 'error'); + } + } + + async _removeFromBlacklist(type, index) { + const items = this._state.blacklist[type] || []; + const value = items[index]; + if (!value) return; + + try { + const response = await this._sendToBackground({ + action: MessageActions.REMOVE_FROM_BLACKLIST, + type, + value + }); + + if (response && response.success && response.data.removed) { + this._state.blacklist[type].splice(index, 1); + this._renderBlacklist(); + this._showToast('已删除', 'success'); + } + } catch (error) { + console.error('从黑名单删除失败:', error); + this._showToast('删除失败', 'error'); + } + } + + async _loadCustomRules() { + try { + const response = await this._sendToBackground({ + action: MessageActions.GET_CUSTOM_RULES + }); + + if (response && response.success) { + this._state.customRules = response.data; + this._renderCustomRules(); + } + } catch (error) { + console.error('加载自定义规则失败:', error); + } + } + + _renderCustomRules() { + const container = document.getElementById('rules-list'); + if (!container) return; + + const rules = this._state.customRules.rules || []; + + if (rules.length === 0) { + container.innerHTML = ` +
    + + + +

    暂无自定义规则

    +
    + `; + return; + } + + let html = ''; + const typeNames = { selector: '选择器', text: '文本匹配', url: 'URL匹配' }; + + rules.forEach(rule => { + html += ` +
    +
    + ${this._escapeHtml(rule.name)} + ${typeNames[rule.type] || rule.type} +
    +
    ${this._escapeHtml(rule.content)}
    + +
    + `; + }); + + container.innerHTML = html; + + container.querySelectorAll('input[type="checkbox"][data-rule-id]').forEach(input => { + input.addEventListener('change', (e) => { + const ruleId = e.target.dataset.ruleId; + const enabled = e.target.checked; + this._toggleRuleEnabled(ruleId, enabled); + }); + }); + + container.querySelectorAll('[data-action="edit"]').forEach(btn => { + btn.addEventListener('click', (e) => { + const ruleId = e.currentTarget.dataset.ruleId; + this._editRule(ruleId); + }); + }); + + container.querySelectorAll('[data-action="delete"]').forEach(btn => { + btn.addEventListener('click', (e) => { + const ruleId = e.currentTarget.dataset.ruleId; + this._confirmDeleteRule(ruleId); + }); + }); + } + + _openRuleModal(rule = null) { + const title = document.getElementById('rule-modal-title'); + if (title) { + title.textContent = rule ? '编辑规则' : '新增规则'; + } + + const nameInput = document.getElementById('rule-name'); + const typeSelect = document.getElementById('rule-type'); + const contentTextarea = document.getElementById('rule-content'); + const enabledCheckbox = document.getElementById('rule-enabled'); + + if (nameInput) nameInput.value = rule?.name || ''; + if (typeSelect) typeSelect.value = rule?.type || 'selector'; + if (contentTextarea) contentTextarea.value = rule?.content || ''; + if (enabledCheckbox) enabledCheckbox.checked = rule?.enabled !== false; + + this._state.editingRuleId = rule?.id || null; + document.getElementById('rule-modal')?.classList.remove('hidden'); + } + + _closeRuleModal() { + document.getElementById('rule-modal')?.classList.add('hidden'); + this._state.editingRuleId = null; + } + + async _saveRule() { + const name = document.getElementById('rule-name')?.value.trim(); + const type = document.getElementById('rule-type')?.value; + const content = document.getElementById('rule-content')?.value.trim(); + const enabled = document.getElementById('rule-enabled')?.checked; + + if (!name) { + this._showToast('请输入规则名称', 'warning'); + return; + } + + if (!content) { + this._showToast('请输入规则内容', 'warning'); + return; + } + + const ruleData = { name, type, content, enabled }; + + try { + let response; + if (this._state.editingRuleId) { + response = await this._sendToBackground({ + action: MessageActions.UPDATE_CUSTOM_RULE, + ruleId: this._state.editingRuleId, + updates: ruleData + }); + } else { + response = await this._sendToBackground({ + action: MessageActions.ADD_CUSTOM_RULE, + rule: ruleData + }); + } + + if (response && response.success) { + await this._loadCustomRules(); + this._closeRuleModal(); + this._showToast(this._state.editingRuleId ? '规则已更新' : '规则已添加', 'success'); + } + } catch (error) { + console.error('保存规则失败:', error); + this._showToast('保存失败', 'error'); + } + } + + _editRule(ruleId) { + const rules = this._state.customRules.rules || []; + const rule = rules.find(r => r.id === ruleId); + if (rule) { + this._openRuleModal(rule); + } + } + + async _toggleRuleEnabled(ruleId, enabled) { + try { + await this._sendToBackground({ + action: MessageActions.UPDATE_CUSTOM_RULE, + ruleId, + updates: { enabled } + }); + + await this._loadCustomRules(); + } catch (error) { + console.error('更新规则状态失败:', error); + this._showToast('更新失败', 'error'); + } + } + + _confirmDeleteRule(ruleId) { + this._showConfirmModal('确定要删除这条规则吗?', () => { + this._deleteRule(ruleId); + }); + } + + async _deleteRule(ruleId) { + try { + const response = await this._sendToBackground({ + action: MessageActions.DELETE_CUSTOM_RULE, + ruleId + }); + + if (response && response.success) { + await this._loadCustomRules(); + this._showToast('规则已删除', 'success'); + } + } catch (error) { + console.error('删除规则失败:', error); + this._showToast('删除失败', 'error'); + } + } + + async _loadHistory() { + try { + const response = await this._sendToBackground({ + action: MessageActions.GET_ALL_HISTORY + }); + + if (response && response.success) { + this._state.history = response.data.items || []; + this._updateHistoryUI(); + } + } catch (error) { + console.error('加载历史失败:', error); + } + } + + _updateHistoryUI() { + const container = document.getElementById('job-list'); + if (!container) return; + + const history = this._state.history.slice(0, 10); + + if (history.length === 0) { + container.innerHTML = ` +
    + + + +

    暂无投递记录

    +
    + `; + return; + } + + let html = ''; + history.forEach(item => { + const statusClass = item.status === 'success' ? 'success' : 'failed'; + const statusText = item.status === 'success' ? '成功' : '失败'; + const time = new Date(item.timestamp).toLocaleString('zh-CN'); + + html += ` +
    + ${this._escapeHtml(item.platformName || '未知平台')} - ${time} + ${statusText} +
    + `; + }); + + container.innerHTML = html; + } + + async _clearHistory() { + this._showConfirmModal('确定要清空所有投递历史吗?', async () => { + try { + await this._sendToBackground({ + action: MessageActions.CLEAR_HISTORY + }); + + await this._loadHistory(); + this._showToast('历史已清空', 'success'); + } catch (error) { + console.error('清空历史失败:', error); + this._showToast('清空失败', 'error'); + } + }); + } + + _confirmResetAll() { + this._showConfirmModal('确定要重置所有设置和数据吗?此操作不可恢复。', async () => { + try { + await this._sendToBackground({ + action: MessageActions.RESET_ALL + }); + + await this._loadSettings(); + await this._loadStatistics(); + await this._loadBlacklist(); + await this._loadCustomRules(); + await this._loadHistory(); + + this._showToast('已重置所有数据', 'success'); + } catch (error) { + console.error('重置失败:', error); + this._showToast('重置失败', 'error'); + } + }); + } + + async _exportLogs() { + try { + const response = await this._sendToBackground({ + action: MessageActions.GET_LOGS + }); + + if (response && response.success) { + const logs = response.data; + const content = JSON.stringify(logs, null, 2); + const blob = new Blob([content], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + + const a = document.createElement('a'); + a.href = url; + a.download = `jobapp_logs_${new Date().toISOString().split('T')[0]}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + this._showToast('日志已导出', 'success'); + } + } catch (error) { + console.error('导出日志失败:', error); + this._showToast('导出失败', 'error'); + } + } + + _showConfirmModal(message, callback) { + const modal = document.getElementById('confirm-modal'); + const messageEl = document.getElementById('confirm-message'); + + if (messageEl) { + messageEl.textContent = message; + } + + this._state.confirmCallback = callback; + modal?.classList.remove('hidden'); + } + + _hideConfirmModal() { + document.getElementById('confirm-modal')?.classList.add('hidden'); + this._state.confirmCallback = null; + } + + _executeConfirmAction() { + if (this._state.confirmCallback) { + this._state.confirmCallback(); + } + this._hideConfirmModal(); + } + + _sendToBackground(message) { + return new Promise((resolve, reject) => { + chrome.runtime.sendMessage(message, (response) => { + if (chrome.runtime.lastError) { + reject(chrome.runtime.lastError); + } else { + resolve(response); + } + }); + }); + } + + _showToast(message, type = 'info') { + const existingToast = document.querySelector('.toast'); + if (existingToast) { + existingToast.remove(); + } + + const toast = document.createElement('div'); + toast.className = `toast ${type}`; + toast.textContent = message; + document.body.appendChild(toast); + + requestAnimationFrame(() => { + toast.classList.add('show'); + }); + + setTimeout(() => { + toast.classList.remove('show'); + setTimeout(() => toast.remove(), 300); + }, 2000); + } - for (var i = 0; i < jobTitles.length; i++) { - var listItem = document.createElement("li"); - listItem.textContent = jobTitles[i]; - jobList.appendChild(listItem); + _escapeHtml(str) { + if (!str) return ''; + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; } - var lastItem = document.createElement("p"); - lastItem.textContent = "请稍等,投递中....."; - jobList.appendChild(lastItem); - lastItem.classList.add("last"); +} - jobCount.textContent = "可投递岗位: " + numJobs; - } +document.addEventListener('DOMContentLoaded', () => { + new PopupManager(); }); diff --git a/scripts/content.js b/scripts/content.js deleted file mode 100644 index 7e28d16..0000000 --- a/scripts/content.js +++ /dev/null @@ -1,84 +0,0 @@ -var count = 0; // initialize a counter variable -var intervalId; // declare a variable for the timer function -var range_value = 1; // declare a variable for -// 监听来自 popup.js 的消息 - -// listen for messages from popup.js -chrome.runtime.onMessage.addListener(function (request, sender, sendResponse) { - if (request.action === "setCount") { - range_value = request.count_value; - range_value = Math.round((range_value / 100) * 24) + 1; - } else if ( - // check if the action is "scroll" - request.action === "scroll" - ) { - // start the timer function - intervalId = setInterval(function () { - // scroll to the bottom of the page - window.scrollTo(0, document.body.scrollHeight); - // increment the counter - count++; - console.log(count, "加载次数", range_value); - if (count == range_value) { - // stop the timer - - clearInterval(intervalId); - printTitles(); - count = 1; // initialize - // send the job titles and number of jobs to popup.js - // var but_arr = document.querySelectorAll(".btn-chat"); - // but_arr.forEach((button) => button.click()); - } - }, 1000); // set the interval to 1000 milliseconds (1 second) - } -}); - -// define another function to print titles -function printTitles() { - var title_arr = document.querySelectorAll(".title-text"); - var jobTitles = []; - title_arr.forEach((item, index) => { - console.log(index + 1, item.innerHTML); - // but_arr[index].click(); - jobTitles.push(item.innerHTML); - }); - - chrome.runtime.sendMessage({ - action: "sendJobTitles", - jobTitles: jobTitles, - numJobs: jobTitles.length, - }); -} - -// const user_name = document.querySelector(".label-text"); -var img = document.querySelector("img.after"); // 获取img元素 - -try { - var imgUrl = img.getAttribute("src"); // 获取img元素的src属性 -} catch (error) { - console.log("请登陆BOSS直聘"); -} -// console.log(imgUrl); // 打印出img标签的URL -var extensionId = chrome.runtime.id; - -// send the message to popup.js with the extension ID -chrome.runtime.sendMessage(extensionId, { - imgUrl: imgUrl, -}); - -// store imgUrl and my_name in local storage -chrome.storage.local.set({ imgUrl: imgUrl }); -console.log("快投简历📄"); - -const logoDiv = document.querySelector(".logo"); -const p = document.createElement("p"); -p.textContent = "快投简历:一键海投,秒投百份"; -p.classList.add("banner-login"); -p.style.left = "3rem"; -p.style.fontSize = "2rem"; -p.style.fontWeight = "600"; -try { - logoDiv.insertAdjacentElement("afterend", p); -} catch (error) { - console.log("欢迎使用"); -}