Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
1 line
795 KiB
JSON
1 line
795 KiB
JSON
{"repo": "chrisbetheking/job-claw", "files": {"chrome-extension/common.js": {"779028be3432bbaf6737731db581e090428a0c0f": {"t": 1785745999.1421301, "n": 2718, "h": "262a34ca1f783821", "c": "export const DEFAULTS = {\n config: {\n executionMode: 'review',\n batchStrategy: 'precise',\n massApplyAnalysis: 'fast',\n pacingPreset: 'standard',\n dryRun: false,\n dailyTarget: 30,\n discoveryLimit: 150,\n aiLimit: 0,\n minScore: 75,\n targetLocations: [],\n employmentTypes: ['不限'],\n experiences: [],\n degrees: [],\n salary: '不限',\n sendResumeImage: true,\n sendOnlineResume: false,\n betweenJobsSeconds: 9,\n attachmentDelaySeconds: 3,\n maxPerCompanyPerDay: 3,\n queueWarmup: 4,\n maxConsecutiveFailures: 3,\n jitterSeconds: 3,\n companyVerificationEnabled: true,\n companyVerificationProvider: 'bridge',\n companyVerificationCacheDays: 14,\n blockUnknownCompanies: false,\n updateCheckEnabled: true,\n dailyReportEnabled: true,\n dailyReportTime: '20:30',\n dailyReportNotification: true,\n rateLimits: {\n discoveryMs: 1000,\n aiMs: 700,\n companyMs: 1800,\n deliveryMs: 9000,\n attachmentMs: 3000,\n updateMs: 5000\n },\n requireSingleJobValidation: true,\n singleJobValidationCompletedAt: 0,\n model: {\n baseUrl: 'https://api.deepseek.com',\n apiKey: '',\n model: 'deepseek-v4-pro',\n temperature: 0.1\n }\n },\n profile: null,\n profileDraft: null,\n directionPlan: null,\n resumeText: '',\n resumeImage: null,\n resumeSourceFile: null,\n stats: {\n date: '',\n sent: 0,\n discovered: 0,\n analyzed: 0,\n pending: 0,\n failed: 0,\n replied: 0,\n interviews: 0,\n verified: 0,\n blocked: 0,\n duplicates: 0,\n simulated: 0\n },\n workflow: {\n running: false,\n paused: true,\n phase: 'idle',\n statusText: '未开始',\n tasks: [],\n taskIndex: 0,\n cardIndex: 0,\n processedKeys: [],\n retries: 0,\n currentJob: null,\n returnUrl: '',\n returnScrollY: 0,\n pendingApplyId: null,\n activeRunId: null\n },\n pending: [],\n taskRuns: [],\n events: [],\n safetyState: {\n lastActionAt: {},\n consecutiveFailures: 0,\n circuitOpen: false,\n circuitReason: '',\n circuitOpenedAt: 0,\n totalThrottled: 0,\n backoffLevel: 0,\n lastBackoffReason: ''\n },\n companyVerificationCache: {},\n deliveryHistory: [],\n updateInfo: {\n currentVersion: '1.7.0',\n latestVersion: '',\n available: false,\n checkedAt: 0,\n url: 'https://github.com/Chrisbetheking/job-claw/releases',\n error: ''\n }\n};\n\nexport const today = () => new Date().toISOString().slice(0, 10);\nexport const uniq = (items = []) => [...new Set(items.filter(Boolean))];\nexport const list = value => String(value || '')\n .split(/[,,\\n]/)\n .map(item => item.trim())\n .filter(Boolean);\nexport const safeClone = value => JSON.parse(JSON.stringify(value ?? null));\n"}, "1717e3d1c43b3d2df618a2320c9d66a2182ce7ee": {"t": 1785758650.7267165, "n": 2718, "h": "262a34ca1f783821", "c": "export const DEFAULTS = {\n config: {\n executionMode: 'review',\n batchStrategy: 'precise',\n massApplyAnalysis: 'fast',\n pacingPreset: 'standard',\n dryRun: false,\n dailyTarget: 30,\n discoveryLimit: 150,\n aiLimit: 0,\n minScore: 75,\n targetLocations: [],\n employmentTypes: ['不限'],\n experiences: [],\n degrees: [],\n salary: '不限',\n sendResumeImage: true,\n sendOnlineResume: false,\n betweenJobsSeconds: 9,\n attachmentDelaySeconds: 3,\n maxPerCompanyPerDay: 3,\n queueWarmup: 4,\n maxConsecutiveFailures: 3,\n jitterSeconds: 3,\n companyVerificationEnabled: true,\n companyVerificationProvider: 'bridge',\n companyVerificationCacheDays: 14,\n blockUnknownCompanies: false,\n updateCheckEnabled: true,\n dailyReportEnabled: true,\n dailyReportTime: '20:30',\n dailyReportNotification: true,\n rateLimits: {\n discoveryMs: 1000,\n aiMs: 700,\n companyMs: 1800,\n deliveryMs: 9000,\n attachmentMs: 3000,\n updateMs: 5000\n },\n requireSingleJobValidation: true,\n singleJobValidationCompletedAt: 0,\n model: {\n baseUrl: 'https://api.deepseek.com',\n apiKey: '',\n model: 'deepseek-v4-pro',\n temperature: 0.1\n }\n },\n profile: null,\n profileDraft: null,\n directionPlan: null,\n resumeText: '',\n resumeImage: null,\n resumeSourceFile: null,\n stats: {\n date: '',\n sent: 0,\n discovered: 0,\n analyzed: 0,\n pending: 0,\n failed: 0,\n replied: 0,\n interviews: 0,\n verified: 0,\n blocked: 0,\n duplicates: 0,\n simulated: 0\n },\n workflow: {\n running: false,\n paused: true,\n phase: 'idle',\n statusText: '未开始',\n tasks: [],\n taskIndex: 0,\n cardIndex: 0,\n processedKeys: [],\n retries: 0,\n currentJob: null,\n returnUrl: '',\n returnScrollY: 0,\n pendingApplyId: null,\n activeRunId: null\n },\n pending: [],\n taskRuns: [],\n events: [],\n safetyState: {\n lastActionAt: {},\n consecutiveFailures: 0,\n circuitOpen: false,\n circuitReason: '',\n circuitOpenedAt: 0,\n totalThrottled: 0,\n backoffLevel: 0,\n lastBackoffReason: ''\n },\n companyVerificationCache: {},\n deliveryHistory: [],\n updateInfo: {\n currentVersion: '1.7.0',\n latestVersion: '',\n available: false,\n checkedAt: 0,\n url: 'https://github.com/Chrisbetheking/job-claw/releases',\n error: ''\n }\n};\n\nexport const today = () => new Date().toISOString().slice(0, 10);\nexport const uniq = (items = []) => [...new Set(items.filter(Boolean))];\nexport const list = value => String(value || '')\n .split(/[,,\\n]/)\n .map(item => item.trim())\n .filter(Boolean);\nexport const safeClone = value => JSON.parse(JSON.stringify(value ?? null));\n"}, "76f53ebdeadb5fe589927cbed2ecce55fc037ed5": {"t": 1785758652.0677931, "n": 1539, "h": "a54180f769ff90d1", "c": "export const DEFAULTS = {\n config: {\n executionMode: 'review',\n dailyTarget: 150,\n discoveryLimit: 0,\n aiLimit: 0,\n minScore: 75,\n targetLocations: [],\n employmentTypes: ['不限'],\n experiences: [],\n degrees: [],\n salary: '不限',\n sendResumeImage: true,\n sendOnlineResume: false,\n betweenJobsSeconds: 12,\n attachmentDelaySeconds: 4,\n requireSingleJobValidation: true,\n singleJobValidationCompletedAt: 0,\n model: {\n baseUrl: 'https://api.deepseek.com',\n apiKey: '',\n model: 'deepseek-v4-pro',\n temperature: 0.1\n }\n },\n profile: null,\n profileDraft: null,\n directionPlan: null,\n resumeText: '',\n resumeImage: null,\n resumeSourceFile: null,\n stats: {\n date: '',\n sent: 0,\n discovered: 0,\n analyzed: 0,\n pending: 0,\n failed: 0,\n replied: 0,\n interviews: 0\n },\n workflow: {\n running: false,\n paused: true,\n phase: 'idle',\n statusText: '未开始',\n tasks: [],\n taskIndex: 0,\n cardIndex: 0,\n processedKeys: [],\n retries: 0,\n currentJob: null,\n returnUrl: '',\n returnScrollY: 0,\n pendingApplyId: null,\n activeRunId: null\n },\n pending: [],\n taskRuns: [],\n events: []\n};\n\nexport const today = () => new Date().toISOString().slice(0, 10);\nexport const uniq = (items = []) => [...new Set(items.filter(Boolean))];\nexport const list = value => String(value || '')\n .split(/[,,\\n]/)\n .map(item => item.trim())\n .filter(Boolean);\nexport const safeClone = value => JSON.parse(JSON.stringify(value ?? null));\n"}, "7e303eedb66fd66f0fe17c3e1630a2b248a4d214": {"t": 1785758653.241241, "n": 1462, "h": "4b6a8ffd48891280", "c": "export const DEFAULTS = {\n config: {\n executionMode: 'review',\n dailyTarget: 150,\n discoveryLimit: 0,\n aiLimit: 0,\n minScore: 75,\n targetLocations: [],\n employmentTypes: ['不限'],\n experiences: [],\n degrees: [],\n salary: '不限',\n sendResumeImage: true,\n sendOnlineResume: false,\n betweenJobsSeconds: 12,\n attachmentDelaySeconds: 4,\n model: {\n baseUrl: 'https://api.deepseek.com',\n apiKey: '',\n model: 'deepseek-v4-pro',\n temperature: 0.1\n }\n },\n profile: null,\n profileDraft: null,\n directionPlan: null,\n resumeText: '',\n resumeImage: null,\n resumeSourceFile: null,\n stats: {\n date: '',\n sent: 0,\n discovered: 0,\n analyzed: 0,\n pending: 0,\n failed: 0,\n replied: 0,\n interviews: 0\n },\n workflow: {\n running: false,\n paused: true,\n phase: 'idle',\n statusText: '未开始',\n tasks: [],\n taskIndex: 0,\n cardIndex: 0,\n processedKeys: [],\n retries: 0,\n currentJob: null,\n returnUrl: '',\n returnScrollY: 0,\n pendingApplyId: null,\n activeRunId: null\n },\n pending: [],\n taskRuns: [],\n events: []\n};\n\nexport const today = () => new Date().toISOString().slice(0, 10);\nexport const uniq = (items = []) => [...new Set(items.filter(Boolean))];\nexport const list = value => String(value || '')\n .split(/[,,\\n]/)\n .map(item => item.trim())\n .filter(Boolean);\nexport const safeClone = value => JSON.parse(JSON.stringify(value ?? null));\n"}, "50acc0b6102f67c00c1ef1ce8cd500e3a9386a31": {"t": 1786067531.7415376, "n": 2718, "h": "262a34ca1f783821", "c": "export const DEFAULTS = {\n config: {\n executionMode: 'review',\n batchStrategy: 'precise',\n massApplyAnalysis: 'fast',\n pacingPreset: 'standard',\n dryRun: false,\n dailyTarget: 30,\n discoveryLimit: 150,\n aiLimit: 0,\n minScore: 75,\n targetLocations: [],\n employmentTypes: ['不限'],\n experiences: [],\n degrees: [],\n salary: '不限',\n sendResumeImage: true,\n sendOnlineResume: false,\n betweenJobsSeconds: 9,\n attachmentDelaySeconds: 3,\n maxPerCompanyPerDay: 3,\n queueWarmup: 4,\n maxConsecutiveFailures: 3,\n jitterSeconds: 3,\n companyVerificationEnabled: true,\n companyVerificationProvider: 'bridge',\n companyVerificationCacheDays: 14,\n blockUnknownCompanies: false,\n updateCheckEnabled: true,\n dailyReportEnabled: true,\n dailyReportTime: '20:30',\n dailyReportNotification: true,\n rateLimits: {\n discoveryMs: 1000,\n aiMs: 700,\n companyMs: 1800,\n deliveryMs: 9000,\n attachmentMs: 3000,\n updateMs: 5000\n },\n requireSingleJobValidation: true,\n singleJobValidationCompletedAt: 0,\n model: {\n baseUrl: 'https://api.deepseek.com',\n apiKey: '',\n model: 'deepseek-v4-pro',\n temperature: 0.1\n }\n },\n profile: null,\n profileDraft: null,\n directionPlan: null,\n resumeText: '',\n resumeImage: null,\n resumeSourceFile: null,\n stats: {\n date: '',\n sent: 0,\n discovered: 0,\n analyzed: 0,\n pending: 0,\n failed: 0,\n replied: 0,\n interviews: 0,\n verified: 0,\n blocked: 0,\n duplicates: 0,\n simulated: 0\n },\n workflow: {\n running: false,\n paused: true,\n phase: 'idle',\n statusText: '未开始',\n tasks: [],\n taskIndex: 0,\n cardIndex: 0,\n processedKeys: [],\n retries: 0,\n currentJob: null,\n returnUrl: '',\n returnScrollY: 0,\n pendingApplyId: null,\n activeRunId: null\n },\n pending: [],\n taskRuns: [],\n events: [],\n safetyState: {\n lastActionAt: {},\n consecutiveFailures: 0,\n circuitOpen: false,\n circuitReason: '',\n circuitOpenedAt: 0,\n totalThrottled: 0,\n backoffLevel: 0,\n lastBackoffReason: ''\n },\n companyVerificationCache: {},\n deliveryHistory: [],\n updateInfo: {\n currentVersion: '1.7.0',\n latestVersion: '',\n available: false,\n checkedAt: 0,\n url: 'https://github.com/Chrisbetheking/job-claw/releases',\n error: ''\n }\n};\n\nexport const today = () => new Date().toISOString().slice(0, 10);\nexport const uniq = (items = []) => [...new Set(items.filter(Boolean))];\nexport const list = value => String(value || '')\n .split(/[,,\\n]/)\n .map(item => item.trim())\n .filter(Boolean);\nexport const safeClone = value => JSON.parse(JSON.stringify(value ?? null));\n"}}, "chrome-extension/background.js": {"779028be3432bbaf6737731db581e090428a0c0f": {"t": 1785746566.6769588, "n": 157588, "h": "b62afbe9c8346314", "c": "import { DEFAULTS, safeClone, today, uniq } from './common.js';\nimport { normalizeConversationIdentity, deriveConversationReservationKey, sameRecruiterReservation } from './lib/conversation-identity.js';\nimport { TERMINAL_RUN_STATUSES, taskStageMeta } from './lib/task-state.js';\nimport { rerankPending } from './lib/job-priority.js';\nimport { computeRateLimitDecision, evaluateStrategy, normalizeStrategy, recordRateAction, recordSafetyOutcome, resetSafetyCircuit, strictHardBlocks } from './lib/safety-control.js';\nimport { companyCacheKey, companyVerificationExpired, heuristicCompanyVerification, mergeCompanyVerification } from './lib/company-verifier.js';\nimport { createHistoryEntry, findDuplicate } from './lib/deduplication.js';\nimport { normalizeRelease } from './lib/update-checker.js';\n\nconst BRIDGE_ENDPOINTS = ['http://127.0.0.1:17899', 'http://localhost:17899'];\nconst NATIVE_BRIDGE_HOST = 'com.jobclaw.bridge';\nlet lastBridgeSnapshotAt = 0;\nlet bridgeUnavailableUntil = 0;\nlet bridgeLastError = '';\nconst EXPECTED_CONTENT_VERSION = '1.7.0';\nconst CONTENT_SCRIPT_FILE = 'content-v37.js';\nconst storage = {\n get: keys => chrome.storage.local.get(keys),\n all: () => chrome.storage.local.get(null),\n set: patch => chrome.storage.local.set(patch)\n};\n\nconst debuggerLocks = new Map();\n\nconst OFFSCREEN_DOCUMENT_PATH = 'offscreen.html';\nlet offscreenCreation = null;\n\nasync function ensureClipboardOffscreen() {\n if (!chrome.offscreen?.createDocument) return false;\n const url = chrome.runtime.getURL(OFFSCREEN_DOCUMENT_PATH);\n if (chrome.runtime.getContexts) {\n const contexts = await chrome.runtime.getContexts({\n contextTypes: ['OFFSCREEN_DOCUMENT'],\n documentUrls: [url]\n }).catch(() => []);\n if (contexts?.length) return true;\n }\n if (!offscreenCreation) {\n offscreenCreation = chrome.offscreen.createDocument({\n url: OFFSCREEN_DOCUMENT_PATH,\n reasons: ['CLIPBOARD'],\n justification: '将求职招呼语以真实粘贴方式写入 BOSS 聊天编辑器'\n }).then(() => true).catch(error => {\n const message = String(error?.message || error || '');\n if (/single offscreen|already exists|existing offscreen/i.test(message)) return true;\n throw error;\n }).finally(() => { offscreenCreation = null; });\n }\n return offscreenCreation;\n}\n\nasync function writeClipboardText(text) {\n const value = String(text || '');\n if (!value) return false;\n try {\n const ready = await ensureClipboardOffscreen();\n if (!ready) return false;\n const response = await chrome.runtime.sendMessage({ type: 'JOBCLAW_CLIPBOARD_WRITE', text: value });\n return response?.ok === true;\n } catch {\n return false;\n }\n}\n\nfunction validPoint(point) {\n const x = Number(point?.x);\n const y = Number(point?.y);\n if (!Number.isFinite(x) || !Number.isFinite(y) || x < 0 || y < 0) {\n throw new Error('聊天输入坐标无效');\n }\n return { x, y };\n}\n\nasync function debuggerBringToFront(target) {\n await chrome.debugger.sendCommand(target, 'Page.bringToFront').catch(() => {});\n await chrome.debugger.sendCommand(target, 'Runtime.evaluate', {\n expression: 'window.focus(); true',\n returnByValue: true\n }).catch(() => {});\n}\n\nasync function debuggerNodeAtPoint(target, point) {\n const { x, y } = validPoint(point);\n const result = await chrome.debugger.sendCommand(target, 'DOM.getNodeForLocation', {\n x, y,\n includeUserAgentShadowDOM: true,\n ignorePointerEventsNone: true\n }).catch(() => null);\n return {\n nodeId: Number(result?.nodeId || 0),\n backendNodeId: Number(result?.backendNodeId || 0),\n frameId: String(result?.frameId || '')\n };\n}\n\nasync function debuggerResolveNodeObject(target, node = {}) {\n const args = {};\n if (Number(node.nodeId || 0)) args.nodeId = Number(node.nodeId);\n else if (Number(node.backendNodeId || 0)) args.backendNodeId = Number(node.backendNodeId);\n else return '';\n const resolved = await chrome.debugger.sendCommand(target, 'DOM.resolveNode', args).catch(() => null);\n return String(resolved?.object?.objectId || '');\n}\n\nasync function debuggerReadObjectText(target, objectId = '') {\n if (!objectId) return '';\n const result = await chrome.debugger.sendCommand(target, 'Runtime.callFunctionOn', {\n objectId,\n functionDeclaration: `function() {\n if ('value' in this && !this.isContentEditable) return String(this.value || '');\n return String(this.innerText || this.textContent || '');\n }`,\n returnByValue: true,\n silent: true\n }).catch(() => null);\n return String(result?.result?.value || '');\n}\n\nasync function debuggerReadNodeText(target, node = {}) {\n const objectId = await debuggerResolveNodeObject(target, node);\n return debuggerReadObjectText(target, objectId);\n}\n\nasync function debuggerFindEditableObject(target, message = {}) {\n const selector = String(message.selector || '').trim();\n const point = validPoint(message.point);\n const expression = `(() => {\n const exactSelector = ${JSON.stringify(selector)};\n const point = ${JSON.stringify(point)};\n const editableSelector = 'textarea,input[type=\"text\"],input:not([type]),[contenteditable]:not([contenteditable=\"false\"]),[role=\"textbox\"],[data-slate-editor=\"true\"],[data-lexical-editor=\"true\"]';\n const isEditable = node => {\n if (!node || node.nodeType !== 1) return false;\n const tag = String(node.tagName || '').toLowerCase();\n if (tag === 'textarea') return true;\n if (tag === 'input') {\n const type = String(node.getAttribute('type') || 'text').toLowerCase();\n return ['text', 'search', ''].includes(type);\n }\n const mode = String(node.getAttribute('contenteditable') || '').toLowerCase();\n return node.isContentEditable\n || Boolean(mode && !['false', 'inherit', 'off'].includes(mode))\n || node.getAttribute('role') === 'textbox'\n || node.getAttribute('data-slate-editor') === 'true'\n || node.getAttribute('data-lexical-editor') === 'true';\n };\n const deepQuery = (root, query) => {\n if (!root) return null;\n try {\n const direct = root.querySelector?.(query);\n if (direct) return direct;\n } catch {}\n let elements = [];\n try { elements = [...(root.querySelectorAll?.('*') || [])]; } catch {}\n for (const element of elements) {\n if (element.shadowRoot) {\n const found = deepQuery(element.shadowRoot, query);\n if (found) return found;\n }\n }\n return null;\n };\n const nearestEditable = node => {\n let current = node;\n for (let depth = 0; current && depth < 16; depth += 1) {\n if (isEditable(current)) return current;\n let nested = null;\n try { nested = current.querySelector?.(editableSelector); } catch {}\n if (nested && isEditable(nested)) return nested;\n const root = current.getRootNode?.();\n current = current.parentElement || (root && root.host) || null;\n }\n return null;\n };\n let node = exactSelector ? deepQuery(document, exactSelector) : null;\n node = nearestEditable(node) || node;\n if (!isEditable(node)) {\n let pointNode = null;\n try { pointNode = document.elementFromPoint(point.x, point.y); } catch {}\n node = nearestEditable(pointNode);\n }\n if (!isEditable(node)) return null;\n try { node.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'instant' }); } catch {}\n try { node.focus({ preventScroll: true }); } catch { try { node.focus(); } catch {} }\n if (node.isContentEditable) {\n try {\n const selection = window.getSelection();\n const range = document.createRange();\n range.selectNodeContents(node);\n range.collapse(false);\n selection.removeAllRanges();\n selection.addRange(range);\n } catch {}\n }\n return node;\n })()`;\n const result = await chrome.debugger.sendCommand(target, 'Runtime.evaluate', {\n expression,\n returnByValue: false,\n awaitPromise: false,\n includeCommandLineAPI: false\n }).catch(() => null);\n const objectId = String(result?.result?.objectId || '');\n if (!objectId) return { objectId: '', nodeId: 0, point, selector };\n const requested = await chrome.debugger.sendCommand(target, 'DOM.requestNode', { objectId }).catch(() => null);\n const nodeId = Number(requested?.nodeId || 0);\n return { objectId, nodeId, point, selector };\n}\n\nasync function debuggerFocusObject(target, objectId = '') {\n if (!objectId) return null;\n const result = await chrome.debugger.sendCommand(target, 'Runtime.callFunctionOn', {\n objectId,\n functionDeclaration: `function() {\n try { this.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'instant' }); } catch {}\n try { this.focus({ preventScroll: true }); } catch { try { this.focus(); } catch {} }\n if (this.isContentEditable) {\n try {\n const selection = window.getSelection();\n const range = document.createRange();\n range.selectNodeContents(this);\n range.collapse(false);\n selection.removeAllRanges();\n selection.addRange(range);\n } catch {}\n }\n const rect = this.getBoundingClientRect();\n return { left: rect.left, top: rect.top, width: rect.width, height: rect.height, tag: this.tagName || '', id: this.id || '' };\n }`,\n returnByValue: true,\n silent: true\n }).catch(() => null);\n return result?.result?.value || null;\n}\n\nasync function debuggerPasteClipboard(target) {\n await chrome.debugger.sendCommand(target, 'Input.dispatchKeyEvent', {\n type: 'rawKeyDown',\n key: 'v',\n code: 'KeyV',\n windowsVirtualKeyCode: 86,\n nativeVirtualKeyCode: 86,\n modifiers: 4,\n commands: ['Paste']\n });\n await chrome.debugger.sendCommand(target, 'Input.dispatchKeyEvent', {\n type: 'keyUp',\n key: 'v',\n code: 'KeyV',\n windowsVirtualKeyCode: 86,\n nativeVirtualKeyCode: 86,\n modifiers: 4\n });\n}\n\nasync function withTabDebugger(tabId, operation) {\n if (!Number.isInteger(Number(tabId))) throw new Error('无法确定当前 BOSS 标签页');\n const id = Number(tabId);\n const previous = debuggerLocks.get(id) || Promise.resolve();\n let release;\n const current = new Promise(resolve => { release = resolve; });\n const queueEntry = previous.then(() => current);\n debuggerLocks.set(id, queueEntry);\n await previous;\n const target = { tabId: id };\n let attached = false;\n try {\n await chrome.debugger.attach(target, '1.3');\n attached = true;\n await debuggerBringToFront(target);\n return await operation(target);\n } catch (error) {\n const message = String(error?.message || error || '');\n if (/Another debugger is already attached|Cannot attach to this target|DevTools/i.test(message)) {\n throw new Error('Chrome 调试通道正被 DevTools 或其他工具占用,请关闭该 BOSS 页面的开发者工具后重试');\n }\n throw error;\n } finally {\n if (attached) await chrome.debugger.detach(target).catch(() => {});\n release?.();\n if (debuggerLocks.get(id) === queueEntry) debuggerLocks.delete(id);\n }\n}\n\nasync function debuggerMouseClick(target, point) {\n const { x, y } = validPoint(point);\n await chrome.debugger.sendCommand(target, 'Input.dispatchMouseEvent', {\n type: 'mouseMoved', x, y, button: 'none', buttons: 0\n });\n await chrome.debugger.sendCommand(target, 'Input.dispatchMouseEvent', {\n type: 'mousePressed', x, y, button: 'left', buttons: 1, clickCount: 1\n });\n await chrome.debugger.sendCommand(target, 'Input.dispatchMouseEvent', {\n type: 'mouseReleased', x, y, button: 'left', buttons: 0, clickCount: 1\n });\n}\n\nasync function debuggerDocumentNode(target) {\n const result = await chrome.debugger.sendCommand(target, 'DOM.getDocument', {\n depth: -1,\n pierce: true\n });\n const nodeId = Number(result?.root?.nodeId || 0);\n if (!nodeId) throw new Error('无法读取当前 BOSS 页面 DOM');\n return nodeId;\n}\n\nasync function debuggerNodeBySelector(target, selector) {\n const normalized = String(selector || '').trim();\n if (!normalized) return 0;\n const rootNodeId = await debuggerDocumentNode(target);\n const result = await chrome.debugger.sendCommand(target, 'DOM.querySelector', {\n nodeId: rootNodeId,\n selector: normalized\n });\n return Number(result?.nodeId || 0);\n}\n\nasync function debuggerFocusTarget(target, message = {}) {\n await debuggerBringToFront(target);\n\n // 必须优先锁定 content script 临时标记的真实编辑节点。旧版先取坐标节点,\n // BOSS 页面上经常命中编辑器外壳/透明层,导致看似有光标但文字没有进入编辑器。\n const exact = await debuggerFindEditableObject(target, message).catch(() => ({ objectId: '', nodeId: 0 }));\n if (exact.objectId) {\n const rect = await debuggerFocusObject(target, exact.objectId);\n if (rect && Number(rect.width) > 0 && Number(rect.height) > 0) {\n const clickPoint = {\n x: Math.round(Number(rect.left) + Math.max(8, Math.min(Number(rect.width) - 8, Number(rect.width) * 0.5))),\n y: Math.round(Number(rect.top) + Math.max(8, Math.min(Number(rect.height) - 8, Number(rect.height) * 0.5)))\n };\n await debuggerMouseClick(target, clickPoint).catch(() => {});\n await debuggerFocusObject(target, exact.objectId);\n return { method: 'exact-selector-object-focus+click', ...exact, point: clickPoint, rect };\n }\n return { method: 'exact-selector-object-focus', ...exact, rect };\n }\n\n const pointNode = await debuggerNodeAtPoint(target, message.point).catch(() => ({ nodeId: 0, backendNodeId: 0 }));\n if (pointNode.nodeId || pointNode.backendNodeId) {\n const args = pointNode.nodeId ? { nodeId: pointNode.nodeId } : { backendNodeId: pointNode.backendNodeId };\n await chrome.debugger.sendCommand(target, 'DOM.focus', args).catch(() => {});\n await debuggerMouseClick(target, message.point).catch(() => {});\n const objectId = await debuggerResolveNodeObject(target, pointNode).catch(() => '');\n return { method: 'point-node-focus+click', ...pointNode, objectId };\n }\n\n await debuggerMouseClick(target, message.point);\n return { method: 'coordinate-click', nodeId: 0, objectId: '' };\n}\n\nasync function debuggerClickTarget(target, message = {}) {\n const selector = String(message.selector || '').trim();\n if (selector) {\n const nodeId = await debuggerNodeBySelector(target, selector);\n if (nodeId) {\n await chrome.debugger.sendCommand(target, 'DOM.scrollIntoViewIfNeeded', { nodeId }).catch(() => {});\n const model = await chrome.debugger.sendCommand(target, 'DOM.getBoxModel', { nodeId });\n const quad = model?.model?.content || model?.model?.border || [];\n if (Array.isArray(quad) && quad.length >= 8) {\n const xs = [quad[0], quad[2], quad[4], quad[6]].map(Number);\n const ys = [quad[1], quad[3], quad[5], quad[7]].map(Number);\n await debuggerMouseClick(target, {\n x: xs.reduce((sum, value) => sum + value, 0) / xs.length,\n y: ys.reduce((sum, value) => sum + value, 0) / ys.length\n });\n return { method: 'dom-box-click', nodeId };\n }\n }\n }\n await debuggerMouseClick(target, message.point);\n return { method: 'coordinate-click', nodeId: 0 };\n}\n\nasync function debuggerReadTargetText(target, selector) {\n const normalized = String(selector || '').trim();\n if (!normalized) return '';\n const expression = `(() => {\n const element = document.querySelector(${JSON.stringify(normalized)});\n if (!element) return '';\n if ('value' in element) return String(element.value || '');\n return String(element.innerText || element.textContent || '');\n })()`;\n const result = await chrome.debugger.sendCommand(target, 'Runtime.evaluate', {\n expression,\n returnByValue: true,\n awaitPromise: false\n });\n return String(result?.result?.value || '');\n}\n\nasync function debuggerSelectAll(target) {\n await chrome.debugger.sendCommand(target, 'Input.dispatchKeyEvent', {\n type: 'rawKeyDown', key: 'a', code: 'KeyA', windowsVirtualKeyCode: 65,\n nativeVirtualKeyCode: 65, modifiers: 4, commands: ['SelectAll']\n });\n await chrome.debugger.sendCommand(target, 'Input.dispatchKeyEvent', {\n type: 'keyUp', key: 'a', code: 'KeyA', windowsVirtualKeyCode: 65,\n nativeVirtualKeyCode: 65, modifiers: 4\n });\n}\n\nasync function debuggerPressEnter(target) {\n // 与 Selenium/Playwright 的真实 Enter 等价:rawKeyDown 携带回车文本后再 keyUp。\n await chrome.debugger.sendCommand(target, 'Input.dispatchKeyEvent', {\n type: 'rawKeyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13,\n nativeVirtualKeyCode: 13, text: '\\r', unmodifiedText: '\\r'\n });\n await chrome.debugger.sendCommand(target, 'Input.dispatchKeyEvent', {\n type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13,\n nativeVirtualKeyCode: 13\n });\n}\n\nasync function debuggerTypeText(target, text) {\n const value = String(text || '');\n for (const character of value) {\n await chrome.debugger.sendCommand(target, 'Input.dispatchKeyEvent', {\n type: 'char',\n text: character,\n unmodifiedText: character\n });\n await new Promise(resolve => setTimeout(resolve, 12));\n }\n}\n\nasync function mainWorldWriteChatText(tabId, selector, text) {\n const normalizedSelector = String(selector || '').trim();\n const desired = String(text || '');\n if (!normalizedSelector || !desired.trim()) throw new Error('聊天输入目标或招呼语为空');\n const results = await chrome.scripting.executeScript({\n target: { tabId: Number(tabId) },\n world: 'MAIN',\n func: (exactSelector, nextText) => {\n const normalizeText = value => String(value || '').replace(/\\s+/g, '').trim();\n const isEditable = node => {\n if (!node || typeof node.matches !== 'function') return false;\n const tag = String(node.tagName || '').toLowerCase();\n if (tag === 'textarea') return true;\n if (tag === 'input') {\n const type = String(node.getAttribute('type') || 'text').toLowerCase();\n return ['text', 'search', ''].includes(type);\n }\n const editableMode = String(node.getAttribute('contenteditable') || '').toLowerCase();\n return node.isContentEditable\n || Boolean(editableMode && !['false', 'inherit', 'off'].includes(editableMode))\n || node.getAttribute('role') === 'textbox'\n || node.getAttribute('data-slate-editor') === 'true'\n || node.getAttribute('data-lexical-editor') === 'true';\n };\n const deepQuery = (rootNode, query) => {\n if (!rootNode) return null;\n try {\n const direct = rootNode.querySelector?.(query);\n if (direct) return direct;\n } catch {}\n let elements = [];\n try { elements = [...(rootNode.querySelectorAll?.('*') || [])]; } catch {}\n for (const element of elements) {\n if (element.shadowRoot) {\n const found = deepQuery(element.shadowRoot, query);\n if (found) return found;\n }\n }\n return null;\n };\n const root = deepQuery(document, exactSelector);\n if (!root) return { ok: false, reason: 'selector-not-found' };\n const editor = isEditable(root)\n ? root\n : [...root.querySelectorAll('textarea,input[type=\"text\"],input:not([type]),[contenteditable]:not([contenteditable=\"false\"]),[role=\"textbox\"],[data-slate-editor=\"true\"],[data-lexical-editor=\"true\"]')].find(isEditable);\n if (!editor) return { ok: false, reason: 'editable-not-found', rootTag: String(root.tagName || '') };\n\n const readValue = () => {\n if ('value' in editor && !editor.isContentEditable) return String(editor.value || '');\n return String(editor.innerText || editor.textContent || '');\n };\n const dispatchInput = (inputType, data) => {\n try {\n editor.dispatchEvent(new InputEvent('input', {\n bubbles: true,\n cancelable: false,\n composed: true,\n inputType,\n data\n }));\n } catch {\n editor.dispatchEvent(new Event('input', { bubbles: true, composed: true }));\n }\n };\n const dispatchBeforeInput = (inputType, data) => {\n try {\n return editor.dispatchEvent(new InputEvent('beforeinput', {\n bubbles: true,\n cancelable: true,\n composed: true,\n inputType,\n data\n }));\n } catch {\n return true;\n }\n };\n\n editor.scrollIntoView?.({ block: 'nearest', inline: 'nearest', behavior: 'instant' });\n try { HTMLElement.prototype.focus.call(editor); } catch { editor.focus?.(); }\n const previous = readValue();\n const tag = String(editor.tagName || '').toLowerCase();\n let method = '';\n\n if ((tag === 'textarea' || tag === 'input') && 'value' in editor) {\n const proto = tag === 'textarea' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;\n const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;\n dispatchBeforeInput('deleteByCut', null);\n if (setter) setter.call(editor, ''); else editor.value = '';\n dispatchInput('deleteByCut', null);\n const tracker = editor._valueTracker;\n if (tracker?.setValue) tracker.setValue(previous);\n dispatchBeforeInput('insertText', nextText);\n if (setter) setter.call(editor, nextText); else editor.value = nextText;\n dispatchInput('insertText', nextText);\n editor.dispatchEvent(new Event('change', { bubbles: true, composed: true }));\n method = 'main-native-value-setter';\n } else {\n const selection = window.getSelection?.();\n if (selection && document.createRange) {\n const range = document.createRange();\n range.selectNodeContents(editor);\n selection.removeAllRanges();\n selection.addRange(range);\n }\n dispatchBeforeInput('deleteContentBackward', null);\n let inserted = false;\n try {\n const transfer = new DataTransfer();\n transfer.setData('text/plain', nextText);\n const pasteEvent = new ClipboardEvent('paste', {\n bubbles: true,\n cancelable: true,\n composed: true,\n clipboardData: transfer\n });\n editor.dispatchEvent(pasteEvent);\n inserted = normalizeText(readValue()).includes(normalizeText(nextText));\n } catch {\n inserted = false;\n }\n if (!inserted) {\n try {\n inserted = Boolean(document.execCommand?.('insertText', false, nextText));\n } catch {\n inserted = false;\n }\n }\n if (!inserted || !normalizeText(readValue()).includes(normalizeText(nextText))) {\n try {\n editor.replaceChildren(document.createTextNode(nextText));\n } catch {\n editor.textContent = nextText;\n }\n }\n dispatchInput('insertText', nextText);\n editor.dispatchEvent(new Event('change', { bubbles: true, composed: true }));\n try {\n editor.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true, composed: true, data: nextText }));\n } catch {}\n method = inserted ? 'main-exec-command' : 'main-contenteditable-replace';\n }\n\n try { HTMLElement.prototype.focus.call(editor); } catch { editor.focus?.(); }\n const actual = readValue();\n const compactExpected = normalizeText(nextText);\n const compactActual = normalizeText(actual);\n return {\n ok: Boolean(compactActual && compactActual.includes(compactExpected)),\n method,\n actual,\n tag,\n id: String(editor.id || ''),\n className: String(editor.className || '').slice(0, 180),\n contenteditable: String(editor.getAttribute?.('contenteditable') || ''),\n role: String(editor.getAttribute?.('role') || '')\n };\n },\n args: [normalizedSelector, desired]\n });\n return results?.[0]?.result || { ok: false, reason: 'main-world-no-result' };\n}\n\nasync function trustedChatInput(tabId, message = {}) {\n const action = String(message.action || '');\n if (action === 'replaceText' || action === 'replaceTextAndEnter') {\n const text = String(message.text || '');\n if (!text.trim()) throw new Error('待输入的招呼语为空');\n const compactExpected = text.replace(/\\s+/g, '');\n\n // 写入和 Enter 必须在同一次 debugger 会话、同一个精确编辑节点上完成。\n // 这是 Selenium sendKeys / Playwright locator.type + Enter 的同类浏览器级路径,\n // 避免旧版“聚焦了外壳、文字没有进入真实编辑器”的问题。\n return withTabDebugger(tabId, async target => {\n let focus = await debuggerFocusTarget(target, message);\n if (!focus.objectId) throw new Error('没有定位到 BOSS 真实聊天编辑节点');\n\n const readExact = async () => debuggerReadObjectText(target, focus.objectId);\n const refocus = async () => {\n const refreshed = await debuggerFocusTarget(target, message);\n if (refreshed.objectId) focus = refreshed;\n if (!focus.objectId) throw new Error('BOSS 聊天编辑节点已失效');\n return focus;\n };\n const matches = value => {\n const compact = String(value || '').replace(/\\s+/g, '');\n return Boolean(compact && (compact === compactExpected || compact.includes(compactExpected)));\n };\n\n let actual = '';\n let writeMethod = '';\n\n // 1) 精确对象聚焦后使用 CDP Input.insertText,最接近真实键盘输入。\n await debuggerSelectAll(target);\n await chrome.debugger.sendCommand(target, 'Input.insertText', { text });\n await new Promise(resolve => setTimeout(resolve, 700));\n actual = await readExact();\n writeMethod = 'exact-cdp-insert-text';\n\n // 2) 某些富文本编辑器只响应原生 paste。\n if (!matches(actual) && await writeClipboardText(text)) {\n await refocus();\n await debuggerSelectAll(target);\n await debuggerPasteClipboard(target);\n await new Promise(resolve => setTimeout(resolve, 900));\n actual = await readExact();\n writeMethod = 'exact-cdp-clipboard-paste';\n }\n\n // 3) React/Vue 受控编辑器可能需要主世界 setter/beforeinput/input。\n if (!matches(actual)) {\n const mainWrite = await mainWorldWriteChatText(tabId, message.selector, text).catch(error => ({\n ok: false,\n reason: String(error?.message || error || 'main-world-write-failed')\n }));\n await refocus();\n await new Promise(resolve => setTimeout(resolve, 650));\n actual = await readExact();\n writeMethod = mainWrite?.method || 'main-world-write';\n }\n\n // 4) 最后兜底为逐字符可信键盘输入,仍锁定同一个真实节点。\n if (!matches(actual)) {\n await refocus();\n await debuggerSelectAll(target);\n await debuggerTypeText(target, text);\n await new Promise(resolve => setTimeout(resolve, 1000));\n actual = await readExact();\n writeMethod = 'exact-cdp-keyboard-type';\n }\n\n if (!matches(actual)) {\n throw new Error(`文字没有进入 BOSS 真实聊天编辑器;实际长度=${String(actual || '').length}`);\n }\n\n if (action === 'replaceTextAndEnter') {\n await refocus();\n // 给 BOSS 受控状态一次提交渲染机会,再发送真实 Enter。\n await new Promise(resolve => setTimeout(resolve, 520));\n await debuggerPressEnter(target);\n }\n\n return {\n ok: true,\n action,\n length: text.length,\n insertedBeforeSend: true,\n writeMethod,\n actualLength: String(actual || '').length,\n focusMethod: focus.method,\n editor: {\n nodeId: Number(focus.nodeId || 0),\n selector: String(message.selector || '')\n }\n };\n });\n }\n\n return withTabDebugger(tabId, async target => {\n if (action === 'pressEnter') {\n const focus = await debuggerFocusTarget(target, message);\n if (!focus.objectId) throw new Error('没有定位到 BOSS 真实聊天编辑节点');\n await new Promise(resolve => setTimeout(resolve, 320));\n await debuggerPressEnter(target);\n return { ok: true, action, focusMethod: focus.method };\n }\n if (action === 'click') {\n const click = await debuggerClickTarget(target, message);\n return { ok: true, action, clickMethod: click.method };\n }\n throw new Error('不支持的可信输入动作');\n });\n}\n\nasync function init() {\n const current = await storage.all();\n const patch = {};\n for (const [key, value] of Object.entries(DEFAULTS)) {\n if (current[key] === undefined) patch[key] = value;\n }\n if (!current.ui9ModeMigration) {\n const currentConfig = current.config || {};\n patch.config = {\n ...DEFAULTS.config,\n ...currentConfig,\n executionMode: currentConfig.executionMode === 'auto' ? 'auto' : 'review',\n dailyTarget: Number(currentConfig.dailyTarget || 0) === 40 ? 150 : Number(currentConfig.dailyTarget || 150)\n };\n patch.ui9ModeMigration = true;\n }\n if (!current.ui10UnlimitedV4Migration) {\n const baseConfig = patch.config || current.config || {};\n const baseModel = { ...DEFAULTS.config.model, ...(baseConfig.model || {}) };\n const baseUrl = String(baseModel.baseUrl || DEFAULTS.config.model.baseUrl);\n const modelName = String(baseModel.model || '').trim();\n const isDeepSeek = /deepseek\\.com/i.test(baseUrl);\n const legacyDeepSeekModel = !modelName || ['deepseek-chat', 'deepseek-reasoner', 'deepseek-v4'].includes(modelName);\n patch.config = {\n ...DEFAULTS.config,\n ...baseConfig,\n discoveryLimit: 0,\n model: {\n ...baseModel,\n model: isDeepSeek && legacyDeepSeekModel ? 'deepseek-v4-pro' : (modelName || 'deepseek-v4-pro')\n }\n };\n patch.ui10UnlimitedV4Migration = true;\n }\n if (!current.ui20ReliableSendMigration) {\n const baseConfig = patch.config || current.config || {};\n patch.config = {\n ...DEFAULTS.config,\n ...baseConfig,\n betweenJobsSeconds: Math.max(8, Math.min(30, Number(baseConfig.betweenJobsSeconds || 8))),\n attachmentDelaySeconds: Math.max(1, Math.min(10, Number(baseConfig.attachmentDelaySeconds || 2)))\n };\n patch.ui20ReliableSendMigration = true;\n }\n if (!current.ui21ConversationBindingMigration) {\n patch.chatTransition = null;\n patch.chatDeliveryLedger = current.chatDeliveryLedger && typeof current.chatDeliveryLedger === 'object'\n ? current.chatDeliveryLedger\n : {};\n patch.ui21ConversationBindingMigration = true;\n }\n if (!current.ui22StrictTranscriptMigration) {\n const baseConfig = patch.config || current.config || {};\n patch.config = {\n ...DEFAULTS.config,\n ...baseConfig,\n betweenJobsSeconds: Math.max(12, Math.min(30, Number(baseConfig.betweenJobsSeconds || 12))),\n attachmentDelaySeconds: Math.max(4, Math.min(10, Number(baseConfig.attachmentDelaySeconds || 4)))\n };\n patch.ui22StrictTranscriptMigration = true;\n }\n if (!current.ui24VerifiedConversationMigration) {\n patch.chatTransition = null;\n patch.ui24VerifiedConversationMigration = true;\n }\n if (!current.ui27TrustedInputMigration) {\n const baseWorkflow = patch.workflow || current.workflow || {};\n patch.workflow = {\n ...DEFAULTS.workflow,\n ...baseWorkflow,\n running: false,\n paused: true,\n phase: 'idle',\n pendingApplyId: null,\n activeRunId: null,\n statusText: '可信发送引擎已升级,请重新开始任务'\n };\n patch.chatTransition = null;\n patch.ui27TrustedInputMigration = true;\n }\n if (!current.ui12ProfileDraftMigration) {\n let migratedProfile = current.profile && typeof current.profile === 'object' ? current.profile : null;\n let migratedDraft = current.profileDraft && typeof current.profileDraft === 'object'\n ? normalizeProfileDraft(current.profileDraft)\n : null;\n if (!profileDraftHasAny(migratedDraft) && profileHasCore(migratedProfile)) {\n migratedDraft = profileToDraft(migratedProfile, 'migration');\n }\n if (!profileDraftHasCore(migratedDraft) && String(current.resumeText || '').trim().length >= 30) {\n try {\n migratedProfile = buildLocalProfile(current.resumeText || '');\n migratedProfile.generation.mode = 'local-recovery';\n migratedProfile.generation.label = '本地初稿';\n migratedProfile.generation.warning = '已从保存的简历恢复初稿。';\n migratedDraft = profileToDraft(migratedProfile, 'recovery');\n patch.profile = migratedProfile;\n } catch {\n // 简历内容不足时只保留现有数据,不生成空画像。\n }\n }\n if (profileDraftHasAny(migratedDraft)) patch.profileDraft = migratedDraft;\n patch.ui12ProfileDraftMigration = true;\n }\n if (!current.ui37DirectionPlanMigration) {\n const profileForPlan = patch.profile || current.profile || null;\n const existingPlan = current.directionPlan && typeof current.directionPlan === 'object'\n ? current.directionPlan\n : null;\n if (profileHasCore(profileForPlan)) {\n patch.directionPlan = buildDirectionPlan(profileForPlan, existingPlan, {\n confirmed: Boolean(existingPlan?.confirmed),\n preserveSelections: true,\n preserveEdits: true,\n preserveCustom: true\n });\n } else if (existingPlan) {\n patch.directionPlan = normalizeDirectionPlan(existingPlan, profileForPlan);\n }\n patch.ui37DirectionPlanMigration = true;\n }\n if (!current.ui18TaskProgressMigration) {\n const migratedPending = Array.isArray(current.pending) ? current.pending.map(entry => ({ ...entry })) : [];\n const migratedRuns = Array.isArray(current.taskRuns) ? current.taskRuns.map(run => normalizeTaskRun(run, run)) : [];\n for (let index = 0; index < migratedPending.length; index += 1) {\n const item = migratedPending[index];\n let run = migratedRuns.find(entry => entry.id === item.runId || entry.pendingId === item.id);\n if (!run) {\n const statusMap = {\n pending: ['waiting_review', 'waiting_review', 60],\n approved: ['running', 'queued', 64],\n approved_queue: ['queued', 'queued', 64],\n sent: ['success', 'success', 100],\n failed: ['failed', 'failed', 100],\n rejected: ['ignored', 'ignored', 100]\n };\n const [status, stage, progress] = statusMap[item.status] || ['skipped', 'skipped', 100];\n run = normalizeTaskRun({\n id: crypto.randomUUID(),\n pendingId: item.id,\n job: item.job,\n analysis: item.analysis,\n searchTask: item.task,\n status,\n stage,\n progress,\n error: item.error || '',\n retryable: item.status === 'failed',\n createdAt: item.createdAt || Date.now(),\n completedAt: ['sent', 'failed', 'rejected'].includes(item.status) ? (item.completedAt || item.rejectedAt || Date.now()) : null\n });\n migratedRuns.push(run);\n }\n migratedPending[index] = { ...item, runId: run.id };\n }\n const workflow = { ...DEFAULTS.workflow, ...(current.workflow || {}) };\n workflow.tasks = (workflow.tasks || []).map((task, index) => ({\n ...task,\n id: task.id || crypto.randomUUID(),\n status: task.status || (index < Number(workflow.taskIndex || 0) ? 'completed' : index === Number(workflow.taskIndex || 0) && workflow.running ? 'running' : 'pending'),\n progress: Number.isFinite(Number(task.progress)) ? Number(task.progress) : (index < Number(workflow.taskIndex || 0) ? 100 : 0),\n stageLabel: task.stageLabel || (index < Number(workflow.taskIndex || 0) ? '已完成' : '等待开始'),\n processed: Number(task.processed || 0),\n discovered: Number(task.discovered || 0),\n analyzed: Number(task.analyzed || 0),\n failed: Number(task.failed || 0)\n }));\n patch.pending = migratedPending;\n patch.taskRuns = migratedRuns;\n patch.workflow = workflow;\n patch.ui18TaskProgressMigration = true;\n }\n if (!current.ui20GreetingLockMigration) {\n const basePending = Array.isArray(patch.pending) ? patch.pending : (Array.isArray(current.pending) ? current.pending : []);\n patch.pending = basePending.map(item => ({\n ...item,\n deliveryGreeting: String(item.deliveryGreeting || item.analysis?.greeting || '').trim()\n }));\n patch.ui20GreetingLockMigration = true;\n }\n if (!current.ui30AtomicSendSortMigration) {\n const basePending = Array.isArray(patch.pending) ? patch.pending : (Array.isArray(current.pending) ? current.pending : []);\n patch.pending = rerankPending(basePending);\n const baseWorkflow = patch.workflow || current.workflow || {};\n patch.workflow = {\n ...DEFAULTS.workflow,\n ...baseWorkflow,\n running: false,\n paused: true,\n pendingApplyId: null,\n activeRunId: null,\n statusText: '投递引擎与岗位自动排序已升级,请重新开始'\n };\n patch.chatTransition = null;\n patch.ui30AtomicSendSortMigration = true;\n }\n if (!current.ui31ConversationSelectionMigration) {\n const basePending = Array.isArray(patch.pending) ? patch.pending : (Array.isArray(current.pending) ? current.pending : []);\n patch.pending = rerankPending(basePending);\n const baseWorkflow = patch.workflow || current.workflow || {};\n patch.workflow = {\n ...DEFAULTS.workflow,\n ...baseWorkflow,\n running: false,\n paused: true,\n pendingApplyId: null,\n activeRunId: null,\n statusText: 'HR 会话选择与身份核对已升级,请重新开始'\n };\n const ledger = current.chatDeliveryLedger && typeof current.chatDeliveryLedger === 'object' ? current.chatDeliveryLedger : {};\n patch.chatDeliveryLedger = Object.fromEntries(Object.entries(ledger).filter(([, entry]) => entry?.status === 'sent'));\n patch.chatTransition = null;\n patch.ui31ConversationSelectionMigration = true;\n }\n if (!current.ui35ConversationKeyMigration) {\n const baseWorkflow = patch.workflow || current.workflow || {};\n patch.workflow = {\n ...DEFAULTS.workflow,\n ...baseWorkflow,\n running: false,\n paused: true,\n pendingApplyId: null,\n activeRunId: null,\n statusText: 'HR 会话锁已升级,请重新开始'\n };\n const ledger = current.chatDeliveryLedger && typeof current.chatDeliveryLedger === 'object' ? current.chatDeliveryLedger : {};\n patch.chatDeliveryLedger = Object.fromEntries(Object.entries(ledger).filter(([, entry]) => entry?.status === 'sent'));\n patch.chatTransition = null;\n patch.ui35ConversationKeyMigration = true;\n }\n if (!current.v130SingleValidationMigration) {\n const baseConfig = patch.config || current.config || {};\n const alreadySuccessful = Number(current.stats?.sent || 0) > 0\n || Number(baseConfig.singleJobValidationCompletedAt || 0) > 0;\n patch.config = {\n ...DEFAULTS.config,\n ...baseConfig,\n requireSingleJobValidation: baseConfig.requireSingleJobValidation !== false,\n singleJobValidationCompletedAt: alreadySuccessful\n ? Number(baseConfig.singleJobValidationCompletedAt || Date.now())\n : 0\n };\n patch.v130SingleValidationMigration = true;\n }\n if (!current.v170SafetyIntelligenceMigration) {\n const baseConfig = patch.config || current.config || {};\n const legacyTarget = Number(baseConfig.dailyTarget || 0);\n const legacyDiscovery = Number(baseConfig.discoveryLimit || 0);\n patch.config = {\n ...DEFAULTS.config,\n ...baseConfig,\n batchStrategy: normalizeStrategy(baseConfig.batchStrategy),\n dryRun: Boolean(baseConfig.dryRun),\n dailyTarget: legacyTarget <= 0 ? 30 : Math.min(150, legacyTarget),\n discoveryLimit: legacyDiscovery <= 0 ? 150 : Math.min(800, legacyDiscovery),\n betweenJobsSeconds: Math.max(6, Number(baseConfig.betweenJobsSeconds || 9)),\n maxPerCompanyPerDay: Math.max(1, Math.min(12, Number(baseConfig.maxPerCompanyPerDay || 3))),\n maxConsecutiveFailures: Math.max(1, Math.min(10, Number(baseConfig.maxConsecutiveFailures || 3))),\n jitterSeconds: Math.max(0, Math.min(15, Number(baseConfig.jitterSeconds ?? 3))),\n companyVerificationEnabled: baseConfig.companyVerificationEnabled !== false,\n companyVerificationProvider: String(baseConfig.companyVerificationProvider || 'bridge'),\n companyVerificationCacheDays: Math.max(1, Math.min(90, Number(baseConfig.companyVerificationCacheDays || 14))),\n blockUnknownCompanies: Boolean(baseConfig.blockUnknownCompanies),\n updateCheckEnabled: baseConfig.updateCheckEnabled !== false,\n rateLimits: { ...DEFAULTS.config.rateLimits, ...(baseConfig.rateLimits || {}) }\n };\n patch.safetyState = { ...DEFAULTS.safetyState, ...(current.safetyState || {}) };\n patch.companyVerificationCache = current.companyVerificationCache && typeof current.companyVerificationCache === 'object' ? current.companyVerificationCache : {};\n patch.deliveryHistory = Array.isArray(current.deliveryHistory) ? current.deliveryHistory : [];\n patch.updateInfo = { ...DEFAULTS.updateInfo, ...(current.updateInfo || {}), currentVersion: '1.7.0' };\n patch.workflow = {\n ...DEFAULTS.workflow,\n ...(patch.workflow || current.workflow || {}),\n running: false,\n paused: true,\n pendingApplyId: null,\n activeRunId: null,\n statusText: 'v1.7 安全投递引擎已升级 请检查限速和企业核验设置后重新开始'\n };\n patch.v170SafetyIntelligenceMigration = true;\n }\n if (!current.v170FormalReleaseMigration) {\n const baseConfig = patch.config || current.config || {};\n const migratedStrategy = normalizeStrategy(baseConfig.batchStrategy);\n patch.config = {\n ...DEFAULTS.config,\n ...baseConfig,\n batchStrategy: migratedStrategy,\n massApplyAnalysis: ['fast', 'ai'].includes(baseConfig.massApplyAnalysis) ? baseConfig.massApplyAnalysis : 'fast',\n pacingPreset: ['conservative', 'standard', 'efficient', 'custom'].includes(baseConfig.pacingPreset) ? baseConfig.pacingPreset : 'standard',\n dailyTarget: Math.max(1, Math.min(150, Number(baseConfig.dailyTarget || 30))),\n discoveryLimit: Math.max(1, Math.min(800, Number(baseConfig.discoveryLimit || 150))),\n betweenJobsSeconds: Math.max(6, Math.min(120, Number(baseConfig.betweenJobsSeconds || 9))),\n attachmentDelaySeconds: Math.max(1.5, Math.min(15, Number(baseConfig.attachmentDelaySeconds || 3))),\n maxPerCompanyPerDay: Math.max(1, Math.min(12, Number(baseConfig.maxPerCompanyPerDay || 3))),\n queueWarmup: Math.max(1, Math.min(10, Number(baseConfig.queueWarmup || 4))),\n dailyReportEnabled: baseConfig.dailyReportEnabled !== false,\n dailyReportTime: /^\\d{2}:\\d{2}$/.test(String(baseConfig.dailyReportTime || '')) ? String(baseConfig.dailyReportTime) : '20:30',\n dailyReportNotification: baseConfig.dailyReportNotification !== false,\n rateLimits: { ...DEFAULTS.config.rateLimits, ...(baseConfig.rateLimits || {}) }\n };\n patch.safetyState = { ...DEFAULTS.safetyState, ...(current.safetyState || {}) };\n patch.v170FormalReleaseMigration = true;\n }\n if (Object.keys(patch).length) await storage.set(patch);\n const { stats } = await storage.get('stats');\n if (!stats || stats.date !== today()) {\n await storage.set({ stats: { ...DEFAULTS.stats, date: today() } });\n }\n await refreshBossTabsForRuntimeVersion();\n await chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => {});\n chrome.alarms.create('jobclaw-tick', { periodInMinutes: 1 });\n chrome.alarms.create('jobclaw-update-check', { periodInMinutes: 360 });\n checkForUpdates(false).catch(() => {});\n syncBridgeSnapshot(true).catch(() => {});\n}\n\nfunction publicState(all) {\n const state = safeClone(all);\n if (state?.config?.model) {\n state.config.model.apiKey = state.config.model.apiKey ? '***' : '';\n }\n if (state?.resumeImage) state.resumeImage = '[已保存]';\n if (state?.resumeSourceFile) {\n const { name, type, size, lastModified } = state.resumeSourceFile;\n state.resumeSourceFile = { name, type, size, lastModified, stored: true };\n }\n return state;\n}\n\nasync function writeEvent(level, message, data = {}) {\n const { events = [] } = await storage.get('events');\n const event = { id: crypto.randomUUID(), ts: Date.now(), level, message, data };\n events.unshift(event);\n await storage.set({ events: events.slice(0, 300) });\n bridge('/sync', { event }).catch(() => {});\n return event;\n}\n\nasync function patchWorkflow(patch) {\n const { workflow } = await storage.get('workflow');\n const next = { ...DEFAULTS.workflow, ...(workflow || {}), ...(patch || {}) };\n await storage.set({ workflow: next });\n return next;\n}\n\nasync function changeStats(delta = {}) {\n const { stats } = await storage.get('stats');\n const next = { ...DEFAULTS.stats, ...(stats || {}), date: today() };\n for (const [key, value] of Object.entries(delta)) {\n next[key] = Math.max(0, Number(next[key] || 0) + Number(value || 0));\n }\n await storage.set({ stats: next });\n return next;\n}\n\n\nfunction runJobKey(job = {}) {\n return String(job.jobId || job.encryptJobId || job.url || [job.title, job.company, job.location].filter(Boolean).join('|') || '').trim();\n}\n\nfunction normalizeTaskRun(input = {}, existing = {}) {\n const now = Date.now();\n const stage = String(input.stage || existing.stage || 'discovered');\n const meta = taskStageMeta(stage, input.stageLabel, input.progress);\n const status = String(input.status || existing.status || 'running');\n const terminal = TERMINAL_RUN_STATUSES.has(status);\n return {\n ...existing,\n ...input,\n id: input.id || existing.id || crypto.randomUUID(),\n jobKey: input.jobKey || existing.jobKey || runJobKey(input.job || existing.job || {}),\n status,\n stage,\n stageLabel: meta.label,\n progress: meta.progress,\n createdAt: existing.createdAt || input.createdAt || now,\n startedAt: existing.startedAt || input.startedAt || (status === 'running' ? now : null),\n updatedAt: now,\n completedAt: terminal ? (input.completedAt || existing.completedAt || now) : null,\n retryCount: Math.max(0, Number(input.retryCount ?? existing.retryCount ?? 0)),\n error: String(input.error ?? existing.error ?? ''),\n retryable: input.retryable ?? existing.retryable ?? Boolean(input.pendingId || existing.pendingId)\n };\n}\n\nasync function upsertTaskRun(patch = {}) {\n const { taskRuns = [] } = await storage.get('taskRuns');\n const jobKey = patch.jobKey || runJobKey(patch.job || {});\n const index = taskRuns.findIndex(run => run.id === patch.id || (jobKey && run.jobKey === jobKey && !TERMINAL_RUN_STATUSES.has(run.status)));\n const existing = index >= 0 ? taskRuns[index] : {};\n const nextRun = normalizeTaskRun({ ...patch, jobKey }, existing);\n const next = index >= 0\n ? taskRuns.map((run, runIndex) => runIndex === index ? nextRun : run)\n : [nextRun, ...taskRuns];\n await storage.set({ taskRuns: next });\n return nextRun;\n}\n\nasync function updateTaskRunByPending(pendingId, patch = {}) {\n if (!pendingId) return null;\n const { pending = [], taskRuns = [] } = await storage.get(['pending', 'taskRuns']);\n const item = pending.find(entry => entry.id === pendingId);\n const run = taskRuns.find(entry => entry.id === item?.runId || entry.pendingId === pendingId);\n if (!run && !item) return null;\n return upsertTaskRun({\n id: run?.id || item?.runId,\n pendingId,\n job: item?.job || run?.job,\n analysis: item?.analysis || run?.analysis,\n searchTask: item?.task || run?.searchTask,\n ...patch\n });\n}\n\nasync function updateSearchTaskProgress(message = {}) {\n const { workflow } = await storage.get('workflow');\n const nextWorkflow = { ...DEFAULTS.workflow, ...(workflow || {}) };\n const tasks = Array.isArray(nextWorkflow.tasks) ? [...nextWorkflow.tasks] : [];\n const index = tasks.findIndex(task => task.id === message.taskId);\n const fallbackIndex = Number.isInteger(message.taskIndex) ? message.taskIndex : -1;\n const targetIndex = index >= 0 ? index : fallbackIndex;\n if (targetIndex < 0 || targetIndex >= tasks.length) return null;\n const current = tasks[targetIndex] || {};\n const progress = Math.max(0, Math.min(100, Number(message.progress ?? current.progress ?? 0)));\n tasks[targetIndex] = {\n ...current,\n status: message.status || current.status || 'pending',\n stageLabel: message.stageLabel || current.stageLabel || '',\n progress,\n processed: Math.max(0, Number(message.processed ?? current.processed ?? 0)),\n discovered: Math.max(0, Number(message.discovered ?? current.discovered ?? 0)),\n analyzed: Math.max(0, Number(message.analyzed ?? current.analyzed ?? 0)),\n failed: Math.max(0, Number(message.failed ?? current.failed ?? 0)),\n updatedAt: Date.now(),\n completedAt: message.status === 'completed' ? Date.now() : current.completedAt || null\n };\n nextWorkflow.tasks = tasks;\n await storage.set({ workflow: nextWorkflow });\n return tasks[targetIndex];\n}\n\nasync function retryFailedTask(runId) {\n const { taskRuns = [], pending = [], workflow = {} } = await storage.get(['taskRuns', 'pending', 'workflow']);\n const run = taskRuns.find(entry => entry.id === runId);\n if (!run) throw new Error('失败任务不存在');\n if (run.status === 'success') throw new Error('该岗位已经投递成功,不会重复投递');\n if (workflow.running && (workflow.activeRunId === run.id || workflow.pendingApplyId === run.pendingId)) {\n throw new Error('该失败任务正在重试,请等待当前结果');\n }\n if (Number(run.retryRequestedAt || 0) > Date.now() - 15000) {\n throw new Error('该任务刚刚提交过重试,请稍后查看结果');\n }\n let item = pending.find(entry => entry.id === run.pendingId || entry.runId === run.id);\n if (!item && run.job && run.analysis) {\n item = {\n id: crypto.randomUUID(),\n runId: run.id,\n job: run.job,\n analysis: run.analysis,\n deliveryGreeting: String(run.analysis?.greeting || '').trim(),\n task: run.searchTask || {},\n status: 'approved',\n createdAt: Date.now(),\n approvedAt: Date.now()\n };\n }\n if (!item) throw new Error('该历史任务缺少岗位或招呼语信息,无法直接重试');\n if (item.status === 'sent') throw new Error('该岗位已经投递成功,不会重复投递');\n item = {\n ...item,\n deliveryGreeting: String(item.deliveryGreeting || item.analysis?.greeting || '').trim(),\n status: 'approved', error: '', approvedAt: Date.now(), runId: run.id\n };\n const nextPending = pending.some(entry => entry.id === item.id)\n ? pending.map(entry => entry.id === item.id ? item : entry)\n : [item, ...pending];\n await storage.set({ pending: rerankPending(nextPending) });\n await upsertTaskRun({\n id: run.id,\n pendingId: item.id,\n job: item.job,\n analysis: item.analysis,\n searchTask: item.task,\n status: 'running',\n stage: 'retry_queued',\n progress: 66,\n stageLabel: '等待重新投递',\n error: '',\n retryable: true,\n retryCount: Number(run.retryCount || 0) + 1,\n retryRequestedAt: Date.now(),\n completedAt: null\n });\n await patchWorkflow({\n running: true,\n paused: false,\n phase: 'apply',\n statusText: `正在重新投递:${item.job?.title || '岗位'}`,\n pendingApplyId: item.id,\n activeRunId: run.id\n });\n try {\n await sendToBoss({ type: 'RUN' });\n setTimeout(() => sendToBoss({ type: 'RUN' }).catch(() => {}), 700);\n } catch (error) {\n await upsertTaskRun({ id: run.id, status: 'failed', stage: 'failed', progress: 100, stageLabel: '重新投递启动失败', error: error.message, retryable: true });\n await patchWorkflow({ running: false, paused: true, phase: 'idle', pendingApplyId: null, activeRunId: null, statusText: '重新投递启动失败' });\n throw error;\n }\n return { runId: run.id, pendingId: item.id };\n}\n\nasync function retryAllFailedTasks() {\n const { taskRuns = [], pending = [], workflow = {} } = await storage.get(['taskRuns', 'pending', 'workflow']);\n if (workflow.running && workflow.activeRunId) {\n throw new Error('当前已有投递任务正在执行,请等待完成后再批量重试');\n }\n const failedRuns = taskRuns.filter(run => run.status === 'failed'\n && run.retryable !== false\n && Number(run.retryRequestedAt || 0) <= Date.now() - 15000);\n if (!failedRuns.length) return { count: 0 };\n const nextPending = [...pending];\n const queue = [];\n for (const run of failedRuns) {\n if (run.status === 'success') continue;\n let item = nextPending.find(entry => entry.id === run.pendingId || entry.runId === run.id);\n if (!item && run.job && run.analysis) {\n item = {\n id: crypto.randomUUID(), runId: run.id, job: run.job, analysis: run.analysis,\n deliveryGreeting: String(run.analysis?.greeting || '').trim(),\n task: run.searchTask || {}, createdAt: Date.now()\n };\n nextPending.unshift(item);\n }\n if (!item || item.status === 'sent') continue;\n item.deliveryGreeting = String(item.deliveryGreeting || item.analysis?.greeting || '').trim();\n item.status = queue.length ? 'approved_queue' : 'approved';\n item.approvedAt = Date.now();\n item.error = '';\n const pendingIndex = nextPending.findIndex(entry => entry.id === item.id);\n if (pendingIndex >= 0) nextPending[pendingIndex] = { ...item };\n queue.push({ run, item });\n }\n if (!queue.length) return { count: 0 };\n await storage.set({ pending: rerankPending(nextPending) });\n for (const [index, entry] of queue.entries()) {\n await upsertTaskRun({\n id: entry.run.id,\n pendingId: entry.item.id,\n status: index === 0 ? 'running' : 'queued',\n stage: 'retry_queued',\n progress: 66,\n stageLabel: index === 0 ? '正在重新投递' : '等待重新投递',\n error: '',\n retryable: true,\n retryCount: Number(entry.run.retryCount || 0) + 1,\n retryRequestedAt: Date.now(),\n completedAt: null\n });\n }\n const first = queue[0];\n await patchWorkflow({\n running: true,\n paused: false,\n phase: 'apply',\n statusText: `重新投递 1/${queue.length}:${first.item.job?.title || '岗位'}`,\n pendingApplyId: first.item.id,\n activeRunId: first.run.id\n });\n try {\n await sendToBoss({ type: 'RUN' });\n } catch (error) {\n for (const entry of queue) {\n await upsertTaskRun({ id: entry.run.id, status: 'failed', stage: 'failed', progress: 100, stageLabel: '批量重试启动失败', error: error.message, retryable: true });\n }\n await patchWorkflow({ running: false, paused: true, phase: 'idle', pendingApplyId: null, activeRunId: null, statusText: '批量重试启动失败' });\n throw error;\n }\n return { count: queue.length };\n}\n\nasync function ignoreFailedTask(runId) {\n const { taskRuns = [], pending = [] } = await storage.get(['taskRuns', 'pending']);\n const run = taskRuns.find(entry => entry.id === runId);\n if (!run) throw new Error('失败任务不存在');\n const nextPending = pending.map(entry => entry.id === run.pendingId || entry.runId === run.id\n ? { ...entry, status: 'rejected', rejectedAt: Date.now() }\n : entry);\n await storage.set({ pending: nextPending });\n return upsertTaskRun({ id: run.id, status: 'ignored', stage: 'ignored', progress: 100, stageLabel: '已忽略', retryable: false });\n}\n\nconst BOSS_URL_PATTERN = /^https:\\/\\/(?:www|app)\\.zhipin\\.com\\//i;\nconst NO_RECEIVER_PATTERN = /Could not establish connection|Receiving end does not exist|message port closed|No tab with id/i;\nconst wait = ms => new Promise(resolve => setTimeout(resolve, ms));\n\nfunction receiverMatchesExpectedVersion(probe) {\n return Boolean(probe?.ok && probe.contentVersion === EXPECTED_CONTENT_VERSION && probe.contentFile === CONTENT_SCRIPT_FILE);\n}\n\nasync function waitForTabReady(tabId, timeout = 16000) {\n if (!chrome.tabs?.get) {\n await wait(900);\n return null;\n }\n const startedAt = Date.now();\n while (Date.now() - startedAt < timeout) {\n const tab = await chrome.tabs.get(tabId).catch(() => null);\n if (tab?.status === 'complete') return tab;\n await wait(180);\n }\n return chrome.tabs.get(tabId).catch(() => null);\n}\n\nasync function reloadBossTab(tab, reason = 'runtime-refresh') {\n if (!tab?.id || !chrome.tabs?.reload) return tab;\n await chrome.tabs.reload(tab.id, { bypassCache: true });\n const ready = await waitForTabReady(tab.id);\n await writeEvent('info', 'BOSS 页面助手已自动刷新', { reason, tabId: tab.id, contentVersion: EXPECTED_CONTENT_VERSION }).catch(() => {});\n return ready || tab;\n}\n\nasync function refreshBossTabsForRuntimeVersion() {\n const key = 'bossContentRuntimeVersion';\n const current = await storage.get(key);\n if (current?.[key] === EXPECTED_CONTENT_VERSION) return;\n await storage.set({ [key]: EXPECTED_CONTENT_VERSION });\n if (!chrome.tabs?.query || !chrome.tabs?.reload) return;\n const tabs = await chrome.tabs.query({ url: ['https://www.zhipin.com/*', 'https://app.zhipin.com/*'] }).catch(() => []);\n for (const tab of tabs || []) {\n if (!tab?.id) continue;\n await chrome.tabs.reload(tab.id, { bypassCache: true }).catch(() => {});\n }\n}\n\nasync function activeBossTab() {\n const tabs = await chrome.tabs.query({ url: ['https://www.zhipin.com/*', 'https://app.zhipin.com/*'] });\n return tabs.find(tab => tab.active) || tabs[0] || null;\n}\n\nfunction bossConnectionError(error) {\n const message = String(error?.message || error || '');\n if (NO_RECEIVER_PATTERN.test(message)) {\n return 'BOSS 页面连接尚未就绪,JobClaw 已尝试自动修复。请刷新当前 BOSS 页面后再点一次开始。';\n }\n if (/Cannot access contents of url|The extensions gallery cannot be scripted|Missing host permission/i.test(message)) {\n return '当前页面无法接入 JobClaw。请确认打开的是 BOSS 直聘岗位页或沟通页。';\n }\n return message || 'BOSS 页面连接失败';\n}\n\nasync function probeBossReceiver(tab) {\n return chrome.tabs.sendMessage(tab.id, { type: 'PROBE', expectedContentVersion: EXPECTED_CONTENT_VERSION });\n}\n\nasync function injectBossContent(tab) {\n if (!chrome.scripting?.executeScript) throw new Error('扩展缺少页面注入能力,请重新加载最新版 JobClaw');\n await chrome.scripting.executeScript({\n target: { tabId: tab.id },\n files: [CONTENT_SCRIPT_FILE]\n });\n}\n\nasync function ensureBossReceiver(tab) {\n if (!tab?.id) throw new Error('请先打开并登录 BOSS 直聘');\n if (!BOSS_URL_PATTERN.test(String(tab.url || ''))) {\n throw new Error('请切换到 BOSS 直聘岗位页或沟通页');\n }\n\n let probe = null;\n try {\n probe = await probeBossReceiver(tab);\n if (receiverMatchesExpectedVersion(probe)) return probe;\n } catch (error) {\n if (!NO_RECEIVER_PATTERN.test(String(error?.message || error))) {\n throw new Error(bossConnectionError(error));\n }\n }\n\n // 能收到响应但版本不一致,说明当前标签页仍保留旧内容脚本。必须整页刷新,\n // 不能继续在同一页面叠加注入,否则旧 RUN 监听器仍可能执行。\n if (probe?.ok && !receiverMatchesExpectedVersion(probe)) {\n tab = await reloadBossTab(tab, `stale-${probe.contentVersion || 'legacy'}`);\n }\n\n for (let attempt = 0; attempt < 5; attempt += 1) {\n await wait(180 + attempt * 140);\n try {\n const currentProbe = await probeBossReceiver(tab);\n if (receiverMatchesExpectedVersion(currentProbe)) return currentProbe;\n if (currentProbe?.ok && !receiverMatchesExpectedVersion(currentProbe) && attempt === 0) {\n tab = await reloadBossTab(tab, `mismatch-${currentProbe.contentVersion || 'legacy'}`);\n }\n } catch (error) {\n if (!NO_RECEIVER_PATTERN.test(String(error?.message || error))) {\n throw new Error(bossConnectionError(error));\n }\n if (attempt === 1) {\n try { await injectBossContent(tab); } catch (injectError) { throw new Error(bossConnectionError(injectError)); }\n }\n }\n }\n throw new Error(`BOSS 页面助手版本未就绪(需要 ${EXPECTED_CONTENT_VERSION})。JobClaw 已自动刷新页面,请再点一次开始。`);\n}\n\nasync function sendToBossTab(tab, message) {\n await ensureBossReceiver(tab);\n try {\n return await chrome.tabs.sendMessage(tab.id, message);\n } catch (error) {\n if (!NO_RECEIVER_PATTERN.test(String(error?.message || error))) {\n throw new Error(bossConnectionError(error));\n }\n tab = await reloadBossTab(tab, 'message-receiver-lost');\n await ensureBossReceiver(tab);\n try {\n return await chrome.tabs.sendMessage(tab.id, message);\n } catch (retryError) {\n throw new Error(bossConnectionError(retryError));\n }\n }\n}\n\nasync function sendToBoss(message) {\n const tab = await activeBossTab();\n if (!tab) throw new Error('请先打开并登录 BOSS 直聘');\n return sendToBossTab(tab, message);\n}\n\nasync function nativeBridge(path, body) {\n if (!chrome.runtime?.sendNativeMessage) throw new Error('当前 Chrome 不支持 Native Messaging');\n const response = await chrome.runtime.sendNativeMessage(NATIVE_BRIDGE_HOST, { path, body: body ?? null });\n if (!response || response.ok === false) throw new Error(response?.error || '本地桥接没有返回结果');\n const payload = response.payload && typeof response.payload === 'object' ? response.payload : response;\n return { ...payload, _transport: 'native' };\n}\n\nasync function bridge(path, body) {\n const failures = [];\n const timeoutMs = path === '/parse-resume' ? 190000 : path === '/company/verify' ? 3500 : path === '/status' ? 3000 : 8000;\n for (const base of BRIDGE_ENDPOINTS) {\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), timeoutMs);\n try {\n const response = await fetch(`${base}${path}`, {\n method: body ? 'POST' : 'GET',\n headers: { 'Content-Type': 'application/json' },\n body: body ? JSON.stringify(body) : undefined,\n cache: 'no-store',\n credentials: 'omit',\n signal: controller.signal\n });\n const payload = await response.json().catch(() => ({}));\n if (!response.ok) throw new Error(payload.error || `本地桥接返回 HTTP ${response.status}`);\n bridgeUnavailableUntil = 0;\n bridgeLastError = '';\n return { ...payload, _transport: 'http', _endpoint: base };\n } catch (error) {\n failures.push(`${base} ${error?.name === 'AbortError' ? '连接超时' : String(error?.message || error)}`);\n } finally {\n clearTimeout(timeout);\n }\n }\n try {\n const payload = await nativeBridge(path, body);\n bridgeUnavailableUntil = 0;\n bridgeLastError = '';\n return payload;\n } catch (error) {\n failures.push(`Native Messaging ${String(error?.message || error)}`);\n bridgeLastError = String(error?.message || error);\n if (path === '/company/verify') bridgeUnavailableUntil = Date.now() + 5 * 60 * 1000;\n }\n throw new Error(`OpenClaw 桌面桥接未连接。请双击“安装桌面桥接-mac.command”完成安装或修复。${failures.length ? ` 诊断:${failures.join(';')}` : ''}`);\n}\n\nasync function diagnoseBridge() {\n const attempts = [];\n for (const base of BRIDGE_ENDPOINTS) {\n const startedAt = Date.now();\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), 2500);\n try {\n const response = await fetch(`${base}/status`, { cache: 'no-store', credentials: 'omit', signal: controller.signal });\n const payload = await response.json().catch(() => ({}));\n attempts.push({ transport: 'http', endpoint: base, ok: response.ok, elapsedMs: Date.now() - startedAt, status: response.status, payload });\n if (response.ok) return { ok: true, transport: 'http', runtimeId: chrome.runtime.id, attempts, status: payload };\n } catch (error) {\n attempts.push({ transport: 'http', endpoint: base, ok: false, elapsedMs: Date.now() - startedAt, error: error?.name === 'AbortError' ? '连接超时' : String(error?.message || error) });\n } finally {\n clearTimeout(timer);\n }\n }\n try {\n const status = await nativeBridge('/status');\n attempts.push({ transport: 'native', host: NATIVE_BRIDGE_HOST, ok: true });\n return { ok: true, transport: 'native', runtimeId: chrome.runtime.id, attempts, status };\n } catch (error) {\n attempts.push({ transport: 'native', host: NATIVE_BRIDGE_HOST, ok: false, error: String(error?.message || error) });\n }\n return {\n ok: false,\n runtimeId: chrome.runtime.id,\n expectedRuntimeId: 'dkfilgjiooigjbollljdionbnnofekkh',\n attempts,\n fix: '退出旧桥接后 双击项目根目录的“安装桌面桥接-mac.command” 安装完成后回到扩展点击检测连接',\n logPaths: ['~/.jobclaw/bridge.log', '~/.jobclaw/bridge-error.log']\n };\n}\n\nfunction bridgeSnapshot(all = {}) {\n const config = all.config || {};\n const pending = Array.isArray(all.pending) ? all.pending : [];\n const taskRuns = Array.isArray(all.taskRuns) ? all.taskRuns : [];\n return {\n ts: Date.now(),\n stats: { ...(all.stats || {}) },\n workflow: {\n running: Boolean(all.workflow?.running),\n paused: Boolean(all.workflow?.paused),\n phase: String(all.workflow?.phase || 'idle'),\n statusText: String(all.workflow?.statusText || '')\n },\n queue: {\n waiting: pending.filter(item => ['pending', 'approved_queue', 'approved'].includes(item.status)).length,\n sent: pending.filter(item => item.status === 'sent').length,\n failed: pending.filter(item => item.status === 'failed').length\n },\n recentRuns: taskRuns.filter(() => true).slice(0, 30).map(run => ({\n status: run.status,\n stageLabel: run.stageLabel,\n job: run.job ? { title: run.job.title || '', company: run.job.company || '' } : null,\n updatedAt: run.updatedAt || run.completedAt || run.createdAt || 0\n })),\n config: {\n batchStrategy: normalizeStrategy(config.batchStrategy),\n massApplyAnalysis: config.massApplyAnalysis || 'fast',\n pacingPreset: config.pacingPreset || 'standard',\n dailyTarget: Number(config.dailyTarget || 30),\n dailyReportEnabled: config.dailyReportEnabled !== false,\n dailyReportTime: String(config.dailyReportTime || '20:30'),\n dailyReportNotification: config.dailyReportNotification !== false\n }\n };\n}\n\nasync function syncBridgeSnapshot(force = false) {\n if (!force && Date.now() - lastBridgeSnapshotAt < 55 * 1000) return { skipped: true };\n lastBridgeSnapshotAt = Date.now();\n const snapshot = bridgeSnapshot(await storage.all());\n return bridge('/sync', { snapshot });\n}\n\nasync function enforceRateLimit(scope = 'discovery') {\n const { config = {}, safetyState = {} } = await storage.get(['config', 'safetyState']);\n const now = Date.now();\n const decision = computeRateLimitDecision(config, safetyState, scope, now);\n if (!decision.allowed) {\n await patchWorkflow({ running: false, paused: true, statusText: decision.reason || '安全熔断已开启' });\n return { ok: false, ...decision };\n }\n const reservedAt = now + Number(decision.waitMs || 0);\n const nextState = recordRateAction(safetyState, scope, reservedAt, Number(decision.waitMs || 0) > 0);\n await storage.set({ safetyState: nextState });\n return { ok: true, ...decision, reservedAt };\n}\n\nasync function applySafetyOutcome(outcome = {}) {\n const { config = {}, safetyState = {} } = await storage.get(['config', 'safetyState']);\n const next = recordSafetyOutcome(config, safetyState, outcome);\n await storage.set({ safetyState: next });\n if (next.circuitOpen) {\n await patchWorkflow({\n running: false,\n paused: true,\n phase: 'safety_stop',\n pendingApplyId: null,\n statusText: `安全熔断:${next.circuitReason || '连续任务失败'}`\n });\n await writeEvent('warning', '安全熔断已开启', { reason: next.circuitReason, failures: next.consecutiveFailures });\n chrome.notifications.create(`jobclaw-safety-${Date.now()}`, {\n type: 'basic',\n iconUrl: 'icon128.png',\n title: 'JobClaw 已安全暂停',\n message: next.circuitReason || '连续任务失败,请检查页面后手动恢复'\n }).catch(() => {});\n }\n return next;\n}\n\nasync function clearSafetyCircuit() {\n const { safetyState = {} } = await storage.get('safetyState');\n const next = resetSafetyCircuit(safetyState);\n await storage.set({ safetyState: next });\n await writeEvent('info', '安全熔断已重置');\n return next;\n}\n\nasync function verifyCompanyForJob(job = {}, force = false) {\n const { config = {}, companyVerificationCache = {} } = await storage.get(['config', 'companyVerificationCache']);\n const fallback = heuristicCompanyVerification(job);\n if (config.companyVerificationEnabled === false) {\n return { ...fallback, provider: 'disabled', status: 'disabled', verified: false, riskLevel: fallback.riskLevel === 'high' ? 'high' : 'unknown' };\n }\n const key = companyCacheKey(job.company);\n const cached = companyVerificationCache[key];\n if (!force && cached && !companyVerificationExpired(cached, config.companyVerificationCacheDays || 14)) {\n return { ...cached, cacheHit: true };\n }\n let providerResult = null;\n if (config.companyVerificationProvider !== 'local' && Date.now() < bridgeUnavailableUntil) {\n providerResult = {\n provider: 'bridge-cooldown',\n status: 'unavailable',\n verified: false,\n riskLevel: 'unknown',\n confidence: 0,\n signals: [`OpenClaw暂不可用 已在本地快速降级 ${bridgeLastError || ''}`.trim()],\n evidence: [],\n checkedAt: Date.now()\n };\n }\n if (config.companyVerificationProvider !== 'local' && !providerResult) {\n const throttle = await enforceRateLimit('company');\n if (throttle.ok && throttle.waitMs) await wait(throttle.waitMs);\n try {\n const response = await bridge('/company/verify', {\n provider: config.companyVerificationProvider || 'bridge',\n companyName: job.company || '',\n job: {\n title: job.title || '',\n company: job.company || '',\n location: job.location || '',\n description: job.description || job.cardText || '',\n url: job.url || ''\n }\n });\n providerResult = response?.result || response;\n } catch (error) {\n providerResult = {\n provider: 'bridge-unavailable',\n status: 'unavailable',\n verified: false,\n riskLevel: 'unknown',\n confidence: 0,\n signals: [`企业数据源暂不可用:${error.message}`],\n evidence: []\n };\n }\n }\n const merged = mergeCompanyVerification(providerResult || {}, fallback);\n const nextCache = { ...companyVerificationCache, [key]: merged };\n const entries = Object.entries(nextCache).sort((a, b) => Number(b[1]?.checkedAt || 0) - Number(a[1]?.checkedAt || 0)).slice(0, 500);\n await storage.set({ companyVerificationCache: Object.fromEntries(entries) });\n return { ...merged, cacheHit: false };\n}\n\nasync function preflightJob(job = {}) {\n const { config = {}, pending = [], deliveryHistory = [] } = await storage.get(['config', 'pending', 'deliveryHistory']);\n const duplicate = findDuplicate(job, {\n pending,\n history: deliveryHistory,\n maxPerCompanyPerDay: config.maxPerCompanyPerDay || 2,\n date: today()\n });\n if (duplicate.duplicate) {\n await changeStats({ duplicates: 1, blocked: 1 });\n return { ok: true, blocked: true, category: 'duplicate', reason: duplicate.reason, duplicate };\n }\n const company = await verifyCompanyForJob(job);\n const blockUnknown = Boolean(config.blockUnknownCompanies);\n const blocked = company.riskLevel === 'high' || (blockUnknown && company.riskLevel === 'unknown');\n await changeStats(blocked ? { blocked: 1 } : company.verified ? { verified: 1 } : {});\n return {\n ok: true,\n blocked,\n category: blocked ? 'company-risk' : 'accepted',\n reason: blocked ? (company.signals?.[0] || '企业核验未通过') : '',\n company,\n duplicate\n };\n}\n\nasync function checkForUpdates(force = false) {\n const currentVersion = chrome.runtime.getManifest().version;\n const { config = {}, updateInfo = {} } = await storage.get(['config', 'updateInfo']);\n if (config.updateCheckEnabled === false && !force) return updateInfo;\n if (!force && Number(updateInfo.checkedAt || 0) && Date.now() - Number(updateInfo.checkedAt) < 6 * 60 * 60 * 1000) return updateInfo;\n try {\n const response = await fetch('https://api.github.com/repos/Chrisbetheking/job-claw/releases/latest', {\n headers: { Accept: 'application/vnd.github+json' },\n cache: 'no-store',\n credentials: 'omit'\n });\n if (response.status === 404) {\n const next = { ...DEFAULTS.updateInfo, currentVersion, checkedAt: Date.now(), error: '仓库暂未发布正式 Release' };\n await storage.set({ updateInfo: next });\n return next;\n }\n if (!response.ok) throw new Error(`GitHub Release HTTP ${response.status}`);\n const next = normalizeRelease(await response.json(), currentVersion);\n const wasAvailable = Boolean(updateInfo.available && updateInfo.latestVersion === next.latestVersion);\n await storage.set({ updateInfo: next });\n if (next.available && !wasAvailable) {\n chrome.notifications.create(`jobclaw-update-${next.latestVersion}`, {\n type: 'basic',\n iconUrl: 'icon128.png',\n title: `JobClaw ${next.latestVersion} 可更新`,\n message: next.name || '发现新的正式版本'\n }).catch(() => {});\n }\n return next;\n } catch (error) {\n const next = { ...updateInfo, currentVersion, checkedAt: Date.now(), error: error.message || '更新检查失败' };\n await storage.set({ updateInfo: next });\n return next;\n }\n}\n\nfunction extractJson(raw) {\n let text = String(raw || '').trim()\n .replace(/^```(?:json)?/i, '')\n .replace(/```$/i, '')\n .trim();\n const start = text.indexOf('{');\n const end = text.lastIndexOf('}');\n if (start >= 0 && end > start) text = text.slice(start, end + 1);\n text = text.replace(/,\\s*([}\\]])/g, '$1');\n return JSON.parse(text);\n}\n\nfunction aiError(code, message, details = {}) {\n const error = new Error(message);\n error.code = code;\n Object.assign(error, details);\n return error;\n}\n\nfunction aiFailureKind(error) {\n const code = String(error?.code || '');\n const message = String(error?.message || '');\n if (code === 'AI_CONFIG' || /API Key|未配置|401|unauthorized/i.test(message)) return 'config-missing';\n if (['AI_NETWORK', 'AI_HTTP', 'AI_TIMEOUT'].includes(code) || /HTTP|fetch|网络|超时|服务不可用/i.test(message)) return 'service-error';\n return 'output-invalid';\n}\n\nfunction isRetryableAiOutputError(error) {\n return ['AI_TRUNCATED', 'AI_EMPTY', 'AI_INVALID_JSON', 'AI_PROFILE_INCOMPLETE'].includes(String(error?.code || ''));\n}\n\nasync function requestModel(url, payload, apiKey, timeoutMs = 90000) {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n try {\n return await fetch(url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`\n },\n body: JSON.stringify(payload),\n signal: controller.signal\n });\n } catch (error) {\n if (error?.name === 'AbortError') throw aiError('AI_TIMEOUT', 'AI 请求超时');\n throw aiError('AI_NETWORK', `AI 网络请求失败:${error?.message || '连接异常'}`);\n } finally {\n clearTimeout(timer);\n }\n}\n\nasync function callModel(messages, jsonMode = true, options = {}) {\n const { config } = await storage.get('config');\n const model = config?.model || {};\n if (!model.apiKey) throw aiError('AI_CONFIG', '请先在设置页填写 AI API Key');\n const url = `${String(model.baseUrl || 'https://api.deepseek.com').replace(/\\/$/, '')}/chat/completions`;\n const payload = {\n model: model.model || 'deepseek-v4-pro',\n messages,\n temperature: Number(options.temperature ?? model.temperature ?? 0.1),\n max_tokens: Number(options.maxTokens ?? 2800)\n };\n if (jsonMode) payload.response_format = { type: 'json_object' };\n\n let response = await requestModel(url, payload, model.apiKey, Number(options.timeoutMs || 90000));\n let bodyText = await response.text();\n\n // 某些兼容网关不支持 response_format,但仍能正常返回 JSON 文本。\n if (!response.ok && jsonMode && [400, 404, 422].includes(response.status) && /response[_ -]?format|json_object|unsupported/i.test(bodyText)) {\n const retryPayload = { ...payload };\n delete retryPayload.response_format;\n response = await requestModel(url, retryPayload, model.apiKey, Number(options.timeoutMs || 90000));\n bodyText = await response.text();\n }\n\n if (!response.ok) {\n throw aiError('AI_HTTP', `AI 请求失败 HTTP ${response.status}: ${bodyText.substring(0, 500)}`, { status: response.status });\n }\n\n let result;\n try {\n result = JSON.parse(bodyText);\n } catch {\n throw aiError('AI_INVALID_RESPONSE', 'AI 接口返回了无法识别的响应');\n }\n const choice = result.choices?.[0];\n const content = String(choice?.message?.content || '').trim();\n if (!content) throw aiError('AI_EMPTY', 'AI 返回为空');\n if (choice.finish_reason === 'length') {\n throw aiError('AI_TRUNCATED', 'AI 输出被截断', { finishReason: choice.finish_reason, partial: content });\n }\n if (!jsonMode) return content;\n try {\n return extractJson(content);\n } catch (error) {\n throw aiError('AI_INVALID_JSON', `AI 返回 JSON 不完整:${error?.message || '解析失败'}`, { partial: content });\n }\n}\n\nfunction cleanResumeText(value) {\n return String(value || '')\n .replace(/\\r/g, '\\n')\n .replace(/[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001F]/g, ' ')\n .replace(/[ \\t]+/g, ' ')\n .replace(/\\n{3,}/g, '\\n\\n')\n .trim();\n}\n\nfunction lineMatches(text, pattern, limit = 6) {\n return uniq(cleanResumeText(text)\n .split('\\n')\n .map(line => line.trim())\n .filter(line => line.length >= 4 && line.length <= 180 && pattern.test(line)))\n .slice(0, limit);\n}\n\nfunction extractSkills(text) {\n const source = cleanResumeText(text);\n const catalog = [\n ['JavaScript', /\\bjavascript\\b|\\bjs\\b/i],\n ['TypeScript', /\\btypescript\\b|\\bts\\b/i],\n ['HTML', /\\bhtml5?\\b/i],\n ['CSS', /\\bcss3?\\b|sass|less/i],\n ['Vue', /\\bvue(?:\\.js|3|2)?\\b/i],\n ['React', /\\breact(?:\\.js)?\\b/i],\n ['Next.js', /\\bnext\\.?js\\b/i],\n ['Node.js', /\\bnode\\.?js\\b/i],\n ['Express', /\\bexpress\\b/i],\n ['Vite', /\\bvite\\b/i],\n ['Webpack', /\\bwebpack\\b/i],\n ['Tailwind CSS', /\\btailwind\\b/i],\n ['ECharts', /\\becharts\\b/i],\n ['D3.js', /\\bd3(?:\\.js)?\\b/i],\n ['Tauri', /\\btauri\\b/i],\n ['Electron', /\\belectron\\b/i],\n ['Chrome Extension', /chrome\\s*(?:extension|扩展)|浏览器扩展/i],\n ['Python', /\\bpython\\b/i],\n ['Java', /\\bjava\\b/i],\n ['C/C++', /\\bc\\+\\+\\b|\\bc语言\\b/i],\n ['Go', /\\bgolang\\b|\\bgo语言\\b/i],\n ['SQL', /\\bsql\\b/i],\n ['MySQL', /\\bmysql\\b/i],\n ['PostgreSQL', /\\bpostgres(?:ql)?\\b/i],\n ['Redis', /\\bredis\\b/i],\n ['Spring Boot', /spring\\s*boot/i],\n ['Flask', /\\bflask\\b/i],\n ['Django', /\\bdjango\\b/i],\n ['FastAPI', /\\bfastapi\\b/i],\n ['Git', /\\bgit\\b|github/i],\n ['Docker', /\\bdocker\\b/i],\n ['Linux', /\\blinux\\b/i],\n ['Playwright', /\\bplaywright\\b/i],\n ['Selenium', /\\bselenium\\b/i],\n ['PyTorch', /\\bpytorch\\b/i],\n ['TensorFlow', /\\btensorflow\\b/i],\n ['OpenCV', /\\bopencv\\b/i],\n ['RAG', /\\brag\\b|检索增强生成/i],\n ['LLM', /\\bllm\\b|大语言模型|语言模型/i],\n ['AI Agent', /ai\\s*agent|智能体/i],\n ['OCR', /\\bocr\\b|文字识别/i],\n ['数据可视化', /数据可视化|可视化看板/i]\n ];\n return catalog.filter(([, pattern]) => pattern.test(source)).map(([name]) => name).slice(0, 40);\n}\n\nfunction extractDegree(text) {\n const source = cleanResumeText(text);\n if (/博士|ph\\.?d/i.test(source)) return '博士';\n if (/硕士|研究生|master/i.test(source)) return '硕士';\n if (/本科|学士|bachelor/i.test(source)) return '本科';\n if (/大专|专科|associate/i.test(source)) return '大专';\n return '不限';\n}\n\nfunction extractLocations(text) {\n const source = cleanResumeText(text);\n const cities = ['北京','上海','广州','深圳','杭州','成都','西安','南京','武汉','苏州','重庆','天津','长沙','郑州','青岛','厦门','合肥','济南','宁波','东莞','珠海','佛山','无锡','兰州','太原','南阳','东京','大阪'];\n return cities.filter(city => source.includes(city)).slice(0, 6);\n}\n\nfunction extractExplicitDirections(text) {\n const source = cleanResumeText(text);\n const matches = [];\n const pattern = /(?:求职意向|求职方向|目标岗位|意向岗位|应聘岗位|期望职位)\\s*[::]?\\s*([^\\n]{2,100})/ig;\n let match;\n while ((match = pattern.exec(source)) && matches.length < 3) {\n matches.push(...String(match[1] || '').split(/[,,、/|]/).map(item => item.trim()).filter(item => item.length >= 2 && item.length <= 28));\n }\n return uniq(matches).slice(0, 3);\n}\n\nfunction inferDirections(text, skills) {\n const source = cleanResumeText(text);\n const explicit = extractExplicitDirections(source);\n if (explicit.length) return explicit;\n const directions = [];\n const has = name => skills.includes(name);\n const internship = /实习|在校|应届|校招/i.test(source);\n if (/前端|web前端|网页开发/i.test(source) || ['JavaScript','TypeScript','Vue','React','HTML','CSS'].some(has)) {\n directions.push(internship ? '前端开发实习生' : '前端开发工程师');\n }\n if (/全栈/i.test(source) || ((has('React') || has('Vue')) && (has('Node.js') || has('Java') || has('Python')))) {\n directions.push(internship ? '全栈开发实习生' : '全栈开发工程师');\n }\n if (/ai应用|人工智能应用|智能体|rag|大模型|llm/i.test(source) || ['RAG','LLM','AI Agent'].some(has)) {\n directions.push(internship ? 'AI 应用开发实习生' : 'AI 应用开发工程师');\n }\n if (/数据可视化|echarts|d3/i.test(source) || has('数据可视化') || has('ECharts') || has('D3.js')) {\n directions.push('数据可视化开发工程师');\n }\n if (/后端|服务端/i.test(source) || ['Spring Boot','Java','Node.js','Python','Go'].filter(has).length >= 2) {\n directions.push(internship ? '后端开发实习生' : '后端开发工程师');\n }\n if (!directions.length) directions.push(internship ? '软件开发实习生' : '软件开发工程师');\n return uniq(directions).slice(0, 3);\n}\n\nfunction buildSearchKeywords(directions, skills) {\n const keywords = [...directions];\n const joined = directions.join(' ');\n if (/前端/.test(joined)) {\n keywords.push('前端开发', 'Web 前端', 'JavaScript 开发');\n if (skills.includes('React')) keywords.push('React 开发');\n if (skills.includes('Vue')) keywords.push('Vue 开发');\n if (skills.includes('TypeScript')) keywords.push('TypeScript 前端');\n }\n if (/全栈/.test(joined)) keywords.push('全栈开发', 'Web 全栈');\n if (/AI 应用/.test(joined)) {\n keywords.push('AI 应用开发');\n if (skills.includes('RAG')) keywords.push('RAG 应用开发');\n if (skills.includes('AI Agent')) keywords.push('AI Agent 开发');\n }\n if (/数据可视化/.test(joined)) keywords.push('数据可视化', '可视化前端');\n if (/后端/.test(joined)) keywords.push('后端开发', '服务端开发');\n return uniq(keywords).slice(0, 12);\n}\n\nfunction explainProfileFallbackReason(reason = '', kind = '') {\n const message = String(reason || '').trim();\n const resolvedKind = kind || aiFailureKind({ message });\n if (!message) return '';\n if (resolvedKind === 'config-missing') {\n return 'AI 尚未配置;本次未调用 AI,当前初稿全部来自本地规则。';\n }\n if (resolvedKind === 'service-error') {\n return 'AI 请求没有成功;本次画像未使用 AI,当前初稿全部来自本地规则。';\n }\n return 'AI 连接可用,但返回内容未通过完整性校验;系统已自动精简重试,仍未成功,因此本次初稿全部来自本地规则。';\n}\n\nfunction validateGeneratedProfile(profile) {\n if (!profile || typeof profile !== 'object' || Array.isArray(profile)) {\n throw aiError('AI_PROFILE_INCOMPLETE', 'AI 画像字段不完整');\n }\n const directions = normalizeStringList(profile.primaryDirections, 3);\n const keywords = normalizeStringList(profile.searchKeywords, 12);\n const summary = String(profile.summary || '').trim();\n if (!directions.length || !keywords.length || summary.length < 12) {\n throw aiError('AI_PROFILE_INCOMPLETE', 'AI 画像字段不完整');\n }\n if (!profile.facts || typeof profile.facts !== 'object' || Array.isArray(profile.facts)) {\n throw aiError('AI_PROFILE_INCOMPLETE', 'AI 画像字段不完整');\n }\n if (!profile.hardConstraints || typeof profile.hardConstraints !== 'object' || Array.isArray(profile.hardConstraints)) {\n throw aiError('AI_PROFILE_INCOMPLETE', 'AI 画像字段不完整');\n }\n return profile;\n}\n\nfunction validateCompactGeneratedProfile(profile) {\n if (!profile || typeof profile !== 'object' || Array.isArray(profile)) {\n throw aiError('AI_PROFILE_INCOMPLETE', 'AI 精简画像字段不完整');\n }\n const normalized = {\n summary: String(profile.summary || '').trim(),\n primaryDirections: normalizeStringList(profile.primaryDirections, 3),\n searchKeywords: normalizeStringList(profile.searchKeywords, 12),\n skills: normalizeStringList(profile.skills, 30),\n locations: normalizeStringList(profile.locations, 20),\n employmentTypes: normalizeStringList(profile.employmentTypes, 10),\n salary: String(profile.salary || '').trim(),\n experience: String(profile.experience || '').trim(),\n degree: String(profile.degree || '').trim(),\n excludeDirections: normalizeStringList(profile.excludeDirections, 20)\n };\n if (!normalized.primaryDirections.length || !normalized.searchKeywords.length || normalized.summary.length < 12) {\n throw aiError('AI_PROFILE_INCOMPLETE', 'AI 精简画像字段不完整');\n }\n return normalized;\n}\n\nfunction buildLocalProfile(resumeText, reason = '', failureKind = '') {\n const text = cleanResumeText(resumeText);\n if (text.length < 30) throw new Error('简历内容太少,暂时无法生成职业画像');\n const skills = extractSkills(text);\n const primaryDirections = inferDirections(text, skills);\n const searchKeywords = buildSearchKeywords(primaryDirections, skills);\n const degree = extractDegree(text);\n const locations = extractLocations(text);\n const internship = /实习|在校|应届|校招/i.test(text);\n const employmentTypes = internship ? ['实习', '校招'] : ['全职'];\n const experience = internship ? '在校/应届' : '按岗位要求匹配';\n const topSkills = skills.slice(0, 7);\n const summaryParts = [];\n if (degree !== '不限') summaryParts.push(`${degree}${internship ? '在读或应届' : '背景'}`);\n if (topSkills.length) summaryParts.push(`具备 ${topSkills.join('、')} 等技能或项目经验`);\n summaryParts.push(`主要关注 ${primaryDirections.join('、')} 方向`);\n\n const education = lineMatches(text, /大学|学院|本科|硕士|博士|教育经历|专业/i, 6);\n const experiences = lineMatches(text, /实习|工作经历|公司|负责|任职|助理|工程师/i, 8);\n const projects = lineMatches(text, /项目|系统|平台|工作台|GitHub|开发|实现|搭建|设计/i, 10);\n const certificates = lineMatches(text, /证书|CET|英语六级|英语四级|资格|获奖|奖项/i, 6);\n const resolvedKind = failureKind || (reason ? aiFailureKind({ message: reason }) : 'not-requested');\n\n return normalizeProfile({\n facts: { education, experiences, projects, skills, certificates },\n primaryDirections: primaryDirections.map(name => ({ name, confidence: 0.72, evidence: ['根据简历中的技能、项目和求职阶段生成'] })),\n secondaryDirections: [],\n searchKeywords,\n hardConstraints: {\n locations,\n employmentTypes,\n salary: '不限',\n experience,\n degree\n },\n excludeDirections: [],\n summary: `${summaryParts.join(',')}。`,\n generation: {\n mode: 'local-fallback',\n label: '本地规则初稿',\n aiStatus: resolvedKind,\n warning: explainProfileFallbackReason(reason, resolvedKind),\n technicalReason: String(reason || ''),\n generatedAt: Date.now()\n }\n });\n}\n\nfunction mergeProfileWithFallback(aiProfile, fallbackProfile, generation = null) {\n const ai = aiProfile && typeof aiProfile === 'object' ? aiProfile : {};\n const aiFacts = ai.facts && typeof ai.facts === 'object' ? ai.facts : {};\n const fallbackFacts = fallbackProfile.facts || {};\n const hard = ai.hardConstraints && typeof ai.hardConstraints === 'object' ? ai.hardConstraints : {};\n const fallbackHard = fallbackProfile.hardConstraints || {};\n return normalizeProfile({\n ...fallbackProfile,\n ...ai,\n facts: {\n ...fallbackFacts,\n ...aiFacts,\n education: Array.isArray(aiFacts.education) && aiFacts.education.length ? aiFacts.education : fallbackFacts.education,\n experiences: Array.isArray(aiFacts.experiences) && aiFacts.experiences.length ? aiFacts.experiences : fallbackFacts.experiences,\n projects: Array.isArray(aiFacts.projects) && aiFacts.projects.length ? aiFacts.projects : fallbackFacts.projects,\n skills: normalizeStringList(aiFacts.skills).length ? aiFacts.skills : fallbackFacts.skills,\n certificates: Array.isArray(aiFacts.certificates) && aiFacts.certificates.length ? aiFacts.certificates : fallbackFacts.certificates\n },\n primaryDirections: normalizeStringList(ai.primaryDirections, 3).length ? ai.primaryDirections : fallbackProfile.primaryDirections,\n searchKeywords: normalizeStringList(ai.searchKeywords, 12).length ? ai.searchKeywords : fallbackProfile.searchKeywords,\n hardConstraints: {\n ...fallbackHard,\n ...hard,\n locations: normalizeStringList(hard.locations).length ? hard.locations : fallbackHard.locations,\n employmentTypes: normalizeStringList(hard.employmentTypes).length ? hard.employmentTypes : fallbackHard.employmentTypes,\n salary: String(hard.salary || fallbackHard.salary || '不限'),\n experience: String(hard.experience || fallbackHard.experience || ''),\n degree: String(hard.degree || fallbackHard.degree || '不限')\n },\n summary: String(ai.summary || fallbackProfile.summary),\n generation: generation || {\n mode: 'ai-assisted',\n label: 'AI 完整画像',\n aiStatus: 'success',\n warning: 'AI 返回的完整 JSON 已通过校验;仅对可选空字段使用本地规则补齐。',\n generatedAt: Date.now()\n }\n });\n}\n\nfunction compactProfileToFull(compact, fallback) {\n return {\n facts: {\n ...(fallback.facts || {}),\n skills: compact.skills.length ? compact.skills : fallback.facts?.skills || []\n },\n primaryDirections: compact.primaryDirections,\n secondaryDirections: [],\n searchKeywords: compact.searchKeywords,\n hardConstraints: {\n ...(fallback.hardConstraints || {}),\n locations: compact.locations.length ? compact.locations : fallback.hardConstraints?.locations || [],\n employmentTypes: compact.employmentTypes.length ? compact.employmentTypes : fallback.hardConstraints?.employmentTypes || [],\n salary: compact.salary || fallback.hardConstraints?.salary || '不限',\n experience: compact.experience || fallback.hardConstraints?.experience || '',\n degree: compact.degree || fallback.hardConstraints?.degree || '不限'\n },\n excludeDirections: compact.excludeDirections,\n summary: compact.summary\n };\n}\n\nasync function buildProfile(resumeText) {\n const text = cleanResumeText(resumeText);\n const fallback = buildLocalProfile(text);\n const schema = '{\"facts\":{\"education\":[],\"experiences\":[],\"projects\":[],\"skills\":[],\"certificates\":[]},\"primaryDirections\":[{\"name\":\"\",\"confidence\":0,\"evidence\":[]}],\"secondaryDirections\":[],\"searchKeywords\":[],\"hardConstraints\":{\"locations\":[],\"employmentTypes\":[],\"salary\":\"\",\"experience\":\"\",\"degree\":\"\"},\"excludeDirections\":[],\"summary\":\"\"}';\n let firstError = null;\n try {\n const profile = validateGeneratedProfile(await callModel([\n {\n role: 'system',\n content: `你是严格的职业画像分析器。只能使用简历真实事实,不能根据项目业务场景推断用户职业。主方向最多3个,搜索词必须是真实岗位名称。数组必须精简,教育/经历/项目各最多4条,每条不超过80字,技能最多15个,摘要不超过180字。即使信息不完整,也必须给出可编辑初稿,禁止返回空内容。输出严格 JSON:${schema}`\n },\n { role: 'user', content: text.slice(0, 22000) }\n ], true, { maxTokens: 4200, temperature: 0.05 }));\n return mergeProfileWithFallback(profile, fallback);\n } catch (error) {\n firstError = error;\n }\n\n // 连接正常但完整画像输出过长、JSON 不完整或字段校验失败时,自动改用精简结构重试。\n if (isRetryableAiOutputError(firstError)) {\n const compactSchema = '{\"summary\":\"\",\"primaryDirections\":[],\"searchKeywords\":[],\"skills\":[],\"locations\":[],\"employmentTypes\":[],\"salary\":\"\",\"experience\":\"\",\"degree\":\"\",\"excludeDirections\":[]}';\n try {\n const compact = validateCompactGeneratedProfile(await callModel([\n {\n role: 'system',\n content: `你是求职职业画像分析器。只使用简历事实。请输出极简 JSON,不要解释,不要证据长句。摘要120字以内;主方向最多3个;搜索词最多10个;技能最多12个;其余字段简短。输出结构:${compactSchema}`\n },\n { role: 'user', content: text.slice(0, 18000) }\n ], true, { maxTokens: 2200, temperature: 0.05 }));\n return mergeProfileWithFallback(compactProfileToFull(compact, fallback), fallback, {\n mode: 'ai-compact-retry',\n label: 'AI 精简重试结果',\n aiStatus: 'success-after-retry',\n warning: 'AI 连接正常。首次完整画像输出未完成,系统已自动精简重试并成功采用 AI 结果。',\n technicalReason: String(firstError?.message || ''),\n generatedAt: Date.now()\n });\n } catch (retryError) {\n const combined = `${firstError?.message || 'AI 首次输出未通过'};精简重试:${retryError?.message || '失败'}`;\n return buildLocalProfile(text, combined, aiFailureKind(firstError));\n }\n }\n\n return buildLocalProfile(text, firstError?.message || 'AI 生成失败', aiFailureKind(firstError));\n}\n\n\nfunction normalizeStringList(value, limit = 30) {\n const values = Array.isArray(value) ? value : String(value || '').split(/[,,\\n]/);\n return uniq(values.map(item => typeof item === 'string' ? item.trim() : String(item?.name || item?.title || '').trim()))\n .filter(Boolean)\n .slice(0, limit);\n}\n\nfunction normalizeDirections(value, existing = []) {\n const previous = new Map((Array.isArray(existing) ? existing : []).map(item => {\n const name = typeof item === 'string' ? item : item?.name;\n return [String(name || '').trim(), item];\n }));\n return normalizeStringList(value, 3).map(name => {\n const old = previous.get(name);\n if (old && typeof old === 'object') return { ...old, name };\n return { name, confidence: 1, evidence: ['用户手动编辑'] };\n });\n}\n\nfunction normalizeProfile(incoming, current = null) {\n const base = current && typeof current === 'object' ? current : {};\n const next = incoming && typeof incoming === 'object' ? incoming : {};\n const profile = {\n ...base,\n ...next,\n facts: {\n education: [], experiences: [], projects: [], skills: [], certificates: [],\n ...(base.facts || {}),\n ...(next.facts || {}),\n skills: normalizeStringList(next.facts?.skills ?? base.facts?.skills, 40)\n },\n primaryDirections: normalizeDirections(next.primaryDirections ?? base.primaryDirections, base.primaryDirections),\n secondaryDirections: normalizeStringList(next.secondaryDirections ?? base.secondaryDirections, 10),\n searchKeywords: normalizeStringList(next.searchKeywords ?? base.searchKeywords, 12),\n excludeDirections: normalizeStringList(next.excludeDirections ?? base.excludeDirections, 20),\n hardConstraints: {\n locations: [], employmentTypes: [], salary: '', experience: '', degree: '',\n ...(base.hardConstraints || {}),\n ...(next.hardConstraints || {})\n },\n summary: String(next.summary ?? base.summary ?? '').trim(),\n editedAt: Date.now()\n };\n profile.hardConstraints.locations = normalizeStringList(profile.hardConstraints.locations, 20);\n profile.hardConstraints.employmentTypes = normalizeStringList(profile.hardConstraints.employmentTypes, 10);\n profile.hardConstraints.salary = String(profile.hardConstraints.salary || '').trim();\n profile.hardConstraints.experience = String(profile.hardConstraints.experience || '').trim();\n profile.hardConstraints.degree = String(profile.hardConstraints.degree || '').trim();\n if (!profile.primaryDirections.length) throw new Error('职业画像至少需要一个主方向');\n if (!profile.searchKeywords.length) throw new Error('职业画像至少需要一个岗位搜索词');\n return profile;\n}\n\n\nfunction profileHasCore(profile) {\n return Boolean(\n normalizeStringList(profile?.primaryDirections, 3).length &&\n normalizeStringList(profile?.searchKeywords, 12).length\n );\n}\n\nfunction normalizeProfileDraft(incoming, fallback = null) {\n const base = fallback && typeof fallback === 'object' ? fallback : {};\n const next = incoming && typeof incoming === 'object' ? incoming : {};\n const chooseList = (key, limit = 30) => Object.prototype.hasOwnProperty.call(next, key)\n ? normalizeStringList(next[key], limit)\n : normalizeStringList(base[key], limit);\n const chooseText = key => Object.prototype.hasOwnProperty.call(next, key)\n ? String(next[key] || '').trim()\n : String(base[key] || '').trim();\n return {\n summary: chooseText('summary'),\n primaryDirections: chooseList('primaryDirections', 3),\n searchKeywords: chooseList('searchKeywords', 12),\n skills: chooseList('skills', 40),\n locations: chooseList('locations', 20),\n employmentTypes: chooseList('employmentTypes', 10),\n experience: chooseText('experience'),\n degree: chooseText('degree'),\n salary: chooseText('salary'),\n excludeDirections: chooseList('excludeDirections', 20),\n source: String(next.source || base.source || 'draft'),\n updatedAt: Number(next.updatedAt || Date.now())\n };\n}\n\nfunction profileDraftHasCore(draft) {\n return Boolean(\n normalizeStringList(draft?.primaryDirections, 3).length &&\n normalizeStringList(draft?.searchKeywords, 12).length\n );\n}\n\nfunction profileDraftHasAny(draft) {\n if (!draft || typeof draft !== 'object') return false;\n return Boolean(\n String(draft.summary || '').trim() ||\n normalizeStringList(draft.primaryDirections).length ||\n normalizeStringList(draft.searchKeywords).length ||\n normalizeStringList(draft.skills).length ||\n normalizeStringList(draft.locations).length ||\n normalizeStringList(draft.employmentTypes).length ||\n String(draft.experience || '').trim() ||\n String(draft.degree || '').trim() ||\n String(draft.salary || '').trim() ||\n normalizeStringList(draft.excludeDirections).length\n );\n}\n\nfunction profileToDraft(profile, source = 'generated') {\n const hard = profile?.hardConstraints || {};\n return normalizeProfileDraft({\n summary: profile?.summary || '',\n primaryDirections: normalizeStringList(profile?.primaryDirections, 3),\n searchKeywords: normalizeStringList(profile?.searchKeywords, 12),\n skills: normalizeStringList(profile?.facts?.skills, 40),\n locations: normalizeStringList(hard.locations, 20),\n employmentTypes: normalizeStringList(hard.employmentTypes, 10),\n experience: hard.experience || '',\n degree: hard.degree || '',\n salary: hard.salary || '',\n excludeDirections: normalizeStringList(profile?.excludeDirections, 20),\n source,\n updatedAt: Date.now()\n });\n}\n\nfunction profileFromDraft(draft, currentProfile = null) {\n const normalized = normalizeProfileDraft(draft);\n return normalizeProfile({\n ...(currentProfile || {}),\n summary: normalized.summary,\n primaryDirections: normalized.primaryDirections,\n searchKeywords: normalized.searchKeywords,\n excludeDirections: normalized.excludeDirections,\n facts: {\n ...(currentProfile?.facts || {}),\n skills: normalized.skills\n },\n hardConstraints: {\n ...(currentProfile?.hardConstraints || {}),\n locations: normalized.locations,\n employmentTypes: normalized.employmentTypes,\n experience: normalized.experience,\n degree: normalized.degree,\n salary: normalized.salary\n }\n }, currentProfile);\n}\n\n\nfunction clampNumber(value, min, max, fallback = min) {\n const number = Number(value);\n return Number.isFinite(number) ? Math.max(min, Math.min(max, number)) : fallback;\n}\n\nfunction normalizeDirectionKey(value) {\n return String(value || '')\n .toLowerCase()\n .replace(/实习生|工程师|开发|岗位|职位|校招|社招|应届/g, '')\n .replace(/[\\s,,/\\\\|·•()()【】\\[\\]_-]+/g, '')\n .trim();\n}\n\nfunction stableDirectionId(name, source = 'profile') {\n const input = `${source}:${normalizeDirectionKey(name) || String(name || '').trim().toLowerCase()}`;\n let hash = 2166136261;\n for (let index = 0; index < input.length; index += 1) {\n hash ^= input.charCodeAt(index);\n hash = Math.imul(hash, 16777619);\n }\n return `direction_${(hash >>> 0).toString(36)}`;\n}\n\nconst DIRECTION_PRESETS = [\n {\n test: /前端|web|react|vue|javascript|typescript/i,\n keywords: ['前端开发实习生', 'Web前端实习生', 'React开发实习生', 'Vue开发实习生'],\n relevantSkills: ['JavaScript', 'TypeScript', 'HTML', 'CSS', 'React', 'Vue', 'Next.js', 'Vite', 'Webpack'],\n gapSkills: ['工程化', '性能优化', '组件库']\n },\n {\n test: /ai应用|人工智能应用|大模型|agent|rag|llm/i,\n keywords: ['AI应用开发实习生', 'AI Agent实习生', 'RAG应用开发实习生', '大模型应用开发实习生'],\n relevantSkills: ['AI Agent', 'RAG', 'Python', 'TypeScript', 'React', 'Node.js', 'OCR'],\n gapSkills: ['Python', '模型评测', '向量数据库']\n },\n {\n test: /全栈|full.?stack|前后端/i,\n keywords: ['全栈开发实习生', 'Web全栈实习生', '前后端开发实习生'],\n relevantSkills: ['JavaScript', 'TypeScript', 'React', 'Vue', 'Node.js', 'Express', 'MySQL'],\n gapSkills: ['后端接口', '数据库', '部署运维']\n },\n {\n test: /开发者工具|研发工具|桌面端|tauri|electron|工具开发/i,\n keywords: ['开发者工具实习生', '研发工具开发实习生', '桌面端开发实习生', '平台工具开发实习生'],\n relevantSkills: ['Tauri', 'Electron', 'TypeScript', 'React', 'Rust', 'Node.js', 'OCR'],\n gapSkills: ['跨平台工程化', '桌面端发布', '系统 API']\n },\n {\n test: /数据可视化|可视化|echarts|大屏/i,\n keywords: ['数据可视化实习生', '前端可视化实习生', 'ECharts开发实习生'],\n relevantSkills: ['ECharts', 'JavaScript', 'TypeScript', 'React', 'Vue', '数据处理'],\n gapSkills: ['图形性能优化', '复杂交互', '可视化工程化']\n },\n {\n test: /后端|java|node|服务端/i,\n keywords: ['后端开发实习生', 'Node.js开发实习生', 'Java开发实习生'],\n relevantSkills: ['Java', 'Node.js', 'Express', 'MySQL', 'Redis', 'Spring'],\n gapSkills: ['数据库', '缓存', '接口设计']\n }\n];\n\nfunction directionPreset(name) {\n return DIRECTION_PRESETS.find(item => item.test.test(String(name || ''))) || null;\n}\n\nfunction normalizedSkillSet(profile = {}) {\n return new Map(normalizeStringList(profile?.facts?.skills, 50).map(skill => [normalizeDirectionKey(skill), skill]));\n}\n\nfunction buildDirectionKeywords(name, profile = {}) {\n const allKeywords = normalizeStringList(profile?.searchKeywords, 30);\n const nameKey = normalizeDirectionKey(name);\n const preset = directionPreset(name);\n const matching = allKeywords.filter(keyword => {\n const key = normalizeDirectionKey(keyword);\n return key && (nameKey.includes(key) || key.includes(nameKey) || [...nameKey].filter(char => key.includes(char)).length >= Math.min(3, nameKey.length));\n });\n return uniq([name, ...matching, ...(preset?.keywords || [])]).filter(Boolean).slice(0, 8);\n}\n\nfunction buildDirectionEvidence(name, candidate = {}, profile = {}) {\n const skills = normalizeStringList(profile?.facts?.skills, 30);\n const skillMap = normalizedSkillSet(profile);\n const preset = directionPreset(name);\n const relevant = (preset?.relevantSkills || skills).filter(skill => {\n const key = normalizeDirectionKey(skill);\n return skillMap.has(key) || skills.some(current => normalizeDirectionKey(current).includes(key) || key.includes(normalizeDirectionKey(current)));\n });\n const matchedSkills = uniq(relevant.length ? relevant : skills.slice(0, 4)).slice(0, 5);\n const gaps = (preset?.gapSkills || []).filter(skill => {\n const key = normalizeDirectionKey(skill);\n return ![...skillMap.keys()].some(existing => existing.includes(key) || key.includes(existing));\n }).slice(0, 3);\n const evidence = normalizeStringList(candidate?.evidence, 3);\n const reason = evidence.length\n ? `职业画像中已提取到:${evidence.join(';')}`.slice(0, 160)\n : matchedSkills.length\n ? `与简历中的 ${matchedSkills.join('、')} 技能和项目经历匹配。`\n : `该方向来自职业画像中的主要求职方向,可继续人工调整。`;\n return { matchedSkills, gaps, reason };\n}\n\nfunction normalizeDirectionItem(item = {}, index = 0) {\n const name = String(item.name || item.title || '').trim().slice(0, 60);\n const source = item.source === 'custom' || item.custom ? 'custom' : 'profile';\n const id = String(item.id || stableDirectionId(name || `custom-${index}`, source));\n return {\n id,\n source,\n custom: source === 'custom',\n sourceName: String(item.sourceName || name).trim().slice(0, 60),\n name,\n enabled: item.enabled !== false,\n priority: Math.round(clampNumber(item.priority, 1, 99, index + 1)),\n score: Math.round(clampNumber(item.score, 0, 100, source === 'custom' ? 70 : Math.max(60, 88 - index * 7))),\n reason: String(item.reason || (source === 'custom' ? '用户自定义岗位方向。' : '根据职业画像推荐。')).trim().slice(0, 240),\n matchedSkills: normalizeStringList(item.matchedSkills, 8),\n gaps: normalizeStringList(item.gaps, 6),\n keywords: uniq(normalizeStringList(item.keywords, 12).length ? normalizeStringList(item.keywords, 12) : [name]).slice(0, 12),\n updatedAt: Number(item.updatedAt || Date.now())\n };\n}\n\nfunction profileDirectionSignature(profile = {}) {\n return JSON.stringify({\n directions: normalizeStringList(profile?.primaryDirections?.map(item => typeof item === 'string' ? item : item?.name), 6).map(normalizeDirectionKey),\n keywords: normalizeStringList(profile?.searchKeywords, 20).map(normalizeDirectionKey)\n });\n}\n\nfunction normalizeDirectionPlan(plan, profile = null, options = {}) {\n const items = (Array.isArray(plan?.items) ? plan.items : [])\n .map((item, index) => normalizeDirectionItem(item, index))\n .filter(item => item.name && item.keywords.length)\n .sort((left, right) => left.priority - right.priority || right.score - left.score)\n .slice(0, 12)\n .map((item, index) => ({ ...item, priority: index + 1 }));\n return {\n version: 1,\n items,\n confirmed: options.confirmed ?? Boolean(plan?.confirmed),\n updatedAt: Number(options.updatedAt || plan?.updatedAt || Date.now()),\n appliedAt: Number(options.appliedAt || plan?.appliedAt || 0),\n profileSignature: String(options.profileSignature || plan?.profileSignature || profileDirectionSignature(profile)).slice(0, 600)\n };\n}\n\nfunction buildDirectionPlan(profile = {}, currentPlan = null, options = {}) {\n const primary = Array.isArray(profile?.primaryDirections) ? profile.primaryDirections : [];\n const secondary = Array.isArray(profile?.secondaryDirections) ? profile.secondaryDirections : [];\n const searchKeywords = normalizeStringList(profile?.searchKeywords, 20);\n const candidates = [];\n for (const item of [...primary, ...secondary]) {\n const name = String(typeof item === 'string' ? item : item?.name || '').trim();\n if (!name || candidates.some(candidate => normalizeDirectionKey(candidate.name) === normalizeDirectionKey(name))) continue;\n candidates.push({ name, raw: typeof item === 'object' ? item : {} });\n }\n for (const keyword of searchKeywords) {\n if (candidates.length >= 6) break;\n if (!/开发|工程师|实习生|前端|后端|全栈|AI|人工智能|工具|可视化/i.test(keyword)) continue;\n if (candidates.some(candidate => normalizeDirectionKey(candidate.name) === normalizeDirectionKey(keyword))) continue;\n candidates.push({ name: keyword, raw: {} });\n }\n if (!candidates.length) candidates.push({ name: '目标岗位', raw: {} });\n\n const existing = new Map((currentPlan?.items || []).map(item => [String(item.id || ''), item]));\n const generated = candidates.slice(0, 6).map((candidate, index) => {\n const id = stableDirectionId(candidate.name, 'profile');\n const previous = existing.get(id);\n const confidence = Number(candidate.raw?.confidence);\n const score = Number.isFinite(confidence)\n ? Math.round(confidence <= 1 ? confidence * 100 : confidence)\n : Math.max(62, 90 - index * 7);\n const evidence = buildDirectionEvidence(candidate.name, candidate.raw, profile);\n return normalizeDirectionItem({\n id,\n source: 'profile',\n sourceName: candidate.name,\n name: options.preserveEdits && previous?.name ? previous.name : candidate.name,\n enabled: options.preserveSelections && previous ? previous.enabled : index < 3,\n priority: previous?.priority || index + 1,\n score,\n reason: evidence.reason,\n matchedSkills: evidence.matchedSkills,\n gaps: evidence.gaps,\n keywords: options.preserveEdits && previous?.keywords?.length\n ? previous.keywords\n : buildDirectionKeywords(candidate.name, profile)\n }, index);\n });\n const custom = options.preserveCustom === false ? [] : (currentPlan?.items || [])\n .filter(item => item?.source === 'custom' || item?.custom)\n .map((item, index) => normalizeDirectionItem(item, generated.length + index));\n return normalizeDirectionPlan({\n items: [...generated, ...custom],\n confirmed: options.confirmed ?? false,\n updatedAt: Date.now(),\n appliedAt: options.confirmed ? Date.now() : Number(currentPlan?.appliedAt || 0)\n }, profile, { confirmed: options.confirmed ?? false, updatedAt: Date.now() });\n}\n\nfunction selectedDirectionItems(plan = {}) {\n return (Array.isArray(plan?.items) ? plan.items : [])\n .map((item, index) => normalizeDirectionItem(item, index))\n .filter(item => item.enabled && item.name && item.keywords.length)\n .sort((left, right) => left.priority - right.priority || right.score - left.score);\n}\n\nfunction fallbackApplicantGreeting(job, profile) {\n const title = String(job?.title || '该岗位').trim();\n const skills = normalizeStringList(profile?.facts?.skills, 4);\n const direction = normalizeStringList(profile?.primaryDirections?.map(item => typeof item === 'string' ? item : item?.name), 2);\n const evidence = skills.length ? `我的简历中有${skills.slice(0, 3).join('、')}相关项目与实践` : (profile?.summary || direction.join('、'));\n const target = `贵公司的${title}`;\n return `您好,我想应聘${target}岗位。${evidence ? `${evidence},` : ''}对岗位的工作内容很感兴趣。方便的话希望进一步了解岗位和团队,谢谢。`\n .replace(/。{2,}/g, '。')\n .slice(0, 140);\n}\n\nfunction normalizeApplicantGreeting(result, job, profile) {\n const raw = String(result?.greeting || '').trim();\n const reversed = /看到你的简历|你的简历|很匹配我们|匹配我们|欢迎.*沟通|期待你|候选人|我们团队|我们公司|团队主要涉及|你很匹配|方便的话来聊聊/i.test(raw);\n const applicantVoice = /我想应聘|我希望应聘|我对.{0,20}(岗位|职位).{0,10}(感兴趣|有兴趣)|想进一步了解|希望进一步沟通/.test(raw);\n if (!raw || reversed || !applicantVoice) return fallbackApplicantGreeting(job, profile);\n return raw.slice(0, 160);\n}\n\nfunction fastMassAnalysis(job, profile) {\n const jobText = [job?.title, job?.description, job?.cardText].filter(Boolean).join('\\n').toLowerCase();\n const skills = normalizeStringList(profile?.facts?.skills, 20);\n const directions = normalizeStringList(profile?.primaryDirections?.map(item => typeof item === 'string' ? item : item?.name), 8);\n const matchedEvidence = [];\n for (const skill of skills) {\n const token = String(skill || '').trim();\n if (token && jobText.includes(token.toLowerCase())) matchedEvidence.push(token);\n }\n const directionMatched = directions.some(item => item && jobText.includes(String(item).toLowerCase()));\n const score = Math.max(25, Math.min(88, 35 + matchedEvidence.length * 9 + (directionMatched ? 15 : 0)));\n return {\n score,\n decision: matchedEvidence.length || directionMatched ? 'recommend' : 'cautious',\n hardBlocks: [],\n strictHardBlocks: [],\n matchedEvidence: matchedEvidence.slice(0, 8),\n gaps: [],\n risks: [],\n reason: '安全海投快速模式 仅做基础排序 不因技能 年限 专业或学历差距直接拦截',\n greeting: fallbackApplicantGreeting(job, profile),\n analysisMode: 'local-mass-fast'\n };\n}\n\nasync function analyzeJob(job) {\n const { profile, resumeText, config } = await storage.get(['profile', 'resumeText', 'config']);\n if (!profile) throw new Error('请先生成职业画像');\n const massMode = normalizeStrategy(config?.batchStrategy) === 'mass';\n if (massMode && config?.massApplyAnalysis !== 'ai') return fastMassAnalysis(job, profile);\n const strategyInstruction = massMode\n ? '当前为安全海投模式。技能、技术栈、专业、学历偏差、经验年限不足和行业经历缺失通常只能放入gaps,不能直接判定reject。只有JD明确写出必须、仅限、不接受,且与用户真实条件发生不可改变冲突时,才写入hardBlocks。'\n : '当前为精准投递模式。可以综合匹配度给出recommend、cautious或reject,但技能缺口仍应与真正不可改变的硬性冲突区分。';\n const result = await callModel([\n {\n role: 'system',\n content: `你是为求职者服务的岗位匹配审查器,不是招聘方。用户是正在应聘岗位的求职者。任何能力、年限、项目和成果都不能超出简历事实。${strategyInstruction} 输出 JSON:{\"score\":0,\"decision\":\"recommend|cautious|reject\",\"hardBlocks\":[],\"matchedEvidence\":[],\"gaps\":[],\"risks\":[],\"reason\":\"\",\"greeting\":\"\"}。greeting 是求职者发给招聘方/HR 的第一人称求职招呼语,50-110字,应使用“您好,我想应聘贵公司的……”口吻。严禁写成招聘方口吻,严禁出现“看到你的简历”“你的经历很匹配我们”“欢迎进一步沟通”“我们团队”“候选人”等表述;不得承诺薪资、到岗、年限或不存在的能力。`\n },\n {\n role: 'user',\n content: `职业画像:${JSON.stringify(profile)}\n简历:${String(resumeText || '').slice(0, 15000)}\n岗位:${JSON.stringify(job)}`\n }\n ]);\n result.score = Math.max(0, Math.min(100, Number(result.score || 0)));\n result.greeting = normalizeApplicantGreeting(result, job, profile);\n result.hardBlocks = Array.isArray(result.hardBlocks) ? result.hardBlocks.map(item => String(item || '').trim()).filter(Boolean) : [];\n result.strictHardBlocks = strictHardBlocks(result.hardBlocks);\n if (result.strictHardBlocks.length) result.decision = 'reject';\n else if (massMode && result.decision === 'reject') result.decision = 'cautious';\n if (!massMode && result.score < Number(config?.minScore || 75) && result.decision === 'recommend') result.decision = 'cautious';\n return result;\n}\n\nfunction createTasks(profile, config, directionPlan) {\n const directions = selectedDirectionItems(directionPlan);\n const locations = config.targetLocations?.length ? config.targetLocations : [''];\n const employmentTypes = config.employmentTypes?.length ? config.employmentTypes : ['不限'];\n const tasks = [];\n const seen = new Set();\n for (const direction of directions) {\n for (const keyword of direction.keywords) {\n for (const location of locations) {\n for (const employmentType of employmentTypes) {\n const dedupeKey = [keyword, location, employmentType].map(value => String(value || '').trim().toLowerCase()).join('|');\n if (seen.has(dedupeKey)) continue;\n seen.add(dedupeKey);\n tasks.push({\n id: crypto.randomUUID(),\n directionId: direction.id,\n directionName: direction.name,\n directionPriority: direction.priority,\n directionScore: direction.score,\n keyword,\n location,\n employmentType,\n experience: config.experiences?.[0] || '',\n degree: config.degrees?.[0] || '',\n salary: config.salary || '不限',\n attempts: 0,\n status: 'pending',\n progress: 0,\n stageLabel: '等待开始',\n processed: 0,\n discovered: 0,\n analyzed: 0,\n failed: 0,\n createdAt: Date.now()\n });\n }\n }\n }\n }\n return tasks;\n}\n\n\nasync function dispatchNextAutoPending() {\n const { pending = [], workflow = {}, config = {} } = await storage.get(['pending', 'workflow', 'config']);\n if (config.executionMode !== 'auto') return { started: false, reason: 'not-auto' };\n if (config.dryRun) return { started: false, reason: 'dry-run' };\n if (workflow.pendingApplyId) return { started: false, reason: 'busy', pendingApplyId: workflow.pendingApplyId };\n const ranked = rerankPending(pending);\n const candidate = ranked.find(entry => entry.status === 'approved_queue');\n if (!candidate) {\n await storage.set({ pending: ranked });\n return { started: false, reason: 'empty' };\n }\n const next = rerankPending(ranked.map(entry => entry.id === candidate.id\n ? { ...entry, status: 'approved', approvedAt: entry.approvedAt || Date.now() }\n : entry));\n await storage.set({ pending: next });\n const run = await updateTaskRunByPending(candidate.id, {\n status: 'running',\n stage: 'queued',\n progress: 64,\n stageLabel: `优先投递 · 队列第 ${candidate.priorityRank || 1} 位`,\n error: '',\n retryable: true,\n completedAt: null\n });\n await patchWorkflow({\n running: true,\n paused: false,\n phase: 'apply',\n statusText: `${normalizeStrategy(config.batchStrategy) === 'mass' ? '安全海投' : '精准投递'}:${candidate.job?.title || '岗位'}(队列优先级 ${candidate.priorityRank || 1})`,\n pendingApplyId: candidate.id,\n activeRunId: run?.id || candidate.runId || null\n });\n await sendToBoss({ type: 'RUN' });\n return { started: true, item: candidate, queueDepth: next.filter(entry => entry.status === 'approved_queue').length };\n}\n\nasync function addPending(item) {\n const { pending = [], config = {} } = await storage.get(['pending', 'config']);\n let run = null;\n if (item.runId) {\n run = await upsertTaskRun({\n id: item.runId,\n job: item.job,\n analysis: item.analysis,\n searchTask: item.task,\n status: config.executionMode === 'auto' && !config.dryRun ? 'queued' : 'waiting_review',\n stage: config.executionMode === 'auto' && !config.dryRun ? 'queued' : 'waiting_review',\n progress: config.executionMode === 'auto' && !config.dryRun ? 64 : 60,\n stageLabel: config.dryRun ? '模拟运行等待确认' : (config.executionMode === 'auto' ? '已进入自动排序队列' : '等待人工确认')\n });\n } else {\n run = await upsertTaskRun({\n job: item.job,\n analysis: item.analysis,\n searchTask: item.task,\n status: config.executionMode === 'auto' && !config.dryRun ? 'queued' : 'waiting_review',\n stage: config.executionMode === 'auto' && !config.dryRun ? 'queued' : 'waiting_review',\n progress: config.executionMode === 'auto' && !config.dryRun ? 64 : 60,\n stageLabel: config.dryRun ? '模拟运行等待确认' : (config.executionMode === 'auto' ? '已进入自动排序队列' : '等待人工确认')\n });\n }\n const nextItem = {\n ...item,\n runId: run.id,\n id: item.id || crypto.randomUUID(),\n deliveryGreeting: String(item.deliveryGreeting || item.analysis?.greeting || '').trim(),\n status: config.executionMode === 'auto' && !config.dryRun ? 'approved_queue' : 'pending',\n createdAt: Date.now()\n };\n await upsertTaskRun({ id: run.id, pendingId: nextItem.id });\n const next = rerankPending([nextItem, ...pending.filter(entry => entry.id !== nextItem.id)]);\n await storage.set({ pending: next });\n const count = next.filter(entry => entry.status === 'pending').length;\n await storage.set({ stats: { ...(await storage.get('stats')).stats, pending: count } });\n return next.find(entry => entry.id === nextItem.id) || nextItem;\n}\n\nasync function approvePending(id, greeting = '') {\n const { pending = [], config = {}, stats = {} } = await storage.get(['pending', 'config', 'stats']);\n const item = pending.find(entry => entry.id === id);\n if (!item) throw new Error('待确认岗位不存在');\n const lockedGreeting = String(greeting || item.deliveryGreeting || item.analysis?.greeting || '').trim();\n if (config.dryRun) {\n const completedAt = Date.now();\n const next = pending.map(entry => entry.id === id ? {\n ...entry,\n deliveryGreeting: lockedGreeting,\n analysis: { ...(entry.analysis || {}), greeting: lockedGreeting },\n status: 'simulated',\n completedAt\n } : entry);\n await storage.set({\n pending: next,\n stats: { ...stats, pending: next.filter(entry => entry.status === 'pending').length, simulated: Number(stats.simulated || 0) + 1 }\n });\n await updateTaskRunByPending(id, { status: 'success', stage: 'simulated', progress: 100, stageLabel: '模拟投递已通过', retryable: false, completedAt, result: 'dry-run' });\n await writeEvent('success', '模拟投递已通过 未执行真实发送', { id, job: item.job });\n return next.find(entry => entry.id === id);\n }\n const updatedItem = {\n ...item,\n deliveryGreeting: lockedGreeting,\n analysis: { ...(item.analysis || {}), greeting: lockedGreeting },\n status: 'approved',\n approvedAt: Date.now()\n };\n const next = pending.map(entry => entry.id === id ? updatedItem : entry);\n await storage.set({ pending: next });\n const pendingCount = next.filter(entry => entry.status === 'pending').length;\n await storage.set({ stats: { ...stats, pending: pendingCount } });\n const run = await updateTaskRunByPending(id, {\n status: 'running',\n stage: 'queued',\n progress: 64,\n stageLabel: '等待投递',\n error: '',\n retryable: true,\n completedAt: null\n });\n await patchWorkflow({\n running: true,\n paused: false,\n phase: 'apply',\n statusText: `准备以求职者身份沟通:${item.job?.title || '岗位'}`,\n pendingApplyId: id,\n activeRunId: run?.id || item.runId || null\n });\n await sendToBoss({ type: 'RUN' });\n setTimeout(() => sendToBoss({ type: 'RUN' }).catch(() => {}), 700);\n return updatedItem;\n}\n\nasync function approveAllPending() {\n const { pending = [], config = {}, stats = {} } = await storage.get(['pending', 'config', 'stats']);\n const candidates = rerankPending(pending).filter(entry => entry.status === 'pending');\n if (!candidates.length) return { count: 0 };\n if (config.dryRun) {\n const completedAt = Date.now();\n const ids = new Set(candidates.map(entry => entry.id));\n const next = pending.map(entry => ids.has(entry.id) ? { ...entry, status: 'simulated', completedAt } : entry);\n await storage.set({ pending: next, stats: { ...stats, pending: 0, simulated: Number(stats.simulated || 0) + candidates.length } });\n for (const candidate of candidates) {\n await updateTaskRunByPending(candidate.id, { status: 'success', stage: 'simulated', progress: 100, stageLabel: '模拟投递已通过', retryable: false, completedAt, result: 'dry-run' });\n }\n await writeEvent('success', `已完成${candidates.length}个岗位的模拟投递 未执行真实发送`);\n return { count: candidates.length, simulated: true };\n }\n const ids = new Set(candidates.map(entry => entry.id));\n const next = pending.map(entry => ids.has(entry.id)\n ? { ...entry, status: 'approved_queue', approvedAt: Date.now() }\n : entry);\n const first = candidates[0];\n await storage.set({ pending: next });\n await storage.set({ stats: { ...stats, pending: 0 } });\n for (const [index, candidate] of candidates.entries()) {\n await updateTaskRunByPending(candidate.id, {\n status: index === 0 ? 'running' : 'queued',\n stage: 'queued',\n progress: 64,\n stageLabel: index === 0 ? '准备投递' : '批量队列等待中',\n error: '',\n retryable: true,\n completedAt: null\n });\n }\n await patchWorkflow({\n running: true,\n paused: false,\n phase: 'apply',\n statusText: `批量投递 1/${candidates.length}:${first.job?.title || '岗位'}`,\n pendingApplyId: first.id,\n activeRunId: first.runId || null\n });\n await sendToBoss({ type: 'RUN' });\n return { count: candidates.length };\n}\n\nasync function rejectAllPending() {\n const { pending = [] } = await storage.get('pending');\n const count = pending.filter(entry => entry.status === 'pending').length;\n const next = pending.map(entry => entry.status === 'pending'\n ? { ...entry, status: 'rejected', rejectedAt: Date.now() }\n : entry);\n await storage.set({ pending: next });\n const { stats } = await storage.get('stats');\n await storage.set({ stats: { ...stats, pending: 0 } });\n for (const item of pending.filter(entry => entry.status === 'pending')) {\n await updateTaskRunByPending(item.id, { status: 'ignored', stage: 'ignored', progress: 100, stageLabel: '已忽略', retryable: false });\n }\n return { count };\n}\n\nasync function rejectPending(id) {\n const { pending = [] } = await storage.get('pending');\n const next = pending.map(entry => entry.id === id ? { ...entry, status: 'rejected', rejectedAt: Date.now() } : entry);\n await storage.set({ pending: next });\n const count = next.filter(entry => entry.status === 'pending').length;\n const { stats } = await storage.get('stats');\n await storage.set({ stats: { ...stats, pending: count } });\n await updateTaskRunByPending(id, { status: 'ignored', stage: 'ignored', progress: 100, stageLabel: '已忽略', retryable: false });\n}\n\n\nasync function skipPendingTask(id, reason = '该岗位需要外部网申,已自动跳过', stageLabel = '外部网申岗位已跳过') {\n const { pending = [], stats = {}, config = {}, chatTransition = null } = await storage.get(['pending', 'stats', 'config', 'chatTransition']);\n const completedAt = Date.now();\n let skippedItem = null;\n let next = pending.map(entry => {\n if (entry.id !== id) return entry;\n skippedItem = { ...entry, status: 'skipped', completedAt, error: reason };\n return skippedItem;\n });\n const updatedStats = {\n ...stats,\n pending: next.filter(entry => entry.status === 'pending').length\n };\n await storage.set({ pending: next, stats: updatedStats, chatTransition: chatTransition?.pendingId === id ? null : chatTransition });\n const run = await updateTaskRunByPending(id, {\n status: 'skipped', stage: 'skipped', progress: 100,\n stageLabel, error: reason, retryable: false, completedAt\n });\n await writeEvent('info', stageLabel, {\n id,\n runId: run?.id || skippedItem?.runId || '',\n job: skippedItem?.job,\n reason\n });\n\n next = rerankPending(next);\n const queued = next.find(entry => entry.status === 'approved_queue');\n if (queued) {\n next = next.map(entry => entry.id === queued.id ? { ...entry, status: 'approved' } : entry);\n await storage.set({ pending: next });\n const queuedRun = await updateTaskRunByPending(queued.id, {\n status: 'running', stage: 'queued', progress: 64,\n stageLabel: '准备投递', error: '', completedAt: null\n });\n await patchWorkflow({\n pendingApplyId: queued.id,\n activeRunId: queuedRun?.id || queued.runId || null,\n running: true,\n paused: false,\n phase: 'apply',\n statusText: `已跳过外部网申岗位,继续投递:${queued.job?.title || '岗位'}`\n });\n } else {\n await patchWorkflow({\n pendingApplyId: null,\n activeRunId: null,\n running: true,\n paused: false,\n phase: 'search',\n statusText: '外部网申岗位已跳过,继续搜索可直接沟通岗位'\n });\n }\n setTimeout(() => sendToBoss({ type: 'RUN' }).catch(() => {}), Math.max(3000, Math.min(10000, Number(config.betweenJobsSeconds || 5) * 1000)));\n return { item: skippedItem, queued: queued || null };\n}\n\nasync function handleBridgeCommands() {\n try {\n const response = await bridge('/commands');\n for (const command of response.commands || []) {\n if (command.type === 'start') {\n await patchWorkflow({ running: true, paused: false, statusText: '由 OpenClaw 启动' });\n await sendToBoss({ type: 'RUN' });\n } else if (command.type === 'pause') {\n await patchWorkflow({ paused: true, statusText: '由 OpenClaw 暂停' });\n } else if (command.type === 'stop') {\n await patchWorkflow({ running: false, paused: true, phase: 'idle', statusText: '由 OpenClaw 停止' });\n }\n }\n } catch {\n // 桌面桥接未运行时保持浏览器扩展独立可用。\n }\n}\n\nchrome.runtime.onInstalled.addListener(init);\nchrome.runtime.onStartup.addListener(init);\ninit();\n\nchrome.alarms.onAlarm.addListener(async alarm => {\n if (alarm.name === 'jobclaw-update-check') {\n await checkForUpdates(false).catch(() => {});\n return;\n }\n if (alarm.name !== 'jobclaw-tick') return;\n const { workflow } = await storage.get('workflow');\n if (workflow?.running && !workflow.paused) sendToBoss({ type: 'RUN' }).catch(() => {});\n handleBridgeCommands();\n syncBridgeSnapshot(false).catch(() => {});\n});\n\nchrome.runtime.onMessage.addListener((message, sender, reply) => {\n (async () => {\n switch (message?.type) {\n case 'TRUSTED_CHAT_INPUT': {\n const tabId = sender?.tab?.id;\n const url = String(sender?.tab?.url || '');\n if (!/^https:\\/\\/(?:www|app)\\.zhipin\\.com\\//i.test(url)) throw new Error('可信输入只能用于当前 BOSS 页面');\n const result = await trustedChatInput(tabId, message);\n reply({ ok: true, ...result });\n break;\n }\n case 'RATE_LIMIT': {\n reply(await enforceRateLimit(message.scope || 'discovery'));\n break;\n }\n case 'SAFETY_OUTCOME': {\n const state = await applySafetyOutcome({ ok: message.ok !== false, reason: message.reason || '' });\n reply({ ok: true, state });\n break;\n }\n case 'RESET_SAFETY': {\n reply({ ok: true, state: await clearSafetyCircuit() });\n break;\n }\n case 'JOB_PREFLIGHT': {\n reply(await preflightJob(message.job || {}));\n break;\n }\n case 'VERIFY_COMPANY': {\n reply({ ok: true, result: await verifyCompanyForJob(message.job || {}, Boolean(message.force)) });\n break;\n }\n case 'EVALUATE_STRATEGY': {\n const { config = {} } = await storage.get('config');\n const result = evaluateStrategy({\n strategy: config.batchStrategy,\n score: message.score,\n minScore: config.minScore,\n riskLevel: message.riskLevel || 'unknown',\n verified: Boolean(message.verified),\n decision: message.decision || 'cautious',\n hardBlocks: message.hardBlocks || []\n });\n reply({ ok: true, result });\n break;\n }\n case 'CHECK_UPDATE': {\n reply({ ok: true, result: await checkForUpdates(Boolean(message.force)) });\n break;\n }\n case 'OPEN_UPDATE': {\n const { updateInfo = {} } = await storage.get('updateInfo');\n const url = String(message.url || updateInfo.url || 'https://github.com/Chrisbetheking/job-claw/releases');\n await chrome.tabs.create({ url });\n reply({ ok: true });\n break;\n }\n case 'GET_STATE':\n reply({ ok: true, state: publicState(await storage.all()) });\n break;\n case 'SAVE_CONFIG': {\n const { config } = await storage.get('config');\n const incoming = message.config || {};\n const oldKey = config?.model?.apiKey || '';\n incoming.model = {\n ...(config?.model || {}),\n ...(incoming.model || {}),\n apiKey: incoming.model?.apiKey && incoming.model.apiKey !== '***'\n ? incoming.model.apiKey\n : oldKey\n };\n incoming.executionMode = incoming.executionMode === 'auto' ? 'auto' : 'review';\n incoming.batchStrategy = normalizeStrategy(incoming.batchStrategy || config?.batchStrategy || 'precise');\n incoming.massApplyAnalysis = ['fast', 'ai'].includes(incoming.massApplyAnalysis) ? incoming.massApplyAnalysis : (config?.massApplyAnalysis || 'fast');\n incoming.pacingPreset = ['conservative', 'standard', 'efficient', 'custom'].includes(incoming.pacingPreset) ? incoming.pacingPreset : (config?.pacingPreset || 'standard');\n incoming.dryRun = Boolean(incoming.dryRun);\n incoming.dailyTarget = Math.max(1, Math.min(150, Number(incoming.dailyTarget || config?.dailyTarget || 30)));\n incoming.discoveryLimit = Math.max(1, Math.min(800, Number(incoming.discoveryLimit || config?.discoveryLimit || 150)));\n const presetDelay = { conservative: 15, standard: 9, efficient: 6 }[incoming.pacingPreset];\n incoming.betweenJobsSeconds = Math.max(6, Math.min(120, Number(presetDelay || incoming.betweenJobsSeconds || config?.betweenJobsSeconds || 9)));\n incoming.attachmentDelaySeconds = Math.max(1.5, Math.min(15, Number(incoming.attachmentDelaySeconds || config?.attachmentDelaySeconds || 3)));\n incoming.maxPerCompanyPerDay = Math.max(1, Math.min(12, Number(incoming.maxPerCompanyPerDay || config?.maxPerCompanyPerDay || 3)));\n incoming.queueWarmup = Math.max(1, Math.min(10, Number(incoming.queueWarmup || config?.queueWarmup || 4)));\n incoming.maxConsecutiveFailures = Math.max(1, Math.min(10, Number(incoming.maxConsecutiveFailures || config?.maxConsecutiveFailures || 3)));\n incoming.jitterSeconds = Math.max(0, Math.min(15, Number(incoming.jitterSeconds ?? config?.jitterSeconds ?? 3)));\n incoming.companyVerificationEnabled = incoming.companyVerificationEnabled !== false;\n incoming.companyVerificationProvider = String(incoming.companyVerificationProvider || config?.companyVerificationProvider || 'bridge');\n incoming.companyVerificationCacheDays = Math.max(1, Math.min(90, Number(incoming.companyVerificationCacheDays || config?.companyVerificationCacheDays || 14)));\n incoming.blockUnknownCompanies = Boolean(incoming.blockUnknownCompanies);\n incoming.updateCheckEnabled = incoming.updateCheckEnabled !== false;\n incoming.dailyReportEnabled = incoming.dailyReportEnabled !== false;\n incoming.dailyReportTime = /^\\d{2}:\\d{2}$/.test(String(incoming.dailyReportTime || '')) ? String(incoming.dailyReportTime) : (config?.dailyReportTime || '20:30');\n incoming.dailyReportNotification = incoming.dailyReportNotification !== false;\n incoming.rateLimits = { ...DEFAULTS.config.rateLimits, ...(config?.rateLimits || {}), ...(incoming.rateLimits || {}), deliveryMs: Math.max(6000, Number(incoming.betweenJobsSeconds || 9) * 1000), attachmentMs: Math.max(1500, Number(incoming.attachmentDelaySeconds || 3) * 1000) };\n incoming.model.baseUrl = String(incoming.model.baseUrl || 'https://api.deepseek.com').trim();\n incoming.model.model = String(incoming.model.model || 'deepseek-v4-pro').trim();\n await storage.set({ config: { ...(config || {}), ...incoming } });\n await writeEvent('info', '设置已保存');\n syncBridgeSnapshot(true).catch(() => {});\n reply({ ok: true });\n break;\n }\n case 'SET_RESUME':\n await storage.set({ resumeText: String(message.text || '') });\n await writeEvent('info', '简历文本已保存', { length: String(message.text || '').length });\n reply({ ok: true });\n break;\n case 'SET_RESUME_SOURCE': {\n const file = message.file || null;\n if (!file?.name || !file?.dataUrl) throw new Error('简历原文件数据不完整');\n if (String(file.dataUrl).length > 26 * 1024 * 1024) throw new Error('简历原文件过大');\n const source = {\n name: String(file.name),\n type: String(file.type || ''),\n size: Number(file.size || 0),\n lastModified: Number(file.lastModified || 0),\n dataUrl: String(file.dataUrl),\n savedAt: Date.now()\n };\n await storage.set({ resumeSourceFile: source });\n await writeEvent('info', '简历原文件已保留', { name: source.name, size: source.size });\n reply({ ok: true, file: { name: source.name, type: source.type, size: source.size, stored: true } });\n break;\n }\n case 'PARSE_RESUME_PDF': {\n const { resumeSourceFile } = await storage.get('resumeSourceFile');\n if (!resumeSourceFile?.dataUrl) throw new Error('请先选择 PDF 简历');\n if (!String(resumeSourceFile.name || '').toLowerCase().endsWith('.pdf') && resumeSourceFile.type !== 'application/pdf') {\n throw new Error('当前保留的文件不是 PDF');\n }\n try {\n const result = await bridge('/parse-resume', {\n name: resumeSourceFile.name,\n type: resumeSourceFile.type,\n dataUrl: resumeSourceFile.dataUrl\n });\n if (result?.text) await writeEvent('info', 'PDF 本机深度识别完成', { method: result.method, length: result.text.length });\n reply({ ok: Boolean(result?.ok), result, error: result?.error || '' });\n } catch (error) {\n reply({ ok: false, error: `桌面桥接不可用:${error.message}` });\n }\n break;\n }\n case 'SET_IMAGE':\n await storage.set({ resumeImage: message.dataUrl || null });\n await writeEvent('info', message.dataUrl ? '简历图片已保存' : '简历图片已移除');\n reply({ ok: true });\n break;\n case 'BUILD_LOCAL_PROFILE':\n case 'ENSURE_PROFILE_DRAFT': {\n const { resumeText, profile: currentProfile, profileDraft: currentDraft } = await storage.get(['resumeText', 'profile', 'profileDraft']);\n if (profileDraftHasCore(currentDraft) && profileHasCore(currentProfile)) {\n reply({ ok: true, profile: currentProfile, profileDraft: currentDraft, skipped: true });\n break;\n }\n if (profileHasCore(currentProfile)) {\n const profileDraft = profileDraftHasAny(currentDraft)\n ? normalizeProfileDraft(currentDraft, profileToDraft(currentProfile, 'profile-repair'))\n : profileToDraft(currentProfile, 'profile-repair');\n await storage.set({ profileDraft });\n reply({ ok: true, profile: currentProfile, profileDraft, repaired: true });\n break;\n }\n const profile = buildLocalProfile(resumeText || '');\n profile.generation.mode = 'local-recovery';\n profile.generation.label = '本地初稿';\n profile.generation.warning = '已从保存的简历自动恢复可编辑初稿。';\n const profileDraft = profileToDraft(profile, 'local-recovery');\n const { directionPlan: currentDirectionPlan } = await storage.get('directionPlan');\n const directionPlan = buildDirectionPlan(profile, currentDirectionPlan, {\n confirmed: false,\n preserveSelections: true,\n preserveEdits: true,\n preserveCustom: true\n });\n await storage.set({ profile, profileDraft, directionPlan });\n await writeEvent('info', '已从已保存简历恢复可编辑画像', { directions: profile.primaryDirections });\n reply({ ok: true, profile, profileDraft, directionPlan, generation: profile.generation });\n break;\n }\n case 'BUILD_PROFILE': {\n const { resumeText, directionPlan: currentDirectionPlan } = await storage.get(['resumeText', 'directionPlan']);\n const profile = await buildProfile(resumeText || '');\n const profileDraft = profileToDraft(profile, profile.generation?.mode || 'generated');\n const directionPlan = buildDirectionPlan(profile, currentDirectionPlan, {\n confirmed: false,\n preserveSelections: true,\n preserveEdits: true,\n preserveCustom: true\n });\n await storage.set({ profile, profileDraft, directionPlan });\n const generation = profile.generation || {};\n const local = generation.mode === 'local-fallback';\n const eventTitle = !local\n ? (generation.mode === 'ai-compact-retry' ? 'AI 精简重试成功,职业画像已生成' : '职业画像已生成')\n : generation.aiStatus === 'service-error'\n ? 'AI 请求失败,已生成本地可编辑画像'\n : generation.aiStatus === 'config-missing'\n ? 'AI 尚未配置,已生成本地可编辑画像'\n : 'AI 输出未通过校验,已生成本地可编辑画像';\n await writeEvent(local ? 'warning' : 'info', eventTitle,\n { directions: profile.primaryDirections, generation });\n reply({ ok: true, profile, profileDraft, directionPlan, generation, warning: generation.warning || '' });\n break;\n }\n case 'SAVE_PROFILE_DRAFT': {\n const { profileDraft: currentDraft } = await storage.get('profileDraft');\n const profileDraft = normalizeProfileDraft(message.profileDraft, currentDraft);\n await storage.set({ profileDraft });\n reply({ ok: true, profileDraft });\n break;\n }\n case 'SAVE_PROFILE': {\n const { profile: currentProfile, config = {}, profileDraft: currentDraft, directionPlan: currentDirectionPlan } = await storage.get(['profile', 'config', 'profileDraft', 'directionPlan']);\n const profile = normalizeProfile(message.profile, currentProfile);\n const profileDraft = profileToDraft(profile, 'manual-save');\n const keepDirectionConfirmation = Boolean(\n currentDirectionPlan?.confirmed\n && profileDirectionSignature(currentProfile || {}) === profileDirectionSignature(profile)\n );\n const directionPlan = buildDirectionPlan(profile, currentDirectionPlan, {\n confirmed: keepDirectionConfirmation,\n preserveSelections: true,\n preserveEdits: true,\n preserveCustom: true\n });\n const hard = profile.hardConstraints || {};\n const nextConfig = {\n ...config,\n targetLocations: hard.locations?.length ? hard.locations : (config.targetLocations || []),\n employmentTypes: hard.employmentTypes?.length ? hard.employmentTypes : (config.employmentTypes || ['不限']),\n experiences: hard.experience ? [hard.experience] : (config.experiences || []),\n degrees: hard.degree ? [hard.degree] : (config.degrees || []),\n salary: hard.salary || config.salary || '不限'\n };\n await storage.set({ profile, profileDraft, directionPlan, config: nextConfig });\n await writeEvent('info', '职业画像已手动保存', { directions: profile.primaryDirections });\n reply({ ok: true, profile, profileDraft, directionPlan, config: nextConfig });\n break;\n }\n case 'REBUILD_DIRECTION_PLAN': {\n const stored = await storage.get(['profile', 'profileDraft', 'directionPlan']);\n let profile = stored.profile;\n if (!profileHasCore(profile) && profileDraftHasCore(stored.profileDraft)) {\n profile = profileFromDraft(stored.profileDraft, profile);\n await storage.set({ profile });\n }\n if (!profileHasCore(profile)) throw new Error('请先生成职业画像');\n const directionPlan = buildDirectionPlan(profile, stored.directionPlan, {\n confirmed: false,\n preserveSelections: false,\n preserveEdits: false,\n preserveCustom: true\n });\n await storage.set({ directionPlan });\n await writeEvent('info', '已重新生成岗位方向推荐', {\n directions: directionPlan.items.map(item => item.name),\n enabled: directionPlan.items.filter(item => item.enabled).length\n });\n reply({ ok: true, directionPlan });\n break;\n }\n case 'SAVE_DIRECTION_PLAN': {\n const stored = await storage.get(['profile', 'config', 'workflow', 'directionPlan']);\n if (!profileHasCore(stored.profile)) throw new Error('请先生成职业画像');\n const directionPlan = normalizeDirectionPlan(message.directionPlan, stored.profile, {\n confirmed: true,\n updatedAt: Date.now(),\n appliedAt: Date.now()\n });\n const selected = selectedDirectionItems(directionPlan);\n if (!selected.length) throw new Error('至少选择一个要投递的岗位方向');\n const tasks = createTasks(stored.profile, stored.config || DEFAULTS.config, directionPlan);\n if (!tasks.length) throw new Error('所选岗位方向没有可用搜索词,请先补充关键词');\n await storage.set({ directionPlan });\n await writeEvent('info', '投递岗位方向已保存', {\n directions: selected.map(item => item.name),\n taskCount: tasks.length,\n appliesNextRun: Boolean(stored.workflow?.running)\n });\n reply({\n ok: true,\n directionPlan,\n selectedCount: selected.length,\n taskCount: tasks.length,\n appliesNextRun: Boolean(stored.workflow?.running)\n });\n break;\n }\n case 'TEST_AI': {\n const text = await callModel([\n { role: 'system', content: '只回复“连接正常”。' },\n { role: 'user', content: '测试连接' }\n ], false);\n reply({ ok: true, text });\n break;\n }\n case 'PROBE_BOSS': {\n const tab = await activeBossTab();\n if (!tab) throw new Error('请先打开并登录 BOSS 直聘');\n const result = await ensureBossReceiver(tab);\n reply({ ok: true, result: { ...result, tabId: tab.id, title: tab.title || '', url: tab.url || '' } });\n break;\n }\n case 'START': {\n const safety = await storage.get('safetyState');\n if (safety.safetyState?.circuitOpen) throw new Error(`安全熔断未重置:${safety.safetyState.circuitReason || '请先检查页面状态'}`);\n const stored = await storage.get(['profile', 'profileDraft', 'resumeText', 'config', 'directionPlan']);\n let profile = stored.profile;\n const config = stored.config;\n if (!profileHasCore(profile) && profileDraftHasCore(stored.profileDraft)) {\n profile = profileFromDraft(stored.profileDraft, profile);\n await storage.set({ profile });\n }\n if (!profileHasCore(profile) && String(stored.resumeText || '').trim().length >= 30) {\n profile = buildLocalProfile(stored.resumeText || '');\n profile.generation.mode = 'local-recovery';\n profile.generation.label = '本地初稿';\n const profileDraft = profileToDraft(profile, 'start-recovery');\n await storage.set({ profile, profileDraft });\n }\n if (!profileHasCore(profile)) throw new Error('请先生成职业画像');\n let directionPlan = stored.directionPlan;\n if (!directionPlan?.items?.length) {\n directionPlan = buildDirectionPlan(profile, null, { confirmed: false });\n await storage.set({ directionPlan });\n }\n directionPlan = normalizeDirectionPlan(directionPlan, profile);\n if (!directionPlan.confirmed) throw new Error('请先在“简历 → 职业画像”中选择要投递的岗位方向并保存');\n if (!selectedDirectionItems(directionPlan).length) throw new Error('至少选择一个要投递的岗位方向');\n const tasks = createTasks(profile, config, directionPlan);\n if (!tasks.length) throw new Error('所选岗位方向没有可执行的搜索词,请补充方向关键词');\n const tab = await activeBossTab();\n if (!tab) throw new Error('请先打开并登录 BOSS 直聘');\n await ensureBossReceiver(tab);\n const workflow = {\n ...DEFAULTS.workflow,\n running: true,\n paused: false,\n phase: 'search',\n statusText: '正在启动岗位采集',\n tasks,\n activeRunId: null\n };\n await storage.set({ workflow });\n try {\n await sendToBossTab(tab, { type: 'RUN' });\n await writeEvent('info', '任务已启动', { taskCount: workflow.tasks.length, tabId: tab.id });\n reply({ ok: true });\n } catch (error) {\n await patchWorkflow({\n running: false,\n paused: true,\n phase: 'idle',\n statusText: 'BOSS 页面连接失败,任务未启动'\n });\n throw error;\n }\n break;\n }\n case 'PAUSE':\n await patchWorkflow({ paused: true, statusText: '用户已暂停' });\n await writeEvent('info', '任务已暂停');\n reply({ ok: true });\n break;\n case 'STOP':\n await patchWorkflow({ running: false, paused: true, phase: 'idle', statusText: '用户已停止', pendingApplyId: null, activeRunId: null });\n await writeEvent('info', '任务已停止');\n reply({ ok: true });\n break;\n case 'AI_JOB':\n reply({ ok: true, result: await analyzeJob(message.job) });\n break;\n case 'CHAT_BINDING_PREPARE': {\n if (!message.pendingId || !message.expected) throw new Error('岗位与 HR 会话绑定信息不完整');\n const transition = {\n pendingId: String(message.pendingId),\n job: safeClone(message.job || {}),\n expected: safeClone(message.expected || {}),\n preparedAt: Date.now()\n };\n await storage.set({ chatTransition: transition });\n reply({ ok: true, transition });\n break;\n }\n case 'CHAT_BINDING_CHECK': {\n const expected = message.expected || {};\n const recruiterName = String(message.context?.recruiterName || expected.recruiterName || '').trim();\n const company = String(message.context?.companyName || expected.company || message.job?.company || '').trim();\n const key = deriveConversationReservationKey(message.context || {}, expected, message.pendingId);\n if (!key) {\n reply({ ok: false, allowed: false, error: '目标 HR 已核验,但无法建立安全投递锁' });\n break;\n }\n const { chatDeliveryLedger = {} } = await storage.get('chatDeliveryLedger');\n const existing = chatDeliveryLedger[key];\n const identityExisting = Object.values(chatDeliveryLedger).find(entry =>\n entry?.pendingId !== message.pendingId\n && ['reserved', 'sent'].includes(String(entry?.status || ''))\n && sameRecruiterReservation(entry, recruiterName, company)\n );\n const conflict = existing && existing.pendingId !== message.pendingId ? existing : identityExisting;\n if (conflict) {\n reply({\n ok: true,\n allowed: false,\n error: `当前 HR 会话已绑定其他岗位任务,禁止继续发送`,\n existing: conflict\n });\n break;\n }\n const reservation = {\n ...(existing || {}),\n conversationKey: key,\n pendingId: String(message.pendingId || ''),\n runId: String(message.runId || ''),\n jobUrl: String(message.job?.url || ''),\n jobTitle: String(message.job?.title || ''),\n company: String(message.job?.company || ''),\n recruiterName,\n company,\n chatUrl: String(message.context?.url || ''),\n status: existing?.status === 'sent' ? 'sent' : 'reserved',\n reservedAt: existing?.reservedAt || Date.now(),\n updatedAt: Date.now()\n };\n await storage.set({ chatDeliveryLedger: { ...chatDeliveryLedger, [key]: reservation } });\n reply({ ok: true, allowed: true, conversationKey: key, reservation });\n break;\n }\n case 'CHAT_BINDING_CONFIRMED': {\n const transition = {\n pendingId: String(message.pendingId || ''),\n job: safeClone(message.job || {}),\n expected: safeClone(message.expected || {}),\n context: safeClone(message.context || {}),\n confirmedAt: Date.now()\n };\n await storage.set({ chatTransition: transition });\n reply({ ok: true, transition });\n break;\n }\n case 'MATERIAL': {\n const { resumeImage, config } = await storage.get(['resumeImage', 'config']);\n reply({ ok: true, resumeImage, config });\n break;\n }\n case 'CONTENT_STATE':\n reply({ ok: true, state: await storage.all() });\n break;\n case 'WORKFLOW':\n reply({ ok: true, workflow: await patchWorkflow(message.patch || {}) });\n break;\n case 'STATS':\n reply({ ok: true, stats: await changeStats(message.delta || {}) });\n break;\n case 'TASK_PROGRESS': {\n const run = await upsertTaskRun(message.run || {});\n if (message.setActive) await patchWorkflow({ activeRunId: run.id });\n reply({ ok: true, run });\n break;\n }\n case 'SEARCH_TASK_PROGRESS':\n reply({ ok: true, task: await updateSearchTaskProgress(message) });\n break;\n case 'RETRY_FAILED_TASK':\n reply({ ok: true, ...(await retryFailedTask(message.runId)) });\n break;\n case 'RETRY_ALL_FAILED_TASKS':\n reply({ ok: true, ...(await retryAllFailedTasks()) });\n break;\n case 'IGNORE_FAILED_TASK':\n reply({ ok: true, run: await ignoreFailedTask(message.runId) });\n break;\n case 'OPEN_TASK_JOB': {\n const url = String(message.url || '').trim();\n if (!/^https:\\/\\/(?:www|app)\\.zhipin\\.com\\//i.test(url)) throw new Error('岗位地址无效');\n const tab = await chrome.tabs.create({ url, active: true });\n reply({ ok: true, tabId: tab.id });\n break;\n }\n case 'EVENT':\n reply({ ok: true, event: await writeEvent(message.level || 'info', message.message || '', message.data || {}) });\n break;\n case 'PENDING':\n reply({ ok: true, item: await addPending(message.item || {}) });\n break;\n case 'APPROVE':\n reply({ ok: true, item: await approvePending(message.id, message.greeting || '') });\n break;\n case 'REJECT':\n await rejectPending(message.id);\n reply({ ok: true });\n break;\n case 'APPROVE_ALL':\n reply({ ok: true, ...(await approveAllPending()) });\n break;\n case 'REJECT_ALL':\n reply({ ok: true, ...(await rejectAllPending()) });\n break;\n case 'AUTO_APPROVE': {\n const { pending = [] } = await storage.get('pending');\n const next = rerankPending(pending.map(entry => entry.id === message.id\n ? {\n ...entry,\n deliveryGreeting: String(message.greeting || entry.deliveryGreeting || entry.analysis?.greeting || '').trim(),\n status: 'approved_queue',\n approvedAt: Date.now()\n }\n : entry));\n await storage.set({ pending: next });\n reply({ ok: true, ...(await dispatchNextAutoPending()) });\n break;\n }\n case 'AUTO_DISPATCH_NEXT':\n reply({ ok: true, ...(await dispatchNextAutoPending()) });\n break;\n case 'SKIP_PENDING':\n reply({ ok: true, ...(await skipPendingTask(message.id, message.reason, message.stageLabel)) });\n break;\n case 'APPLY_COMPLETE': {\n const { pending = [], config = {}, stats = {}, chatDeliveryLedger = {}, chatTransition = null, deliveryHistory = [] } = await storage.get(['pending', 'config', 'stats', 'chatDeliveryLedger', 'chatTransition', 'deliveryHistory']);\n const completedAt = Date.now();\n let completedItem = null;\n let next = pending.map(entry => {\n if (entry.id !== message.id) return entry;\n completedItem = { ...entry, status: message.ok ? 'sent' : 'failed', completedAt, error: message.error || '' };\n return completedItem;\n });\n const updatedStats = {\n ...stats,\n pending: next.filter(entry => entry.status === 'pending').length,\n sent: Number(stats?.sent || 0) + (message.ok ? 1 : 0),\n failed: Number(stats?.failed || 0) + (message.ok ? 0 : 1)\n };\n const conversationKey = String(message.conversation?.key || '');\n let nextLedger = chatDeliveryLedger;\n if (conversationKey && chatDeliveryLedger[conversationKey]?.pendingId === message.id) {\n nextLedger = {\n ...chatDeliveryLedger,\n [conversationKey]: {\n ...chatDeliveryLedger[conversationKey],\n status: message.ok ? 'sent' : 'failed',\n sentAt: message.ok ? completedAt : (chatDeliveryLedger[conversationKey].sentAt || null),\n failedAt: message.ok ? null : completedAt,\n error: message.error || '',\n updatedAt: completedAt\n }\n };\n }\n const nextTransition = chatTransition?.pendingId === message.id ? null : chatTransition;\n const nextHistory = message.ok && completedItem?.job\n ? [createHistoryEntry(completedItem.job, 'sent', completedAt, { pendingId: message.id, runId: completedItem.runId || '' }), ...deliveryHistory].slice(0, 2000)\n : deliveryHistory;\n await storage.set({ pending: next, stats: updatedStats, chatDeliveryLedger: nextLedger, chatTransition: nextTransition, deliveryHistory: nextHistory });\n const safetyAfter = await applySafetyOutcome({ ok: Boolean(message.ok), reason: message.error || (message.ok ? '' : '投递失败') });\n const completedRun = await updateTaskRunByPending(message.id, message.ok\n ? {\n status: 'success', stage: 'success', progress: 100, stageLabel: '投递成功', error: '',\n retryable: false, completedAt, result: message.result || 'sent'\n }\n : {\n status: 'failed', stage: message.stage || 'failed', progress: 100,\n stageLabel: message.stageLabel || '投递失败', error: message.error || '投递失败',\n retryable: message.retryable !== false, retryRequestedAt: 0, completedAt\n });\n await writeEvent(message.ok ? 'success' : 'error', message.ok ? '求职投递成功' : '求职投递失败', {\n id: message.id,\n runId: completedRun?.id || completedItem?.runId || '',\n job: completedItem?.job,\n stage: message.stage || '',\n error: message.error || ''\n });\n if (safetyAfter.circuitOpen) {\n reply({ ok: true, circuitOpen: true, reason: safetyAfter.circuitReason });\n break;\n }\n\n const validationPending = message.ok\n && config.executionMode === 'auto'\n && config.requireSingleJobValidation !== false\n && !Number(config.singleJobValidationCompletedAt || 0);\n if (validationPending) {\n const nextConfig = { ...config, singleJobValidationCompletedAt: completedAt };\n await storage.set({ config: nextConfig });\n await patchWorkflow({\n running: false,\n paused: true,\n pendingApplyId: null,\n activeRunId: null,\n phase: 'idle',\n statusText: '单条验收已通过并自动暂停;确认聊天文字和附件后可继续批量'\n });\n await writeEvent('success', '首次单条投递验收通过', {\n id: message.id,\n runId: completedRun?.id || completedItem?.runId || '',\n job: completedItem?.job,\n completedAt\n });\n reply({ ok: true, validationCompleted: true, pausedForValidation: true });\n break;\n }\n\n if (updatedStats.sent >= Number(config.dailyTarget || 30)) {\n await patchWorkflow({ running: false, paused: true, pendingApplyId: null, activeRunId: null, phase: 'idle', statusText: `已完成今日 ${updatedStats.sent} 次成功投递` });\n reply({ ok: true, targetReached: true });\n break;\n }\n\n // 任何文字未确认场景都暂停当前队列,但只有输入框中确实存在完整招呼语时,\n // 才提示“草稿已保留”。输入框为空时必须明确提示写入失败,不能误导用户。\n if (!message.ok && message.pauseQueue) {\n const draftPresent = message.preserveDraft === true || message.draftPresent === true;\n await patchWorkflow({\n running: false,\n paused: true,\n pendingApplyId: null,\n activeRunId: completedRun?.id || completedItem?.runId || null,\n phase: 'apply',\n statusText: draftPresent\n ? '发送未确认,已暂停队列;当前招呼语仍在输入框'\n : '文字没有写入或没有发送,已暂停队列并停留在当前 HR 页面'\n });\n await writeEvent('warning', draftPresent\n ? '自动投递已暂停,当前招呼语仍保留在输入框'\n : '自动投递已暂停,聊天输入框中没有完整招呼语', {\n id: message.id,\n runId: completedRun?.id || completedItem?.runId || '',\n job: completedItem?.job,\n draftPresent,\n editorDiagnostics: message.editorDiagnostics || null,\n error: message.error || ''\n });\n reply({ ok: true, pausedForDraft: draftPresent, pausedForSendFailure: true });\n break;\n }\n\n next = rerankPending(next);\n const queued = next.find(entry => entry.status === 'approved_queue');\n if (queued) {\n next = next.map(entry => entry.id === queued.id ? { ...entry, status: 'approved' } : entry);\n await storage.set({ pending: next });\n const remaining = next.filter(entry => entry.status === 'approved_queue').length;\n const queuedRun = await updateTaskRunByPending(queued.id, {\n status: 'running', stage: 'queued', progress: 64, stageLabel: '准备投递', error: '', completedAt: null\n });\n await patchWorkflow({\n pendingApplyId: queued.id,\n activeRunId: queuedRun?.id || queued.runId || null,\n running: true,\n paused: false,\n phase: 'apply',\n statusText: `继续批量投递:${queued.job?.title || '岗位'}(剩余 ${remaining})`\n });\n const pacingMs = Math.max(3000, Math.min(30000, Number(config.betweenJobsSeconds || 12) * 1000));\n await patchWorkflow({ statusText: `当前岗位处理完成,${Math.round(pacingMs / 1000)} 秒后继续批量投递` });\n setTimeout(() => sendToBoss({ type: 'RUN' }).catch(() => {}), pacingMs);\n } else {\n const pacingMs = Math.max(3000, Math.min(30000, Number(config.betweenJobsSeconds || 12) * 1000));\n await patchWorkflow({ pendingApplyId: null, activeRunId: null, phase: 'search', statusText: message.ok ? `文字投递已确认,${Math.round(pacingMs / 1000)} 秒后继续筛选` : `当前岗位失败,${Math.round(pacingMs / 1000)} 秒后继续筛选` });\n setTimeout(() => sendToBoss({ type: 'RUN' }).catch(() => {}), pacingMs);\n }\n reply({ ok: true });\n break;\n }\n case 'BRIDGE_STATUS':\n try { reply({ ok: true, result: await bridge('/status') }); }\n catch (error) { reply({ ok: false, error: error.message }); }\n break;\n case 'BRIDGE_DIAGNOSE':\n reply({ ok: true, result: await diagnoseBridge() });\n break;\n case 'BRIDGE_REPORT_NOW':\n try {\n await syncBridgeSnapshot(true);\n reply({ ok: true, result: await bridge('/report/generate', { force: true }) });\n } catch (error) { reply({ ok: false, error: error.message }); }\n break;\n case 'BRIDGE_REPORT':\n try { reply({ ok: true, result: await bridge('/report') }); }\n catch (error) { reply({ ok: false, error: error.message }); }\n break;\n case 'BRIDGE_COMMAND':\n try { reply({ ok: true, result: await bridge('/command', { type: message.command }) }); }\n catch (error) { reply({ ok: false, error: error.message }); }\n break;\n default:\n reply({ ok: false, error: 'unknown message' });\n }\n })().catch(async error => {\n await writeEvent('error', '后台处理失败', { type: message?.type, error: error.message });\n reply({ ok: false, error: error.message });\n });\n return true;\n});\n"}, "1717e3d1c43b3d2df618a2320c9d66a2182ce7ee": {"t": 1785758650.9880707, "n": 157588, "h": "b62afbe9c8346314", "c": "import { DEFAULTS, safeClone, today, uniq } from './common.js';\nimport { normalizeConversationIdentity, deriveConversationReservationKey, sameRecruiterReservation } from './lib/conversation-identity.js';\nimport { TERMINAL_RUN_STATUSES, taskStageMeta } from './lib/task-state.js';\nimport { rerankPending } from './lib/job-priority.js';\nimport { computeRateLimitDecision, evaluateStrategy, normalizeStrategy, recordRateAction, recordSafetyOutcome, resetSafetyCircuit, strictHardBlocks } from './lib/safety-control.js';\nimport { companyCacheKey, companyVerificationExpired, heuristicCompanyVerification, mergeCompanyVerification } from './lib/company-verifier.js';\nimport { createHistoryEntry, findDuplicate } from './lib/deduplication.js';\nimport { normalizeRelease } from './lib/update-checker.js';\n\nconst BRIDGE_ENDPOINTS = ['http://127.0.0.1:17899', 'http://localhost:17899'];\nconst NATIVE_BRIDGE_HOST = 'com.jobclaw.bridge';\nlet lastBridgeSnapshotAt = 0;\nlet bridgeUnavailableUntil = 0;\nlet bridgeLastError = '';\nconst EXPECTED_CONTENT_VERSION = '1.7.0';\nconst CONTENT_SCRIPT_FILE = 'content-v37.js';\nconst storage = {\n get: keys => chrome.storage.local.get(keys),\n all: () => chrome.storage.local.get(null),\n set: patch => chrome.storage.local.set(patch)\n};\n\nconst debuggerLocks = new Map();\n\nconst OFFSCREEN_DOCUMENT_PATH = 'offscreen.html';\nlet offscreenCreation = null;\n\nasync function ensureClipboardOffscreen() {\n if (!chrome.offscreen?.createDocument) return false;\n const url = chrome.runtime.getURL(OFFSCREEN_DOCUMENT_PATH);\n if (chrome.runtime.getContexts) {\n const contexts = await chrome.runtime.getContexts({\n contextTypes: ['OFFSCREEN_DOCUMENT'],\n documentUrls: [url]\n }).catch(() => []);\n if (contexts?.length) return true;\n }\n if (!offscreenCreation) {\n offscreenCreation = chrome.offscreen.createDocument({\n url: OFFSCREEN_DOCUMENT_PATH,\n reasons: ['CLIPBOARD'],\n justification: '将求职招呼语以真实粘贴方式写入 BOSS 聊天编辑器'\n }).then(() => true).catch(error => {\n const message = String(error?.message || error || '');\n if (/single offscreen|already exists|existing offscreen/i.test(message)) return true;\n throw error;\n }).finally(() => { offscreenCreation = null; });\n }\n return offscreenCreation;\n}\n\nasync function writeClipboardText(text) {\n const value = String(text || '');\n if (!value) return false;\n try {\n const ready = await ensureClipboardOffscreen();\n if (!ready) return false;\n const response = await chrome.runtime.sendMessage({ type: 'JOBCLAW_CLIPBOARD_WRITE', text: value });\n return response?.ok === true;\n } catch {\n return false;\n }\n}\n\nfunction validPoint(point) {\n const x = Number(point?.x);\n const y = Number(point?.y);\n if (!Number.isFinite(x) || !Number.isFinite(y) || x < 0 || y < 0) {\n throw new Error('聊天输入坐标无效');\n }\n return { x, y };\n}\n\nasync function debuggerBringToFront(target) {\n await chrome.debugger.sendCommand(target, 'Page.bringToFront').catch(() => {});\n await chrome.debugger.sendCommand(target, 'Runtime.evaluate', {\n expression: 'window.focus(); true',\n returnByValue: true\n }).catch(() => {});\n}\n\nasync function debuggerNodeAtPoint(target, point) {\n const { x, y } = validPoint(point);\n const result = await chrome.debugger.sendCommand(target, 'DOM.getNodeForLocation', {\n x, y,\n includeUserAgentShadowDOM: true,\n ignorePointerEventsNone: true\n }).catch(() => null);\n return {\n nodeId: Number(result?.nodeId || 0),\n backendNodeId: Number(result?.backendNodeId || 0),\n frameId: String(result?.frameId || '')\n };\n}\n\nasync function debuggerResolveNodeObject(target, node = {}) {\n const args = {};\n if (Number(node.nodeId || 0)) args.nodeId = Number(node.nodeId);\n else if (Number(node.backendNodeId || 0)) args.backendNodeId = Number(node.backendNodeId);\n else return '';\n const resolved = await chrome.debugger.sendCommand(target, 'DOM.resolveNode', args).catch(() => null);\n return String(resolved?.object?.objectId || '');\n}\n\nasync function debuggerReadObjectText(target, objectId = '') {\n if (!objectId) return '';\n const result = await chrome.debugger.sendCommand(target, 'Runtime.callFunctionOn', {\n objectId,\n functionDeclaration: `function() {\n if ('value' in this && !this.isContentEditable) return String(this.value || '');\n return String(this.innerText || this.textContent || '');\n }`,\n returnByValue: true,\n silent: true\n }).catch(() => null);\n return String(result?.result?.value || '');\n}\n\nasync function debuggerReadNodeText(target, node = {}) {\n const objectId = await debuggerResolveNodeObject(target, node);\n return debuggerReadObjectText(target, objectId);\n}\n\nasync function debuggerFindEditableObject(target, message = {}) {\n const selector = String(message.selector || '').trim();\n const point = validPoint(message.point);\n const expression = `(() => {\n const exactSelector = ${JSON.stringify(selector)};\n const point = ${JSON.stringify(point)};\n const editableSelector = 'textarea,input[type=\"text\"],input:not([type]),[contenteditable]:not([contenteditable=\"false\"]),[role=\"textbox\"],[data-slate-editor=\"true\"],[data-lexical-editor=\"true\"]';\n const isEditable = node => {\n if (!node || node.nodeType !== 1) return false;\n const tag = String(node.tagName || '').toLowerCase();\n if (tag === 'textarea') return true;\n if (tag === 'input') {\n const type = String(node.getAttribute('type') || 'text').toLowerCase();\n return ['text', 'search', ''].includes(type);\n }\n const mode = String(node.getAttribute('contenteditable') || '').toLowerCase();\n return node.isContentEditable\n || Boolean(mode && !['false', 'inherit', 'off'].includes(mode))\n || node.getAttribute('role') === 'textbox'\n || node.getAttribute('data-slate-editor') === 'true'\n || node.getAttribute('data-lexical-editor') === 'true';\n };\n const deepQuery = (root, query) => {\n if (!root) return null;\n try {\n const direct = root.querySelector?.(query);\n if (direct) return direct;\n } catch {}\n let elements = [];\n try { elements = [...(root.querySelectorAll?.('*') || [])]; } catch {}\n for (const element of elements) {\n if (element.shadowRoot) {\n const found = deepQuery(element.shadowRoot, query);\n if (found) return found;\n }\n }\n return null;\n };\n const nearestEditable = node => {\n let current = node;\n for (let depth = 0; current && depth < 16; depth += 1) {\n if (isEditable(current)) return current;\n let nested = null;\n try { nested = current.querySelector?.(editableSelector); } catch {}\n if (nested && isEditable(nested)) return nested;\n const root = current.getRootNode?.();\n current = current.parentElement || (root && root.host) || null;\n }\n return null;\n };\n let node = exactSelector ? deepQuery(document, exactSelector) : null;\n node = nearestEditable(node) || node;\n if (!isEditable(node)) {\n let pointNode = null;\n try { pointNode = document.elementFromPoint(point.x, point.y); } catch {}\n node = nearestEditable(pointNode);\n }\n if (!isEditable(node)) return null;\n try { node.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'instant' }); } catch {}\n try { node.focus({ preventScroll: true }); } catch { try { node.focus(); } catch {} }\n if (node.isContentEditable) {\n try {\n const selection = window.getSelection();\n const range = document.createRange();\n range.selectNodeContents(node);\n range.collapse(false);\n selection.removeAllRanges();\n selection.addRange(range);\n } catch {}\n }\n return node;\n })()`;\n const result = await chrome.debugger.sendCommand(target, 'Runtime.evaluate', {\n expression,\n returnByValue: false,\n awaitPromise: false,\n includeCommandLineAPI: false\n }).catch(() => null);\n const objectId = String(result?.result?.objectId || '');\n if (!objectId) return { objectId: '', nodeId: 0, point, selector };\n const requested = await chrome.debugger.sendCommand(target, 'DOM.requestNode', { objectId }).catch(() => null);\n const nodeId = Number(requested?.nodeId || 0);\n return { objectId, nodeId, point, selector };\n}\n\nasync function debuggerFocusObject(target, objectId = '') {\n if (!objectId) return null;\n const result = await chrome.debugger.sendCommand(target, 'Runtime.callFunctionOn', {\n objectId,\n functionDeclaration: `function() {\n try { this.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'instant' }); } catch {}\n try { this.focus({ preventScroll: true }); } catch { try { this.focus(); } catch {} }\n if (this.isContentEditable) {\n try {\n const selection = window.getSelection();\n const range = document.createRange();\n range.selectNodeContents(this);\n range.collapse(false);\n selection.removeAllRanges();\n selection.addRange(range);\n } catch {}\n }\n const rect = this.getBoundingClientRect();\n return { left: rect.left, top: rect.top, width: rect.width, height: rect.height, tag: this.tagName || '', id: this.id || '' };\n }`,\n returnByValue: true,\n silent: true\n }).catch(() => null);\n return result?.result?.value || null;\n}\n\nasync function debuggerPasteClipboard(target) {\n await chrome.debugger.sendCommand(target, 'Input.dispatchKeyEvent', {\n type: 'rawKeyDown',\n key: 'v',\n code: 'KeyV',\n windowsVirtualKeyCode: 86,\n nativeVirtualKeyCode: 86,\n modifiers: 4,\n commands: ['Paste']\n });\n await chrome.debugger.sendCommand(target, 'Input.dispatchKeyEvent', {\n type: 'keyUp',\n key: 'v',\n code: 'KeyV',\n windowsVirtualKeyCode: 86,\n nativeVirtualKeyCode: 86,\n modifiers: 4\n });\n}\n\nasync function withTabDebugger(tabId, operation) {\n if (!Number.isInteger(Number(tabId))) throw new Error('无法确定当前 BOSS 标签页');\n const id = Number(tabId);\n const previous = debuggerLocks.get(id) || Promise.resolve();\n let release;\n const current = new Promise(resolve => { release = resolve; });\n const queueEntry = previous.then(() => current);\n debuggerLocks.set(id, queueEntry);\n await previous;\n const target = { tabId: id };\n let attached = false;\n try {\n await chrome.debugger.attach(target, '1.3');\n attached = true;\n await debuggerBringToFront(target);\n return await operation(target);\n } catch (error) {\n const message = String(error?.message || error || '');\n if (/Another debugger is already attached|Cannot attach to this target|DevTools/i.test(message)) {\n throw new Error('Chrome 调试通道正被 DevTools 或其他工具占用,请关闭该 BOSS 页面的开发者工具后重试');\n }\n throw error;\n } finally {\n if (attached) await chrome.debugger.detach(target).catch(() => {});\n release?.();\n if (debuggerLocks.get(id) === queueEntry) debuggerLocks.delete(id);\n }\n}\n\nasync function debuggerMouseClick(target, point) {\n const { x, y } = validPoint(point);\n await chrome.debugger.sendCommand(target, 'Input.dispatchMouseEvent', {\n type: 'mouseMoved', x, y, button: 'none', buttons: 0\n });\n await chrome.debugger.sendCommand(target, 'Input.dispatchMouseEvent', {\n type: 'mousePressed', x, y, button: 'left', buttons: 1, clickCount: 1\n });\n await chrome.debugger.sendCommand(target, 'Input.dispatchMouseEvent', {\n type: 'mouseReleased', x, y, button: 'left', buttons: 0, clickCount: 1\n });\n}\n\nasync function debuggerDocumentNode(target) {\n const result = await chrome.debugger.sendCommand(target, 'DOM.getDocument', {\n depth: -1,\n pierce: true\n });\n const nodeId = Number(result?.root?.nodeId || 0);\n if (!nodeId) throw new Error('无法读取当前 BOSS 页面 DOM');\n return nodeId;\n}\n\nasync function debuggerNodeBySelector(target, selector) {\n const normalized = String(selector || '').trim();\n if (!normalized) return 0;\n const rootNodeId = await debuggerDocumentNode(target);\n const result = await chrome.debugger.sendCommand(target, 'DOM.querySelector', {\n nodeId: rootNodeId,\n selector: normalized\n });\n return Number(result?.nodeId || 0);\n}\n\nasync function debuggerFocusTarget(target, message = {}) {\n await debuggerBringToFront(target);\n\n // 必须优先锁定 content script 临时标记的真实编辑节点。旧版先取坐标节点,\n // BOSS 页面上经常命中编辑器外壳/透明层,导致看似有光标但文字没有进入编辑器。\n const exact = await debuggerFindEditableObject(target, message).catch(() => ({ objectId: '', nodeId: 0 }));\n if (exact.objectId) {\n const rect = await debuggerFocusObject(target, exact.objectId);\n if (rect && Number(rect.width) > 0 && Number(rect.height) > 0) {\n const clickPoint = {\n x: Math.round(Number(rect.left) + Math.max(8, Math.min(Number(rect.width) - 8, Number(rect.width) * 0.5))),\n y: Math.round(Number(rect.top) + Math.max(8, Math.min(Number(rect.height) - 8, Number(rect.height) * 0.5)))\n };\n await debuggerMouseClick(target, clickPoint).catch(() => {});\n await debuggerFocusObject(target, exact.objectId);\n return { method: 'exact-selector-object-focus+click', ...exact, point: clickPoint, rect };\n }\n return { method: 'exact-selector-object-focus', ...exact, rect };\n }\n\n const pointNode = await debuggerNodeAtPoint(target, message.point).catch(() => ({ nodeId: 0, backendNodeId: 0 }));\n if (pointNode.nodeId || pointNode.backendNodeId) {\n const args = pointNode.nodeId ? { nodeId: pointNode.nodeId } : { backendNodeId: pointNode.backendNodeId };\n await chrome.debugger.sendCommand(target, 'DOM.focus', args).catch(() => {});\n await debuggerMouseClick(target, message.point).catch(() => {});\n const objectId = await debuggerResolveNodeObject(target, pointNode).catch(() => '');\n return { method: 'point-node-focus+click', ...pointNode, objectId };\n }\n\n await debuggerMouseClick(target, message.point);\n return { method: 'coordinate-click', nodeId: 0, objectId: '' };\n}\n\nasync function debuggerClickTarget(target, message = {}) {\n const selector = String(message.selector || '').trim();\n if (selector) {\n const nodeId = await debuggerNodeBySelector(target, selector);\n if (nodeId) {\n await chrome.debugger.sendCommand(target, 'DOM.scrollIntoViewIfNeeded', { nodeId }).catch(() => {});\n const model = await chrome.debugger.sendCommand(target, 'DOM.getBoxModel', { nodeId });\n const quad = model?.model?.content || model?.model?.border || [];\n if (Array.isArray(quad) && quad.length >= 8) {\n const xs = [quad[0], quad[2], quad[4], quad[6]].map(Number);\n const ys = [quad[1], quad[3], quad[5], quad[7]].map(Number);\n await debuggerMouseClick(target, {\n x: xs.reduce((sum, value) => sum + value, 0) / xs.length,\n y: ys.reduce((sum, value) => sum + value, 0) / ys.length\n });\n return { method: 'dom-box-click', nodeId };\n }\n }\n }\n await debuggerMouseClick(target, message.point);\n return { method: 'coordinate-click', nodeId: 0 };\n}\n\nasync function debuggerReadTargetText(target, selector) {\n const normalized = String(selector || '').trim();\n if (!normalized) return '';\n const expression = `(() => {\n const element = document.querySelector(${JSON.stringify(normalized)});\n if (!element) return '';\n if ('value' in element) return String(element.value || '');\n return String(element.innerText || element.textContent || '');\n })()`;\n const result = await chrome.debugger.sendCommand(target, 'Runtime.evaluate', {\n expression,\n returnByValue: true,\n awaitPromise: false\n });\n return String(result?.result?.value || '');\n}\n\nasync function debuggerSelectAll(target) {\n await chrome.debugger.sendCommand(target, 'Input.dispatchKeyEvent', {\n type: 'rawKeyDown', key: 'a', code: 'KeyA', windowsVirtualKeyCode: 65,\n nativeVirtualKeyCode: 65, modifiers: 4, commands: ['SelectAll']\n });\n await chrome.debugger.sendCommand(target, 'Input.dispatchKeyEvent', {\n type: 'keyUp', key: 'a', code: 'KeyA', windowsVirtualKeyCode: 65,\n nativeVirtualKeyCode: 65, modifiers: 4\n });\n}\n\nasync function debuggerPressEnter(target) {\n // 与 Selenium/Playwright 的真实 Enter 等价:rawKeyDown 携带回车文本后再 keyUp。\n await chrome.debugger.sendCommand(target, 'Input.dispatchKeyEvent', {\n type: 'rawKeyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13,\n nativeVirtualKeyCode: 13, text: '\\r', unmodifiedText: '\\r'\n });\n await chrome.debugger.sendCommand(target, 'Input.dispatchKeyEvent', {\n type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13,\n nativeVirtualKeyCode: 13\n });\n}\n\nasync function debuggerTypeText(target, text) {\n const value = String(text || '');\n for (const character of value) {\n await chrome.debugger.sendCommand(target, 'Input.dispatchKeyEvent', {\n type: 'char',\n text: character,\n unmodifiedText: character\n });\n await new Promise(resolve => setTimeout(resolve, 12));\n }\n}\n\nasync function mainWorldWriteChatText(tabId, selector, text) {\n const normalizedSelector = String(selector || '').trim();\n const desired = String(text || '');\n if (!normalizedSelector || !desired.trim()) throw new Error('聊天输入目标或招呼语为空');\n const results = await chrome.scripting.executeScript({\n target: { tabId: Number(tabId) },\n world: 'MAIN',\n func: (exactSelector, nextText) => {\n const normalizeText = value => String(value || '').replace(/\\s+/g, '').trim();\n const isEditable = node => {\n if (!node || typeof node.matches !== 'function') return false;\n const tag = String(node.tagName || '').toLowerCase();\n if (tag === 'textarea') return true;\n if (tag === 'input') {\n const type = String(node.getAttribute('type') || 'text').toLowerCase();\n return ['text', 'search', ''].includes(type);\n }\n const editableMode = String(node.getAttribute('contenteditable') || '').toLowerCase();\n return node.isContentEditable\n || Boolean(editableMode && !['false', 'inherit', 'off'].includes(editableMode))\n || node.getAttribute('role') === 'textbox'\n || node.getAttribute('data-slate-editor') === 'true'\n || node.getAttribute('data-lexical-editor') === 'true';\n };\n const deepQuery = (rootNode, query) => {\n if (!rootNode) return null;\n try {\n const direct = rootNode.querySelector?.(query);\n if (direct) return direct;\n } catch {}\n let elements = [];\n try { elements = [...(rootNode.querySelectorAll?.('*') || [])]; } catch {}\n for (const element of elements) {\n if (element.shadowRoot) {\n const found = deepQuery(element.shadowRoot, query);\n if (found) return found;\n }\n }\n return null;\n };\n const root = deepQuery(document, exactSelector);\n if (!root) return { ok: false, reason: 'selector-not-found' };\n const editor = isEditable(root)\n ? root\n : [...root.querySelectorAll('textarea,input[type=\"text\"],input:not([type]),[contenteditable]:not([contenteditable=\"false\"]),[role=\"textbox\"],[data-slate-editor=\"true\"],[data-lexical-editor=\"true\"]')].find(isEditable);\n if (!editor) return { ok: false, reason: 'editable-not-found', rootTag: String(root.tagName || '') };\n\n const readValue = () => {\n if ('value' in editor && !editor.isContentEditable) return String(editor.value || '');\n return String(editor.innerText || editor.textContent || '');\n };\n const dispatchInput = (inputType, data) => {\n try {\n editor.dispatchEvent(new InputEvent('input', {\n bubbles: true,\n cancelable: false,\n composed: true,\n inputType,\n data\n }));\n } catch {\n editor.dispatchEvent(new Event('input', { bubbles: true, composed: true }));\n }\n };\n const dispatchBeforeInput = (inputType, data) => {\n try {\n return editor.dispatchEvent(new InputEvent('beforeinput', {\n bubbles: true,\n cancelable: true,\n composed: true,\n inputType,\n data\n }));\n } catch {\n return true;\n }\n };\n\n editor.scrollIntoView?.({ block: 'nearest', inline: 'nearest', behavior: 'instant' });\n try { HTMLElement.prototype.focus.call(editor); } catch { editor.focus?.(); }\n const previous = readValue();\n const tag = String(editor.tagName || '').toLowerCase();\n let method = '';\n\n if ((tag === 'textarea' || tag === 'input') && 'value' in editor) {\n const proto = tag === 'textarea' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;\n const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;\n dispatchBeforeInput('deleteByCut', null);\n if (setter) setter.call(editor, ''); else editor.value = '';\n dispatchInput('deleteByCut', null);\n const tracker = editor._valueTracker;\n if (tracker?.setValue) tracker.setValue(previous);\n dispatchBeforeInput('insertText', nextText);\n if (setter) setter.call(editor, nextText); else editor.value = nextText;\n dispatchInput('insertText', nextText);\n editor.dispatchEvent(new Event('change', { bubbles: true, composed: true }));\n method = 'main-native-value-setter';\n } else {\n const selection = window.getSelection?.();\n if (selection && document.createRange) {\n const range = document.createRange();\n range.selectNodeContents(editor);\n selection.removeAllRanges();\n selection.addRange(range);\n }\n dispatchBeforeInput('deleteContentBackward', null);\n let inserted = false;\n try {\n const transfer = new DataTransfer();\n transfer.setData('text/plain', nextText);\n const pasteEvent = new ClipboardEvent('paste', {\n bubbles: true,\n cancelable: true,\n composed: true,\n clipboardData: transfer\n });\n editor.dispatchEvent(pasteEvent);\n inserted = normalizeText(readValue()).includes(normalizeText(nextText));\n } catch {\n inserted = false;\n }\n if (!inserted) {\n try {\n inserted = Boolean(document.execCommand?.('insertText', false, nextText));\n } catch {\n inserted = false;\n }\n }\n if (!inserted || !normalizeText(readValue()).includes(normalizeText(nextText))) {\n try {\n editor.replaceChildren(document.createTextNode(nextText));\n } catch {\n editor.textContent = nextText;\n }\n }\n dispatchInput('insertText', nextText);\n editor.dispatchEvent(new Event('change', { bubbles: true, composed: true }));\n try {\n editor.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true, composed: true, data: nextText }));\n } catch {}\n method = inserted ? 'main-exec-command' : 'main-contenteditable-replace';\n }\n\n try { HTMLElement.prototype.focus.call(editor); } catch { editor.focus?.(); }\n const actual = readValue();\n const compactExpected = normalizeText(nextText);\n const compactActual = normalizeText(actual);\n return {\n ok: Boolean(compactActual && compactActual.includes(compactExpected)),\n method,\n actual,\n tag,\n id: String(editor.id || ''),\n className: String(editor.className || '').slice(0, 180),\n contenteditable: String(editor.getAttribute?.('contenteditable') || ''),\n role: String(editor.getAttribute?.('role') || '')\n };\n },\n args: [normalizedSelector, desired]\n });\n return results?.[0]?.result || { ok: false, reason: 'main-world-no-result' };\n}\n\nasync function trustedChatInput(tabId, message = {}) {\n const action = String(message.action || '');\n if (action === 'replaceText' || action === 'replaceTextAndEnter') {\n const text = String(message.text || '');\n if (!text.trim()) throw new Error('待输入的招呼语为空');\n const compactExpected = text.replace(/\\s+/g, '');\n\n // 写入和 Enter 必须在同一次 debugger 会话、同一个精确编辑节点上完成。\n // 这是 Selenium sendKeys / Playwright locator.type + Enter 的同类浏览器级路径,\n // 避免旧版“聚焦了外壳、文字没有进入真实编辑器”的问题。\n return withTabDebugger(tabId, async target => {\n let focus = await debuggerFocusTarget(target, message);\n if (!focus.objectId) throw new Error('没有定位到 BOSS 真实聊天编辑节点');\n\n const readExact = async () => debuggerReadObjectText(target, focus.objectId);\n const refocus = async () => {\n const refreshed = await debuggerFocusTarget(target, message);\n if (refreshed.objectId) focus = refreshed;\n if (!focus.objectId) throw new Error('BOSS 聊天编辑节点已失效');\n return focus;\n };\n const matches = value => {\n const compact = String(value || '').replace(/\\s+/g, '');\n return Boolean(compact && (compact === compactExpected || compact.includes(compactExpected)));\n };\n\n let actual = '';\n let writeMethod = '';\n\n // 1) 精确对象聚焦后使用 CDP Input.insertText,最接近真实键盘输入。\n await debuggerSelectAll(target);\n await chrome.debugger.sendCommand(target, 'Input.insertText', { text });\n await new Promise(resolve => setTimeout(resolve, 700));\n actual = await readExact();\n writeMethod = 'exact-cdp-insert-text';\n\n // 2) 某些富文本编辑器只响应原生 paste。\n if (!matches(actual) && await writeClipboardText(text)) {\n await refocus();\n await debuggerSelectAll(target);\n await debuggerPasteClipboard(target);\n await new Promise(resolve => setTimeout(resolve, 900));\n actual = await readExact();\n writeMethod = 'exact-cdp-clipboard-paste';\n }\n\n // 3) React/Vue 受控编辑器可能需要主世界 setter/beforeinput/input。\n if (!matches(actual)) {\n const mainWrite = await mainWorldWriteChatText(tabId, message.selector, text).catch(error => ({\n ok: false,\n reason: String(error?.message || error || 'main-world-write-failed')\n }));\n await refocus();\n await new Promise(resolve => setTimeout(resolve, 650));\n actual = await readExact();\n writeMethod = mainWrite?.method || 'main-world-write';\n }\n\n // 4) 最后兜底为逐字符可信键盘输入,仍锁定同一个真实节点。\n if (!matches(actual)) {\n await refocus();\n await debuggerSelectAll(target);\n await debuggerTypeText(target, text);\n await new Promise(resolve => setTimeout(resolve, 1000));\n actual = await readExact();\n writeMethod = 'exact-cdp-keyboard-type';\n }\n\n if (!matches(actual)) {\n throw new Error(`文字没有进入 BOSS 真实聊天编辑器;实际长度=${String(actual || '').length}`);\n }\n\n if (action === 'replaceTextAndEnter') {\n await refocus();\n // 给 BOSS 受控状态一次提交渲染机会,再发送真实 Enter。\n await new Promise(resolve => setTimeout(resolve, 520));\n await debuggerPressEnter(target);\n }\n\n return {\n ok: true,\n action,\n length: text.length,\n insertedBeforeSend: true,\n writeMethod,\n actualLength: String(actual || '').length,\n focusMethod: focus.method,\n editor: {\n nodeId: Number(focus.nodeId || 0),\n selector: String(message.selector || '')\n }\n };\n });\n }\n\n return withTabDebugger(tabId, async target => {\n if (action === 'pressEnter') {\n const focus = await debuggerFocusTarget(target, message);\n if (!focus.objectId) throw new Error('没有定位到 BOSS 真实聊天编辑节点');\n await new Promise(resolve => setTimeout(resolve, 320));\n await debuggerPressEnter(target);\n return { ok: true, action, focusMethod: focus.method };\n }\n if (action === 'click') {\n const click = await debuggerClickTarget(target, message);\n return { ok: true, action, clickMethod: click.method };\n }\n throw new Error('不支持的可信输入动作');\n });\n}\n\nasync function init() {\n const current = await storage.all();\n const patch = {};\n for (const [key, value] of Object.entries(DEFAULTS)) {\n if (current[key] === undefined) patch[key] = value;\n }\n if (!current.ui9ModeMigration) {\n const currentConfig = current.config || {};\n patch.config = {\n ...DEFAULTS.config,\n ...currentConfig,\n executionMode: currentConfig.executionMode === 'auto' ? 'auto' : 'review',\n dailyTarget: Number(currentConfig.dailyTarget || 0) === 40 ? 150 : Number(currentConfig.dailyTarget || 150)\n };\n patch.ui9ModeMigration = true;\n }\n if (!current.ui10UnlimitedV4Migration) {\n const baseConfig = patch.config || current.config || {};\n const baseModel = { ...DEFAULTS.config.model, ...(baseConfig.model || {}) };\n const baseUrl = String(baseModel.baseUrl || DEFAULTS.config.model.baseUrl);\n const modelName = String(baseModel.model || '').trim();\n const isDeepSeek = /deepseek\\.com/i.test(baseUrl);\n const legacyDeepSeekModel = !modelName || ['deepseek-chat', 'deepseek-reasoner', 'deepseek-v4'].includes(modelName);\n patch.config = {\n ...DEFAULTS.config,\n ...baseConfig,\n discoveryLimit: 0,\n model: {\n ...baseModel,\n model: isDeepSeek && legacyDeepSeekModel ? 'deepseek-v4-pro' : (modelName || 'deepseek-v4-pro')\n }\n };\n patch.ui10UnlimitedV4Migration = true;\n }\n if (!current.ui20ReliableSendMigration) {\n const baseConfig = patch.config || current.config || {};\n patch.config = {\n ...DEFAULTS.config,\n ...baseConfig,\n betweenJobsSeconds: Math.max(8, Math.min(30, Number(baseConfig.betweenJobsSeconds || 8))),\n attachmentDelaySeconds: Math.max(1, Math.min(10, Number(baseConfig.attachmentDelaySeconds || 2)))\n };\n patch.ui20ReliableSendMigration = true;\n }\n if (!current.ui21ConversationBindingMigration) {\n patch.chatTransition = null;\n patch.chatDeliveryLedger = current.chatDeliveryLedger && typeof current.chatDeliveryLedger === 'object'\n ? current.chatDeliveryLedger\n : {};\n patch.ui21ConversationBindingMigration = true;\n }\n if (!current.ui22StrictTranscriptMigration) {\n const baseConfig = patch.config || current.config || {};\n patch.config = {\n ...DEFAULTS.config,\n ...baseConfig,\n betweenJobsSeconds: Math.max(12, Math.min(30, Number(baseConfig.betweenJobsSeconds || 12))),\n attachmentDelaySeconds: Math.max(4, Math.min(10, Number(baseConfig.attachmentDelaySeconds || 4)))\n };\n patch.ui22StrictTranscriptMigration = true;\n }\n if (!current.ui24VerifiedConversationMigration) {\n patch.chatTransition = null;\n patch.ui24VerifiedConversationMigration = true;\n }\n if (!current.ui27TrustedInputMigration) {\n const baseWorkflow = patch.workflow || current.workflow || {};\n patch.workflow = {\n ...DEFAULTS.workflow,\n ...baseWorkflow,\n running: false,\n paused: true,\n phase: 'idle',\n pendingApplyId: null,\n activeRunId: null,\n statusText: '可信发送引擎已升级,请重新开始任务'\n };\n patch.chatTransition = null;\n patch.ui27TrustedInputMigration = true;\n }\n if (!current.ui12ProfileDraftMigration) {\n let migratedProfile = current.profile && typeof current.profile === 'object' ? current.profile : null;\n let migratedDraft = current.profileDraft && typeof current.profileDraft === 'object'\n ? normalizeProfileDraft(current.profileDraft)\n : null;\n if (!profileDraftHasAny(migratedDraft) && profileHasCore(migratedProfile)) {\n migratedDraft = profileToDraft(migratedProfile, 'migration');\n }\n if (!profileDraftHasCore(migratedDraft) && String(current.resumeText || '').trim().length >= 30) {\n try {\n migratedProfile = buildLocalProfile(current.resumeText || '');\n migratedProfile.generation.mode = 'local-recovery';\n migratedProfile.generation.label = '本地初稿';\n migratedProfile.generation.warning = '已从保存的简历恢复初稿。';\n migratedDraft = profileToDraft(migratedProfile, 'recovery');\n patch.profile = migratedProfile;\n } catch {\n // 简历内容不足时只保留现有数据,不生成空画像。\n }\n }\n if (profileDraftHasAny(migratedDraft)) patch.profileDraft = migratedDraft;\n patch.ui12ProfileDraftMigration = true;\n }\n if (!current.ui37DirectionPlanMigration) {\n const profileForPlan = patch.profile || current.profile || null;\n const existingPlan = current.directionPlan && typeof current.directionPlan === 'object'\n ? current.directionPlan\n : null;\n if (profileHasCore(profileForPlan)) {\n patch.directionPlan = buildDirectionPlan(profileForPlan, existingPlan, {\n confirmed: Boolean(existingPlan?.confirmed),\n preserveSelections: true,\n preserveEdits: true,\n preserveCustom: true\n });\n } else if (existingPlan) {\n patch.directionPlan = normalizeDirectionPlan(existingPlan, profileForPlan);\n }\n patch.ui37DirectionPlanMigration = true;\n }\n if (!current.ui18TaskProgressMigration) {\n const migratedPending = Array.isArray(current.pending) ? current.pending.map(entry => ({ ...entry })) : [];\n const migratedRuns = Array.isArray(current.taskRuns) ? current.taskRuns.map(run => normalizeTaskRun(run, run)) : [];\n for (let index = 0; index < migratedPending.length; index += 1) {\n const item = migratedPending[index];\n let run = migratedRuns.find(entry => entry.id === item.runId || entry.pendingId === item.id);\n if (!run) {\n const statusMap = {\n pending: ['waiting_review', 'waiting_review', 60],\n approved: ['running', 'queued', 64],\n approved_queue: ['queued', 'queued', 64],\n sent: ['success', 'success', 100],\n failed: ['failed', 'failed', 100],\n rejected: ['ignored', 'ignored', 100]\n };\n const [status, stage, progress] = statusMap[item.status] || ['skipped', 'skipped', 100];\n run = normalizeTaskRun({\n id: crypto.randomUUID(),\n pendingId: item.id,\n job: item.job,\n analysis: item.analysis,\n searchTask: item.task,\n status,\n stage,\n progress,\n error: item.error || '',\n retryable: item.status === 'failed',\n createdAt: item.createdAt || Date.now(),\n completedAt: ['sent', 'failed', 'rejected'].includes(item.status) ? (item.completedAt || item.rejectedAt || Date.now()) : null\n });\n migratedRuns.push(run);\n }\n migratedPending[index] = { ...item, runId: run.id };\n }\n const workflow = { ...DEFAULTS.workflow, ...(current.workflow || {}) };\n workflow.tasks = (workflow.tasks || []).map((task, index) => ({\n ...task,\n id: task.id || crypto.randomUUID(),\n status: task.status || (index < Number(workflow.taskIndex || 0) ? 'completed' : index === Number(workflow.taskIndex || 0) && workflow.running ? 'running' : 'pending'),\n progress: Number.isFinite(Number(task.progress)) ? Number(task.progress) : (index < Number(workflow.taskIndex || 0) ? 100 : 0),\n stageLabel: task.stageLabel || (index < Number(workflow.taskIndex || 0) ? '已完成' : '等待开始'),\n processed: Number(task.processed || 0),\n discovered: Number(task.discovered || 0),\n analyzed: Number(task.analyzed || 0),\n failed: Number(task.failed || 0)\n }));\n patch.pending = migratedPending;\n patch.taskRuns = migratedRuns;\n patch.workflow = workflow;\n patch.ui18TaskProgressMigration = true;\n }\n if (!current.ui20GreetingLockMigration) {\n const basePending = Array.isArray(patch.pending) ? patch.pending : (Array.isArray(current.pending) ? current.pending : []);\n patch.pending = basePending.map(item => ({\n ...item,\n deliveryGreeting: String(item.deliveryGreeting || item.analysis?.greeting || '').trim()\n }));\n patch.ui20GreetingLockMigration = true;\n }\n if (!current.ui30AtomicSendSortMigration) {\n const basePending = Array.isArray(patch.pending) ? patch.pending : (Array.isArray(current.pending) ? current.pending : []);\n patch.pending = rerankPending(basePending);\n const baseWorkflow = patch.workflow || current.workflow || {};\n patch.workflow = {\n ...DEFAULTS.workflow,\n ...baseWorkflow,\n running: false,\n paused: true,\n pendingApplyId: null,\n activeRunId: null,\n statusText: '投递引擎与岗位自动排序已升级,请重新开始'\n };\n patch.chatTransition = null;\n patch.ui30AtomicSendSortMigration = true;\n }\n if (!current.ui31ConversationSelectionMigration) {\n const basePending = Array.isArray(patch.pending) ? patch.pending : (Array.isArray(current.pending) ? current.pending : []);\n patch.pending = rerankPending(basePending);\n const baseWorkflow = patch.workflow || current.workflow || {};\n patch.workflow = {\n ...DEFAULTS.workflow,\n ...baseWorkflow,\n running: false,\n paused: true,\n pendingApplyId: null,\n activeRunId: null,\n statusText: 'HR 会话选择与身份核对已升级,请重新开始'\n };\n const ledger = current.chatDeliveryLedger && typeof current.chatDeliveryLedger === 'object' ? current.chatDeliveryLedger : {};\n patch.chatDeliveryLedger = Object.fromEntries(Object.entries(ledger).filter(([, entry]) => entry?.status === 'sent'));\n patch.chatTransition = null;\n patch.ui31ConversationSelectionMigration = true;\n }\n if (!current.ui35ConversationKeyMigration) {\n const baseWorkflow = patch.workflow || current.workflow || {};\n patch.workflow = {\n ...DEFAULTS.workflow,\n ...baseWorkflow,\n running: false,\n paused: true,\n pendingApplyId: null,\n activeRunId: null,\n statusText: 'HR 会话锁已升级,请重新开始'\n };\n const ledger = current.chatDeliveryLedger && typeof current.chatDeliveryLedger === 'object' ? current.chatDeliveryLedger : {};\n patch.chatDeliveryLedger = Object.fromEntries(Object.entries(ledger).filter(([, entry]) => entry?.status === 'sent'));\n patch.chatTransition = null;\n patch.ui35ConversationKeyMigration = true;\n }\n if (!current.v130SingleValidationMigration) {\n const baseConfig = patch.config || current.config || {};\n const alreadySuccessful = Number(current.stats?.sent || 0) > 0\n || Number(baseConfig.singleJobValidationCompletedAt || 0) > 0;\n patch.config = {\n ...DEFAULTS.config,\n ...baseConfig,\n requireSingleJobValidation: baseConfig.requireSingleJobValidation !== false,\n singleJobValidationCompletedAt: alreadySuccessful\n ? Number(baseConfig.singleJobValidationCompletedAt || Date.now())\n : 0\n };\n patch.v130SingleValidationMigration = true;\n }\n if (!current.v170SafetyIntelligenceMigration) {\n const baseConfig = patch.config || current.config || {};\n const legacyTarget = Number(baseConfig.dailyTarget || 0);\n const legacyDiscovery = Number(baseConfig.discoveryLimit || 0);\n patch.config = {\n ...DEFAULTS.config,\n ...baseConfig,\n batchStrategy: normalizeStrategy(baseConfig.batchStrategy),\n dryRun: Boolean(baseConfig.dryRun),\n dailyTarget: legacyTarget <= 0 ? 30 : Math.min(150, legacyTarget),\n discoveryLimit: legacyDiscovery <= 0 ? 150 : Math.min(800, legacyDiscovery),\n betweenJobsSeconds: Math.max(6, Number(baseConfig.betweenJobsSeconds || 9)),\n maxPerCompanyPerDay: Math.max(1, Math.min(12, Number(baseConfig.maxPerCompanyPerDay || 3))),\n maxConsecutiveFailures: Math.max(1, Math.min(10, Number(baseConfig.maxConsecutiveFailures || 3))),\n jitterSeconds: Math.max(0, Math.min(15, Number(baseConfig.jitterSeconds ?? 3))),\n companyVerificationEnabled: baseConfig.companyVerificationEnabled !== false,\n companyVerificationProvider: String(baseConfig.companyVerificationProvider || 'bridge'),\n companyVerificationCacheDays: Math.max(1, Math.min(90, Number(baseConfig.companyVerificationCacheDays || 14))),\n blockUnknownCompanies: Boolean(baseConfig.blockUnknownCompanies),\n updateCheckEnabled: baseConfig.updateCheckEnabled !== false,\n rateLimits: { ...DEFAULTS.config.rateLimits, ...(baseConfig.rateLimits || {}) }\n };\n patch.safetyState = { ...DEFAULTS.safetyState, ...(current.safetyState || {}) };\n patch.companyVerificationCache = current.companyVerificationCache && typeof current.companyVerificationCache === 'object' ? current.companyVerificationCache : {};\n patch.deliveryHistory = Array.isArray(current.deliveryHistory) ? current.deliveryHistory : [];\n patch.updateInfo = { ...DEFAULTS.updateInfo, ...(current.updateInfo || {}), currentVersion: '1.7.0' };\n patch.workflow = {\n ...DEFAULTS.workflow,\n ...(patch.workflow || current.workflow || {}),\n running: false,\n paused: true,\n pendingApplyId: null,\n activeRunId: null,\n statusText: 'v1.7 安全投递引擎已升级 请检查限速和企业核验设置后重新开始'\n };\n patch.v170SafetyIntelligenceMigration = true;\n }\n if (!current.v170FormalReleaseMigration) {\n const baseConfig = patch.config || current.config || {};\n const migratedStrategy = normalizeStrategy(baseConfig.batchStrategy);\n patch.config = {\n ...DEFAULTS.config,\n ...baseConfig,\n batchStrategy: migratedStrategy,\n massApplyAnalysis: ['fast', 'ai'].includes(baseConfig.massApplyAnalysis) ? baseConfig.massApplyAnalysis : 'fast',\n pacingPreset: ['conservative', 'standard', 'efficient', 'custom'].includes(baseConfig.pacingPreset) ? baseConfig.pacingPreset : 'standard',\n dailyTarget: Math.max(1, Math.min(150, Number(baseConfig.dailyTarget || 30))),\n discoveryLimit: Math.max(1, Math.min(800, Number(baseConfig.discoveryLimit || 150))),\n betweenJobsSeconds: Math.max(6, Math.min(120, Number(baseConfig.betweenJobsSeconds || 9))),\n attachmentDelaySeconds: Math.max(1.5, Math.min(15, Number(baseConfig.attachmentDelaySeconds || 3))),\n maxPerCompanyPerDay: Math.max(1, Math.min(12, Number(baseConfig.maxPerCompanyPerDay || 3))),\n queueWarmup: Math.max(1, Math.min(10, Number(baseConfig.queueWarmup || 4))),\n dailyReportEnabled: baseConfig.dailyReportEnabled !== false,\n dailyReportTime: /^\\d{2}:\\d{2}$/.test(String(baseConfig.dailyReportTime || '')) ? String(baseConfig.dailyReportTime) : '20:30',\n dailyReportNotification: baseConfig.dailyReportNotification !== false,\n rateLimits: { ...DEFAULTS.config.rateLimits, ...(baseConfig.rateLimits || {}) }\n };\n patch.safetyState = { ...DEFAULTS.safetyState, ...(current.safetyState || {}) };\n patch.v170FormalReleaseMigration = true;\n }\n if (Object.keys(patch).length) await storage.set(patch);\n const { stats } = await storage.get('stats');\n if (!stats || stats.date !== today()) {\n await storage.set({ stats: { ...DEFAULTS.stats, date: today() } });\n }\n await refreshBossTabsForRuntimeVersion();\n await chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => {});\n chrome.alarms.create('jobclaw-tick', { periodInMinutes: 1 });\n chrome.alarms.create('jobclaw-update-check', { periodInMinutes: 360 });\n checkForUpdates(false).catch(() => {});\n syncBridgeSnapshot(true).catch(() => {});\n}\n\nfunction publicState(all) {\n const state = safeClone(all);\n if (state?.config?.model) {\n state.config.model.apiKey = state.config.model.apiKey ? '***' : '';\n }\n if (state?.resumeImage) state.resumeImage = '[已保存]';\n if (state?.resumeSourceFile) {\n const { name, type, size, lastModified } = state.resumeSourceFile;\n state.resumeSourceFile = { name, type, size, lastModified, stored: true };\n }\n return state;\n}\n\nasync function writeEvent(level, message, data = {}) {\n const { events = [] } = await storage.get('events');\n const event = { id: crypto.randomUUID(), ts: Date.now(), level, message, data };\n events.unshift(event);\n await storage.set({ events: events.slice(0, 300) });\n bridge('/sync', { event }).catch(() => {});\n return event;\n}\n\nasync function patchWorkflow(patch) {\n const { workflow } = await storage.get('workflow');\n const next = { ...DEFAULTS.workflow, ...(workflow || {}), ...(patch || {}) };\n await storage.set({ workflow: next });\n return next;\n}\n\nasync function changeStats(delta = {}) {\n const { stats } = await storage.get('stats');\n const next = { ...DEFAULTS.stats, ...(stats || {}), date: today() };\n for (const [key, value] of Object.entries(delta)) {\n next[key] = Math.max(0, Number(next[key] || 0) + Number(value || 0));\n }\n await storage.set({ stats: next });\n return next;\n}\n\n\nfunction runJobKey(job = {}) {\n return String(job.jobId || job.encryptJobId || job.url || [job.title, job.company, job.location].filter(Boolean).join('|') || '').trim();\n}\n\nfunction normalizeTaskRun(input = {}, existing = {}) {\n const now = Date.now();\n const stage = String(input.stage || existing.stage || 'discovered');\n const meta = taskStageMeta(stage, input.stageLabel, input.progress);\n const status = String(input.status || existing.status || 'running');\n const terminal = TERMINAL_RUN_STATUSES.has(status);\n return {\n ...existing,\n ...input,\n id: input.id || existing.id || crypto.randomUUID(),\n jobKey: input.jobKey || existing.jobKey || runJobKey(input.job || existing.job || {}),\n status,\n stage,\n stageLabel: meta.label,\n progress: meta.progress,\n createdAt: existing.createdAt || input.createdAt || now,\n startedAt: existing.startedAt || input.startedAt || (status === 'running' ? now : null),\n updatedAt: now,\n completedAt: terminal ? (input.completedAt || existing.completedAt || now) : null,\n retryCount: Math.max(0, Number(input.retryCount ?? existing.retryCount ?? 0)),\n error: String(input.error ?? existing.error ?? ''),\n retryable: input.retryable ?? existing.retryable ?? Boolean(input.pendingId || existing.pendingId)\n };\n}\n\nasync function upsertTaskRun(patch = {}) {\n const { taskRuns = [] } = await storage.get('taskRuns');\n const jobKey = patch.jobKey || runJobKey(patch.job || {});\n const index = taskRuns.findIndex(run => run.id === patch.id || (jobKey && run.jobKey === jobKey && !TERMINAL_RUN_STATUSES.has(run.status)));\n const existing = index >= 0 ? taskRuns[index] : {};\n const nextRun = normalizeTaskRun({ ...patch, jobKey }, existing);\n const next = index >= 0\n ? taskRuns.map((run, runIndex) => runIndex === index ? nextRun : run)\n : [nextRun, ...taskRuns];\n await storage.set({ taskRuns: next });\n return nextRun;\n}\n\nasync function updateTaskRunByPending(pendingId, patch = {}) {\n if (!pendingId) return null;\n const { pending = [], taskRuns = [] } = await storage.get(['pending', 'taskRuns']);\n const item = pending.find(entry => entry.id === pendingId);\n const run = taskRuns.find(entry => entry.id === item?.runId || entry.pendingId === pendingId);\n if (!run && !item) return null;\n return upsertTaskRun({\n id: run?.id || item?.runId,\n pendingId,\n job: item?.job || run?.job,\n analysis: item?.analysis || run?.analysis,\n searchTask: item?.task || run?.searchTask,\n ...patch\n });\n}\n\nasync function updateSearchTaskProgress(message = {}) {\n const { workflow } = await storage.get('workflow');\n const nextWorkflow = { ...DEFAULTS.workflow, ...(workflow || {}) };\n const tasks = Array.isArray(nextWorkflow.tasks) ? [...nextWorkflow.tasks] : [];\n const index = tasks.findIndex(task => task.id === message.taskId);\n const fallbackIndex = Number.isInteger(message.taskIndex) ? message.taskIndex : -1;\n const targetIndex = index >= 0 ? index : fallbackIndex;\n if (targetIndex < 0 || targetIndex >= tasks.length) return null;\n const current = tasks[targetIndex] || {};\n const progress = Math.max(0, Math.min(100, Number(message.progress ?? current.progress ?? 0)));\n tasks[targetIndex] = {\n ...current,\n status: message.status || current.status || 'pending',\n stageLabel: message.stageLabel || current.stageLabel || '',\n progress,\n processed: Math.max(0, Number(message.processed ?? current.processed ?? 0)),\n discovered: Math.max(0, Number(message.discovered ?? current.discovered ?? 0)),\n analyzed: Math.max(0, Number(message.analyzed ?? current.analyzed ?? 0)),\n failed: Math.max(0, Number(message.failed ?? current.failed ?? 0)),\n updatedAt: Date.now(),\n completedAt: message.status === 'completed' ? Date.now() : current.completedAt || null\n };\n nextWorkflow.tasks = tasks;\n await storage.set({ workflow: nextWorkflow });\n return tasks[targetIndex];\n}\n\nasync function retryFailedTask(runId) {\n const { taskRuns = [], pending = [], workflow = {} } = await storage.get(['taskRuns', 'pending', 'workflow']);\n const run = taskRuns.find(entry => entry.id === runId);\n if (!run) throw new Error('失败任务不存在');\n if (run.status === 'success') throw new Error('该岗位已经投递成功,不会重复投递');\n if (workflow.running && (workflow.activeRunId === run.id || workflow.pendingApplyId === run.pendingId)) {\n throw new Error('该失败任务正在重试,请等待当前结果');\n }\n if (Number(run.retryRequestedAt || 0) > Date.now() - 15000) {\n throw new Error('该任务刚刚提交过重试,请稍后查看结果');\n }\n let item = pending.find(entry => entry.id === run.pendingId || entry.runId === run.id);\n if (!item && run.job && run.analysis) {\n item = {\n id: crypto.randomUUID(),\n runId: run.id,\n job: run.job,\n analysis: run.analysis,\n deliveryGreeting: String(run.analysis?.greeting || '').trim(),\n task: run.searchTask || {},\n status: 'approved',\n createdAt: Date.now(),\n approvedAt: Date.now()\n };\n }\n if (!item) throw new Error('该历史任务缺少岗位或招呼语信息,无法直接重试');\n if (item.status === 'sent') throw new Error('该岗位已经投递成功,不会重复投递');\n item = {\n ...item,\n deliveryGreeting: String(item.deliveryGreeting || item.analysis?.greeting || '').trim(),\n status: 'approved', error: '', approvedAt: Date.now(), runId: run.id\n };\n const nextPending = pending.some(entry => entry.id === item.id)\n ? pending.map(entry => entry.id === item.id ? item : entry)\n : [item, ...pending];\n await storage.set({ pending: rerankPending(nextPending) });\n await upsertTaskRun({\n id: run.id,\n pendingId: item.id,\n job: item.job,\n analysis: item.analysis,\n searchTask: item.task,\n status: 'running',\n stage: 'retry_queued',\n progress: 66,\n stageLabel: '等待重新投递',\n error: '',\n retryable: true,\n retryCount: Number(run.retryCount || 0) + 1,\n retryRequestedAt: Date.now(),\n completedAt: null\n });\n await patchWorkflow({\n running: true,\n paused: false,\n phase: 'apply',\n statusText: `正在重新投递:${item.job?.title || '岗位'}`,\n pendingApplyId: item.id,\n activeRunId: run.id\n });\n try {\n await sendToBoss({ type: 'RUN' });\n setTimeout(() => sendToBoss({ type: 'RUN' }).catch(() => {}), 700);\n } catch (error) {\n await upsertTaskRun({ id: run.id, status: 'failed', stage: 'failed', progress: 100, stageLabel: '重新投递启动失败', error: error.message, retryable: true });\n await patchWorkflow({ running: false, paused: true, phase: 'idle', pendingApplyId: null, activeRunId: null, statusText: '重新投递启动失败' });\n throw error;\n }\n return { runId: run.id, pendingId: item.id };\n}\n\nasync function retryAllFailedTasks() {\n const { taskRuns = [], pending = [], workflow = {} } = await storage.get(['taskRuns', 'pending', 'workflow']);\n if (workflow.running && workflow.activeRunId) {\n throw new Error('当前已有投递任务正在执行,请等待完成后再批量重试');\n }\n const failedRuns = taskRuns.filter(run => run.status === 'failed'\n && run.retryable !== false\n && Number(run.retryRequestedAt || 0) <= Date.now() - 15000);\n if (!failedRuns.length) return { count: 0 };\n const nextPending = [...pending];\n const queue = [];\n for (const run of failedRuns) {\n if (run.status === 'success') continue;\n let item = nextPending.find(entry => entry.id === run.pendingId || entry.runId === run.id);\n if (!item && run.job && run.analysis) {\n item = {\n id: crypto.randomUUID(), runId: run.id, job: run.job, analysis: run.analysis,\n deliveryGreeting: String(run.analysis?.greeting || '').trim(),\n task: run.searchTask || {}, createdAt: Date.now()\n };\n nextPending.unshift(item);\n }\n if (!item || item.status === 'sent') continue;\n item.deliveryGreeting = String(item.deliveryGreeting || item.analysis?.greeting || '').trim();\n item.status = queue.length ? 'approved_queue' : 'approved';\n item.approvedAt = Date.now();\n item.error = '';\n const pendingIndex = nextPending.findIndex(entry => entry.id === item.id);\n if (pendingIndex >= 0) nextPending[pendingIndex] = { ...item };\n queue.push({ run, item });\n }\n if (!queue.length) return { count: 0 };\n await storage.set({ pending: rerankPending(nextPending) });\n for (const [index, entry] of queue.entries()) {\n await upsertTaskRun({\n id: entry.run.id,\n pendingId: entry.item.id,\n status: index === 0 ? 'running' : 'queued',\n stage: 'retry_queued',\n progress: 66,\n stageLabel: index === 0 ? '正在重新投递' : '等待重新投递',\n error: '',\n retryable: true,\n retryCount: Number(entry.run.retryCount || 0) + 1,\n retryRequestedAt: Date.now(),\n completedAt: null\n });\n }\n const first = queue[0];\n await patchWorkflow({\n running: true,\n paused: false,\n phase: 'apply',\n statusText: `重新投递 1/${queue.length}:${first.item.job?.title || '岗位'}`,\n pendingApplyId: first.item.id,\n activeRunId: first.run.id\n });\n try {\n await sendToBoss({ type: 'RUN' });\n } catch (error) {\n for (const entry of queue) {\n await upsertTaskRun({ id: entry.run.id, status: 'failed', stage: 'failed', progress: 100, stageLabel: '批量重试启动失败', error: error.message, retryable: true });\n }\n await patchWorkflow({ running: false, paused: true, phase: 'idle', pendingApplyId: null, activeRunId: null, statusText: '批量重试启动失败' });\n throw error;\n }\n return { count: queue.length };\n}\n\nasync function ignoreFailedTask(runId) {\n const { taskRuns = [], pending = [] } = await storage.get(['taskRuns', 'pending']);\n const run = taskRuns.find(entry => entry.id === runId);\n if (!run) throw new Error('失败任务不存在');\n const nextPending = pending.map(entry => entry.id === run.pendingId || entry.runId === run.id\n ? { ...entry, status: 'rejected', rejectedAt: Date.now() }\n : entry);\n await storage.set({ pending: nextPending });\n return upsertTaskRun({ id: run.id, status: 'ignored', stage: 'ignored', progress: 100, stageLabel: '已忽略', retryable: false });\n}\n\nconst BOSS_URL_PATTERN = /^https:\\/\\/(?:www|app)\\.zhipin\\.com\\//i;\nconst NO_RECEIVER_PATTERN = /Could not establish connection|Receiving end does not exist|message port closed|No tab with id/i;\nconst wait = ms => new Promise(resolve => setTimeout(resolve, ms));\n\nfunction receiverMatchesExpectedVersion(probe) {\n return Boolean(probe?.ok && probe.contentVersion === EXPECTED_CONTENT_VERSION && probe.contentFile === CONTENT_SCRIPT_FILE);\n}\n\nasync function waitForTabReady(tabId, timeout = 16000) {\n if (!chrome.tabs?.get) {\n await wait(900);\n return null;\n }\n const startedAt = Date.now();\n while (Date.now() - startedAt < timeout) {\n const tab = await chrome.tabs.get(tabId).catch(() => null);\n if (tab?.status === 'complete') return tab;\n await wait(180);\n }\n return chrome.tabs.get(tabId).catch(() => null);\n}\n\nasync function reloadBossTab(tab, reason = 'runtime-refresh') {\n if (!tab?.id || !chrome.tabs?.reload) return tab;\n await chrome.tabs.reload(tab.id, { bypassCache: true });\n const ready = await waitForTabReady(tab.id);\n await writeEvent('info', 'BOSS 页面助手已自动刷新', { reason, tabId: tab.id, contentVersion: EXPECTED_CONTENT_VERSION }).catch(() => {});\n return ready || tab;\n}\n\nasync function refreshBossTabsForRuntimeVersion() {\n const key = 'bossContentRuntimeVersion';\n const current = await storage.get(key);\n if (current?.[key] === EXPECTED_CONTENT_VERSION) return;\n await storage.set({ [key]: EXPECTED_CONTENT_VERSION });\n if (!chrome.tabs?.query || !chrome.tabs?.reload) return;\n const tabs = await chrome.tabs.query({ url: ['https://www.zhipin.com/*', 'https://app.zhipin.com/*'] }).catch(() => []);\n for (const tab of tabs || []) {\n if (!tab?.id) continue;\n await chrome.tabs.reload(tab.id, { bypassCache: true }).catch(() => {});\n }\n}\n\nasync function activeBossTab() {\n const tabs = await chrome.tabs.query({ url: ['https://www.zhipin.com/*', 'https://app.zhipin.com/*'] });\n return tabs.find(tab => tab.active) || tabs[0] || null;\n}\n\nfunction bossConnectionError(error) {\n const message = String(error?.message || error || '');\n if (NO_RECEIVER_PATTERN.test(message)) {\n return 'BOSS 页面连接尚未就绪,JobClaw 已尝试自动修复。请刷新当前 BOSS 页面后再点一次开始。';\n }\n if (/Cannot access contents of url|The extensions gallery cannot be scripted|Missing host permission/i.test(message)) {\n return '当前页面无法接入 JobClaw。请确认打开的是 BOSS 直聘岗位页或沟通页。';\n }\n return message || 'BOSS 页面连接失败';\n}\n\nasync function probeBossReceiver(tab) {\n return chrome.tabs.sendMessage(tab.id, { type: 'PROBE', expectedContentVersion: EXPECTED_CONTENT_VERSION });\n}\n\nasync function injectBossContent(tab) {\n if (!chrome.scripting?.executeScript) throw new Error('扩展缺少页面注入能力,请重新加载最新版 JobClaw');\n await chrome.scripting.executeScript({\n target: { tabId: tab.id },\n files: [CONTENT_SCRIPT_FILE]\n });\n}\n\nasync function ensureBossReceiver(tab) {\n if (!tab?.id) throw new Error('请先打开并登录 BOSS 直聘');\n if (!BOSS_URL_PATTERN.test(String(tab.url || ''))) {\n throw new Error('请切换到 BOSS 直聘岗位页或沟通页');\n }\n\n let probe = null;\n try {\n probe = await probeBossReceiver(tab);\n if (receiverMatchesExpectedVersion(probe)) return probe;\n } catch (error) {\n if (!NO_RECEIVER_PATTERN.test(String(error?.message || error))) {\n throw new Error(bossConnectionError(error));\n }\n }\n\n // 能收到响应但版本不一致,说明当前标签页仍保留旧内容脚本。必须整页刷新,\n // 不能继续在同一页面叠加注入,否则旧 RUN 监听器仍可能执行。\n if (probe?.ok && !receiverMatchesExpectedVersion(probe)) {\n tab = await reloadBossTab(tab, `stale-${probe.contentVersion || 'legacy'}`);\n }\n\n for (let attempt = 0; attempt < 5; attempt += 1) {\n await wait(180 + attempt * 140);\n try {\n const currentProbe = await probeBossReceiver(tab);\n if (receiverMatchesExpectedVersion(currentProbe)) return currentProbe;\n if (currentProbe?.ok && !receiverMatchesExpectedVersion(currentProbe) && attempt === 0) {\n tab = await reloadBossTab(tab, `mismatch-${currentProbe.contentVersion || 'legacy'}`);\n }\n } catch (error) {\n if (!NO_RECEIVER_PATTERN.test(String(error?.message || error))) {\n throw new Error(bossConnectionError(error));\n }\n if (attempt === 1) {\n try { await injectBossContent(tab); } catch (injectError) { throw new Error(bossConnectionError(injectError)); }\n }\n }\n }\n throw new Error(`BOSS 页面助手版本未就绪(需要 ${EXPECTED_CONTENT_VERSION})。JobClaw 已自动刷新页面,请再点一次开始。`);\n}\n\nasync function sendToBossTab(tab, message) {\n await ensureBossReceiver(tab);\n try {\n return await chrome.tabs.sendMessage(tab.id, message);\n } catch (error) {\n if (!NO_RECEIVER_PATTERN.test(String(error?.message || error))) {\n throw new Error(bossConnectionError(error));\n }\n tab = await reloadBossTab(tab, 'message-receiver-lost');\n await ensureBossReceiver(tab);\n try {\n return await chrome.tabs.sendMessage(tab.id, message);\n } catch (retryError) {\n throw new Error(bossConnectionError(retryError));\n }\n }\n}\n\nasync function sendToBoss(message) {\n const tab = await activeBossTab();\n if (!tab) throw new Error('请先打开并登录 BOSS 直聘');\n return sendToBossTab(tab, message);\n}\n\nasync function nativeBridge(path, body) {\n if (!chrome.runtime?.sendNativeMessage) throw new Error('当前 Chrome 不支持 Native Messaging');\n const response = await chrome.runtime.sendNativeMessage(NATIVE_BRIDGE_HOST, { path, body: body ?? null });\n if (!response || response.ok === false) throw new Error(response?.error || '本地桥接没有返回结果');\n const payload = response.payload && typeof response.payload === 'object' ? response.payload : response;\n return { ...payload, _transport: 'native' };\n}\n\nasync function bridge(path, body) {\n const failures = [];\n const timeoutMs = path === '/parse-resume' ? 190000 : path === '/company/verify' ? 3500 : path === '/status' ? 3000 : 8000;\n for (const base of BRIDGE_ENDPOINTS) {\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), timeoutMs);\n try {\n const response = await fetch(`${base}${path}`, {\n method: body ? 'POST' : 'GET',\n headers: { 'Content-Type': 'application/json' },\n body: body ? JSON.stringify(body) : undefined,\n cache: 'no-store',\n credentials: 'omit',\n signal: controller.signal\n });\n const payload = await response.json().catch(() => ({}));\n if (!response.ok) throw new Error(payload.error || `本地桥接返回 HTTP ${response.status}`);\n bridgeUnavailableUntil = 0;\n bridgeLastError = '';\n return { ...payload, _transport: 'http', _endpoint: base };\n } catch (error) {\n failures.push(`${base} ${error?.name === 'AbortError' ? '连接超时' : String(error?.message || error)}`);\n } finally {\n clearTimeout(timeout);\n }\n }\n try {\n const payload = await nativeBridge(path, body);\n bridgeUnavailableUntil = 0;\n bridgeLastError = '';\n return payload;\n } catch (error) {\n failures.push(`Native Messaging ${String(error?.message || error)}`);\n bridgeLastError = String(error?.message || error);\n if (path === '/company/verify') bridgeUnavailableUntil = Date.now() + 5 * 60 * 1000;\n }\n throw new Error(`OpenClaw 桌面桥接未连接。请双击“安装桌面桥接-mac.command”完成安装或修复。${failures.length ? ` 诊断:${failures.join(';')}` : ''}`);\n}\n\nasync function diagnoseBridge() {\n const attempts = [];\n for (const base of BRIDGE_ENDPOINTS) {\n const startedAt = Date.now();\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), 2500);\n try {\n const response = await fetch(`${base}/status`, { cache: 'no-store', credentials: 'omit', signal: controller.signal });\n const payload = await response.json().catch(() => ({}));\n attempts.push({ transport: 'http', endpoint: base, ok: response.ok, elapsedMs: Date.now() - startedAt, status: response.status, payload });\n if (response.ok) return { ok: true, transport: 'http', runtimeId: chrome.runtime.id, attempts, status: payload };\n } catch (error) {\n attempts.push({ transport: 'http', endpoint: base, ok: false, elapsedMs: Date.now() - startedAt, error: error?.name === 'AbortError' ? '连接超时' : String(error?.message || error) });\n } finally {\n clearTimeout(timer);\n }\n }\n try {\n const status = await nativeBridge('/status');\n attempts.push({ transport: 'native', host: NATIVE_BRIDGE_HOST, ok: true });\n return { ok: true, transport: 'native', runtimeId: chrome.runtime.id, attempts, status };\n } catch (error) {\n attempts.push({ transport: 'native', host: NATIVE_BRIDGE_HOST, ok: false, error: String(error?.message || error) });\n }\n return {\n ok: false,\n runtimeId: chrome.runtime.id,\n expectedRuntimeId: 'dkfilgjiooigjbollljdionbnnofekkh',\n attempts,\n fix: '退出旧桥接后 双击项目根目录的“安装桌面桥接-mac.command” 安装完成后回到扩展点击检测连接',\n logPaths: ['~/.jobclaw/bridge.log', '~/.jobclaw/bridge-error.log']\n };\n}\n\nfunction bridgeSnapshot(all = {}) {\n const config = all.config || {};\n const pending = Array.isArray(all.pending) ? all.pending : [];\n const taskRuns = Array.isArray(all.taskRuns) ? all.taskRuns : [];\n return {\n ts: Date.now(),\n stats: { ...(all.stats || {}) },\n workflow: {\n running: Boolean(all.workflow?.running),\n paused: Boolean(all.workflow?.paused),\n phase: String(all.workflow?.phase || 'idle'),\n statusText: String(all.workflow?.statusText || '')\n },\n queue: {\n waiting: pending.filter(item => ['pending', 'approved_queue', 'approved'].includes(item.status)).length,\n sent: pending.filter(item => item.status === 'sent').length,\n failed: pending.filter(item => item.status === 'failed').length\n },\n recentRuns: taskRuns.filter(() => true).slice(0, 30).map(run => ({\n status: run.status,\n stageLabel: run.stageLabel,\n job: run.job ? { title: run.job.title || '', company: run.job.company || '' } : null,\n updatedAt: run.updatedAt || run.completedAt || run.createdAt || 0\n })),\n config: {\n batchStrategy: normalizeStrategy(config.batchStrategy),\n massApplyAnalysis: config.massApplyAnalysis || 'fast',\n pacingPreset: config.pacingPreset || 'standard',\n dailyTarget: Number(config.dailyTarget || 30),\n dailyReportEnabled: config.dailyReportEnabled !== false,\n dailyReportTime: String(config.dailyReportTime || '20:30'),\n dailyReportNotification: config.dailyReportNotification !== false\n }\n };\n}\n\nasync function syncBridgeSnapshot(force = false) {\n if (!force && Date.now() - lastBridgeSnapshotAt < 55 * 1000) return { skipped: true };\n lastBridgeSnapshotAt = Date.now();\n const snapshot = bridgeSnapshot(await storage.all());\n return bridge('/sync', { snapshot });\n}\n\nasync function enforceRateLimit(scope = 'discovery') {\n const { config = {}, safetyState = {} } = await storage.get(['config', 'safetyState']);\n const now = Date.now();\n const decision = computeRateLimitDecision(config, safetyState, scope, now);\n if (!decision.allowed) {\n await patchWorkflow({ running: false, paused: true, statusText: decision.reason || '安全熔断已开启' });\n return { ok: false, ...decision };\n }\n const reservedAt = now + Number(decision.waitMs || 0);\n const nextState = recordRateAction(safetyState, scope, reservedAt, Number(decision.waitMs || 0) > 0);\n await storage.set({ safetyState: nextState });\n return { ok: true, ...decision, reservedAt };\n}\n\nasync function applySafetyOutcome(outcome = {}) {\n const { config = {}, safetyState = {} } = await storage.get(['config', 'safetyState']);\n const next = recordSafetyOutcome(config, safetyState, outcome);\n await storage.set({ safetyState: next });\n if (next.circuitOpen) {\n await patchWorkflow({\n running: false,\n paused: true,\n phase: 'safety_stop',\n pendingApplyId: null,\n statusText: `安全熔断:${next.circuitReason || '连续任务失败'}`\n });\n await writeEvent('warning', '安全熔断已开启', { reason: next.circuitReason, failures: next.consecutiveFailures });\n chrome.notifications.create(`jobclaw-safety-${Date.now()}`, {\n type: 'basic',\n iconUrl: 'icon128.png',\n title: 'JobClaw 已安全暂停',\n message: next.circuitReason || '连续任务失败,请检查页面后手动恢复'\n }).catch(() => {});\n }\n return next;\n}\n\nasync function clearSafetyCircuit() {\n const { safetyState = {} } = await storage.get('safetyState');\n const next = resetSafetyCircuit(safetyState);\n await storage.set({ safetyState: next });\n await writeEvent('info', '安全熔断已重置');\n return next;\n}\n\nasync function verifyCompanyForJob(job = {}, force = false) {\n const { config = {}, companyVerificationCache = {} } = await storage.get(['config', 'companyVerificationCache']);\n const fallback = heuristicCompanyVerification(job);\n if (config.companyVerificationEnabled === false) {\n return { ...fallback, provider: 'disabled', status: 'disabled', verified: false, riskLevel: fallback.riskLevel === 'high' ? 'high' : 'unknown' };\n }\n const key = companyCacheKey(job.company);\n const cached = companyVerificationCache[key];\n if (!force && cached && !companyVerificationExpired(cached, config.companyVerificationCacheDays || 14)) {\n return { ...cached, cacheHit: true };\n }\n let providerResult = null;\n if (config.companyVerificationProvider !== 'local' && Date.now() < bridgeUnavailableUntil) {\n providerResult = {\n provider: 'bridge-cooldown',\n status: 'unavailable',\n verified: false,\n riskLevel: 'unknown',\n confidence: 0,\n signals: [`OpenClaw暂不可用 已在本地快速降级 ${bridgeLastError || ''}`.trim()],\n evidence: [],\n checkedAt: Date.now()\n };\n }\n if (config.companyVerificationProvider !== 'local' && !providerResult) {\n const throttle = await enforceRateLimit('company');\n if (throttle.ok && throttle.waitMs) await wait(throttle.waitMs);\n try {\n const response = await bridge('/company/verify', {\n provider: config.companyVerificationProvider || 'bridge',\n companyName: job.company || '',\n job: {\n title: job.title || '',\n company: job.company || '',\n location: job.location || '',\n description: job.description || job.cardText || '',\n url: job.url || ''\n }\n });\n providerResult = response?.result || response;\n } catch (error) {\n providerResult = {\n provider: 'bridge-unavailable',\n status: 'unavailable',\n verified: false,\n riskLevel: 'unknown',\n confidence: 0,\n signals: [`企业数据源暂不可用:${error.message}`],\n evidence: []\n };\n }\n }\n const merged = mergeCompanyVerification(providerResult || {}, fallback);\n const nextCache = { ...companyVerificationCache, [key]: merged };\n const entries = Object.entries(nextCache).sort((a, b) => Number(b[1]?.checkedAt || 0) - Number(a[1]?.checkedAt || 0)).slice(0, 500);\n await storage.set({ companyVerificationCache: Object.fromEntries(entries) });\n return { ...merged, cacheHit: false };\n}\n\nasync function preflightJob(job = {}) {\n const { config = {}, pending = [], deliveryHistory = [] } = await storage.get(['config', 'pending', 'deliveryHistory']);\n const duplicate = findDuplicate(job, {\n pending,\n history: deliveryHistory,\n maxPerCompanyPerDay: config.maxPerCompanyPerDay || 2,\n date: today()\n });\n if (duplicate.duplicate) {\n await changeStats({ duplicates: 1, blocked: 1 });\n return { ok: true, blocked: true, category: 'duplicate', reason: duplicate.reason, duplicate };\n }\n const company = await verifyCompanyForJob(job);\n const blockUnknown = Boolean(config.blockUnknownCompanies);\n const blocked = company.riskLevel === 'high' || (blockUnknown && company.riskLevel === 'unknown');\n await changeStats(blocked ? { blocked: 1 } : company.verified ? { verified: 1 } : {});\n return {\n ok: true,\n blocked,\n category: blocked ? 'company-risk' : 'accepted',\n reason: blocked ? (company.signals?.[0] || '企业核验未通过') : '',\n company,\n duplicate\n };\n}\n\nasync function checkForUpdates(force = false) {\n const currentVersion = chrome.runtime.getManifest().version;\n const { config = {}, updateInfo = {} } = await storage.get(['config', 'updateInfo']);\n if (config.updateCheckEnabled === false && !force) return updateInfo;\n if (!force && Number(updateInfo.checkedAt || 0) && Date.now() - Number(updateInfo.checkedAt) < 6 * 60 * 60 * 1000) return updateInfo;\n try {\n const response = await fetch('https://api.github.com/repos/Chrisbetheking/job-claw/releases/latest', {\n headers: { Accept: 'application/vnd.github+json' },\n cache: 'no-store',\n credentials: 'omit'\n });\n if (response.status === 404) {\n const next = { ...DEFAULTS.updateInfo, currentVersion, checkedAt: Date.now(), error: '仓库暂未发布正式 Release' };\n await storage.set({ updateInfo: next });\n return next;\n }\n if (!response.ok) throw new Error(`GitHub Release HTTP ${response.status}`);\n const next = normalizeRelease(await response.json(), currentVersion);\n const wasAvailable = Boolean(updateInfo.available && updateInfo.latestVersion === next.latestVersion);\n await storage.set({ updateInfo: next });\n if (next.available && !wasAvailable) {\n chrome.notifications.create(`jobclaw-update-${next.latestVersion}`, {\n type: 'basic',\n iconUrl: 'icon128.png',\n title: `JobClaw ${next.latestVersion} 可更新`,\n message: next.name || '发现新的正式版本'\n }).catch(() => {});\n }\n return next;\n } catch (error) {\n const next = { ...updateInfo, currentVersion, checkedAt: Date.now(), error: error.message || '更新检查失败' };\n await storage.set({ updateInfo: next });\n return next;\n }\n}\n\nfunction extractJson(raw) {\n let text = String(raw || '').trim()\n .replace(/^```(?:json)?/i, '')\n .replace(/```$/i, '')\n .trim();\n const start = text.indexOf('{');\n const end = text.lastIndexOf('}');\n if (start >= 0 && end > start) text = text.slice(start, end + 1);\n text = text.replace(/,\\s*([}\\]])/g, '$1');\n return JSON.parse(text);\n}\n\nfunction aiError(code, message, details = {}) {\n const error = new Error(message);\n error.code = code;\n Object.assign(error, details);\n return error;\n}\n\nfunction aiFailureKind(error) {\n const code = String(error?.code || '');\n const message = String(error?.message || '');\n if (code === 'AI_CONFIG' || /API Key|未配置|401|unauthorized/i.test(message)) return 'config-missing';\n if (['AI_NETWORK', 'AI_HTTP', 'AI_TIMEOUT'].includes(code) || /HTTP|fetch|网络|超时|服务不可用/i.test(message)) return 'service-error';\n return 'output-invalid';\n}\n\nfunction isRetryableAiOutputError(error) {\n return ['AI_TRUNCATED', 'AI_EMPTY', 'AI_INVALID_JSON', 'AI_PROFILE_INCOMPLETE'].includes(String(error?.code || ''));\n}\n\nasync function requestModel(url, payload, apiKey, timeoutMs = 90000) {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n try {\n return await fetch(url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`\n },\n body: JSON.stringify(payload),\n signal: controller.signal\n });\n } catch (error) {\n if (error?.name === 'AbortError') throw aiError('AI_TIMEOUT', 'AI 请求超时');\n throw aiError('AI_NETWORK', `AI 网络请求失败:${error?.message || '连接异常'}`);\n } finally {\n clearTimeout(timer);\n }\n}\n\nasync function callModel(messages, jsonMode = true, options = {}) {\n const { config } = await storage.get('config');\n const model = config?.model || {};\n if (!model.apiKey) throw aiError('AI_CONFIG', '请先在设置页填写 AI API Key');\n const url = `${String(model.baseUrl || 'https://api.deepseek.com').replace(/\\/$/, '')}/chat/completions`;\n const payload = {\n model: model.model || 'deepseek-v4-pro',\n messages,\n temperature: Number(options.temperature ?? model.temperature ?? 0.1),\n max_tokens: Number(options.maxTokens ?? 2800)\n };\n if (jsonMode) payload.response_format = { type: 'json_object' };\n\n let response = await requestModel(url, payload, model.apiKey, Number(options.timeoutMs || 90000));\n let bodyText = await response.text();\n\n // 某些兼容网关不支持 response_format,但仍能正常返回 JSON 文本。\n if (!response.ok && jsonMode && [400, 404, 422].includes(response.status) && /response[_ -]?format|json_object|unsupported/i.test(bodyText)) {\n const retryPayload = { ...payload };\n delete retryPayload.response_format;\n response = await requestModel(url, retryPayload, model.apiKey, Number(options.timeoutMs || 90000));\n bodyText = await response.text();\n }\n\n if (!response.ok) {\n throw aiError('AI_HTTP', `AI 请求失败 HTTP ${response.status}: ${bodyText.substring(0, 500)}`, { status: response.status });\n }\n\n let result;\n try {\n result = JSON.parse(bodyText);\n } catch {\n throw aiError('AI_INVALID_RESPONSE', 'AI 接口返回了无法识别的响应');\n }\n const choice = result.choices?.[0];\n const content = String(choice?.message?.content || '').trim();\n if (!content) throw aiError('AI_EMPTY', 'AI 返回为空');\n if (choice.finish_reason === 'length') {\n throw aiError('AI_TRUNCATED', 'AI 输出被截断', { finishReason: choice.finish_reason, partial: content });\n }\n if (!jsonMode) return content;\n try {\n return extractJson(content);\n } catch (error) {\n throw aiError('AI_INVALID_JSON', `AI 返回 JSON 不完整:${error?.message || '解析失败'}`, { partial: content });\n }\n}\n\nfunction cleanResumeText(value) {\n return String(value || '')\n .replace(/\\r/g, '\\n')\n .replace(/[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001F]/g, ' ')\n .replace(/[ \\t]+/g, ' ')\n .replace(/\\n{3,}/g, '\\n\\n')\n .trim();\n}\n\nfunction lineMatches(text, pattern, limit = 6) {\n return uniq(cleanResumeText(text)\n .split('\\n')\n .map(line => line.trim())\n .filter(line => line.length >= 4 && line.length <= 180 && pattern.test(line)))\n .slice(0, limit);\n}\n\nfunction extractSkills(text) {\n const source = cleanResumeText(text);\n const catalog = [\n ['JavaScript', /\\bjavascript\\b|\\bjs\\b/i],\n ['TypeScript', /\\btypescript\\b|\\bts\\b/i],\n ['HTML', /\\bhtml5?\\b/i],\n ['CSS', /\\bcss3?\\b|sass|less/i],\n ['Vue', /\\bvue(?:\\.js|3|2)?\\b/i],\n ['React', /\\breact(?:\\.js)?\\b/i],\n ['Next.js', /\\bnext\\.?js\\b/i],\n ['Node.js', /\\bnode\\.?js\\b/i],\n ['Express', /\\bexpress\\b/i],\n ['Vite', /\\bvite\\b/i],\n ['Webpack', /\\bwebpack\\b/i],\n ['Tailwind CSS', /\\btailwind\\b/i],\n ['ECharts', /\\becharts\\b/i],\n ['D3.js', /\\bd3(?:\\.js)?\\b/i],\n ['Tauri', /\\btauri\\b/i],\n ['Electron', /\\belectron\\b/i],\n ['Chrome Extension', /chrome\\s*(?:extension|扩展)|浏览器扩展/i],\n ['Python', /\\bpython\\b/i],\n ['Java', /\\bjava\\b/i],\n ['C/C++', /\\bc\\+\\+\\b|\\bc语言\\b/i],\n ['Go', /\\bgolang\\b|\\bgo语言\\b/i],\n ['SQL', /\\bsql\\b/i],\n ['MySQL', /\\bmysql\\b/i],\n ['PostgreSQL', /\\bpostgres(?:ql)?\\b/i],\n ['Redis', /\\bredis\\b/i],\n ['Spring Boot', /spring\\s*boot/i],\n ['Flask', /\\bflask\\b/i],\n ['Django', /\\bdjango\\b/i],\n ['FastAPI', /\\bfastapi\\b/i],\n ['Git', /\\bgit\\b|github/i],\n ['Docker', /\\bdocker\\b/i],\n ['Linux', /\\blinux\\b/i],\n ['Playwright', /\\bplaywright\\b/i],\n ['Selenium', /\\bselenium\\b/i],\n ['PyTorch', /\\bpytorch\\b/i],\n ['TensorFlow', /\\btensorflow\\b/i],\n ['OpenCV', /\\bopencv\\b/i],\n ['RAG', /\\brag\\b|检索增强生成/i],\n ['LLM', /\\bllm\\b|大语言模型|语言模型/i],\n ['AI Agent', /ai\\s*agent|智能体/i],\n ['OCR', /\\bocr\\b|文字识别/i],\n ['数据可视化', /数据可视化|可视化看板/i]\n ];\n return catalog.filter(([, pattern]) => pattern.test(source)).map(([name]) => name).slice(0, 40);\n}\n\nfunction extractDegree(text) {\n const source = cleanResumeText(text);\n if (/博士|ph\\.?d/i.test(source)) return '博士';\n if (/硕士|研究生|master/i.test(source)) return '硕士';\n if (/本科|学士|bachelor/i.test(source)) return '本科';\n if (/大专|专科|associate/i.test(source)) return '大专';\n return '不限';\n}\n\nfunction extractLocations(text) {\n const source = cleanResumeText(text);\n const cities = ['北京','上海','广州','深圳','杭州','成都','西安','南京','武汉','苏州','重庆','天津','长沙','郑州','青岛','厦门','合肥','济南','宁波','东莞','珠海','佛山','无锡','兰州','太原','南阳','东京','大阪'];\n return cities.filter(city => source.includes(city)).slice(0, 6);\n}\n\nfunction extractExplicitDirections(text) {\n const source = cleanResumeText(text);\n const matches = [];\n const pattern = /(?:求职意向|求职方向|目标岗位|意向岗位|应聘岗位|期望职位)\\s*[::]?\\s*([^\\n]{2,100})/ig;\n let match;\n while ((match = pattern.exec(source)) && matches.length < 3) {\n matches.push(...String(match[1] || '').split(/[,,、/|]/).map(item => item.trim()).filter(item => item.length >= 2 && item.length <= 28));\n }\n return uniq(matches).slice(0, 3);\n}\n\nfunction inferDirections(text, skills) {\n const source = cleanResumeText(text);\n const explicit = extractExplicitDirections(source);\n if (explicit.length) return explicit;\n const directions = [];\n const has = name => skills.includes(name);\n const internship = /实习|在校|应届|校招/i.test(source);\n if (/前端|web前端|网页开发/i.test(source) || ['JavaScript','TypeScript','Vue','React','HTML','CSS'].some(has)) {\n directions.push(internship ? '前端开发实习生' : '前端开发工程师');\n }\n if (/全栈/i.test(source) || ((has('React') || has('Vue')) && (has('Node.js') || has('Java') || has('Python')))) {\n directions.push(internship ? '全栈开发实习生' : '全栈开发工程师');\n }\n if (/ai应用|人工智能应用|智能体|rag|大模型|llm/i.test(source) || ['RAG','LLM','AI Agent'].some(has)) {\n directions.push(internship ? 'AI 应用开发实习生' : 'AI 应用开发工程师');\n }\n if (/数据可视化|echarts|d3/i.test(source) || has('数据可视化') || has('ECharts') || has('D3.js')) {\n directions.push('数据可视化开发工程师');\n }\n if (/后端|服务端/i.test(source) || ['Spring Boot','Java','Node.js','Python','Go'].filter(has).length >= 2) {\n directions.push(internship ? '后端开发实习生' : '后端开发工程师');\n }\n if (!directions.length) directions.push(internship ? '软件开发实习生' : '软件开发工程师');\n return uniq(directions).slice(0, 3);\n}\n\nfunction buildSearchKeywords(directions, skills) {\n const keywords = [...directions];\n const joined = directions.join(' ');\n if (/前端/.test(joined)) {\n keywords.push('前端开发', 'Web 前端', 'JavaScript 开发');\n if (skills.includes('React')) keywords.push('React 开发');\n if (skills.includes('Vue')) keywords.push('Vue 开发');\n if (skills.includes('TypeScript')) keywords.push('TypeScript 前端');\n }\n if (/全栈/.test(joined)) keywords.push('全栈开发', 'Web 全栈');\n if (/AI 应用/.test(joined)) {\n keywords.push('AI 应用开发');\n if (skills.includes('RAG')) keywords.push('RAG 应用开发');\n if (skills.includes('AI Agent')) keywords.push('AI Agent 开发');\n }\n if (/数据可视化/.test(joined)) keywords.push('数据可视化', '可视化前端');\n if (/后端/.test(joined)) keywords.push('后端开发', '服务端开发');\n return uniq(keywords).slice(0, 12);\n}\n\nfunction explainProfileFallbackReason(reason = '', kind = '') {\n const message = String(reason || '').trim();\n const resolvedKind = kind || aiFailureKind({ message });\n if (!message) return '';\n if (resolvedKind === 'config-missing') {\n return 'AI 尚未配置;本次未调用 AI,当前初稿全部来自本地规则。';\n }\n if (resolvedKind === 'service-error') {\n return 'AI 请求没有成功;本次画像未使用 AI,当前初稿全部来自本地规则。';\n }\n return 'AI 连接可用,但返回内容未通过完整性校验;系统已自动精简重试,仍未成功,因此本次初稿全部来自本地规则。';\n}\n\nfunction validateGeneratedProfile(profile) {\n if (!profile || typeof profile !== 'object' || Array.isArray(profile)) {\n throw aiError('AI_PROFILE_INCOMPLETE', 'AI 画像字段不完整');\n }\n const directions = normalizeStringList(profile.primaryDirections, 3);\n const keywords = normalizeStringList(profile.searchKeywords, 12);\n const summary = String(profile.summary || '').trim();\n if (!directions.length || !keywords.length || summary.length < 12) {\n throw aiError('AI_PROFILE_INCOMPLETE', 'AI 画像字段不完整');\n }\n if (!profile.facts || typeof profile.facts !== 'object' || Array.isArray(profile.facts)) {\n throw aiError('AI_PROFILE_INCOMPLETE', 'AI 画像字段不完整');\n }\n if (!profile.hardConstraints || typeof profile.hardConstraints !== 'object' || Array.isArray(profile.hardConstraints)) {\n throw aiError('AI_PROFILE_INCOMPLETE', 'AI 画像字段不完整');\n }\n return profile;\n}\n\nfunction validateCompactGeneratedProfile(profile) {\n if (!profile || typeof profile !== 'object' || Array.isArray(profile)) {\n throw aiError('AI_PROFILE_INCOMPLETE', 'AI 精简画像字段不完整');\n }\n const normalized = {\n summary: String(profile.summary || '').trim(),\n primaryDirections: normalizeStringList(profile.primaryDirections, 3),\n searchKeywords: normalizeStringList(profile.searchKeywords, 12),\n skills: normalizeStringList(profile.skills, 30),\n locations: normalizeStringList(profile.locations, 20),\n employmentTypes: normalizeStringList(profile.employmentTypes, 10),\n salary: String(profile.salary || '').trim(),\n experience: String(profile.experience || '').trim(),\n degree: String(profile.degree || '').trim(),\n excludeDirections: normalizeStringList(profile.excludeDirections, 20)\n };\n if (!normalized.primaryDirections.length || !normalized.searchKeywords.length || normalized.summary.length < 12) {\n throw aiError('AI_PROFILE_INCOMPLETE', 'AI 精简画像字段不完整');\n }\n return normalized;\n}\n\nfunction buildLocalProfile(resumeText, reason = '', failureKind = '') {\n const text = cleanResumeText(resumeText);\n if (text.length < 30) throw new Error('简历内容太少,暂时无法生成职业画像');\n const skills = extractSkills(text);\n const primaryDirections = inferDirections(text, skills);\n const searchKeywords = buildSearchKeywords(primaryDirections, skills);\n const degree = extractDegree(text);\n const locations = extractLocations(text);\n const internship = /实习|在校|应届|校招/i.test(text);\n const employmentTypes = internship ? ['实习', '校招'] : ['全职'];\n const experience = internship ? '在校/应届' : '按岗位要求匹配';\n const topSkills = skills.slice(0, 7);\n const summaryParts = [];\n if (degree !== '不限') summaryParts.push(`${degree}${internship ? '在读或应届' : '背景'}`);\n if (topSkills.length) summaryParts.push(`具备 ${topSkills.join('、')} 等技能或项目经验`);\n summaryParts.push(`主要关注 ${primaryDirections.join('、')} 方向`);\n\n const education = lineMatches(text, /大学|学院|本科|硕士|博士|教育经历|专业/i, 6);\n const experiences = lineMatches(text, /实习|工作经历|公司|负责|任职|助理|工程师/i, 8);\n const projects = lineMatches(text, /项目|系统|平台|工作台|GitHub|开发|实现|搭建|设计/i, 10);\n const certificates = lineMatches(text, /证书|CET|英语六级|英语四级|资格|获奖|奖项/i, 6);\n const resolvedKind = failureKind || (reason ? aiFailureKind({ message: reason }) : 'not-requested');\n\n return normalizeProfile({\n facts: { education, experiences, projects, skills, certificates },\n primaryDirections: primaryDirections.map(name => ({ name, confidence: 0.72, evidence: ['根据简历中的技能、项目和求职阶段生成'] })),\n secondaryDirections: [],\n searchKeywords,\n hardConstraints: {\n locations,\n employmentTypes,\n salary: '不限',\n experience,\n degree\n },\n excludeDirections: [],\n summary: `${summaryParts.join(',')}。`,\n generation: {\n mode: 'local-fallback',\n label: '本地规则初稿',\n aiStatus: resolvedKind,\n warning: explainProfileFallbackReason(reason, resolvedKind),\n technicalReason: String(reason || ''),\n generatedAt: Date.now()\n }\n });\n}\n\nfunction mergeProfileWithFallback(aiProfile, fallbackProfile, generation = null) {\n const ai = aiProfile && typeof aiProfile === 'object' ? aiProfile : {};\n const aiFacts = ai.facts && typeof ai.facts === 'object' ? ai.facts : {};\n const fallbackFacts = fallbackProfile.facts || {};\n const hard = ai.hardConstraints && typeof ai.hardConstraints === 'object' ? ai.hardConstraints : {};\n const fallbackHard = fallbackProfile.hardConstraints || {};\n return normalizeProfile({\n ...fallbackProfile,\n ...ai,\n facts: {\n ...fallbackFacts,\n ...aiFacts,\n education: Array.isArray(aiFacts.education) && aiFacts.education.length ? aiFacts.education : fallbackFacts.education,\n experiences: Array.isArray(aiFacts.experiences) && aiFacts.experiences.length ? aiFacts.experiences : fallbackFacts.experiences,\n projects: Array.isArray(aiFacts.projects) && aiFacts.projects.length ? aiFacts.projects : fallbackFacts.projects,\n skills: normalizeStringList(aiFacts.skills).length ? aiFacts.skills : fallbackFacts.skills,\n certificates: Array.isArray(aiFacts.certificates) && aiFacts.certificates.length ? aiFacts.certificates : fallbackFacts.certificates\n },\n primaryDirections: normalizeStringList(ai.primaryDirections, 3).length ? ai.primaryDirections : fallbackProfile.primaryDirections,\n searchKeywords: normalizeStringList(ai.searchKeywords, 12).length ? ai.searchKeywords : fallbackProfile.searchKeywords,\n hardConstraints: {\n ...fallbackHard,\n ...hard,\n locations: normalizeStringList(hard.locations).length ? hard.locations : fallbackHard.locations,\n employmentTypes: normalizeStringList(hard.employmentTypes).length ? hard.employmentTypes : fallbackHard.employmentTypes,\n salary: String(hard.salary || fallbackHard.salary || '不限'),\n experience: String(hard.experience || fallbackHard.experience || ''),\n degree: String(hard.degree || fallbackHard.degree || '不限')\n },\n summary: String(ai.summary || fallbackProfile.summary),\n generation: generation || {\n mode: 'ai-assisted',\n label: 'AI 完整画像',\n aiStatus: 'success',\n warning: 'AI 返回的完整 JSON 已通过校验;仅对可选空字段使用本地规则补齐。',\n generatedAt: Date.now()\n }\n });\n}\n\nfunction compactProfileToFull(compact, fallback) {\n return {\n facts: {\n ...(fallback.facts || {}),\n skills: compact.skills.length ? compact.skills : fallback.facts?.skills || []\n },\n primaryDirections: compact.primaryDirections,\n secondaryDirections: [],\n searchKeywords: compact.searchKeywords,\n hardConstraints: {\n ...(fallback.hardConstraints || {}),\n locations: compact.locations.length ? compact.locations : fallback.hardConstraints?.locations || [],\n employmentTypes: compact.employmentTypes.length ? compact.employmentTypes : fallback.hardConstraints?.employmentTypes || [],\n salary: compact.salary || fallback.hardConstraints?.salary || '不限',\n experience: compact.experience || fallback.hardConstraints?.experience || '',\n degree: compact.degree || fallback.hardConstraints?.degree || '不限'\n },\n excludeDirections: compact.excludeDirections,\n summary: compact.summary\n };\n}\n\nasync function buildProfile(resumeText) {\n const text = cleanResumeText(resumeText);\n const fallback = buildLocalProfile(text);\n const schema = '{\"facts\":{\"education\":[],\"experiences\":[],\"projects\":[],\"skills\":[],\"certificates\":[]},\"primaryDirections\":[{\"name\":\"\",\"confidence\":0,\"evidence\":[]}],\"secondaryDirections\":[],\"searchKeywords\":[],\"hardConstraints\":{\"locations\":[],\"employmentTypes\":[],\"salary\":\"\",\"experience\":\"\",\"degree\":\"\"},\"excludeDirections\":[],\"summary\":\"\"}';\n let firstError = null;\n try {\n const profile = validateGeneratedProfile(await callModel([\n {\n role: 'system',\n content: `你是严格的职业画像分析器。只能使用简历真实事实,不能根据项目业务场景推断用户职业。主方向最多3个,搜索词必须是真实岗位名称。数组必须精简,教育/经历/项目各最多4条,每条不超过80字,技能最多15个,摘要不超过180字。即使信息不完整,也必须给出可编辑初稿,禁止返回空内容。输出严格 JSON:${schema}`\n },\n { role: 'user', content: text.slice(0, 22000) }\n ], true, { maxTokens: 4200, temperature: 0.05 }));\n return mergeProfileWithFallback(profile, fallback);\n } catch (error) {\n firstError = error;\n }\n\n // 连接正常但完整画像输出过长、JSON 不完整或字段校验失败时,自动改用精简结构重试。\n if (isRetryableAiOutputError(firstError)) {\n const compactSchema = '{\"summary\":\"\",\"primaryDirections\":[],\"searchKeywords\":[],\"skills\":[],\"locations\":[],\"employmentTypes\":[],\"salary\":\"\",\"experience\":\"\",\"degree\":\"\",\"excludeDirections\":[]}';\n try {\n const compact = validateCompactGeneratedProfile(await callModel([\n {\n role: 'system',\n content: `你是求职职业画像分析器。只使用简历事实。请输出极简 JSON,不要解释,不要证据长句。摘要120字以内;主方向最多3个;搜索词最多10个;技能最多12个;其余字段简短。输出结构:${compactSchema}`\n },\n { role: 'user', content: text.slice(0, 18000) }\n ], true, { maxTokens: 2200, temperature: 0.05 }));\n return mergeProfileWithFallback(compactProfileToFull(compact, fallback), fallback, {\n mode: 'ai-compact-retry',\n label: 'AI 精简重试结果',\n aiStatus: 'success-after-retry',\n warning: 'AI 连接正常。首次完整画像输出未完成,系统已自动精简重试并成功采用 AI 结果。',\n technicalReason: String(firstError?.message || ''),\n generatedAt: Date.now()\n });\n } catch (retryError) {\n const combined = `${firstError?.message || 'AI 首次输出未通过'};精简重试:${retryError?.message || '失败'}`;\n return buildLocalProfile(text, combined, aiFailureKind(firstError));\n }\n }\n\n return buildLocalProfile(text, firstError?.message || 'AI 生成失败', aiFailureKind(firstError));\n}\n\n\nfunction normalizeStringList(value, limit = 30) {\n const values = Array.isArray(value) ? value : String(value || '').split(/[,,\\n]/);\n return uniq(values.map(item => typeof item === 'string' ? item.trim() : String(item?.name || item?.title || '').trim()))\n .filter(Boolean)\n .slice(0, limit);\n}\n\nfunction normalizeDirections(value, existing = []) {\n const previous = new Map((Array.isArray(existing) ? existing : []).map(item => {\n const name = typeof item === 'string' ? item : item?.name;\n return [String(name || '').trim(), item];\n }));\n return normalizeStringList(value, 3).map(name => {\n const old = previous.get(name);\n if (old && typeof old === 'object') return { ...old, name };\n return { name, confidence: 1, evidence: ['用户手动编辑'] };\n });\n}\n\nfunction normalizeProfile(incoming, current = null) {\n const base = current && typeof current === 'object' ? current : {};\n const next = incoming && typeof incoming === 'object' ? incoming : {};\n const profile = {\n ...base,\n ...next,\n facts: {\n education: [], experiences: [], projects: [], skills: [], certificates: [],\n ...(base.facts || {}),\n ...(next.facts || {}),\n skills: normalizeStringList(next.facts?.skills ?? base.facts?.skills, 40)\n },\n primaryDirections: normalizeDirections(next.primaryDirections ?? base.primaryDirections, base.primaryDirections),\n secondaryDirections: normalizeStringList(next.secondaryDirections ?? base.secondaryDirections, 10),\n searchKeywords: normalizeStringList(next.searchKeywords ?? base.searchKeywords, 12),\n excludeDirections: normalizeStringList(next.excludeDirections ?? base.excludeDirections, 20),\n hardConstraints: {\n locations: [], employmentTypes: [], salary: '', experience: '', degree: '',\n ...(base.hardConstraints || {}),\n ...(next.hardConstraints || {})\n },\n summary: String(next.summary ?? base.summary ?? '').trim(),\n editedAt: Date.now()\n };\n profile.hardConstraints.locations = normalizeStringList(profile.hardConstraints.locations, 20);\n profile.hardConstraints.employmentTypes = normalizeStringList(profile.hardConstraints.employmentTypes, 10);\n profile.hardConstraints.salary = String(profile.hardConstraints.salary || '').trim();\n profile.hardConstraints.experience = String(profile.hardConstraints.experience || '').trim();\n profile.hardConstraints.degree = String(profile.hardConstraints.degree || '').trim();\n if (!profile.primaryDirections.length) throw new Error('职业画像至少需要一个主方向');\n if (!profile.searchKeywords.length) throw new Error('职业画像至少需要一个岗位搜索词');\n return profile;\n}\n\n\nfunction profileHasCore(profile) {\n return Boolean(\n normalizeStringList(profile?.primaryDirections, 3).length &&\n normalizeStringList(profile?.searchKeywords, 12).length\n );\n}\n\nfunction normalizeProfileDraft(incoming, fallback = null) {\n const base = fallback && typeof fallback === 'object' ? fallback : {};\n const next = incoming && typeof incoming === 'object' ? incoming : {};\n const chooseList = (key, limit = 30) => Object.prototype.hasOwnProperty.call(next, key)\n ? normalizeStringList(next[key], limit)\n : normalizeStringList(base[key], limit);\n const chooseText = key => Object.prototype.hasOwnProperty.call(next, key)\n ? String(next[key] || '').trim()\n : String(base[key] || '').trim();\n return {\n summary: chooseText('summary'),\n primaryDirections: chooseList('primaryDirections', 3),\n searchKeywords: chooseList('searchKeywords', 12),\n skills: chooseList('skills', 40),\n locations: chooseList('locations', 20),\n employmentTypes: chooseList('employmentTypes', 10),\n experience: chooseText('experience'),\n degree: chooseText('degree'),\n salary: chooseText('salary'),\n excludeDirections: chooseList('excludeDirections', 20),\n source: String(next.source || base.source || 'draft'),\n updatedAt: Number(next.updatedAt || Date.now())\n };\n}\n\nfunction profileDraftHasCore(draft) {\n return Boolean(\n normalizeStringList(draft?.primaryDirections, 3).length &&\n normalizeStringList(draft?.searchKeywords, 12).length\n );\n}\n\nfunction profileDraftHasAny(draft) {\n if (!draft || typeof draft !== 'object') return false;\n return Boolean(\n String(draft.summary || '').trim() ||\n normalizeStringList(draft.primaryDirections).length ||\n normalizeStringList(draft.searchKeywords).length ||\n normalizeStringList(draft.skills).length ||\n normalizeStringList(draft.locations).length ||\n normalizeStringList(draft.employmentTypes).length ||\n String(draft.experience || '').trim() ||\n String(draft.degree || '').trim() ||\n String(draft.salary || '').trim() ||\n normalizeStringList(draft.excludeDirections).length\n );\n}\n\nfunction profileToDraft(profile, source = 'generated') {\n const hard = profile?.hardConstraints || {};\n return normalizeProfileDraft({\n summary: profile?.summary || '',\n primaryDirections: normalizeStringList(profile?.primaryDirections, 3),\n searchKeywords: normalizeStringList(profile?.searchKeywords, 12),\n skills: normalizeStringList(profile?.facts?.skills, 40),\n locations: normalizeStringList(hard.locations, 20),\n employmentTypes: normalizeStringList(hard.employmentTypes, 10),\n experience: hard.experience || '',\n degree: hard.degree || '',\n salary: hard.salary || '',\n excludeDirections: normalizeStringList(profile?.excludeDirections, 20),\n source,\n updatedAt: Date.now()\n });\n}\n\nfunction profileFromDraft(draft, currentProfile = null) {\n const normalized = normalizeProfileDraft(draft);\n return normalizeProfile({\n ...(currentProfile || {}),\n summary: normalized.summary,\n primaryDirections: normalized.primaryDirections,\n searchKeywords: normalized.searchKeywords,\n excludeDirections: normalized.excludeDirections,\n facts: {\n ...(currentProfile?.facts || {}),\n skills: normalized.skills\n },\n hardConstraints: {\n ...(currentProfile?.hardConstraints || {}),\n locations: normalized.locations,\n employmentTypes: normalized.employmentTypes,\n experience: normalized.experience,\n degree: normalized.degree,\n salary: normalized.salary\n }\n }, currentProfile);\n}\n\n\nfunction clampNumber(value, min, max, fallback = min) {\n const number = Number(value);\n return Number.isFinite(number) ? Math.max(min, Math.min(max, number)) : fallback;\n}\n\nfunction normalizeDirectionKey(value) {\n return String(value || '')\n .toLowerCase()\n .replace(/实习生|工程师|开发|岗位|职位|校招|社招|应届/g, '')\n .replace(/[\\s,,/\\\\|·•()()【】\\[\\]_-]+/g, '')\n .trim();\n}\n\nfunction stableDirectionId(name, source = 'profile') {\n const input = `${source}:${normalizeDirectionKey(name) || String(name || '').trim().toLowerCase()}`;\n let hash = 2166136261;\n for (let index = 0; index < input.length; index += 1) {\n hash ^= input.charCodeAt(index);\n hash = Math.imul(hash, 16777619);\n }\n return `direction_${(hash >>> 0).toString(36)}`;\n}\n\nconst DIRECTION_PRESETS = [\n {\n test: /前端|web|react|vue|javascript|typescript/i,\n keywords: ['前端开发实习生', 'Web前端实习生', 'React开发实习生', 'Vue开发实习生'],\n relevantSkills: ['JavaScript', 'TypeScript', 'HTML', 'CSS', 'React', 'Vue', 'Next.js', 'Vite', 'Webpack'],\n gapSkills: ['工程化', '性能优化', '组件库']\n },\n {\n test: /ai应用|人工智能应用|大模型|agent|rag|llm/i,\n keywords: ['AI应用开发实习生', 'AI Agent实习生', 'RAG应用开发实习生', '大模型应用开发实习生'],\n relevantSkills: ['AI Agent', 'RAG', 'Python', 'TypeScript', 'React', 'Node.js', 'OCR'],\n gapSkills: ['Python', '模型评测', '向量数据库']\n },\n {\n test: /全栈|full.?stack|前后端/i,\n keywords: ['全栈开发实习生', 'Web全栈实习生', '前后端开发实习生'],\n relevantSkills: ['JavaScript', 'TypeScript', 'React', 'Vue', 'Node.js', 'Express', 'MySQL'],\n gapSkills: ['后端接口', '数据库', '部署运维']\n },\n {\n test: /开发者工具|研发工具|桌面端|tauri|electron|工具开发/i,\n keywords: ['开发者工具实习生', '研发工具开发实习生', '桌面端开发实习生', '平台工具开发实习生'],\n relevantSkills: ['Tauri', 'Electron', 'TypeScript', 'React', 'Rust', 'Node.js', 'OCR'],\n gapSkills: ['跨平台工程化', '桌面端发布', '系统 API']\n },\n {\n test: /数据可视化|可视化|echarts|大屏/i,\n keywords: ['数据可视化实习生', '前端可视化实习生', 'ECharts开发实习生'],\n relevantSkills: ['ECharts', 'JavaScript', 'TypeScript', 'React', 'Vue', '数据处理'],\n gapSkills: ['图形性能优化', '复杂交互', '可视化工程化']\n },\n {\n test: /后端|java|node|服务端/i,\n keywords: ['后端开发实习生', 'Node.js开发实习生', 'Java开发实习生'],\n relevantSkills: ['Java', 'Node.js', 'Express', 'MySQL', 'Redis', 'Spring'],\n gapSkills: ['数据库', '缓存', '接口设计']\n }\n];\n\nfunction directionPreset(name) {\n return DIRECTION_PRESETS.find(item => item.test.test(String(name || ''))) || null;\n}\n\nfunction normalizedSkillSet(profile = {}) {\n return new Map(normalizeStringList(profile?.facts?.skills, 50).map(skill => [normalizeDirectionKey(skill), skill]));\n}\n\nfunction buildDirectionKeywords(name, profile = {}) {\n const allKeywords = normalizeStringList(profile?.searchKeywords, 30);\n const nameKey = normalizeDirectionKey(name);\n const preset = directionPreset(name);\n const matching = allKeywords.filter(keyword => {\n const key = normalizeDirectionKey(keyword);\n return key && (nameKey.includes(key) || key.includes(nameKey) || [...nameKey].filter(char => key.includes(char)).length >= Math.min(3, nameKey.length));\n });\n return uniq([name, ...matching, ...(preset?.keywords || [])]).filter(Boolean).slice(0, 8);\n}\n\nfunction buildDirectionEvidence(name, candidate = {}, profile = {}) {\n const skills = normalizeStringList(profile?.facts?.skills, 30);\n const skillMap = normalizedSkillSet(profile);\n const preset = directionPreset(name);\n const relevant = (preset?.relevantSkills || skills).filter(skill => {\n const key = normalizeDirectionKey(skill);\n return skillMap.has(key) || skills.some(current => normalizeDirectionKey(current).includes(key) || key.includes(normalizeDirectionKey(current)));\n });\n const matchedSkills = uniq(relevant.length ? relevant : skills.slice(0, 4)).slice(0, 5);\n const gaps = (preset?.gapSkills || []).filter(skill => {\n const key = normalizeDirectionKey(skill);\n return ![...skillMap.keys()].some(existing => existing.includes(key) || key.includes(existing));\n }).slice(0, 3);\n const evidence = normalizeStringList(candidate?.evidence, 3);\n const reason = evidence.length\n ? `职业画像中已提取到:${evidence.join(';')}`.slice(0, 160)\n : matchedSkills.length\n ? `与简历中的 ${matchedSkills.join('、')} 技能和项目经历匹配。`\n : `该方向来自职业画像中的主要求职方向,可继续人工调整。`;\n return { matchedSkills, gaps, reason };\n}\n\nfunction normalizeDirectionItem(item = {}, index = 0) {\n const name = String(item.name || item.title || '').trim().slice(0, 60);\n const source = item.source === 'custom' || item.custom ? 'custom' : 'profile';\n const id = String(item.id || stableDirectionId(name || `custom-${index}`, source));\n return {\n id,\n source,\n custom: source === 'custom',\n sourceName: String(item.sourceName || name).trim().slice(0, 60),\n name,\n enabled: item.enabled !== false,\n priority: Math.round(clampNumber(item.priority, 1, 99, index + 1)),\n score: Math.round(clampNumber(item.score, 0, 100, source === 'custom' ? 70 : Math.max(60, 88 - index * 7))),\n reason: String(item.reason || (source === 'custom' ? '用户自定义岗位方向。' : '根据职业画像推荐。')).trim().slice(0, 240),\n matchedSkills: normalizeStringList(item.matchedSkills, 8),\n gaps: normalizeStringList(item.gaps, 6),\n keywords: uniq(normalizeStringList(item.keywords, 12).length ? normalizeStringList(item.keywords, 12) : [name]).slice(0, 12),\n updatedAt: Number(item.updatedAt || Date.now())\n };\n}\n\nfunction profileDirectionSignature(profile = {}) {\n return JSON.stringify({\n directions: normalizeStringList(profile?.primaryDirections?.map(item => typeof item === 'string' ? item : item?.name), 6).map(normalizeDirectionKey),\n keywords: normalizeStringList(profile?.searchKeywords, 20).map(normalizeDirectionKey)\n });\n}\n\nfunction normalizeDirectionPlan(plan, profile = null, options = {}) {\n const items = (Array.isArray(plan?.items) ? plan.items : [])\n .map((item, index) => normalizeDirectionItem(item, index))\n .filter(item => item.name && item.keywords.length)\n .sort((left, right) => left.priority - right.priority || right.score - left.score)\n .slice(0, 12)\n .map((item, index) => ({ ...item, priority: index + 1 }));\n return {\n version: 1,\n items,\n confirmed: options.confirmed ?? Boolean(plan?.confirmed),\n updatedAt: Number(options.updatedAt || plan?.updatedAt || Date.now()),\n appliedAt: Number(options.appliedAt || plan?.appliedAt || 0),\n profileSignature: String(options.profileSignature || plan?.profileSignature || profileDirectionSignature(profile)).slice(0, 600)\n };\n}\n\nfunction buildDirectionPlan(profile = {}, currentPlan = null, options = {}) {\n const primary = Array.isArray(profile?.primaryDirections) ? profile.primaryDirections : [];\n const secondary = Array.isArray(profile?.secondaryDirections) ? profile.secondaryDirections : [];\n const searchKeywords = normalizeStringList(profile?.searchKeywords, 20);\n const candidates = [];\n for (const item of [...primary, ...secondary]) {\n const name = String(typeof item === 'string' ? item : item?.name || '').trim();\n if (!name || candidates.some(candidate => normalizeDirectionKey(candidate.name) === normalizeDirectionKey(name))) continue;\n candidates.push({ name, raw: typeof item === 'object' ? item : {} });\n }\n for (const keyword of searchKeywords) {\n if (candidates.length >= 6) break;\n if (!/开发|工程师|实习生|前端|后端|全栈|AI|人工智能|工具|可视化/i.test(keyword)) continue;\n if (candidates.some(candidate => normalizeDirectionKey(candidate.name) === normalizeDirectionKey(keyword))) continue;\n candidates.push({ name: keyword, raw: {} });\n }\n if (!candidates.length) candidates.push({ name: '目标岗位', raw: {} });\n\n const existing = new Map((currentPlan?.items || []).map(item => [String(item.id || ''), item]));\n const generated = candidates.slice(0, 6).map((candidate, index) => {\n const id = stableDirectionId(candidate.name, 'profile');\n const previous = existing.get(id);\n const confidence = Number(candidate.raw?.confidence);\n const score = Number.isFinite(confidence)\n ? Math.round(confidence <= 1 ? confidence * 100 : confidence)\n : Math.max(62, 90 - index * 7);\n const evidence = buildDirectionEvidence(candidate.name, candidate.raw, profile);\n return normalizeDirectionItem({\n id,\n source: 'profile',\n sourceName: candidate.name,\n name: options.preserveEdits && previous?.name ? previous.name : candidate.name,\n enabled: options.preserveSelections && previous ? previous.enabled : index < 3,\n priority: previous?.priority || index + 1,\n score,\n reason: evidence.reason,\n matchedSkills: evidence.matchedSkills,\n gaps: evidence.gaps,\n keywords: options.preserveEdits && previous?.keywords?.length\n ? previous.keywords\n : buildDirectionKeywords(candidate.name, profile)\n }, index);\n });\n const custom = options.preserveCustom === false ? [] : (currentPlan?.items || [])\n .filter(item => item?.source === 'custom' || item?.custom)\n .map((item, index) => normalizeDirectionItem(item, generated.length + index));\n return normalizeDirectionPlan({\n items: [...generated, ...custom],\n confirmed: options.confirmed ?? false,\n updatedAt: Date.now(),\n appliedAt: options.confirmed ? Date.now() : Number(currentPlan?.appliedAt || 0)\n }, profile, { confirmed: options.confirmed ?? false, updatedAt: Date.now() });\n}\n\nfunction selectedDirectionItems(plan = {}) {\n return (Array.isArray(plan?.items) ? plan.items : [])\n .map((item, index) => normalizeDirectionItem(item, index))\n .filter(item => item.enabled && item.name && item.keywords.length)\n .sort((left, right) => left.priority - right.priority || right.score - left.score);\n}\n\nfunction fallbackApplicantGreeting(job, profile) {\n const title = String(job?.title || '该岗位').trim();\n const skills = normalizeStringList(profile?.facts?.skills, 4);\n const direction = normalizeStringList(profile?.primaryDirections?.map(item => typeof item === 'string' ? item : item?.name), 2);\n const evidence = skills.length ? `我的简历中有${skills.slice(0, 3).join('、')}相关项目与实践` : (profile?.summary || direction.join('、'));\n const target = `贵公司的${title}`;\n return `您好,我想应聘${target}岗位。${evidence ? `${evidence},` : ''}对岗位的工作内容很感兴趣。方便的话希望进一步了解岗位和团队,谢谢。`\n .replace(/。{2,}/g, '。')\n .slice(0, 140);\n}\n\nfunction normalizeApplicantGreeting(result, job, profile) {\n const raw = String(result?.greeting || '').trim();\n const reversed = /看到你的简历|你的简历|很匹配我们|匹配我们|欢迎.*沟通|期待你|候选人|我们团队|我们公司|团队主要涉及|你很匹配|方便的话来聊聊/i.test(raw);\n const applicantVoice = /我想应聘|我希望应聘|我对.{0,20}(岗位|职位).{0,10}(感兴趣|有兴趣)|想进一步了解|希望进一步沟通/.test(raw);\n if (!raw || reversed || !applicantVoice) return fallbackApplicantGreeting(job, profile);\n return raw.slice(0, 160);\n}\n\nfunction fastMassAnalysis(job, profile) {\n const jobText = [job?.title, job?.description, job?.cardText].filter(Boolean).join('\\n').toLowerCase();\n const skills = normalizeStringList(profile?.facts?.skills, 20);\n const directions = normalizeStringList(profile?.primaryDirections?.map(item => typeof item === 'string' ? item : item?.name), 8);\n const matchedEvidence = [];\n for (const skill of skills) {\n const token = String(skill || '').trim();\n if (token && jobText.includes(token.toLowerCase())) matchedEvidence.push(token);\n }\n const directionMatched = directions.some(item => item && jobText.includes(String(item).toLowerCase()));\n const score = Math.max(25, Math.min(88, 35 + matchedEvidence.length * 9 + (directionMatched ? 15 : 0)));\n return {\n score,\n decision: matchedEvidence.length || directionMatched ? 'recommend' : 'cautious',\n hardBlocks: [],\n strictHardBlocks: [],\n matchedEvidence: matchedEvidence.slice(0, 8),\n gaps: [],\n risks: [],\n reason: '安全海投快速模式 仅做基础排序 不因技能 年限 专业或学历差距直接拦截',\n greeting: fallbackApplicantGreeting(job, profile),\n analysisMode: 'local-mass-fast'\n };\n}\n\nasync function analyzeJob(job) {\n const { profile, resumeText, config } = await storage.get(['profile', 'resumeText', 'config']);\n if (!profile) throw new Error('请先生成职业画像');\n const massMode = normalizeStrategy(config?.batchStrategy) === 'mass';\n if (massMode && config?.massApplyAnalysis !== 'ai') return fastMassAnalysis(job, profile);\n const strategyInstruction = massMode\n ? '当前为安全海投模式。技能、技术栈、专业、学历偏差、经验年限不足和行业经历缺失通常只能放入gaps,不能直接判定reject。只有JD明确写出必须、仅限、不接受,且与用户真实条件发生不可改变冲突时,才写入hardBlocks。'\n : '当前为精准投递模式。可以综合匹配度给出recommend、cautious或reject,但技能缺口仍应与真正不可改变的硬性冲突区分。';\n const result = await callModel([\n {\n role: 'system',\n content: `你是为求职者服务的岗位匹配审查器,不是招聘方。用户是正在应聘岗位的求职者。任何能力、年限、项目和成果都不能超出简历事实。${strategyInstruction} 输出 JSON:{\"score\":0,\"decision\":\"recommend|cautious|reject\",\"hardBlocks\":[],\"matchedEvidence\":[],\"gaps\":[],\"risks\":[],\"reason\":\"\",\"greeting\":\"\"}。greeting 是求职者发给招聘方/HR 的第一人称求职招呼语,50-110字,应使用“您好,我想应聘贵公司的……”口吻。严禁写成招聘方口吻,严禁出现“看到你的简历”“你的经历很匹配我们”“欢迎进一步沟通”“我们团队”“候选人”等表述;不得承诺薪资、到岗、年限或不存在的能力。`\n },\n {\n role: 'user',\n content: `职业画像:${JSON.stringify(profile)}\n简历:${String(resumeText || '').slice(0, 15000)}\n岗位:${JSON.stringify(job)}`\n }\n ]);\n result.score = Math.max(0, Math.min(100, Number(result.score || 0)));\n result.greeting = normalizeApplicantGreeting(result, job, profile);\n result.hardBlocks = Array.isArray(result.hardBlocks) ? result.hardBlocks.map(item => String(item || '').trim()).filter(Boolean) : [];\n result.strictHardBlocks = strictHardBlocks(result.hardBlocks);\n if (result.strictHardBlocks.length) result.decision = 'reject';\n else if (massMode && result.decision === 'reject') result.decision = 'cautious';\n if (!massMode && result.score < Number(config?.minScore || 75) && result.decision === 'recommend') result.decision = 'cautious';\n return result;\n}\n\nfunction createTasks(profile, config, directionPlan) {\n const directions = selectedDirectionItems(directionPlan);\n const locations = config.targetLocations?.length ? config.targetLocations : [''];\n const employmentTypes = config.employmentTypes?.length ? config.employmentTypes : ['不限'];\n const tasks = [];\n const seen = new Set();\n for (const direction of directions) {\n for (const keyword of direction.keywords) {\n for (const location of locations) {\n for (const employmentType of employmentTypes) {\n const dedupeKey = [keyword, location, employmentType].map(value => String(value || '').trim().toLowerCase()).join('|');\n if (seen.has(dedupeKey)) continue;\n seen.add(dedupeKey);\n tasks.push({\n id: crypto.randomUUID(),\n directionId: direction.id,\n directionName: direction.name,\n directionPriority: direction.priority,\n directionScore: direction.score,\n keyword,\n location,\n employmentType,\n experience: config.experiences?.[0] || '',\n degree: config.degrees?.[0] || '',\n salary: config.salary || '不限',\n attempts: 0,\n status: 'pending',\n progress: 0,\n stageLabel: '等待开始',\n processed: 0,\n discovered: 0,\n analyzed: 0,\n failed: 0,\n createdAt: Date.now()\n });\n }\n }\n }\n }\n return tasks;\n}\n\n\nasync function dispatchNextAutoPending() {\n const { pending = [], workflow = {}, config = {} } = await storage.get(['pending', 'workflow', 'config']);\n if (config.executionMode !== 'auto') return { started: false, reason: 'not-auto' };\n if (config.dryRun) return { started: false, reason: 'dry-run' };\n if (workflow.pendingApplyId) return { started: false, reason: 'busy', pendingApplyId: workflow.pendingApplyId };\n const ranked = rerankPending(pending);\n const candidate = ranked.find(entry => entry.status === 'approved_queue');\n if (!candidate) {\n await storage.set({ pending: ranked });\n return { started: false, reason: 'empty' };\n }\n const next = rerankPending(ranked.map(entry => entry.id === candidate.id\n ? { ...entry, status: 'approved', approvedAt: entry.approvedAt || Date.now() }\n : entry));\n await storage.set({ pending: next });\n const run = await updateTaskRunByPending(candidate.id, {\n status: 'running',\n stage: 'queued',\n progress: 64,\n stageLabel: `优先投递 · 队列第 ${candidate.priorityRank || 1} 位`,\n error: '',\n retryable: true,\n completedAt: null\n });\n await patchWorkflow({\n running: true,\n paused: false,\n phase: 'apply',\n statusText: `${normalizeStrategy(config.batchStrategy) === 'mass' ? '安全海投' : '精准投递'}:${candidate.job?.title || '岗位'}(队列优先级 ${candidate.priorityRank || 1})`,\n pendingApplyId: candidate.id,\n activeRunId: run?.id || candidate.runId || null\n });\n await sendToBoss({ type: 'RUN' });\n return { started: true, item: candidate, queueDepth: next.filter(entry => entry.status === 'approved_queue').length };\n}\n\nasync function addPending(item) {\n const { pending = [], config = {} } = await storage.get(['pending', 'config']);\n let run = null;\n if (item.runId) {\n run = await upsertTaskRun({\n id: item.runId,\n job: item.job,\n analysis: item.analysis,\n searchTask: item.task,\n status: config.executionMode === 'auto' && !config.dryRun ? 'queued' : 'waiting_review',\n stage: config.executionMode === 'auto' && !config.dryRun ? 'queued' : 'waiting_review',\n progress: config.executionMode === 'auto' && !config.dryRun ? 64 : 60,\n stageLabel: config.dryRun ? '模拟运行等待确认' : (config.executionMode === 'auto' ? '已进入自动排序队列' : '等待人工确认')\n });\n } else {\n run = await upsertTaskRun({\n job: item.job,\n analysis: item.analysis,\n searchTask: item.task,\n status: config.executionMode === 'auto' && !config.dryRun ? 'queued' : 'waiting_review',\n stage: config.executionMode === 'auto' && !config.dryRun ? 'queued' : 'waiting_review',\n progress: config.executionMode === 'auto' && !config.dryRun ? 64 : 60,\n stageLabel: config.dryRun ? '模拟运行等待确认' : (config.executionMode === 'auto' ? '已进入自动排序队列' : '等待人工确认')\n });\n }\n const nextItem = {\n ...item,\n runId: run.id,\n id: item.id || crypto.randomUUID(),\n deliveryGreeting: String(item.deliveryGreeting || item.analysis?.greeting || '').trim(),\n status: config.executionMode === 'auto' && !config.dryRun ? 'approved_queue' : 'pending',\n createdAt: Date.now()\n };\n await upsertTaskRun({ id: run.id, pendingId: nextItem.id });\n const next = rerankPending([nextItem, ...pending.filter(entry => entry.id !== nextItem.id)]);\n await storage.set({ pending: next });\n const count = next.filter(entry => entry.status === 'pending').length;\n await storage.set({ stats: { ...(await storage.get('stats')).stats, pending: count } });\n return next.find(entry => entry.id === nextItem.id) || nextItem;\n}\n\nasync function approvePending(id, greeting = '') {\n const { pending = [], config = {}, stats = {} } = await storage.get(['pending', 'config', 'stats']);\n const item = pending.find(entry => entry.id === id);\n if (!item) throw new Error('待确认岗位不存在');\n const lockedGreeting = String(greeting || item.deliveryGreeting || item.analysis?.greeting || '').trim();\n if (config.dryRun) {\n const completedAt = Date.now();\n const next = pending.map(entry => entry.id === id ? {\n ...entry,\n deliveryGreeting: lockedGreeting,\n analysis: { ...(entry.analysis || {}), greeting: lockedGreeting },\n status: 'simulated',\n completedAt\n } : entry);\n await storage.set({\n pending: next,\n stats: { ...stats, pending: next.filter(entry => entry.status === 'pending').length, simulated: Number(stats.simulated || 0) + 1 }\n });\n await updateTaskRunByPending(id, { status: 'success', stage: 'simulated', progress: 100, stageLabel: '模拟投递已通过', retryable: false, completedAt, result: 'dry-run' });\n await writeEvent('success', '模拟投递已通过 未执行真实发送', { id, job: item.job });\n return next.find(entry => entry.id === id);\n }\n const updatedItem = {\n ...item,\n deliveryGreeting: lockedGreeting,\n analysis: { ...(item.analysis || {}), greeting: lockedGreeting },\n status: 'approved',\n approvedAt: Date.now()\n };\n const next = pending.map(entry => entry.id === id ? updatedItem : entry);\n await storage.set({ pending: next });\n const pendingCount = next.filter(entry => entry.status === 'pending').length;\n await storage.set({ stats: { ...stats, pending: pendingCount } });\n const run = await updateTaskRunByPending(id, {\n status: 'running',\n stage: 'queued',\n progress: 64,\n stageLabel: '等待投递',\n error: '',\n retryable: true,\n completedAt: null\n });\n await patchWorkflow({\n running: true,\n paused: false,\n phase: 'apply',\n statusText: `准备以求职者身份沟通:${item.job?.title || '岗位'}`,\n pendingApplyId: id,\n activeRunId: run?.id || item.runId || null\n });\n await sendToBoss({ type: 'RUN' });\n setTimeout(() => sendToBoss({ type: 'RUN' }).catch(() => {}), 700);\n return updatedItem;\n}\n\nasync function approveAllPending() {\n const { pending = [], config = {}, stats = {} } = await storage.get(['pending', 'config', 'stats']);\n const candidates = rerankPending(pending).filter(entry => entry.status === 'pending');\n if (!candidates.length) return { count: 0 };\n if (config.dryRun) {\n const completedAt = Date.now();\n const ids = new Set(candidates.map(entry => entry.id));\n const next = pending.map(entry => ids.has(entry.id) ? { ...entry, status: 'simulated', completedAt } : entry);\n await storage.set({ pending: next, stats: { ...stats, pending: 0, simulated: Number(stats.simulated || 0) + candidates.length } });\n for (const candidate of candidates) {\n await updateTaskRunByPending(candidate.id, { status: 'success', stage: 'simulated', progress: 100, stageLabel: '模拟投递已通过', retryable: false, completedAt, result: 'dry-run' });\n }\n await writeEvent('success', `已完成${candidates.length}个岗位的模拟投递 未执行真实发送`);\n return { count: candidates.length, simulated: true };\n }\n const ids = new Set(candidates.map(entry => entry.id));\n const next = pending.map(entry => ids.has(entry.id)\n ? { ...entry, status: 'approved_queue', approvedAt: Date.now() }\n : entry);\n const first = candidates[0];\n await storage.set({ pending: next });\n await storage.set({ stats: { ...stats, pending: 0 } });\n for (const [index, candidate] of candidates.entries()) {\n await updateTaskRunByPending(candidate.id, {\n status: index === 0 ? 'running' : 'queued',\n stage: 'queued',\n progress: 64,\n stageLabel: index === 0 ? '准备投递' : '批量队列等待中',\n error: '',\n retryable: true,\n completedAt: null\n });\n }\n await patchWorkflow({\n running: true,\n paused: false,\n phase: 'apply',\n statusText: `批量投递 1/${candidates.length}:${first.job?.title || '岗位'}`,\n pendingApplyId: first.id,\n activeRunId: first.runId || null\n });\n await sendToBoss({ type: 'RUN' });\n return { count: candidates.length };\n}\n\nasync function rejectAllPending() {\n const { pending = [] } = await storage.get('pending');\n const count = pending.filter(entry => entry.status === 'pending').length;\n const next = pending.map(entry => entry.status === 'pending'\n ? { ...entry, status: 'rejected', rejectedAt: Date.now() }\n : entry);\n await storage.set({ pending: next });\n const { stats } = await storage.get('stats');\n await storage.set({ stats: { ...stats, pending: 0 } });\n for (const item of pending.filter(entry => entry.status === 'pending')) {\n await updateTaskRunByPending(item.id, { status: 'ignored', stage: 'ignored', progress: 100, stageLabel: '已忽略', retryable: false });\n }\n return { count };\n}\n\nasync function rejectPending(id) {\n const { pending = [] } = await storage.get('pending');\n const next = pending.map(entry => entry.id === id ? { ...entry, status: 'rejected', rejectedAt: Date.now() } : entry);\n await storage.set({ pending: next });\n const count = next.filter(entry => entry.status === 'pending').length;\n const { stats } = await storage.get('stats');\n await storage.set({ stats: { ...stats, pending: count } });\n await updateTaskRunByPending(id, { status: 'ignored', stage: 'ignored', progress: 100, stageLabel: '已忽略', retryable: false });\n}\n\n\nasync function skipPendingTask(id, reason = '该岗位需要外部网申,已自动跳过', stageLabel = '外部网申岗位已跳过') {\n const { pending = [], stats = {}, config = {}, chatTransition = null } = await storage.get(['pending', 'stats', 'config', 'chatTransition']);\n const completedAt = Date.now();\n let skippedItem = null;\n let next = pending.map(entry => {\n if (entry.id !== id) return entry;\n skippedItem = { ...entry, status: 'skipped', completedAt, error: reason };\n return skippedItem;\n });\n const updatedStats = {\n ...stats,\n pending: next.filter(entry => entry.status === 'pending').length\n };\n await storage.set({ pending: next, stats: updatedStats, chatTransition: chatTransition?.pendingId === id ? null : chatTransition });\n const run = await updateTaskRunByPending(id, {\n status: 'skipped', stage: 'skipped', progress: 100,\n stageLabel, error: reason, retryable: false, completedAt\n });\n await writeEvent('info', stageLabel, {\n id,\n runId: run?.id || skippedItem?.runId || '',\n job: skippedItem?.job,\n reason\n });\n\n next = rerankPending(next);\n const queued = next.find(entry => entry.status === 'approved_queue');\n if (queued) {\n next = next.map(entry => entry.id === queued.id ? { ...entry, status: 'approved' } : entry);\n await storage.set({ pending: next });\n const queuedRun = await updateTaskRunByPending(queued.id, {\n status: 'running', stage: 'queued', progress: 64,\n stageLabel: '准备投递', error: '', completedAt: null\n });\n await patchWorkflow({\n pendingApplyId: queued.id,\n activeRunId: queuedRun?.id || queued.runId || null,\n running: true,\n paused: false,\n phase: 'apply',\n statusText: `已跳过外部网申岗位,继续投递:${queued.job?.title || '岗位'}`\n });\n } else {\n await patchWorkflow({\n pendingApplyId: null,\n activeRunId: null,\n running: true,\n paused: false,\n phase: 'search',\n statusText: '外部网申岗位已跳过,继续搜索可直接沟通岗位'\n });\n }\n setTimeout(() => sendToBoss({ type: 'RUN' }).catch(() => {}), Math.max(3000, Math.min(10000, Number(config.betweenJobsSeconds || 5) * 1000)));\n return { item: skippedItem, queued: queued || null };\n}\n\nasync function handleBridgeCommands() {\n try {\n const response = await bridge('/commands');\n for (const command of response.commands || []) {\n if (command.type === 'start') {\n await patchWorkflow({ running: true, paused: false, statusText: '由 OpenClaw 启动' });\n await sendToBoss({ type: 'RUN' });\n } else if (command.type === 'pause') {\n await patchWorkflow({ paused: true, statusText: '由 OpenClaw 暂停' });\n } else if (command.type === 'stop') {\n await patchWorkflow({ running: false, paused: true, phase: 'idle', statusText: '由 OpenClaw 停止' });\n }\n }\n } catch {\n // 桌面桥接未运行时保持浏览器扩展独立可用。\n }\n}\n\nchrome.runtime.onInstalled.addListener(init);\nchrome.runtime.onStartup.addListener(init);\ninit();\n\nchrome.alarms.onAlarm.addListener(async alarm => {\n if (alarm.name === 'jobclaw-update-check') {\n await checkForUpdates(false).catch(() => {});\n return;\n }\n if (alarm.name !== 'jobclaw-tick') return;\n const { workflow } = await storage.get('workflow');\n if (workflow?.running && !workflow.paused) sendToBoss({ type: 'RUN' }).catch(() => {});\n handleBridgeCommands();\n syncBridgeSnapshot(false).catch(() => {});\n});\n\nchrome.runtime.onMessage.addListener((message, sender, reply) => {\n (async () => {\n switch (message?.type) {\n case 'TRUSTED_CHAT_INPUT': {\n const tabId = sender?.tab?.id;\n const url = String(sender?.tab?.url || '');\n if (!/^https:\\/\\/(?:www|app)\\.zhipin\\.com\\//i.test(url)) throw new Error('可信输入只能用于当前 BOSS 页面');\n const result = await trustedChatInput(tabId, message);\n reply({ ok: true, ...result });\n break;\n }\n case 'RATE_LIMIT': {\n reply(await enforceRateLimit(message.scope || 'discovery'));\n break;\n }\n case 'SAFETY_OUTCOME': {\n const state = await applySafetyOutcome({ ok: message.ok !== false, reason: message.reason || '' });\n reply({ ok: true, state });\n break;\n }\n case 'RESET_SAFETY': {\n reply({ ok: true, state: await clearSafetyCircuit() });\n break;\n }\n case 'JOB_PREFLIGHT': {\n reply(await preflightJob(message.job || {}));\n break;\n }\n case 'VERIFY_COMPANY': {\n reply({ ok: true, result: await verifyCompanyForJob(message.job || {}, Boolean(message.force)) });\n break;\n }\n case 'EVALUATE_STRATEGY': {\n const { config = {} } = await storage.get('config');\n const result = evaluateStrategy({\n strategy: config.batchStrategy,\n score: message.score,\n minScore: config.minScore,\n riskLevel: message.riskLevel || 'unknown',\n verified: Boolean(message.verified),\n decision: message.decision || 'cautious',\n hardBlocks: message.hardBlocks || []\n });\n reply({ ok: true, result });\n break;\n }\n case 'CHECK_UPDATE': {\n reply({ ok: true, result: await checkForUpdates(Boolean(message.force)) });\n break;\n }\n case 'OPEN_UPDATE': {\n const { updateInfo = {} } = await storage.get('updateInfo');\n const url = String(message.url || updateInfo.url || 'https://github.com/Chrisbetheking/job-claw/releases');\n await chrome.tabs.create({ url });\n reply({ ok: true });\n break;\n }\n case 'GET_STATE':\n reply({ ok: true, state: publicState(await storage.all()) });\n break;\n case 'SAVE_CONFIG': {\n const { config } = await storage.get('config');\n const incoming = message.config || {};\n const oldKey = config?.model?.apiKey || '';\n incoming.model = {\n ...(config?.model || {}),\n ...(incoming.model || {}),\n apiKey: incoming.model?.apiKey && incoming.model.apiKey !== '***'\n ? incoming.model.apiKey\n : oldKey\n };\n incoming.executionMode = incoming.executionMode === 'auto' ? 'auto' : 'review';\n incoming.batchStrategy = normalizeStrategy(incoming.batchStrategy || config?.batchStrategy || 'precise');\n incoming.massApplyAnalysis = ['fast', 'ai'].includes(incoming.massApplyAnalysis) ? incoming.massApplyAnalysis : (config?.massApplyAnalysis || 'fast');\n incoming.pacingPreset = ['conservative', 'standard', 'efficient', 'custom'].includes(incoming.pacingPreset) ? incoming.pacingPreset : (config?.pacingPreset || 'standard');\n incoming.dryRun = Boolean(incoming.dryRun);\n incoming.dailyTarget = Math.max(1, Math.min(150, Number(incoming.dailyTarget || config?.dailyTarget || 30)));\n incoming.discoveryLimit = Math.max(1, Math.min(800, Number(incoming.discoveryLimit || config?.discoveryLimit || 150)));\n const presetDelay = { conservative: 15, standard: 9, efficient: 6 }[incoming.pacingPreset];\n incoming.betweenJobsSeconds = Math.max(6, Math.min(120, Number(presetDelay || incoming.betweenJobsSeconds || config?.betweenJobsSeconds || 9)));\n incoming.attachmentDelaySeconds = Math.max(1.5, Math.min(15, Number(incoming.attachmentDelaySeconds || config?.attachmentDelaySeconds || 3)));\n incoming.maxPerCompanyPerDay = Math.max(1, Math.min(12, Number(incoming.maxPerCompanyPerDay || config?.maxPerCompanyPerDay || 3)));\n incoming.queueWarmup = Math.max(1, Math.min(10, Number(incoming.queueWarmup || config?.queueWarmup || 4)));\n incoming.maxConsecutiveFailures = Math.max(1, Math.min(10, Number(incoming.maxConsecutiveFailures || config?.maxConsecutiveFailures || 3)));\n incoming.jitterSeconds = Math.max(0, Math.min(15, Number(incoming.jitterSeconds ?? config?.jitterSeconds ?? 3)));\n incoming.companyVerificationEnabled = incoming.companyVerificationEnabled !== false;\n incoming.companyVerificationProvider = String(incoming.companyVerificationProvider || config?.companyVerificationProvider || 'bridge');\n incoming.companyVerificationCacheDays = Math.max(1, Math.min(90, Number(incoming.companyVerificationCacheDays || config?.companyVerificationCacheDays || 14)));\n incoming.blockUnknownCompanies = Boolean(incoming.blockUnknownCompanies);\n incoming.updateCheckEnabled = incoming.updateCheckEnabled !== false;\n incoming.dailyReportEnabled = incoming.dailyReportEnabled !== false;\n incoming.dailyReportTime = /^\\d{2}:\\d{2}$/.test(String(incoming.dailyReportTime || '')) ? String(incoming.dailyReportTime) : (config?.dailyReportTime || '20:30');\n incoming.dailyReportNotification = incoming.dailyReportNotification !== false;\n incoming.rateLimits = { ...DEFAULTS.config.rateLimits, ...(config?.rateLimits || {}), ...(incoming.rateLimits || {}), deliveryMs: Math.max(6000, Number(incoming.betweenJobsSeconds || 9) * 1000), attachmentMs: Math.max(1500, Number(incoming.attachmentDelaySeconds || 3) * 1000) };\n incoming.model.baseUrl = String(incoming.model.baseUrl || 'https://api.deepseek.com').trim();\n incoming.model.model = String(incoming.model.model || 'deepseek-v4-pro').trim();\n await storage.set({ config: { ...(config || {}), ...incoming } });\n await writeEvent('info', '设置已保存');\n syncBridgeSnapshot(true).catch(() => {});\n reply({ ok: true });\n break;\n }\n case 'SET_RESUME':\n await storage.set({ resumeText: String(message.text || '') });\n await writeEvent('info', '简历文本已保存', { length: String(message.text || '').length });\n reply({ ok: true });\n break;\n case 'SET_RESUME_SOURCE': {\n const file = message.file || null;\n if (!file?.name || !file?.dataUrl) throw new Error('简历原文件数据不完整');\n if (String(file.dataUrl).length > 26 * 1024 * 1024) throw new Error('简历原文件过大');\n const source = {\n name: String(file.name),\n type: String(file.type || ''),\n size: Number(file.size || 0),\n lastModified: Number(file.lastModified || 0),\n dataUrl: String(file.dataUrl),\n savedAt: Date.now()\n };\n await storage.set({ resumeSourceFile: source });\n await writeEvent('info', '简历原文件已保留', { name: source.name, size: source.size });\n reply({ ok: true, file: { name: source.name, type: source.type, size: source.size, stored: true } });\n break;\n }\n case 'PARSE_RESUME_PDF': {\n const { resumeSourceFile } = await storage.get('resumeSourceFile');\n if (!resumeSourceFile?.dataUrl) throw new Error('请先选择 PDF 简历');\n if (!String(resumeSourceFile.name || '').toLowerCase().endsWith('.pdf') && resumeSourceFile.type !== 'application/pdf') {\n throw new Error('当前保留的文件不是 PDF');\n }\n try {\n const result = await bridge('/parse-resume', {\n name: resumeSourceFile.name,\n type: resumeSourceFile.type,\n dataUrl: resumeSourceFile.dataUrl\n });\n if (result?.text) await writeEvent('info', 'PDF 本机深度识别完成', { method: result.method, length: result.text.length });\n reply({ ok: Boolean(result?.ok), result, error: result?.error || '' });\n } catch (error) {\n reply({ ok: false, error: `桌面桥接不可用:${error.message}` });\n }\n break;\n }\n case 'SET_IMAGE':\n await storage.set({ resumeImage: message.dataUrl || null });\n await writeEvent('info', message.dataUrl ? '简历图片已保存' : '简历图片已移除');\n reply({ ok: true });\n break;\n case 'BUILD_LOCAL_PROFILE':\n case 'ENSURE_PROFILE_DRAFT': {\n const { resumeText, profile: currentProfile, profileDraft: currentDraft } = await storage.get(['resumeText', 'profile', 'profileDraft']);\n if (profileDraftHasCore(currentDraft) && profileHasCore(currentProfile)) {\n reply({ ok: true, profile: currentProfile, profileDraft: currentDraft, skipped: true });\n break;\n }\n if (profileHasCore(currentProfile)) {\n const profileDraft = profileDraftHasAny(currentDraft)\n ? normalizeProfileDraft(currentDraft, profileToDraft(currentProfile, 'profile-repair'))\n : profileToDraft(currentProfile, 'profile-repair');\n await storage.set({ profileDraft });\n reply({ ok: true, profile: currentProfile, profileDraft, repaired: true });\n break;\n }\n const profile = buildLocalProfile(resumeText || '');\n profile.generation.mode = 'local-recovery';\n profile.generation.label = '本地初稿';\n profile.generation.warning = '已从保存的简历自动恢复可编辑初稿。';\n const profileDraft = profileToDraft(profile, 'local-recovery');\n const { directionPlan: currentDirectionPlan } = await storage.get('directionPlan');\n const directionPlan = buildDirectionPlan(profile, currentDirectionPlan, {\n confirmed: false,\n preserveSelections: true,\n preserveEdits: true,\n preserveCustom: true\n });\n await storage.set({ profile, profileDraft, directionPlan });\n await writeEvent('info', '已从已保存简历恢复可编辑画像', { directions: profile.primaryDirections });\n reply({ ok: true, profile, profileDraft, directionPlan, generation: profile.generation });\n break;\n }\n case 'BUILD_PROFILE': {\n const { resumeText, directionPlan: currentDirectionPlan } = await storage.get(['resumeText', 'directionPlan']);\n const profile = await buildProfile(resumeText || '');\n const profileDraft = profileToDraft(profile, profile.generation?.mode || 'generated');\n const directionPlan = buildDirectionPlan(profile, currentDirectionPlan, {\n confirmed: false,\n preserveSelections: true,\n preserveEdits: true,\n preserveCustom: true\n });\n await storage.set({ profile, profileDraft, directionPlan });\n const generation = profile.generation || {};\n const local = generation.mode === 'local-fallback';\n const eventTitle = !local\n ? (generation.mode === 'ai-compact-retry' ? 'AI 精简重试成功,职业画像已生成' : '职业画像已生成')\n : generation.aiStatus === 'service-error'\n ? 'AI 请求失败,已生成本地可编辑画像'\n : generation.aiStatus === 'config-missing'\n ? 'AI 尚未配置,已生成本地可编辑画像'\n : 'AI 输出未通过校验,已生成本地可编辑画像';\n await writeEvent(local ? 'warning' : 'info', eventTitle,\n { directions: profile.primaryDirections, generation });\n reply({ ok: true, profile, profileDraft, directionPlan, generation, warning: generation.warning || '' });\n break;\n }\n case 'SAVE_PROFILE_DRAFT': {\n const { profileDraft: currentDraft } = await storage.get('profileDraft');\n const profileDraft = normalizeProfileDraft(message.profileDraft, currentDraft);\n await storage.set({ profileDraft });\n reply({ ok: true, profileDraft });\n break;\n }\n case 'SAVE_PROFILE': {\n const { profile: currentProfile, config = {}, profileDraft: currentDraft, directionPlan: currentDirectionPlan } = await storage.get(['profile', 'config', 'profileDraft', 'directionPlan']);\n const profile = normalizeProfile(message.profile, currentProfile);\n const profileDraft = profileToDraft(profile, 'manual-save');\n const keepDirectionConfirmation = Boolean(\n currentDirectionPlan?.confirmed\n && profileDirectionSignature(currentProfile || {}) === profileDirectionSignature(profile)\n );\n const directionPlan = buildDirectionPlan(profile, currentDirectionPlan, {\n confirmed: keepDirectionConfirmation,\n preserveSelections: true,\n preserveEdits: true,\n preserveCustom: true\n });\n const hard = profile.hardConstraints || {};\n const nextConfig = {\n ...config,\n targetLocations: hard.locations?.length ? hard.locations : (config.targetLocations || []),\n employmentTypes: hard.employmentTypes?.length ? hard.employmentTypes : (config.employmentTypes || ['不限']),\n experiences: hard.experience ? [hard.experience] : (config.experiences || []),\n degrees: hard.degree ? [hard.degree] : (config.degrees || []),\n salary: hard.salary || config.salary || '不限'\n };\n await storage.set({ profile, profileDraft, directionPlan, config: nextConfig });\n await writeEvent('info', '职业画像已手动保存', { directions: profile.primaryDirections });\n reply({ ok: true, profile, profileDraft, directionPlan, config: nextConfig });\n break;\n }\n case 'REBUILD_DIRECTION_PLAN': {\n const stored = await storage.get(['profile', 'profileDraft', 'directionPlan']);\n let profile = stored.profile;\n if (!profileHasCore(profile) && profileDraftHasCore(stored.profileDraft)) {\n profile = profileFromDraft(stored.profileDraft, profile);\n await storage.set({ profile });\n }\n if (!profileHasCore(profile)) throw new Error('请先生成职业画像');\n const directionPlan = buildDirectionPlan(profile, stored.directionPlan, {\n confirmed: false,\n preserveSelections: false,\n preserveEdits: false,\n preserveCustom: true\n });\n await storage.set({ directionPlan });\n await writeEvent('info', '已重新生成岗位方向推荐', {\n directions: directionPlan.items.map(item => item.name),\n enabled: directionPlan.items.filter(item => item.enabled).length\n });\n reply({ ok: true, directionPlan });\n break;\n }\n case 'SAVE_DIRECTION_PLAN': {\n const stored = await storage.get(['profile', 'config', 'workflow', 'directionPlan']);\n if (!profileHasCore(stored.profile)) throw new Error('请先生成职业画像');\n const directionPlan = normalizeDirectionPlan(message.directionPlan, stored.profile, {\n confirmed: true,\n updatedAt: Date.now(),\n appliedAt: Date.now()\n });\n const selected = selectedDirectionItems(directionPlan);\n if (!selected.length) throw new Error('至少选择一个要投递的岗位方向');\n const tasks = createTasks(stored.profile, stored.config || DEFAULTS.config, directionPlan);\n if (!tasks.length) throw new Error('所选岗位方向没有可用搜索词,请先补充关键词');\n await storage.set({ directionPlan });\n await writeEvent('info', '投递岗位方向已保存', {\n directions: selected.map(item => item.name),\n taskCount: tasks.length,\n appliesNextRun: Boolean(stored.workflow?.running)\n });\n reply({\n ok: true,\n directionPlan,\n selectedCount: selected.length,\n taskCount: tasks.length,\n appliesNextRun: Boolean(stored.workflow?.running)\n });\n break;\n }\n case 'TEST_AI': {\n const text = await callModel([\n { role: 'system', content: '只回复“连接正常”。' },\n { role: 'user', content: '测试连接' }\n ], false);\n reply({ ok: true, text });\n break;\n }\n case 'PROBE_BOSS': {\n const tab = await activeBossTab();\n if (!tab) throw new Error('请先打开并登录 BOSS 直聘');\n const result = await ensureBossReceiver(tab);\n reply({ ok: true, result: { ...result, tabId: tab.id, title: tab.title || '', url: tab.url || '' } });\n break;\n }\n case 'START': {\n const safety = await storage.get('safetyState');\n if (safety.safetyState?.circuitOpen) throw new Error(`安全熔断未重置:${safety.safetyState.circuitReason || '请先检查页面状态'}`);\n const stored = await storage.get(['profile', 'profileDraft', 'resumeText', 'config', 'directionPlan']);\n let profile = stored.profile;\n const config = stored.config;\n if (!profileHasCore(profile) && profileDraftHasCore(stored.profileDraft)) {\n profile = profileFromDraft(stored.profileDraft, profile);\n await storage.set({ profile });\n }\n if (!profileHasCore(profile) && String(stored.resumeText || '').trim().length >= 30) {\n profile = buildLocalProfile(stored.resumeText || '');\n profile.generation.mode = 'local-recovery';\n profile.generation.label = '本地初稿';\n const profileDraft = profileToDraft(profile, 'start-recovery');\n await storage.set({ profile, profileDraft });\n }\n if (!profileHasCore(profile)) throw new Error('请先生成职业画像');\n let directionPlan = stored.directionPlan;\n if (!directionPlan?.items?.length) {\n directionPlan = buildDirectionPlan(profile, null, { confirmed: false });\n await storage.set({ directionPlan });\n }\n directionPlan = normalizeDirectionPlan(directionPlan, profile);\n if (!directionPlan.confirmed) throw new Error('请先在“简历 → 职业画像”中选择要投递的岗位方向并保存');\n if (!selectedDirectionItems(directionPlan).length) throw new Error('至少选择一个要投递的岗位方向');\n const tasks = createTasks(profile, config, directionPlan);\n if (!tasks.length) throw new Error('所选岗位方向没有可执行的搜索词,请补充方向关键词');\n const tab = await activeBossTab();\n if (!tab) throw new Error('请先打开并登录 BOSS 直聘');\n await ensureBossReceiver(tab);\n const workflow = {\n ...DEFAULTS.workflow,\n running: true,\n paused: false,\n phase: 'search',\n statusText: '正在启动岗位采集',\n tasks,\n activeRunId: null\n };\n await storage.set({ workflow });\n try {\n await sendToBossTab(tab, { type: 'RUN' });\n await writeEvent('info', '任务已启动', { taskCount: workflow.tasks.length, tabId: tab.id });\n reply({ ok: true });\n } catch (error) {\n await patchWorkflow({\n running: false,\n paused: true,\n phase: 'idle',\n statusText: 'BOSS 页面连接失败,任务未启动'\n });\n throw error;\n }\n break;\n }\n case 'PAUSE':\n await patchWorkflow({ paused: true, statusText: '用户已暂停' });\n await writeEvent('info', '任务已暂停');\n reply({ ok: true });\n break;\n case 'STOP':\n await patchWorkflow({ running: false, paused: true, phase: 'idle', statusText: '用户已停止', pendingApplyId: null, activeRunId: null });\n await writeEvent('info', '任务已停止');\n reply({ ok: true });\n break;\n case 'AI_JOB':\n reply({ ok: true, result: await analyzeJob(message.job) });\n break;\n case 'CHAT_BINDING_PREPARE': {\n if (!message.pendingId || !message.expected) throw new Error('岗位与 HR 会话绑定信息不完整');\n const transition = {\n pendingId: String(message.pendingId),\n job: safeClone(message.job || {}),\n expected: safeClone(message.expected || {}),\n preparedAt: Date.now()\n };\n await storage.set({ chatTransition: transition });\n reply({ ok: true, transition });\n break;\n }\n case 'CHAT_BINDING_CHECK': {\n const expected = message.expected || {};\n const recruiterName = String(message.context?.recruiterName || expected.recruiterName || '').trim();\n const company = String(message.context?.companyName || expected.company || message.job?.company || '').trim();\n const key = deriveConversationReservationKey(message.context || {}, expected, message.pendingId);\n if (!key) {\n reply({ ok: false, allowed: false, error: '目标 HR 已核验,但无法建立安全投递锁' });\n break;\n }\n const { chatDeliveryLedger = {} } = await storage.get('chatDeliveryLedger');\n const existing = chatDeliveryLedger[key];\n const identityExisting = Object.values(chatDeliveryLedger).find(entry =>\n entry?.pendingId !== message.pendingId\n && ['reserved', 'sent'].includes(String(entry?.status || ''))\n && sameRecruiterReservation(entry, recruiterName, company)\n );\n const conflict = existing && existing.pendingId !== message.pendingId ? existing : identityExisting;\n if (conflict) {\n reply({\n ok: true,\n allowed: false,\n error: `当前 HR 会话已绑定其他岗位任务,禁止继续发送`,\n existing: conflict\n });\n break;\n }\n const reservation = {\n ...(existing || {}),\n conversationKey: key,\n pendingId: String(message.pendingId || ''),\n runId: String(message.runId || ''),\n jobUrl: String(message.job?.url || ''),\n jobTitle: String(message.job?.title || ''),\n company: String(message.job?.company || ''),\n recruiterName,\n company,\n chatUrl: String(message.context?.url || ''),\n status: existing?.status === 'sent' ? 'sent' : 'reserved',\n reservedAt: existing?.reservedAt || Date.now(),\n updatedAt: Date.now()\n };\n await storage.set({ chatDeliveryLedger: { ...chatDeliveryLedger, [key]: reservation } });\n reply({ ok: true, allowed: true, conversationKey: key, reservation });\n break;\n }\n case 'CHAT_BINDING_CONFIRMED': {\n const transition = {\n pendingId: String(message.pendingId || ''),\n job: safeClone(message.job || {}),\n expected: safeClone(message.expected || {}),\n context: safeClone(message.context || {}),\n confirmedAt: Date.now()\n };\n await storage.set({ chatTransition: transition });\n reply({ ok: true, transition });\n break;\n }\n case 'MATERIAL': {\n const { resumeImage, config } = await storage.get(['resumeImage', 'config']);\n reply({ ok: true, resumeImage, config });\n break;\n }\n case 'CONTENT_STATE':\n reply({ ok: true, state: await storage.all() });\n break;\n case 'WORKFLOW':\n reply({ ok: true, workflow: await patchWorkflow(message.patch || {}) });\n break;\n case 'STATS':\n reply({ ok: true, stats: await changeStats(message.delta || {}) });\n break;\n case 'TASK_PROGRESS': {\n const run = await upsertTaskRun(message.run || {});\n if (message.setActive) await patchWorkflow({ activeRunId: run.id });\n reply({ ok: true, run });\n break;\n }\n case 'SEARCH_TASK_PROGRESS':\n reply({ ok: true, task: await updateSearchTaskProgress(message) });\n break;\n case 'RETRY_FAILED_TASK':\n reply({ ok: true, ...(await retryFailedTask(message.runId)) });\n break;\n case 'RETRY_ALL_FAILED_TASKS':\n reply({ ok: true, ...(await retryAllFailedTasks()) });\n break;\n case 'IGNORE_FAILED_TASK':\n reply({ ok: true, run: await ignoreFailedTask(message.runId) });\n break;\n case 'OPEN_TASK_JOB': {\n const url = String(message.url || '').trim();\n if (!/^https:\\/\\/(?:www|app)\\.zhipin\\.com\\//i.test(url)) throw new Error('岗位地址无效');\n const tab = await chrome.tabs.create({ url, active: true });\n reply({ ok: true, tabId: tab.id });\n break;\n }\n case 'EVENT':\n reply({ ok: true, event: await writeEvent(message.level || 'info', message.message || '', message.data || {}) });\n break;\n case 'PENDING':\n reply({ ok: true, item: await addPending(message.item || {}) });\n break;\n case 'APPROVE':\n reply({ ok: true, item: await approvePending(message.id, message.greeting || '') });\n break;\n case 'REJECT':\n await rejectPending(message.id);\n reply({ ok: true });\n break;\n case 'APPROVE_ALL':\n reply({ ok: true, ...(await approveAllPending()) });\n break;\n case 'REJECT_ALL':\n reply({ ok: true, ...(await rejectAllPending()) });\n break;\n case 'AUTO_APPROVE': {\n const { pending = [] } = await storage.get('pending');\n const next = rerankPending(pending.map(entry => entry.id === message.id\n ? {\n ...entry,\n deliveryGreeting: String(message.greeting || entry.deliveryGreeting || entry.analysis?.greeting || '').trim(),\n status: 'approved_queue',\n approvedAt: Date.now()\n }\n : entry));\n await storage.set({ pending: next });\n reply({ ok: true, ...(await dispatchNextAutoPending()) });\n break;\n }\n case 'AUTO_DISPATCH_NEXT':\n reply({ ok: true, ...(await dispatchNextAutoPending()) });\n break;\n case 'SKIP_PENDING':\n reply({ ok: true, ...(await skipPendingTask(message.id, message.reason, message.stageLabel)) });\n break;\n case 'APPLY_COMPLETE': {\n const { pending = [], config = {}, stats = {}, chatDeliveryLedger = {}, chatTransition = null, deliveryHistory = [] } = await storage.get(['pending', 'config', 'stats', 'chatDeliveryLedger', 'chatTransition', 'deliveryHistory']);\n const completedAt = Date.now();\n let completedItem = null;\n let next = pending.map(entry => {\n if (entry.id !== message.id) return entry;\n completedItem = { ...entry, status: message.ok ? 'sent' : 'failed', completedAt, error: message.error || '' };\n return completedItem;\n });\n const updatedStats = {\n ...stats,\n pending: next.filter(entry => entry.status === 'pending').length,\n sent: Number(stats?.sent || 0) + (message.ok ? 1 : 0),\n failed: Number(stats?.failed || 0) + (message.ok ? 0 : 1)\n };\n const conversationKey = String(message.conversation?.key || '');\n let nextLedger = chatDeliveryLedger;\n if (conversationKey && chatDeliveryLedger[conversationKey]?.pendingId === message.id) {\n nextLedger = {\n ...chatDeliveryLedger,\n [conversationKey]: {\n ...chatDeliveryLedger[conversationKey],\n status: message.ok ? 'sent' : 'failed',\n sentAt: message.ok ? completedAt : (chatDeliveryLedger[conversationKey].sentAt || null),\n failedAt: message.ok ? null : completedAt,\n error: message.error || '',\n updatedAt: completedAt\n }\n };\n }\n const nextTransition = chatTransition?.pendingId === message.id ? null : chatTransition;\n const nextHistory = message.ok && completedItem?.job\n ? [createHistoryEntry(completedItem.job, 'sent', completedAt, { pendingId: message.id, runId: completedItem.runId || '' }), ...deliveryHistory].slice(0, 2000)\n : deliveryHistory;\n await storage.set({ pending: next, stats: updatedStats, chatDeliveryLedger: nextLedger, chatTransition: nextTransition, deliveryHistory: nextHistory });\n const safetyAfter = await applySafetyOutcome({ ok: Boolean(message.ok), reason: message.error || (message.ok ? '' : '投递失败') });\n const completedRun = await updateTaskRunByPending(message.id, message.ok\n ? {\n status: 'success', stage: 'success', progress: 100, stageLabel: '投递成功', error: '',\n retryable: false, completedAt, result: message.result || 'sent'\n }\n : {\n status: 'failed', stage: message.stage || 'failed', progress: 100,\n stageLabel: message.stageLabel || '投递失败', error: message.error || '投递失败',\n retryable: message.retryable !== false, retryRequestedAt: 0, completedAt\n });\n await writeEvent(message.ok ? 'success' : 'error', message.ok ? '求职投递成功' : '求职投递失败', {\n id: message.id,\n runId: completedRun?.id || completedItem?.runId || '',\n job: completedItem?.job,\n stage: message.stage || '',\n error: message.error || ''\n });\n if (safetyAfter.circuitOpen) {\n reply({ ok: true, circuitOpen: true, reason: safetyAfter.circuitReason });\n break;\n }\n\n const validationPending = message.ok\n && config.executionMode === 'auto'\n && config.requireSingleJobValidation !== false\n && !Number(config.singleJobValidationCompletedAt || 0);\n if (validationPending) {\n const nextConfig = { ...config, singleJobValidationCompletedAt: completedAt };\n await storage.set({ config: nextConfig });\n await patchWorkflow({\n running: false,\n paused: true,\n pendingApplyId: null,\n activeRunId: null,\n phase: 'idle',\n statusText: '单条验收已通过并自动暂停;确认聊天文字和附件后可继续批量'\n });\n await writeEvent('success', '首次单条投递验收通过', {\n id: message.id,\n runId: completedRun?.id || completedItem?.runId || '',\n job: completedItem?.job,\n completedAt\n });\n reply({ ok: true, validationCompleted: true, pausedForValidation: true });\n break;\n }\n\n if (updatedStats.sent >= Number(config.dailyTarget || 30)) {\n await patchWorkflow({ running: false, paused: true, pendingApplyId: null, activeRunId: null, phase: 'idle', statusText: `已完成今日 ${updatedStats.sent} 次成功投递` });\n reply({ ok: true, targetReached: true });\n break;\n }\n\n // 任何文字未确认场景都暂停当前队列,但只有输入框中确实存在完整招呼语时,\n // 才提示“草稿已保留”。输入框为空时必须明确提示写入失败,不能误导用户。\n if (!message.ok && message.pauseQueue) {\n const draftPresent = message.preserveDraft === true || message.draftPresent === true;\n await patchWorkflow({\n running: false,\n paused: true,\n pendingApplyId: null,\n activeRunId: completedRun?.id || completedItem?.runId || null,\n phase: 'apply',\n statusText: draftPresent\n ? '发送未确认,已暂停队列;当前招呼语仍在输入框'\n : '文字没有写入或没有发送,已暂停队列并停留在当前 HR 页面'\n });\n await writeEvent('warning', draftPresent\n ? '自动投递已暂停,当前招呼语仍保留在输入框'\n : '自动投递已暂停,聊天输入框中没有完整招呼语', {\n id: message.id,\n runId: completedRun?.id || completedItem?.runId || '',\n job: completedItem?.job,\n draftPresent,\n editorDiagnostics: message.editorDiagnostics || null,\n error: message.error || ''\n });\n reply({ ok: true, pausedForDraft: draftPresent, pausedForSendFailure: true });\n break;\n }\n\n next = rerankPending(next);\n const queued = next.find(entry => entry.status === 'approved_queue');\n if (queued) {\n next = next.map(entry => entry.id === queued.id ? { ...entry, status: 'approved' } : entry);\n await storage.set({ pending: next });\n const remaining = next.filter(entry => entry.status === 'approved_queue').length;\n const queuedRun = await updateTaskRunByPending(queued.id, {\n status: 'running', stage: 'queued', progress: 64, stageLabel: '准备投递', error: '', completedAt: null\n });\n await patchWorkflow({\n pendingApplyId: queued.id,\n activeRunId: queuedRun?.id || queued.runId || null,\n running: true,\n paused: false,\n phase: 'apply',\n statusText: `继续批量投递:${queued.job?.title || '岗位'}(剩余 ${remaining})`\n });\n const pacingMs = Math.max(3000, Math.min(30000, Number(config.betweenJobsSeconds || 12) * 1000));\n await patchWorkflow({ statusText: `当前岗位处理完成,${Math.round(pacingMs / 1000)} 秒后继续批量投递` });\n setTimeout(() => sendToBoss({ type: 'RUN' }).catch(() => {}), pacingMs);\n } else {\n const pacingMs = Math.max(3000, Math.min(30000, Number(config.betweenJobsSeconds || 12) * 1000));\n await patchWorkflow({ pendingApplyId: null, activeRunId: null, phase: 'search', statusText: message.ok ? `文字投递已确认,${Math.round(pacingMs / 1000)} 秒后继续筛选` : `当前岗位失败,${Math.round(pacingMs / 1000)} 秒后继续筛选` });\n setTimeout(() => sendToBoss({ type: 'RUN' }).catch(() => {}), pacingMs);\n }\n reply({ ok: true });\n break;\n }\n case 'BRIDGE_STATUS':\n try { reply({ ok: true, result: await bridge('/status') }); }\n catch (error) { reply({ ok: false, error: error.message }); }\n break;\n case 'BRIDGE_DIAGNOSE':\n reply({ ok: true, result: await diagnoseBridge() });\n break;\n case 'BRIDGE_REPORT_NOW':\n try {\n await syncBridgeSnapshot(true);\n reply({ ok: true, result: await bridge('/report/generate', { force: true }) });\n } catch (error) { reply({ ok: false, error: error.message }); }\n break;\n case 'BRIDGE_REPORT':\n try { reply({ ok: true, result: await bridge('/report') }); }\n catch (error) { reply({ ok: false, error: error.message }); }\n break;\n case 'BRIDGE_COMMAND':\n try { reply({ ok: true, result: await bridge('/command', { type: message.command }) }); }\n catch (error) { reply({ ok: false, error: error.message }); }\n break;\n default:\n reply({ ok: false, error: 'unknown message' });\n }\n })().catch(async error => {\n await writeEvent('error', '后台处理失败', { type: message?.type, error: error.message });\n reply({ ok: false, error: error.message });\n });\n return true;\n});\n"}, "76f53ebdeadb5fe589927cbed2ecce55fc037ed5": {"t": 1785758652.7648964, "n": 132197, "h": "09d43e3692436939", "c": "import { DEFAULTS, safeClone, today, uniq } from './common.js';\nimport { normalizeConversationIdentity, deriveConversationReservationKey, sameRecruiterReservation } from './lib/conversation-identity.js';\nimport { TERMINAL_RUN_STATUSES, taskStageMeta } from './lib/task-state.js';\nimport { rerankPending } from './lib/job-priority.js';\n\nconst BRIDGE_ENDPOINTS = ['http://127.0.0.1:17899', 'http://localhost:17899'];\nconst EXPECTED_CONTENT_VERSION = '1.3.0';\nconst CONTENT_SCRIPT_FILE = 'content-v37.js';\nconst storage = {\n get: keys => chrome.storage.local.get(keys),\n all: () => chrome.storage.local.get(null),\n set: patch => chrome.storage.local.set(patch)\n};\n\nconst debuggerLocks = new Map();\n\nconst OFFSCREEN_DOCUMENT_PATH = 'offscreen.html';\nlet offscreenCreation = null;\n\nasync function ensureClipboardOffscreen() {\n if (!chrome.offscreen?.createDocument) return false;\n const url = chrome.runtime.getURL(OFFSCREEN_DOCUMENT_PATH);\n if (chrome.runtime.getContexts) {\n const contexts = await chrome.runtime.getContexts({\n contextTypes: ['OFFSCREEN_DOCUMENT'],\n documentUrls: [url]\n }).catch(() => []);\n if (contexts?.length) return true;\n }\n if (!offscreenCreation) {\n offscreenCreation = chrome.offscreen.createDocument({\n url: OFFSCREEN_DOCUMENT_PATH,\n reasons: ['CLIPBOARD'],\n justification: '将求职招呼语以真实粘贴方式写入 BOSS 聊天编辑器'\n }).then(() => true).catch(error => {\n const message = String(error?.message || error || '');\n if (/single offscreen|already exists|existing offscreen/i.test(message)) return true;\n throw error;\n }).finally(() => { offscreenCreation = null; });\n }\n return offscreenCreation;\n}\n\nasync function writeClipboardText(text) {\n const value = String(text || '');\n if (!value) return false;\n try {\n const ready = await ensureClipboardOffscreen();\n if (!ready) return false;\n const response = await chrome.runtime.sendMessage({ type: 'JOBCLAW_CLIPBOARD_WRITE', text: value });\n return response?.ok === true;\n } catch {\n return false;\n }\n}\n\nfunction validPoint(point) {\n const x = Number(point?.x);\n const y = Number(point?.y);\n if (!Number.isFinite(x) || !Number.isFinite(y) || x < 0 || y < 0) {\n throw new Error('聊天输入坐标无效');\n }\n return { x, y };\n}\n\nasync function debuggerBringToFront(target) {\n await chrome.debugger.sendCommand(target, 'Page.bringToFront').catch(() => {});\n await chrome.debugger.sendCommand(target, 'Runtime.evaluate', {\n expression: 'window.focus(); true',\n returnByValue: true\n }).catch(() => {});\n}\n\nasync function debuggerNodeAtPoint(target, point) {\n const { x, y } = validPoint(point);\n const result = await chrome.debugger.sendCommand(target, 'DOM.getNodeForLocation', {\n x, y,\n includeUserAgentShadowDOM: true,\n ignorePointerEventsNone: true\n }).catch(() => null);\n return {\n nodeId: Number(result?.nodeId || 0),\n backendNodeId: Number(result?.backendNodeId || 0),\n frameId: String(result?.frameId || '')\n };\n}\n\nasync function debuggerResolveNodeObject(target, node = {}) {\n const args = {};\n if (Number(node.nodeId || 0)) args.nodeId = Number(node.nodeId);\n else if (Number(node.backendNodeId || 0)) args.backendNodeId = Number(node.backendNodeId);\n else return '';\n const resolved = await chrome.debugger.sendCommand(target, 'DOM.resolveNode', args).catch(() => null);\n return String(resolved?.object?.objectId || '');\n}\n\nasync function debuggerReadObjectText(target, objectId = '') {\n if (!objectId) return '';\n const result = await chrome.debugger.sendCommand(target, 'Runtime.callFunctionOn', {\n objectId,\n functionDeclaration: `function() {\n if ('value' in this && !this.isContentEditable) return String(this.value || '');\n return String(this.innerText || this.textContent || '');\n }`,\n returnByValue: true,\n silent: true\n }).catch(() => null);\n return String(result?.result?.value || '');\n}\n\nasync function debuggerReadNodeText(target, node = {}) {\n const objectId = await debuggerResolveNodeObject(target, node);\n return debuggerReadObjectText(target, objectId);\n}\n\nasync function debuggerFindEditableObject(target, message = {}) {\n const selector = String(message.selector || '').trim();\n const point = validPoint(message.point);\n const expression = `(() => {\n const exactSelector = ${JSON.stringify(selector)};\n const point = ${JSON.stringify(point)};\n const editableSelector = 'textarea,input[type=\"text\"],input:not([type]),[contenteditable]:not([contenteditable=\"false\"]),[role=\"textbox\"],[data-slate-editor=\"true\"],[data-lexical-editor=\"true\"]';\n const isEditable = node => {\n if (!node || node.nodeType !== 1) return false;\n const tag = String(node.tagName || '').toLowerCase();\n if (tag === 'textarea') return true;\n if (tag === 'input') {\n const type = String(node.getAttribute('type') || 'text').toLowerCase();\n return ['text', 'search', ''].includes(type);\n }\n const mode = String(node.getAttribute('contenteditable') || '').toLowerCase();\n return node.isContentEditable\n || Boolean(mode && !['false', 'inherit', 'off'].includes(mode))\n || node.getAttribute('role') === 'textbox'\n || node.getAttribute('data-slate-editor') === 'true'\n || node.getAttribute('data-lexical-editor') === 'true';\n };\n const deepQuery = (root, query) => {\n if (!root) return null;\n try {\n const direct = root.querySelector?.(query);\n if (direct) return direct;\n } catch {}\n let elements = [];\n try { elements = [...(root.querySelectorAll?.('*') || [])]; } catch {}\n for (const element of elements) {\n if (element.shadowRoot) {\n const found = deepQuery(element.shadowRoot, query);\n if (found) return found;\n }\n }\n return null;\n };\n const nearestEditable = node => {\n let current = node;\n for (let depth = 0; current && depth < 16; depth += 1) {\n if (isEditable(current)) return current;\n let nested = null;\n try { nested = current.querySelector?.(editableSelector); } catch {}\n if (nested && isEditable(nested)) return nested;\n const root = current.getRootNode?.();\n current = current.parentElement || (root && root.host) || null;\n }\n return null;\n };\n let node = exactSelector ? deepQuery(document, exactSelector) : null;\n node = nearestEditable(node) || node;\n if (!isEditable(node)) {\n let pointNode = null;\n try { pointNode = document.elementFromPoint(point.x, point.y); } catch {}\n node = nearestEditable(pointNode);\n }\n if (!isEditable(node)) return null;\n try { node.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'instant' }); } catch {}\n try { node.focus({ preventScroll: true }); } catch { try { node.focus(); } catch {} }\n if (node.isContentEditable) {\n try {\n const selection = window.getSelection();\n const range = document.createRange();\n range.selectNodeContents(node);\n range.collapse(false);\n selection.removeAllRanges();\n selection.addRange(range);\n } catch {}\n }\n return node;\n })()`;\n const result = await chrome.debugger.sendCommand(target, 'Runtime.evaluate', {\n expression,\n returnByValue: false,\n awaitPromise: false,\n includeCommandLineAPI: false\n }).catch(() => null);\n const objectId = String(result?.result?.objectId || '');\n if (!objectId) return { objectId: '', nodeId: 0, point, selector };\n const requested = await chrome.debugger.sendCommand(target, 'DOM.requestNode', { objectId }).catch(() => null);\n const nodeId = Number(requested?.nodeId || 0);\n return { objectId, nodeId, point, selector };\n}\n\nasync function debuggerFocusObject(target, objectId = '') {\n if (!objectId) return null;\n const result = await chrome.debugger.sendCommand(target, 'Runtime.callFunctionOn', {\n objectId,\n functionDeclaration: `function() {\n try { this.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'instant' }); } catch {}\n try { this.focus({ preventScroll: true }); } catch { try { this.focus(); } catch {} }\n if (this.isContentEditable) {\n try {\n const selection = window.getSelection();\n const range = document.createRange();\n range.selectNodeContents(this);\n range.collapse(false);\n selection.removeAllRanges();\n selection.addRange(range);\n } catch {}\n }\n const rect = this.getBoundingClientRect();\n return { left: rect.left, top: rect.top, width: rect.width, height: rect.height, tag: this.tagName || '', id: this.id || '' };\n }`,\n returnByValue: true,\n silent: true\n }).catch(() => null);\n return result?.result?.value || null;\n}\n\nasync function debuggerPasteClipboard(target) {\n await chrome.debugger.sendCommand(target, 'Input.dispatchKeyEvent', {\n type: 'rawKeyDown',\n key: 'v',\n code: 'KeyV',\n windowsVirtualKeyCode: 86,\n nativeVirtualKeyCode: 86,\n modifiers: 4,\n commands: ['Paste']\n });\n await chrome.debugger.sendCommand(target, 'Input.dispatchKeyEvent', {\n type: 'keyUp',\n key: 'v',\n code: 'KeyV',\n windowsVirtualKeyCode: 86,\n nativeVirtualKeyCode: 86,\n modifiers: 4\n });\n}\n\nasync function withTabDebugger(tabId, operation) {\n if (!Number.isInteger(Number(tabId))) throw new Error('无法确定当前 BOSS 标签页');\n const id = Number(tabId);\n const previous = debuggerLocks.get(id) || Promise.resolve();\n let release;\n const current = new Promise(resolve => { release = resolve; });\n const queueEntry = previous.then(() => current);\n debuggerLocks.set(id, queueEntry);\n await previous;\n const target = { tabId: id };\n let attached = false;\n try {\n await chrome.debugger.attach(target, '1.3');\n attached = true;\n await debuggerBringToFront(target);\n return await operation(target);\n } catch (error) {\n const message = String(error?.message || error || '');\n if (/Another debugger is already attached|Cannot attach to this target|DevTools/i.test(message)) {\n throw new Error('Chrome 调试通道正被 DevTools 或其他工具占用,请关闭该 BOSS 页面的开发者工具后重试');\n }\n throw error;\n } finally {\n if (attached) await chrome.debugger.detach(target).catch(() => {});\n release?.();\n if (debuggerLocks.get(id) === queueEntry) debuggerLocks.delete(id);\n }\n}\n\nasync function debuggerMouseClick(target, point) {\n const { x, y } = validPoint(point);\n await chrome.debugger.sendCommand(target, 'Input.dispatchMouseEvent', {\n type: 'mouseMoved', x, y, button: 'none', buttons: 0\n });\n await chrome.debugger.sendCommand(target, 'Input.dispatchMouseEvent', {\n type: 'mousePressed', x, y, button: 'left', buttons: 1, clickCount: 1\n });\n await chrome.debugger.sendCommand(target, 'Input.dispatchMouseEvent', {\n type: 'mouseReleased', x, y, button: 'left', buttons: 0, clickCount: 1\n });\n}\n\nasync function debuggerDocumentNode(target) {\n const result = await chrome.debugger.sendCommand(target, 'DOM.getDocument', {\n depth: -1,\n pierce: true\n });\n const nodeId = Number(result?.root?.nodeId || 0);\n if (!nodeId) throw new Error('无法读取当前 BOSS 页面 DOM');\n return nodeId;\n}\n\nasync function debuggerNodeBySelector(target, selector) {\n const normalized = String(selector || '').trim();\n if (!normalized) return 0;\n const rootNodeId = await debuggerDocumentNode(target);\n const result = await chrome.debugger.sendCommand(target, 'DOM.querySelector', {\n nodeId: rootNodeId,\n selector: normalized\n });\n return Number(result?.nodeId || 0);\n}\n\nasync function debuggerFocusTarget(target, message = {}) {\n await debuggerBringToFront(target);\n\n // 必须优先锁定 content script 临时标记的真实编辑节点。旧版先取坐标节点,\n // BOSS 页面上经常命中编辑器外壳/透明层,导致看似有光标但文字没有进入编辑器。\n const exact = await debuggerFindEditableObject(target, message).catch(() => ({ objectId: '', nodeId: 0 }));\n if (exact.objectId) {\n const rect = await debuggerFocusObject(target, exact.objectId);\n if (rect && Number(rect.width) > 0 && Number(rect.height) > 0) {\n const clickPoint = {\n x: Math.round(Number(rect.left) + Math.max(8, Math.min(Number(rect.width) - 8, Number(rect.width) * 0.5))),\n y: Math.round(Number(rect.top) + Math.max(8, Math.min(Number(rect.height) - 8, Number(rect.height) * 0.5)))\n };\n await debuggerMouseClick(target, clickPoint).catch(() => {});\n await debuggerFocusObject(target, exact.objectId);\n return { method: 'exact-selector-object-focus+click', ...exact, point: clickPoint, rect };\n }\n return { method: 'exact-selector-object-focus', ...exact, rect };\n }\n\n const pointNode = await debuggerNodeAtPoint(target, message.point).catch(() => ({ nodeId: 0, backendNodeId: 0 }));\n if (pointNode.nodeId || pointNode.backendNodeId) {\n const args = pointNode.nodeId ? { nodeId: pointNode.nodeId } : { backendNodeId: pointNode.backendNodeId };\n await chrome.debugger.sendCommand(target, 'DOM.focus', args).catch(() => {});\n await debuggerMouseClick(target, message.point).catch(() => {});\n const objectId = await debuggerResolveNodeObject(target, pointNode).catch(() => '');\n return { method: 'point-node-focus+click', ...pointNode, objectId };\n }\n\n await debuggerMouseClick(target, message.point);\n return { method: 'coordinate-click', nodeId: 0, objectId: '' };\n}\n\nasync function debuggerClickTarget(target, message = {}) {\n const selector = String(message.selector || '').trim();\n if (selector) {\n const nodeId = await debuggerNodeBySelector(target, selector);\n if (nodeId) {\n await chrome.debugger.sendCommand(target, 'DOM.scrollIntoViewIfNeeded', { nodeId }).catch(() => {});\n const model = await chrome.debugger.sendCommand(target, 'DOM.getBoxModel', { nodeId });\n const quad = model?.model?.content || model?.model?.border || [];\n if (Array.isArray(quad) && quad.length >= 8) {\n const xs = [quad[0], quad[2], quad[4], quad[6]].map(Number);\n const ys = [quad[1], quad[3], quad[5], quad[7]].map(Number);\n await debuggerMouseClick(target, {\n x: xs.reduce((sum, value) => sum + value, 0) / xs.length,\n y: ys.reduce((sum, value) => sum + value, 0) / ys.length\n });\n return { method: 'dom-box-click', nodeId };\n }\n }\n }\n await debuggerMouseClick(target, message.point);\n return { method: 'coordinate-click', nodeId: 0 };\n}\n\nasync function debuggerReadTargetText(target, selector) {\n const normalized = String(selector || '').trim();\n if (!normalized) return '';\n const expression = `(() => {\n const element = document.querySelector(${JSON.stringify(normalized)});\n if (!element) return '';\n if ('value' in element) return String(element.value || '');\n return String(element.innerText || element.textContent || '');\n })()`;\n const result = await chrome.debugger.sendCommand(target, 'Runtime.evaluate', {\n expression,\n returnByValue: true,\n awaitPromise: false\n });\n return String(result?.result?.value || '');\n}\n\nasync function debuggerSelectAll(target) {\n await chrome.debugger.sendCommand(target, 'Input.dispatchKeyEvent', {\n type: 'rawKeyDown', key: 'a', code: 'KeyA', windowsVirtualKeyCode: 65,\n nativeVirtualKeyCode: 65, modifiers: 4, commands: ['SelectAll']\n });\n await chrome.debugger.sendCommand(target, 'Input.dispatchKeyEvent', {\n type: 'keyUp', key: 'a', code: 'KeyA', windowsVirtualKeyCode: 65,\n nativeVirtualKeyCode: 65, modifiers: 4\n });\n}\n\nasync function debuggerPressEnter(target) {\n // 与 Selenium/Playwright 的真实 Enter 等价:rawKeyDown 携带回车文本后再 keyUp。\n await chrome.debugger.sendCommand(target, 'Input.dispatchKeyEvent', {\n type: 'rawKeyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13,\n nativeVirtualKeyCode: 13, text: '\\r', unmodifiedText: '\\r'\n });\n await chrome.debugger.sendCommand(target, 'Input.dispatchKeyEvent', {\n type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13,\n nativeVirtualKeyCode: 13\n });\n}\n\nasync function debuggerTypeText(target, text) {\n const value = String(text || '');\n for (const character of value) {\n await chrome.debugger.sendCommand(target, 'Input.dispatchKeyEvent', {\n type: 'char',\n text: character,\n unmodifiedText: character\n });\n await new Promise(resolve => setTimeout(resolve, 12));\n }\n}\n\nasync function mainWorldWriteChatText(tabId, selector, text) {\n const normalizedSelector = String(selector || '').trim();\n const desired = String(text || '');\n if (!normalizedSelector || !desired.trim()) throw new Error('聊天输入目标或招呼语为空');\n const results = await chrome.scripting.executeScript({\n target: { tabId: Number(tabId) },\n world: 'MAIN',\n func: (exactSelector, nextText) => {\n const normalizeText = value => String(value || '').replace(/\\s+/g, '').trim();\n const isEditable = node => {\n if (!node || typeof node.matches !== 'function') return false;\n const tag = String(node.tagName || '').toLowerCase();\n if (tag === 'textarea') return true;\n if (tag === 'input') {\n const type = String(node.getAttribute('type') || 'text').toLowerCase();\n return ['text', 'search', ''].includes(type);\n }\n const editableMode = String(node.getAttribute('contenteditable') || '').toLowerCase();\n return node.isContentEditable\n || Boolean(editableMode && !['false', 'inherit', 'off'].includes(editableMode))\n || node.getAttribute('role') === 'textbox'\n || node.getAttribute('data-slate-editor') === 'true'\n || node.getAttribute('data-lexical-editor') === 'true';\n };\n const deepQuery = (rootNode, query) => {\n if (!rootNode) return null;\n try {\n const direct = rootNode.querySelector?.(query);\n if (direct) return direct;\n } catch {}\n let elements = [];\n try { elements = [...(rootNode.querySelectorAll?.('*') || [])]; } catch {}\n for (const element of elements) {\n if (element.shadowRoot) {\n const found = deepQuery(element.shadowRoot, query);\n if (found) return found;\n }\n }\n return null;\n };\n const root = deepQuery(document, exactSelector);\n if (!root) return { ok: false, reason: 'selector-not-found' };\n const editor = isEditable(root)\n ? root\n : [...root.querySelectorAll('textarea,input[type=\"text\"],input:not([type]),[contenteditable]:not([contenteditable=\"false\"]),[role=\"textbox\"],[data-slate-editor=\"true\"],[data-lexical-editor=\"true\"]')].find(isEditable);\n if (!editor) return { ok: false, reason: 'editable-not-found', rootTag: String(root.tagName || '') };\n\n const readValue = () => {\n if ('value' in editor && !editor.isContentEditable) return String(editor.value || '');\n return String(editor.innerText || editor.textContent || '');\n };\n const dispatchInput = (inputType, data) => {\n try {\n editor.dispatchEvent(new InputEvent('input', {\n bubbles: true,\n cancelable: false,\n composed: true,\n inputType,\n data\n }));\n } catch {\n editor.dispatchEvent(new Event('input', { bubbles: true, composed: true }));\n }\n };\n const dispatchBeforeInput = (inputType, data) => {\n try {\n return editor.dispatchEvent(new InputEvent('beforeinput', {\n bubbles: true,\n cancelable: true,\n composed: true,\n inputType,\n data\n }));\n } catch {\n return true;\n }\n };\n\n editor.scrollIntoView?.({ block: 'nearest', inline: 'nearest', behavior: 'instant' });\n try { HTMLElement.prototype.focus.call(editor); } catch { editor.focus?.(); }\n const previous = readValue();\n const tag = String(editor.tagName || '').toLowerCase();\n let method = '';\n\n if ((tag === 'textarea' || tag === 'input') && 'value' in editor) {\n const proto = tag === 'textarea' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;\n const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;\n dispatchBeforeInput('deleteByCut', null);\n if (setter) setter.call(editor, ''); else editor.value = '';\n dispatchInput('deleteByCut', null);\n const tracker = editor._valueTracker;\n if (tracker?.setValue) tracker.setValue(previous);\n dispatchBeforeInput('insertText', nextText);\n if (setter) setter.call(editor, nextText); else editor.value = nextText;\n dispatchInput('insertText', nextText);\n editor.dispatchEvent(new Event('change', { bubbles: true, composed: true }));\n method = 'main-native-value-setter';\n } else {\n const selection = window.getSelection?.();\n if (selection && document.createRange) {\n const range = document.createRange();\n range.selectNodeContents(editor);\n selection.removeAllRanges();\n selection.addRange(range);\n }\n dispatchBeforeInput('deleteContentBackward', null);\n let inserted = false;\n try {\n const transfer = new DataTransfer();\n transfer.setData('text/plain', nextText);\n const pasteEvent = new ClipboardEvent('paste', {\n bubbles: true,\n cancelable: true,\n composed: true,\n clipboardData: transfer\n });\n editor.dispatchEvent(pasteEvent);\n inserted = normalizeText(readValue()).includes(normalizeText(nextText));\n } catch {\n inserted = false;\n }\n if (!inserted) {\n try {\n inserted = Boolean(document.execCommand?.('insertText', false, nextText));\n } catch {\n inserted = false;\n }\n }\n if (!inserted || !normalizeText(readValue()).includes(normalizeText(nextText))) {\n try {\n editor.replaceChildren(document.createTextNode(nextText));\n } catch {\n editor.textContent = nextText;\n }\n }\n dispatchInput('insertText', nextText);\n editor.dispatchEvent(new Event('change', { bubbles: true, composed: true }));\n try {\n editor.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true, composed: true, data: nextText }));\n } catch {}\n method = inserted ? 'main-exec-command' : 'main-contenteditable-replace';\n }\n\n try { HTMLElement.prototype.focus.call(editor); } catch { editor.focus?.(); }\n const actual = readValue();\n const compactExpected = normalizeText(nextText);\n const compactActual = normalizeText(actual);\n return {\n ok: Boolean(compactActual && compactActual.includes(compactExpected)),\n method,\n actual,\n tag,\n id: String(editor.id || ''),\n className: String(editor.className || '').slice(0, 180),\n contenteditable: String(editor.getAttribute?.('contenteditable') || ''),\n role: String(editor.getAttribute?.('role') || '')\n };\n },\n args: [normalizedSelector, desired]\n });\n return results?.[0]?.result || { ok: false, reason: 'main-world-no-result' };\n}\n\nasync function trustedChatInput(tabId, message = {}) {\n const action = String(message.action || '');\n if (action === 'replaceText' || action === 'replaceTextAndEnter') {\n const text = String(message.text || '');\n if (!text.trim()) throw new Error('待输入的招呼语为空');\n const compactExpected = text.replace(/\\s+/g, '');\n\n // 写入和 Enter 必须在同一次 debugger 会话、同一个精确编辑节点上完成。\n // 这是 Selenium sendKeys / Playwright locator.type + Enter 的同类浏览器级路径,\n // 避免旧版“聚焦了外壳、文字没有进入真实编辑器”的问题。\n return withTabDebugger(tabId, async target => {\n let focus = await debuggerFocusTarget(target, message);\n if (!focus.objectId) throw new Error('没有定位到 BOSS 真实聊天编辑节点');\n\n const readExact = async () => debuggerReadObjectText(target, focus.objectId);\n const refocus = async () => {\n const refreshed = await debuggerFocusTarget(target, message);\n if (refreshed.objectId) focus = refreshed;\n if (!focus.objectId) throw new Error('BOSS 聊天编辑节点已失效');\n return focus;\n };\n const matches = value => {\n const compact = String(value || '').replace(/\\s+/g, '');\n return Boolean(compact && (compact === compactExpected || compact.includes(compactExpected)));\n };\n\n let actual = '';\n let writeMethod = '';\n\n // 1) 精确对象聚焦后使用 CDP Input.insertText,最接近真实键盘输入。\n await debuggerSelectAll(target);\n await chrome.debugger.sendCommand(target, 'Input.insertText', { text });\n await new Promise(resolve => setTimeout(resolve, 700));\n actual = await readExact();\n writeMethod = 'exact-cdp-insert-text';\n\n // 2) 某些富文本编辑器只响应原生 paste。\n if (!matches(actual) && await writeClipboardText(text)) {\n await refocus();\n await debuggerSelectAll(target);\n await debuggerPasteClipboard(target);\n await new Promise(resolve => setTimeout(resolve, 900));\n actual = await readExact();\n writeMethod = 'exact-cdp-clipboard-paste';\n }\n\n // 3) React/Vue 受控编辑器可能需要主世界 setter/beforeinput/input。\n if (!matches(actual)) {\n const mainWrite = await mainWorldWriteChatText(tabId, message.selector, text).catch(error => ({\n ok: false,\n reason: String(error?.message || error || 'main-world-write-failed')\n }));\n await refocus();\n await new Promise(resolve => setTimeout(resolve, 650));\n actual = await readExact();\n writeMethod = mainWrite?.method || 'main-world-write';\n }\n\n // 4) 最后兜底为逐字符可信键盘输入,仍锁定同一个真实节点。\n if (!matches(actual)) {\n await refocus();\n await debuggerSelectAll(target);\n await debuggerTypeText(target, text);\n await new Promise(resolve => setTimeout(resolve, 1000));\n actual = await readExact();\n writeMethod = 'exact-cdp-keyboard-type';\n }\n\n if (!matches(actual)) {\n throw new Error(`文字没有进入 BOSS 真实聊天编辑器;实际长度=${String(actual || '').length}`);\n }\n\n if (action === 'replaceTextAndEnter') {\n await refocus();\n // 给 BOSS 受控状态一次提交渲染机会,再发送真实 Enter。\n await new Promise(resolve => setTimeout(resolve, 520));\n await debuggerPressEnter(target);\n }\n\n return {\n ok: true,\n action,\n length: text.length,\n insertedBeforeSend: true,\n writeMethod,\n actualLength: String(actual || '').length,\n focusMethod: focus.method,\n editor: {\n nodeId: Number(focus.nodeId || 0),\n selector: String(message.selector || '')\n }\n };\n });\n }\n\n return withTabDebugger(tabId, async target => {\n if (action === 'pressEnter') {\n const focus = await debuggerFocusTarget(target, message);\n if (!focus.objectId) throw new Error('没有定位到 BOSS 真实聊天编辑节点');\n await new Promise(resolve => setTimeout(resolve, 320));\n await debuggerPressEnter(target);\n return { ok: true, action, focusMethod: focus.method };\n }\n if (action === 'click') {\n const click = await debuggerClickTarget(target, message);\n return { ok: true, action, clickMethod: click.method };\n }\n throw new Error('不支持的可信输入动作');\n });\n}\n\nasync function init() {\n const current = await storage.all();\n const patch = {};\n for (const [key, value] of Object.entries(DEFAULTS)) {\n if (current[key] === undefined) patch[key] = value;\n }\n if (!current.ui9ModeMigration) {\n const currentConfig = current.config || {};\n patch.config = {\n ...DEFAULTS.config,\n ...currentConfig,\n executionMode: currentConfig.executionMode === 'auto' ? 'auto' : 'review',\n dailyTarget: Number(currentConfig.dailyTarget || 0) === 40 ? 150 : Number(currentConfig.dailyTarget || 150)\n };\n patch.ui9ModeMigration = true;\n }\n if (!current.ui10UnlimitedV4Migration) {\n const baseConfig = patch.config || current.config || {};\n const baseModel = { ...DEFAULTS.config.model, ...(baseConfig.model || {}) };\n const baseUrl = String(baseModel.baseUrl || DEFAULTS.config.model.baseUrl);\n const modelName = String(baseModel.model || '').trim();\n const isDeepSeek = /deepseek\\.com/i.test(baseUrl);\n const legacyDeepSeekModel = !modelName || ['deepseek-chat', 'deepseek-reasoner', 'deepseek-v4'].includes(modelName);\n patch.config = {\n ...DEFAULTS.config,\n ...baseConfig,\n discoveryLimit: 0,\n model: {\n ...baseModel,\n model: isDeepSeek && legacyDeepSeekModel ? 'deepseek-v4-pro' : (modelName || 'deepseek-v4-pro')\n }\n };\n patch.ui10UnlimitedV4Migration = true;\n }\n if (!current.ui20ReliableSendMigration) {\n const baseConfig = patch.config || current.config || {};\n patch.config = {\n ...DEFAULTS.config,\n ...baseConfig,\n betweenJobsSeconds: Math.max(8, Math.min(30, Number(baseConfig.betweenJobsSeconds || 8))),\n attachmentDelaySeconds: Math.max(1, Math.min(10, Number(baseConfig.attachmentDelaySeconds || 2)))\n };\n patch.ui20ReliableSendMigration = true;\n }\n if (!current.ui21ConversationBindingMigration) {\n patch.chatTransition = null;\n patch.chatDeliveryLedger = current.chatDeliveryLedger && typeof current.chatDeliveryLedger === 'object'\n ? current.chatDeliveryLedger\n : {};\n patch.ui21ConversationBindingMigration = true;\n }\n if (!current.ui22StrictTranscriptMigration) {\n const baseConfig = patch.config || current.config || {};\n patch.config = {\n ...DEFAULTS.config,\n ...baseConfig,\n betweenJobsSeconds: Math.max(12, Math.min(30, Number(baseConfig.betweenJobsSeconds || 12))),\n attachmentDelaySeconds: Math.max(4, Math.min(10, Number(baseConfig.attachmentDelaySeconds || 4)))\n };\n patch.ui22StrictTranscriptMigration = true;\n }\n if (!current.ui24VerifiedConversationMigration) {\n patch.chatTransition = null;\n patch.ui24VerifiedConversationMigration = true;\n }\n if (!current.ui27TrustedInputMigration) {\n const baseWorkflow = patch.workflow || current.workflow || {};\n patch.workflow = {\n ...DEFAULTS.workflow,\n ...baseWorkflow,\n running: false,\n paused: true,\n phase: 'idle',\n pendingApplyId: null,\n activeRunId: null,\n statusText: '可信发送引擎已升级,请重新开始任务'\n };\n patch.chatTransition = null;\n patch.ui27TrustedInputMigration = true;\n }\n if (!current.ui12ProfileDraftMigration) {\n let migratedProfile = current.profile && typeof current.profile === 'object' ? current.profile : null;\n let migratedDraft = current.profileDraft && typeof current.profileDraft === 'object'\n ? normalizeProfileDraft(current.profileDraft)\n : null;\n if (!profileDraftHasAny(migratedDraft) && profileHasCore(migratedProfile)) {\n migratedDraft = profileToDraft(migratedProfile, 'migration');\n }\n if (!profileDraftHasCore(migratedDraft) && String(current.resumeText || '').trim().length >= 30) {\n try {\n migratedProfile = buildLocalProfile(current.resumeText || '');\n migratedProfile.generation.mode = 'local-recovery';\n migratedProfile.generation.label = '本地初稿';\n migratedProfile.generation.warning = '已从保存的简历恢复初稿。';\n migratedDraft = profileToDraft(migratedProfile, 'recovery');\n patch.profile = migratedProfile;\n } catch {\n // 简历内容不足时只保留现有数据,不生成空画像。\n }\n }\n if (profileDraftHasAny(migratedDraft)) patch.profileDraft = migratedDraft;\n patch.ui12ProfileDraftMigration = true;\n }\n if (!current.ui37DirectionPlanMigration) {\n const profileForPlan = patch.profile || current.profile || null;\n const existingPlan = current.directionPlan && typeof current.directionPlan === 'object'\n ? current.directionPlan\n : null;\n if (profileHasCore(profileForPlan)) {\n patch.directionPlan = buildDirectionPlan(profileForPlan, existingPlan, {\n confirmed: Boolean(existingPlan?.confirmed),\n preserveSelections: true,\n preserveEdits: true,\n preserveCustom: true\n });\n } else if (existingPlan) {\n patch.directionPlan = normalizeDirectionPlan(existingPlan, profileForPlan);\n }\n patch.ui37DirectionPlanMigration = true;\n }\n if (!current.ui18TaskProgressMigration) {\n const migratedPending = Array.isArray(current.pending) ? current.pending.map(entry => ({ ...entry })) : [];\n const migratedRuns = Array.isArray(current.taskRuns) ? current.taskRuns.map(run => normalizeTaskRun(run, run)) : [];\n for (let index = 0; index < migratedPending.length; index += 1) {\n const item = migratedPending[index];\n let run = migratedRuns.find(entry => entry.id === item.runId || entry.pendingId === item.id);\n if (!run) {\n const statusMap = {\n pending: ['waiting_review', 'waiting_review', 60],\n approved: ['running', 'queued', 64],\n approved_queue: ['queued', 'queued', 64],\n sent: ['success', 'success', 100],\n failed: ['failed', 'failed', 100],\n rejected: ['ignored', 'ignored', 100]\n };\n const [status, stage, progress] = statusMap[item.status] || ['skipped', 'skipped', 100];\n run = normalizeTaskRun({\n id: crypto.randomUUID(),\n pendingId: item.id,\n job: item.job,\n analysis: item.analysis,\n searchTask: item.task,\n status,\n stage,\n progress,\n error: item.error || '',\n retryable: item.status === 'failed',\n createdAt: item.createdAt || Date.now(),\n completedAt: ['sent', 'failed', 'rejected'].includes(item.status) ? (item.completedAt || item.rejectedAt || Date.now()) : null\n });\n migratedRuns.push(run);\n }\n migratedPending[index] = { ...item, runId: run.id };\n }\n const workflow = { ...DEFAULTS.workflow, ...(current.workflow || {}) };\n workflow.tasks = (workflow.tasks || []).map((task, index) => ({\n ...task,\n id: task.id || crypto.randomUUID(),\n status: task.status || (index < Number(workflow.taskIndex || 0) ? 'completed' : index === Number(workflow.taskIndex || 0) && workflow.running ? 'running' : 'pending'),\n progress: Number.isFinite(Number(task.progress)) ? Number(task.progress) : (index < Number(workflow.taskIndex || 0) ? 100 : 0),\n stageLabel: task.stageLabel || (index < Number(workflow.taskIndex || 0) ? '已完成' : '等待开始'),\n processed: Number(task.processed || 0),\n discovered: Number(task.discovered || 0),\n analyzed: Number(task.analyzed || 0),\n failed: Number(task.failed || 0)\n }));\n patch.pending = migratedPending;\n patch.taskRuns = migratedRuns;\n patch.workflow = workflow;\n patch.ui18TaskProgressMigration = true;\n }\n if (!current.ui20GreetingLockMigration) {\n const basePending = Array.isArray(patch.pending) ? patch.pending : (Array.isArray(current.pending) ? current.pending : []);\n patch.pending = basePending.map(item => ({\n ...item,\n deliveryGreeting: String(item.deliveryGreeting || item.analysis?.greeting || '').trim()\n }));\n patch.ui20GreetingLockMigration = true;\n }\n if (!current.ui30AtomicSendSortMigration) {\n const basePending = Array.isArray(patch.pending) ? patch.pending : (Array.isArray(current.pending) ? current.pending : []);\n patch.pending = rerankPending(basePending);\n const baseWorkflow = patch.workflow || current.workflow || {};\n patch.workflow = {\n ...DEFAULTS.workflow,\n ...baseWorkflow,\n running: false,\n paused: true,\n pendingApplyId: null,\n activeRunId: null,\n statusText: '投递引擎与岗位自动排序已升级,请重新开始'\n };\n patch.chatTransition = null;\n patch.ui30AtomicSendSortMigration = true;\n }\n if (!current.ui31ConversationSelectionMigration) {\n const basePending = Array.isArray(patch.pending) ? patch.pending : (Array.isArray(current.pending) ? current.pending : []);\n patch.pending = rerankPending(basePending);\n const baseWorkflow = patch.workflow || current.workflow || {};\n patch.workflow = {\n ...DEFAULTS.workflow,\n ...baseWorkflow,\n running: false,\n paused: true,\n pendingApplyId: null,\n activeRunId: null,\n statusText: 'HR 会话选择与身份核对已升级,请重新开始'\n };\n const ledger = current.chatDeliveryLedger && typeof current.chatDeliveryLedger === 'object' ? current.chatDeliveryLedger : {};\n patch.chatDeliveryLedger = Object.fromEntries(Object.entries(ledger).filter(([, entry]) => entry?.status === 'sent'));\n patch.chatTransition = null;\n patch.ui31ConversationSelectionMigration = true;\n }\n if (!current.ui35ConversationKeyMigration) {\n const baseWorkflow = patch.workflow || current.workflow || {};\n patch.workflow = {\n ...DEFAULTS.workflow,\n ...baseWorkflow,\n running: false,\n paused: true,\n pendingApplyId: null,\n activeRunId: null,\n statusText: 'HR 会话锁已升级,请重新开始'\n };\n const ledger = current.chatDeliveryLedger && typeof current.chatDeliveryLedger === 'object' ? current.chatDeliveryLedger : {};\n patch.chatDeliveryLedger = Object.fromEntries(Object.entries(ledger).filter(([, entry]) => entry?.status === 'sent'));\n patch.chatTransition = null;\n patch.ui35ConversationKeyMigration = true;\n }\n if (!current.v130SingleValidationMigration) {\n const baseConfig = patch.config || current.config || {};\n const alreadySuccessful = Number(current.stats?.sent || 0) > 0\n || Number(baseConfig.singleJobValidationCompletedAt || 0) > 0;\n patch.config = {\n ...DEFAULTS.config,\n ...baseConfig,\n requireSingleJobValidation: baseConfig.requireSingleJobValidation !== false,\n singleJobValidationCompletedAt: alreadySuccessful\n ? Number(baseConfig.singleJobValidationCompletedAt || Date.now())\n : 0\n };\n patch.v130SingleValidationMigration = true;\n }\n if (Object.keys(patch).length) await storage.set(patch);\n const { stats } = await storage.get('stats');\n if (!stats || stats.date !== today()) {\n await storage.set({ stats: { ...DEFAULTS.stats, date: today() } });\n }\n await refreshBossTabsForRuntimeVersion();\n await chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => {});\n chrome.alarms.create('jobclaw-tick', { periodInMinutes: 1 });\n}\n\nfunction publicState(all) {\n const state = safeClone(all);\n if (state?.config?.model) {\n state.config.model.apiKey = state.config.model.apiKey ? '***' : '';\n }\n if (state?.resumeImage) state.resumeImage = '[已保存]';\n if (state?.resumeSourceFile) {\n const { name, type, size, lastModified } = state.resumeSourceFile;\n state.resumeSourceFile = { name, type, size, lastModified, stored: true };\n }\n return state;\n}\n\nasync function writeEvent(level, message, data = {}) {\n const { events = [] } = await storage.get('events');\n const event = { id: crypto.randomUUID(), ts: Date.now(), level, message, data };\n events.unshift(event);\n await storage.set({ events: events.slice(0, 300) });\n bridge('/sync', { event }).catch(() => {});\n return event;\n}\n\nasync function patchWorkflow(patch) {\n const { workflow } = await storage.get('workflow');\n const next = { ...DEFAULTS.workflow, ...(workflow || {}), ...(patch || {}) };\n await storage.set({ workflow: next });\n return next;\n}\n\nasync function changeStats(delta = {}) {\n const { stats } = await storage.get('stats');\n const next = { ...DEFAULTS.stats, ...(stats || {}), date: today() };\n for (const [key, value] of Object.entries(delta)) {\n next[key] = Math.max(0, Number(next[key] || 0) + Number(value || 0));\n }\n await storage.set({ stats: next });\n return next;\n}\n\n\nfunction runJobKey(job = {}) {\n return String(job.jobId || job.encryptJobId || job.url || [job.title, job.company, job.location].filter(Boolean).join('|') || '').trim();\n}\n\nfunction normalizeTaskRun(input = {}, existing = {}) {\n const now = Date.now();\n const stage = String(input.stage || existing.stage || 'discovered');\n const meta = taskStageMeta(stage, input.stageLabel, input.progress);\n const status = String(input.status || existing.status || 'running');\n const terminal = TERMINAL_RUN_STATUSES.has(status);\n return {\n ...existing,\n ...input,\n id: input.id || existing.id || crypto.randomUUID(),\n jobKey: input.jobKey || existing.jobKey || runJobKey(input.job || existing.job || {}),\n status,\n stage,\n stageLabel: meta.label,\n progress: meta.progress,\n createdAt: existing.createdAt || input.createdAt || now,\n startedAt: existing.startedAt || input.startedAt || (status === 'running' ? now : null),\n updatedAt: now,\n completedAt: terminal ? (input.completedAt || existing.completedAt || now) : null,\n retryCount: Math.max(0, Number(input.retryCount ?? existing.retryCount ?? 0)),\n error: String(input.error ?? existing.error ?? ''),\n retryable: input.retryable ?? existing.retryable ?? Boolean(input.pendingId || existing.pendingId)\n };\n}\n\nasync function upsertTaskRun(patch = {}) {\n const { taskRuns = [] } = await storage.get('taskRuns');\n const jobKey = patch.jobKey || runJobKey(patch.job || {});\n const index = taskRuns.findIndex(run => run.id === patch.id || (jobKey && run.jobKey === jobKey && !TERMINAL_RUN_STATUSES.has(run.status)));\n const existing = index >= 0 ? taskRuns[index] : {};\n const nextRun = normalizeTaskRun({ ...patch, jobKey }, existing);\n const next = index >= 0\n ? taskRuns.map((run, runIndex) => runIndex === index ? nextRun : run)\n : [nextRun, ...taskRuns];\n await storage.set({ taskRuns: next });\n return nextRun;\n}\n\nasync function updateTaskRunByPending(pendingId, patch = {}) {\n if (!pendingId) return null;\n const { pending = [], taskRuns = [] } = await storage.get(['pending', 'taskRuns']);\n const item = pending.find(entry => entry.id === pendingId);\n const run = taskRuns.find(entry => entry.id === item?.runId || entry.pendingId === pendingId);\n if (!run && !item) return null;\n return upsertTaskRun({\n id: run?.id || item?.runId,\n pendingId,\n job: item?.job || run?.job,\n analysis: item?.analysis || run?.analysis,\n searchTask: item?.task || run?.searchTask,\n ...patch\n });\n}\n\nasync function updateSearchTaskProgress(message = {}) {\n const { workflow } = await storage.get('workflow');\n const nextWorkflow = { ...DEFAULTS.workflow, ...(workflow || {}) };\n const tasks = Array.isArray(nextWorkflow.tasks) ? [...nextWorkflow.tasks] : [];\n const index = tasks.findIndex(task => task.id === message.taskId);\n const fallbackIndex = Number.isInteger(message.taskIndex) ? message.taskIndex : -1;\n const targetIndex = index >= 0 ? index : fallbackIndex;\n if (targetIndex < 0 || targetIndex >= tasks.length) return null;\n const current = tasks[targetIndex] || {};\n const progress = Math.max(0, Math.min(100, Number(message.progress ?? current.progress ?? 0)));\n tasks[targetIndex] = {\n ...current,\n status: message.status || current.status || 'pending',\n stageLabel: message.stageLabel || current.stageLabel || '',\n progress,\n processed: Math.max(0, Number(message.processed ?? current.processed ?? 0)),\n discovered: Math.max(0, Number(message.discovered ?? current.discovered ?? 0)),\n analyzed: Math.max(0, Number(message.analyzed ?? current.analyzed ?? 0)),\n failed: Math.max(0, Number(message.failed ?? current.failed ?? 0)),\n updatedAt: Date.now(),\n completedAt: message.status === 'completed' ? Date.now() : current.completedAt || null\n };\n nextWorkflow.tasks = tasks;\n await storage.set({ workflow: nextWorkflow });\n return tasks[targetIndex];\n}\n\nasync function retryFailedTask(runId) {\n const { taskRuns = [], pending = [], workflow = {} } = await storage.get(['taskRuns', 'pending', 'workflow']);\n const run = taskRuns.find(entry => entry.id === runId);\n if (!run) throw new Error('失败任务不存在');\n if (run.status === 'success') throw new Error('该岗位已经投递成功,不会重复投递');\n if (workflow.running && (workflow.activeRunId === run.id || workflow.pendingApplyId === run.pendingId)) {\n throw new Error('该失败任务正在重试,请等待当前结果');\n }\n if (Number(run.retryRequestedAt || 0) > Date.now() - 15000) {\n throw new Error('该任务刚刚提交过重试,请稍后查看结果');\n }\n let item = pending.find(entry => entry.id === run.pendingId || entry.runId === run.id);\n if (!item && run.job && run.analysis) {\n item = {\n id: crypto.randomUUID(),\n runId: run.id,\n job: run.job,\n analysis: run.analysis,\n deliveryGreeting: String(run.analysis?.greeting || '').trim(),\n task: run.searchTask || {},\n status: 'approved',\n createdAt: Date.now(),\n approvedAt: Date.now()\n };\n }\n if (!item) throw new Error('该历史任务缺少岗位或招呼语信息,无法直接重试');\n if (item.status === 'sent') throw new Error('该岗位已经投递成功,不会重复投递');\n item = {\n ...item,\n deliveryGreeting: String(item.deliveryGreeting || item.analysis?.greeting || '').trim(),\n status: 'approved', error: '', approvedAt: Date.now(), runId: run.id\n };\n const nextPending = pending.some(entry => entry.id === item.id)\n ? pending.map(entry => entry.id === item.id ? item : entry)\n : [item, ...pending];\n await storage.set({ pending: rerankPending(nextPending) });\n await upsertTaskRun({\n id: run.id,\n pendingId: item.id,\n job: item.job,\n analysis: item.analysis,\n searchTask: item.task,\n status: 'running',\n stage: 'retry_queued',\n progress: 66,\n stageLabel: '等待重新投递',\n error: '',\n retryable: true,\n retryCount: Number(run.retryCount || 0) + 1,\n retryRequestedAt: Date.now(),\n completedAt: null\n });\n await patchWorkflow({\n running: true,\n paused: false,\n phase: 'apply',\n statusText: `正在重新投递:${item.job?.title || '岗位'}`,\n pendingApplyId: item.id,\n activeRunId: run.id\n });\n try {\n await sendToBoss({ type: 'RUN' });\n setTimeout(() => sendToBoss({ type: 'RUN' }).catch(() => {}), 700);\n } catch (error) {\n await upsertTaskRun({ id: run.id, status: 'failed', stage: 'failed', progress: 100, stageLabel: '重新投递启动失败', error: error.message, retryable: true });\n await patchWorkflow({ running: false, paused: true, phase: 'idle', pendingApplyId: null, activeRunId: null, statusText: '重新投递启动失败' });\n throw error;\n }\n return { runId: run.id, pendingId: item.id };\n}\n\nasync function retryAllFailedTasks() {\n const { taskRuns = [], pending = [], workflow = {} } = await storage.get(['taskRuns', 'pending', 'workflow']);\n if (workflow.running && workflow.activeRunId) {\n throw new Error('当前已有投递任务正在执行,请等待完成后再批量重试');\n }\n const failedRuns = taskRuns.filter(run => run.status === 'failed'\n && run.retryable !== false\n && Number(run.retryRequestedAt || 0) <= Date.now() - 15000);\n if (!failedRuns.length) return { count: 0 };\n const nextPending = [...pending];\n const queue = [];\n for (const run of failedRuns) {\n if (run.status === 'success') continue;\n let item = nextPending.find(entry => entry.id === run.pendingId || entry.runId === run.id);\n if (!item && run.job && run.analysis) {\n item = {\n id: crypto.randomUUID(), runId: run.id, job: run.job, analysis: run.analysis,\n deliveryGreeting: String(run.analysis?.greeting || '').trim(),\n task: run.searchTask || {}, createdAt: Date.now()\n };\n nextPending.unshift(item);\n }\n if (!item || item.status === 'sent') continue;\n item.deliveryGreeting = String(item.deliveryGreeting || item.analysis?.greeting || '').trim();\n item.status = queue.length ? 'approved_queue' : 'approved';\n item.approvedAt = Date.now();\n item.error = '';\n const pendingIndex = nextPending.findIndex(entry => entry.id === item.id);\n if (pendingIndex >= 0) nextPending[pendingIndex] = { ...item };\n queue.push({ run, item });\n }\n if (!queue.length) return { count: 0 };\n await storage.set({ pending: rerankPending(nextPending) });\n for (const [index, entry] of queue.entries()) {\n await upsertTaskRun({\n id: entry.run.id,\n pendingId: entry.item.id,\n status: index === 0 ? 'running' : 'queued',\n stage: 'retry_queued',\n progress: 66,\n stageLabel: index === 0 ? '正在重新投递' : '等待重新投递',\n error: '',\n retryable: true,\n retryCount: Number(entry.run.retryCount || 0) + 1,\n retryRequestedAt: Date.now(),\n completedAt: null\n });\n }\n const first = queue[0];\n await patchWorkflow({\n running: true,\n paused: false,\n phase: 'apply',\n statusText: `重新投递 1/${queue.length}:${first.item.job?.title || '岗位'}`,\n pendingApplyId: first.item.id,\n activeRunId: first.run.id\n });\n try {\n await sendToBoss({ type: 'RUN' });\n } catch (error) {\n for (const entry of queue) {\n await upsertTaskRun({ id: entry.run.id, status: 'failed', stage: 'failed', progress: 100, stageLabel: '批量重试启动失败', error: error.message, retryable: true });\n }\n await patchWorkflow({ running: false, paused: true, phase: 'idle', pendingApplyId: null, activeRunId: null, statusText: '批量重试启动失败' });\n throw error;\n }\n return { count: queue.length };\n}\n\nasync function ignoreFailedTask(runId) {\n const { taskRuns = [], pending = [] } = await storage.get(['taskRuns', 'pending']);\n const run = taskRuns.find(entry => entry.id === runId);\n if (!run) throw new Error('失败任务不存在');\n const nextPending = pending.map(entry => entry.id === run.pendingId || entry.runId === run.id\n ? { ...entry, status: 'rejected', rejectedAt: Date.now() }\n : entry);\n await storage.set({ pending: nextPending });\n return upsertTaskRun({ id: run.id, status: 'ignored', stage: 'ignored', progress: 100, stageLabel: '已忽略', retryable: false });\n}\n\nconst BOSS_URL_PATTERN = /^https:\\/\\/(?:www|app)\\.zhipin\\.com\\//i;\nconst NO_RECEIVER_PATTERN = /Could not establish connection|Receiving end does not exist|message port closed|No tab with id/i;\nconst wait = ms => new Promise(resolve => setTimeout(resolve, ms));\n\nfunction receiverMatchesExpectedVersion(probe) {\n return Boolean(probe?.ok && probe.contentVersion === EXPECTED_CONTENT_VERSION && probe.contentFile === CONTENT_SCRIPT_FILE);\n}\n\nasync function waitForTabReady(tabId, timeout = 16000) {\n if (!chrome.tabs?.get) {\n await wait(900);\n return null;\n }\n const startedAt = Date.now();\n while (Date.now() - startedAt < timeout) {\n const tab = await chrome.tabs.get(tabId).catch(() => null);\n if (tab?.status === 'complete') return tab;\n await wait(180);\n }\n return chrome.tabs.get(tabId).catch(() => null);\n}\n\nasync function reloadBossTab(tab, reason = 'runtime-refresh') {\n if (!tab?.id || !chrome.tabs?.reload) return tab;\n await chrome.tabs.reload(tab.id, { bypassCache: true });\n const ready = await waitForTabReady(tab.id);\n await writeEvent('info', 'BOSS 页面助手已自动刷新', { reason, tabId: tab.id, contentVersion: EXPECTED_CONTENT_VERSION }).catch(() => {});\n return ready || tab;\n}\n\nasync function refreshBossTabsForRuntimeVersion() {\n const key = 'bossContentRuntimeVersion';\n const current = await storage.get(key);\n if (current?.[key] === EXPECTED_CONTENT_VERSION) return;\n await storage.set({ [key]: EXPECTED_CONTENT_VERSION });\n if (!chrome.tabs?.query || !chrome.tabs?.reload) return;\n const tabs = await chrome.tabs.query({ url: ['https://www.zhipin.com/*', 'https://app.zhipin.com/*'] }).catch(() => []);\n for (const tab of tabs || []) {\n if (!tab?.id) continue;\n await chrome.tabs.reload(tab.id, { bypassCache: true }).catch(() => {});\n }\n}\n\nasync function activeBossTab() {\n const tabs = await chrome.tabs.query({ url: ['https://www.zhipin.com/*', 'https://app.zhipin.com/*'] });\n return tabs.find(tab => tab.active) || tabs[0] || null;\n}\n\nfunction bossConnectionError(error) {\n const message = String(error?.message || error || '');\n if (NO_RECEIVER_PATTERN.test(message)) {\n return 'BOSS 页面连接尚未就绪,JobClaw 已尝试自动修复。请刷新当前 BOSS 页面后再点一次开始。';\n }\n if (/Cannot access contents of url|The extensions gallery cannot be scripted|Missing host permission/i.test(message)) {\n return '当前页面无法接入 JobClaw。请确认打开的是 BOSS 直聘岗位页或沟通页。';\n }\n return message || 'BOSS 页面连接失败';\n}\n\nasync function probeBossReceiver(tab) {\n return chrome.tabs.sendMessage(tab.id, { type: 'PROBE', expectedContentVersion: EXPECTED_CONTENT_VERSION });\n}\n\nasync function injectBossContent(tab) {\n if (!chrome.scripting?.executeScript) throw new Error('扩展缺少页面注入能力,请重新加载最新版 JobClaw');\n await chrome.scripting.executeScript({\n target: { tabId: tab.id },\n files: [CONTENT_SCRIPT_FILE]\n });\n}\n\nasync function ensureBossReceiver(tab) {\n if (!tab?.id) throw new Error('请先打开并登录 BOSS 直聘');\n if (!BOSS_URL_PATTERN.test(String(tab.url || ''))) {\n throw new Error('请切换到 BOSS 直聘岗位页或沟通页');\n }\n\n let probe = null;\n try {\n probe = await probeBossReceiver(tab);\n if (receiverMatchesExpectedVersion(probe)) return probe;\n } catch (error) {\n if (!NO_RECEIVER_PATTERN.test(String(error?.message || error))) {\n throw new Error(bossConnectionError(error));\n }\n }\n\n // 能收到响应但版本不一致,说明当前标签页仍保留旧内容脚本。必须整页刷新,\n // 不能继续在同一页面叠加注入,否则旧 RUN 监听器仍可能执行。\n if (probe?.ok && !receiverMatchesExpectedVersion(probe)) {\n tab = await reloadBossTab(tab, `stale-${probe.contentVersion || 'legacy'}`);\n }\n\n for (let attempt = 0; attempt < 5; attempt += 1) {\n await wait(180 + attempt * 140);\n try {\n const currentProbe = await probeBossReceiver(tab);\n if (receiverMatchesExpectedVersion(currentProbe)) return currentProbe;\n if (currentProbe?.ok && !receiverMatchesExpectedVersion(currentProbe) && attempt === 0) {\n tab = await reloadBossTab(tab, `mismatch-${currentProbe.contentVersion || 'legacy'}`);\n }\n } catch (error) {\n if (!NO_RECEIVER_PATTERN.test(String(error?.message || error))) {\n throw new Error(bossConnectionError(error));\n }\n if (attempt === 1) {\n try { await injectBossContent(tab); } catch (injectError) { throw new Error(bossConnectionError(injectError)); }\n }\n }\n }\n throw new Error(`BOSS 页面助手版本未就绪(需要 ${EXPECTED_CONTENT_VERSION})。JobClaw 已自动刷新页面,请再点一次开始。`);\n}\n\nasync function sendToBossTab(tab, message) {\n await ensureBossReceiver(tab);\n try {\n return await chrome.tabs.sendMessage(tab.id, message);\n } catch (error) {\n if (!NO_RECEIVER_PATTERN.test(String(error?.message || error))) {\n throw new Error(bossConnectionError(error));\n }\n tab = await reloadBossTab(tab, 'message-receiver-lost');\n await ensureBossReceiver(tab);\n try {\n return await chrome.tabs.sendMessage(tab.id, message);\n } catch (retryError) {\n throw new Error(bossConnectionError(retryError));\n }\n }\n}\n\nasync function sendToBoss(message) {\n const tab = await activeBossTab();\n if (!tab) throw new Error('请先打开并登录 BOSS 直聘');\n return sendToBossTab(tab, message);\n}\n\nasync function bridge(path, body) {\n const failures = [];\n const timeoutMs = path === '/parse-resume' ? 190000 : 8000;\n for (const base of BRIDGE_ENDPOINTS) {\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), timeoutMs);\n try {\n const response = await fetch(`${base}${path}`, {\n method: body ? 'POST' : 'GET',\n headers: { 'Content-Type': 'application/json' },\n body: body ? JSON.stringify(body) : undefined,\n cache: 'no-store',\n credentials: 'omit',\n signal: controller.signal\n });\n const payload = await response.json().catch(() => ({}));\n if (!response.ok) throw new Error(payload.error || `增强识别组件返回 HTTP ${response.status}`);\n return payload;\n } catch (error) {\n failures.push(error?.name === 'AbortError' ? '连接超时' : String(error?.message || error));\n } finally {\n clearTimeout(timeout);\n }\n }\n const detail = failures.find(message => message && message !== 'Failed to fetch');\n throw new Error(detail || '增强识别组件未启动');\n}\n\nfunction extractJson(raw) {\n let text = String(raw || '').trim()\n .replace(/^```(?:json)?/i, '')\n .replace(/```$/i, '')\n .trim();\n const start = text.indexOf('{');\n const end = text.lastIndexOf('}');\n if (start >= 0 && end > start) text = text.slice(start, end + 1);\n text = text.replace(/,\\s*([}\\]])/g, '$1');\n return JSON.parse(text);\n}\n\nfunction aiError(code, message, details = {}) {\n const error = new Error(message);\n error.code = code;\n Object.assign(error, details);\n return error;\n}\n\nfunction aiFailureKind(error) {\n const code = String(error?.code || '');\n const message = String(error?.message || '');\n if (code === 'AI_CONFIG' || /API Key|未配置|401|unauthorized/i.test(message)) return 'config-missing';\n if (['AI_NETWORK', 'AI_HTTP', 'AI_TIMEOUT'].includes(code) || /HTTP|fetch|网络|超时|服务不可用/i.test(message)) return 'service-error';\n return 'output-invalid';\n}\n\nfunction isRetryableAiOutputError(error) {\n return ['AI_TRUNCATED', 'AI_EMPTY', 'AI_INVALID_JSON', 'AI_PROFILE_INCOMPLETE'].includes(String(error?.code || ''));\n}\n\nasync function requestModel(url, payload, apiKey, timeoutMs = 90000) {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n try {\n return await fetch(url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`\n },\n body: JSON.stringify(payload),\n signal: controller.signal\n });\n } catch (error) {\n if (error?.name === 'AbortError') throw aiError('AI_TIMEOUT', 'AI 请求超时');\n throw aiError('AI_NETWORK', `AI 网络请求失败:${error?.message || '连接异常'}`);\n } finally {\n clearTimeout(timer);\n }\n}\n\nasync function callModel(messages, jsonMode = true, options = {}) {\n const { config } = await storage.get('config');\n const model = config?.model || {};\n if (!model.apiKey) throw aiError('AI_CONFIG', '请先在设置页填写 AI API Key');\n const url = `${String(model.baseUrl || 'https://api.deepseek.com').replace(/\\/$/, '')}/chat/completions`;\n const payload = {\n model: model.model || 'deepseek-v4-pro',\n messages,\n temperature: Number(options.temperature ?? model.temperature ?? 0.1),\n max_tokens: Number(options.maxTokens ?? 2800)\n };\n if (jsonMode) payload.response_format = { type: 'json_object' };\n\n let response = await requestModel(url, payload, model.apiKey, Number(options.timeoutMs || 90000));\n let bodyText = await response.text();\n\n // 某些兼容网关不支持 response_format,但仍能正常返回 JSON 文本。\n if (!response.ok && jsonMode && [400, 404, 422].includes(response.status) && /response[_ -]?format|json_object|unsupported/i.test(bodyText)) {\n const retryPayload = { ...payload };\n delete retryPayload.response_format;\n response = await requestModel(url, retryPayload, model.apiKey, Number(options.timeoutMs || 90000));\n bodyText = await response.text();\n }\n\n if (!response.ok) {\n throw aiError('AI_HTTP', `AI 请求失败 HTTP ${response.status}: ${bodyText.substring(0, 500)}`, { status: response.status });\n }\n\n let result;\n try {\n result = JSON.parse(bodyText);\n } catch {\n throw aiError('AI_INVALID_RESPONSE', 'AI 接口返回了无法识别的响应');\n }\n const choice = result.choices?.[0];\n const content = String(choice?.message?.content || '').trim();\n if (!content) throw aiError('AI_EMPTY', 'AI 返回为空');\n if (choice.finish_reason === 'length') {\n throw aiError('AI_TRUNCATED', 'AI 输出被截断', { finishReason: choice.finish_reason, partial: content });\n }\n if (!jsonMode) return content;\n try {\n return extractJson(content);\n } catch (error) {\n throw aiError('AI_INVALID_JSON', `AI 返回 JSON 不完整:${error?.message || '解析失败'}`, { partial: content });\n }\n}\n\nfunction cleanResumeText(value) {\n return String(value || '')\n .replace(/\\r/g, '\\n')\n .replace(/[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001F]/g, ' ')\n .replace(/[ \\t]+/g, ' ')\n .replace(/\\n{3,}/g, '\\n\\n')\n .trim();\n}\n\nfunction lineMatches(text, pattern, limit = 6) {\n return uniq(cleanResumeText(text)\n .split('\\n')\n .map(line => line.trim())\n .filter(line => line.length >= 4 && line.length <= 180 && pattern.test(line)))\n .slice(0, limit);\n}\n\nfunction extractSkills(text) {\n const source = cleanResumeText(text);\n const catalog = [\n ['JavaScript', /\\bjavascript\\b|\\bjs\\b/i],\n ['TypeScript', /\\btypescript\\b|\\bts\\b/i],\n ['HTML', /\\bhtml5?\\b/i],\n ['CSS', /\\bcss3?\\b|sass|less/i],\n ['Vue', /\\bvue(?:\\.js|3|2)?\\b/i],\n ['React', /\\breact(?:\\.js)?\\b/i],\n ['Next.js', /\\bnext\\.?js\\b/i],\n ['Node.js', /\\bnode\\.?js\\b/i],\n ['Express', /\\bexpress\\b/i],\n ['Vite', /\\bvite\\b/i],\n ['Webpack', /\\bwebpack\\b/i],\n ['Tailwind CSS', /\\btailwind\\b/i],\n ['ECharts', /\\becharts\\b/i],\n ['D3.js', /\\bd3(?:\\.js)?\\b/i],\n ['Tauri', /\\btauri\\b/i],\n ['Electron', /\\belectron\\b/i],\n ['Chrome Extension', /chrome\\s*(?:extension|扩展)|浏览器扩展/i],\n ['Python', /\\bpython\\b/i],\n ['Java', /\\bjava\\b/i],\n ['C/C++', /\\bc\\+\\+\\b|\\bc语言\\b/i],\n ['Go', /\\bgolang\\b|\\bgo语言\\b/i],\n ['SQL', /\\bsql\\b/i],\n ['MySQL', /\\bmysql\\b/i],\n ['PostgreSQL', /\\bpostgres(?:ql)?\\b/i],\n ['Redis', /\\bredis\\b/i],\n ['Spring Boot', /spring\\s*boot/i],\n ['Flask', /\\bflask\\b/i],\n ['Django', /\\bdjango\\b/i],\n ['FastAPI', /\\bfastapi\\b/i],\n ['Git', /\\bgit\\b|github/i],\n ['Docker', /\\bdocker\\b/i],\n ['Linux', /\\blinux\\b/i],\n ['Playwright', /\\bplaywright\\b/i],\n ['Selenium', /\\bselenium\\b/i],\n ['PyTorch', /\\bpytorch\\b/i],\n ['TensorFlow', /\\btensorflow\\b/i],\n ['OpenCV', /\\bopencv\\b/i],\n ['RAG', /\\brag\\b|检索增强生成/i],\n ['LLM', /\\bllm\\b|大语言模型|语言模型/i],\n ['AI Agent', /ai\\s*agent|智能体/i],\n ['OCR', /\\bocr\\b|文字识别/i],\n ['数据可视化', /数据可视化|可视化看板/i]\n ];\n return catalog.filter(([, pattern]) => pattern.test(source)).map(([name]) => name).slice(0, 40);\n}\n\nfunction extractDegree(text) {\n const source = cleanResumeText(text);\n if (/博士|ph\\.?d/i.test(source)) return '博士';\n if (/硕士|研究生|master/i.test(source)) return '硕士';\n if (/本科|学士|bachelor/i.test(source)) return '本科';\n if (/大专|专科|associate/i.test(source)) return '大专';\n return '不限';\n}\n\nfunction extractLocations(text) {\n const source = cleanResumeText(text);\n const cities = ['北京','上海','广州','深圳','杭州','成都','西安','南京','武汉','苏州','重庆','天津','长沙','郑州','青岛','厦门','合肥','济南','宁波','东莞','珠海','佛山','无锡','兰州','太原','南阳','东京','大阪'];\n return cities.filter(city => source.includes(city)).slice(0, 6);\n}\n\nfunction extractExplicitDirections(text) {\n const source = cleanResumeText(text);\n const matches = [];\n const pattern = /(?:求职意向|求职方向|目标岗位|意向岗位|应聘岗位|期望职位)\\s*[::]?\\s*([^\\n]{2,100})/ig;\n let match;\n while ((match = pattern.exec(source)) && matches.length < 3) {\n matches.push(...String(match[1] || '').split(/[,,、/|]/).map(item => item.trim()).filter(item => item.length >= 2 && item.length <= 28));\n }\n return uniq(matches).slice(0, 3);\n}\n\nfunction inferDirections(text, skills) {\n const source = cleanResumeText(text);\n const explicit = extractExplicitDirections(source);\n if (explicit.length) return explicit;\n const directions = [];\n const has = name => skills.includes(name);\n const internship = /实习|在校|应届|校招/i.test(source);\n if (/前端|web前端|网页开发/i.test(source) || ['JavaScript','TypeScript','Vue','React','HTML','CSS'].some(has)) {\n directions.push(internship ? '前端开发实习生' : '前端开发工程师');\n }\n if (/全栈/i.test(source) || ((has('React') || has('Vue')) && (has('Node.js') || has('Java') || has('Python')))) {\n directions.push(internship ? '全栈开发实习生' : '全栈开发工程师');\n }\n if (/ai应用|人工智能应用|智能体|rag|大模型|llm/i.test(source) || ['RAG','LLM','AI Agent'].some(has)) {\n directions.push(internship ? 'AI 应用开发实习生' : 'AI 应用开发工程师');\n }\n if (/数据可视化|echarts|d3/i.test(source) || has('数据可视化') || has('ECharts') || has('D3.js')) {\n directions.push('数据可视化开发工程师');\n }\n if (/后端|服务端/i.test(source) || ['Spring Boot','Java','Node.js','Python','Go'].filter(has).length >= 2) {\n directions.push(internship ? '后端开发实习生' : '后端开发工程师');\n }\n if (!directions.length) directions.push(internship ? '软件开发实习生' : '软件开发工程师');\n return uniq(directions).slice(0, 3);\n}\n\nfunction buildSearchKeywords(directions, skills) {\n const keywords = [...directions];\n const joined = directions.join(' ');\n if (/前端/.test(joined)) {\n keywords.push('前端开发', 'Web 前端', 'JavaScript 开发');\n if (skills.includes('React')) keywords.push('React 开发');\n if (skills.includes('Vue')) keywords.push('Vue 开发');\n if (skills.includes('TypeScript')) keywords.push('TypeScript 前端');\n }\n if (/全栈/.test(joined)) keywords.push('全栈开发', 'Web 全栈');\n if (/AI 应用/.test(joined)) {\n keywords.push('AI 应用开发');\n if (skills.includes('RAG')) keywords.push('RAG 应用开发');\n if (skills.includes('AI Agent')) keywords.push('AI Agent 开发');\n }\n if (/数据可视化/.test(joined)) keywords.push('数据可视化', '可视化前端');\n if (/后端/.test(joined)) keywords.push('后端开发', '服务端开发');\n return uniq(keywords).slice(0, 12);\n}\n\nfunction explainProfileFallbackReason(reason = '', kind = '') {\n const message = String(reason || '').trim();\n const resolvedKind = kind || aiFailureKind({ message });\n if (!message) return '';\n if (resolvedKind === 'config-missing') {\n return 'AI 尚未配置;本次未调用 AI,当前初稿全部来自本地规则。';\n }\n if (resolvedKind === 'service-error') {\n return 'AI 请求没有成功;本次画像未使用 AI,当前初稿全部来自本地规则。';\n }\n return 'AI 连接可用,但返回内容未通过完整性校验;系统已自动精简重试,仍未成功,因此本次初稿全部来自本地规则。';\n}\n\nfunction validateGeneratedProfile(profile) {\n if (!profile || typeof profile !== 'object' || Array.isArray(profile)) {\n throw aiError('AI_PROFILE_INCOMPLETE', 'AI 画像字段不完整');\n }\n const directions = normalizeStringList(profile.primaryDirections, 3);\n const keywords = normalizeStringList(profile.searchKeywords, 12);\n const summary = String(profile.summary || '').trim();\n if (!directions.length || !keywords.length || summary.length < 12) {\n throw aiError('AI_PROFILE_INCOMPLETE', 'AI 画像字段不完整');\n }\n if (!profile.facts || typeof profile.facts !== 'object' || Array.isArray(profile.facts)) {\n throw aiError('AI_PROFILE_INCOMPLETE', 'AI 画像字段不完整');\n }\n if (!profile.hardConstraints || typeof profile.hardConstraints !== 'object' || Array.isArray(profile.hardConstraints)) {\n throw aiError('AI_PROFILE_INCOMPLETE', 'AI 画像字段不完整');\n }\n return profile;\n}\n\nfunction validateCompactGeneratedProfile(profile) {\n if (!profile || typeof profile !== 'object' || Array.isArray(profile)) {\n throw aiError('AI_PROFILE_INCOMPLETE', 'AI 精简画像字段不完整');\n }\n const normalized = {\n summary: String(profile.summary || '').trim(),\n primaryDirections: normalizeStringList(profile.primaryDirections, 3),\n searchKeywords: normalizeStringList(profile.searchKeywords, 12),\n skills: normalizeStringList(profile.skills, 30),\n locations: normalizeStringList(profile.locations, 20),\n employmentTypes: normalizeStringList(profile.employmentTypes, 10),\n salary: String(profile.salary || '').trim(),\n experience: String(profile.experience || '').trim(),\n degree: String(profile.degree || '').trim(),\n excludeDirections: normalizeStringList(profile.excludeDirections, 20)\n };\n if (!normalized.primaryDirections.length || !normalized.searchKeywords.length || normalized.summary.length < 12) {\n throw aiError('AI_PROFILE_INCOMPLETE', 'AI 精简画像字段不完整');\n }\n return normalized;\n}\n\nfunction buildLocalProfile(resumeText, reason = '', failureKind = '') {\n const text = cleanResumeText(resumeText);\n if (text.length < 30) throw new Error('简历内容太少,暂时无法生成职业画像');\n const skills = extractSkills(text);\n const primaryDirections = inferDirections(text, skills);\n const searchKeywords = buildSearchKeywords(primaryDirections, skills);\n const degree = extractDegree(text);\n const locations = extractLocations(text);\n const internship = /实习|在校|应届|校招/i.test(text);\n const employmentTypes = internship ? ['实习', '校招'] : ['全职'];\n const experience = internship ? '在校/应届' : '按岗位要求匹配';\n const topSkills = skills.slice(0, 7);\n const summaryParts = [];\n if (degree !== '不限') summaryParts.push(`${degree}${internship ? '在读或应届' : '背景'}`);\n if (topSkills.length) summaryParts.push(`具备 ${topSkills.join('、')} 等技能或项目经验`);\n summaryParts.push(`主要关注 ${primaryDirections.join('、')} 方向`);\n\n const education = lineMatches(text, /大学|学院|本科|硕士|博士|教育经历|专业/i, 6);\n const experiences = lineMatches(text, /实习|工作经历|公司|负责|任职|助理|工程师/i, 8);\n const projects = lineMatches(text, /项目|系统|平台|工作台|GitHub|开发|实现|搭建|设计/i, 10);\n const certificates = lineMatches(text, /证书|CET|英语六级|英语四级|资格|获奖|奖项/i, 6);\n const resolvedKind = failureKind || (reason ? aiFailureKind({ message: reason }) : 'not-requested');\n\n return normalizeProfile({\n facts: { education, experiences, projects, skills, certificates },\n primaryDirections: primaryDirections.map(name => ({ name, confidence: 0.72, evidence: ['根据简历中的技能、项目和求职阶段生成'] })),\n secondaryDirections: [],\n searchKeywords,\n hardConstraints: {\n locations,\n employmentTypes,\n salary: '不限',\n experience,\n degree\n },\n excludeDirections: [],\n summary: `${summaryParts.join(',')}。`,\n generation: {\n mode: 'local-fallback',\n label: '本地规则初稿',\n aiStatus: resolvedKind,\n warning: explainProfileFallbackReason(reason, resolvedKind),\n technicalReason: String(reason || ''),\n generatedAt: Date.now()\n }\n });\n}\n\nfunction mergeProfileWithFallback(aiProfile, fallbackProfile, generation = null) {\n const ai = aiProfile && typeof aiProfile === 'object' ? aiProfile : {};\n const aiFacts = ai.facts && typeof ai.facts === 'object' ? ai.facts : {};\n const fallbackFacts = fallbackProfile.facts || {};\n const hard = ai.hardConstraints && typeof ai.hardConstraints === 'object' ? ai.hardConstraints : {};\n const fallbackHard = fallbackProfile.hardConstraints || {};\n return normalizeProfile({\n ...fallbackProfile,\n ...ai,\n facts: {\n ...fallbackFacts,\n ...aiFacts,\n education: Array.isArray(aiFacts.education) && aiFacts.education.length ? aiFacts.education : fallbackFacts.education,\n experiences: Array.isArray(aiFacts.experiences) && aiFacts.experiences.length ? aiFacts.experiences : fallbackFacts.experiences,\n projects: Array.isArray(aiFacts.projects) && aiFacts.projects.length ? aiFacts.projects : fallbackFacts.projects,\n skills: normalizeStringList(aiFacts.skills).length ? aiFacts.skills : fallbackFacts.skills,\n certificates: Array.isArray(aiFacts.certificates) && aiFacts.certificates.length ? aiFacts.certificates : fallbackFacts.certificates\n },\n primaryDirections: normalizeStringList(ai.primaryDirections, 3).length ? ai.primaryDirections : fallbackProfile.primaryDirections,\n searchKeywords: normalizeStringList(ai.searchKeywords, 12).length ? ai.searchKeywords : fallbackProfile.searchKeywords,\n hardConstraints: {\n ...fallbackHard,\n ...hard,\n locations: normalizeStringList(hard.locations).length ? hard.locations : fallbackHard.locations,\n employmentTypes: normalizeStringList(hard.employmentTypes).length ? hard.employmentTypes : fallbackHard.employmentTypes,\n salary: String(hard.salary || fallbackHard.salary || '不限'),\n experience: String(hard.experience || fallbackHard.experience || ''),\n degree: String(hard.degree || fallbackHard.degree || '不限')\n },\n summary: String(ai.summary || fallbackProfile.summary),\n generation: generation || {\n mode: 'ai-assisted',\n label: 'AI 完整画像',\n aiStatus: 'success',\n warning: 'AI 返回的完整 JSON 已通过校验;仅对可选空字段使用本地规则补齐。',\n generatedAt: Date.now()\n }\n });\n}\n\nfunction compactProfileToFull(compact, fallback) {\n return {\n facts: {\n ...(fallback.facts || {}),\n skills: compact.skills.length ? compact.skills : fallback.facts?.skills || []\n },\n primaryDirections: compact.primaryDirections,\n secondaryDirections: [],\n searchKeywords: compact.searchKeywords,\n hardConstraints: {\n ...(fallback.hardConstraints || {}),\n locations: compact.locations.length ? compact.locations : fallback.hardConstraints?.locations || [],\n employmentTypes: compact.employmentTypes.length ? compact.employmentTypes : fallback.hardConstraints?.employmentTypes || [],\n salary: compact.salary || fallback.hardConstraints?.salary || '不限',\n experience: compact.experience || fallback.hardConstraints?.experience || '',\n degree: compact.degree || fallback.hardConstraints?.degree || '不限'\n },\n excludeDirections: compact.excludeDirections,\n summary: compact.summary\n };\n}\n\nasync function buildProfile(resumeText) {\n const text = cleanResumeText(resumeText);\n const fallback = buildLocalProfile(text);\n const schema = '{\"facts\":{\"education\":[],\"experiences\":[],\"projects\":[],\"skills\":[],\"certificates\":[]},\"primaryDirections\":[{\"name\":\"\",\"confidence\":0,\"evidence\":[]}],\"secondaryDirections\":[],\"searchKeywords\":[],\"hardConstraints\":{\"locations\":[],\"employmentTypes\":[],\"salary\":\"\",\"experience\":\"\",\"degree\":\"\"},\"excludeDirections\":[],\"summary\":\"\"}';\n let firstError = null;\n try {\n const profile = validateGeneratedProfile(await callModel([\n {\n role: 'system',\n content: `你是严格的职业画像分析器。只能使用简历真实事实,不能根据项目业务场景推断用户职业。主方向最多3个,搜索词必须是真实岗位名称。数组必须精简,教育/经历/项目各最多4条,每条不超过80字,技能最多15个,摘要不超过180字。即使信息不完整,也必须给出可编辑初稿,禁止返回空内容。输出严格 JSON:${schema}`\n },\n { role: 'user', content: text.slice(0, 22000) }\n ], true, { maxTokens: 4200, temperature: 0.05 }));\n return mergeProfileWithFallback(profile, fallback);\n } catch (error) {\n firstError = error;\n }\n\n // 连接正常但完整画像输出过长、JSON 不完整或字段校验失败时,自动改用精简结构重试。\n if (isRetryableAiOutputError(firstError)) {\n const compactSchema = '{\"summary\":\"\",\"primaryDirections\":[],\"searchKeywords\":[],\"skills\":[],\"locations\":[],\"employmentTypes\":[],\"salary\":\"\",\"experience\":\"\",\"degree\":\"\",\"excludeDirections\":[]}';\n try {\n const compact = validateCompactGeneratedProfile(await callModel([\n {\n role: 'system',\n content: `你是求职职业画像分析器。只使用简历事实。请输出极简 JSON,不要解释,不要证据长句。摘要120字以内;主方向最多3个;搜索词最多10个;技能最多12个;其余字段简短。输出结构:${compactSchema}`\n },\n { role: 'user', content: text.slice(0, 18000) }\n ], true, { maxTokens: 2200, temperature: 0.05 }));\n return mergeProfileWithFallback(compactProfileToFull(compact, fallback), fallback, {\n mode: 'ai-compact-retry',\n label: 'AI 精简重试结果',\n aiStatus: 'success-after-retry',\n warning: 'AI 连接正常。首次完整画像输出未完成,系统已自动精简重试并成功采用 AI 结果。',\n technicalReason: String(firstError?.message || ''),\n generatedAt: Date.now()\n });\n } catch (retryError) {\n const combined = `${firstError?.message || 'AI 首次输出未通过'};精简重试:${retryError?.message || '失败'}`;\n return buildLocalProfile(text, combined, aiFailureKind(firstError));\n }\n }\n\n return buildLocalProfile(text, firstError?.message || 'AI 生成失败', aiFailureKind(firstError));\n}\n\n\nfunction normalizeStringList(value, limit = 30) {\n const values = Array.isArray(value) ? value : String(value || '').split(/[,,\\n]/);\n return uniq(values.map(item => typeof item === 'string' ? item.trim() : String(item?.name || item?.title || '').trim()))\n .filter(Boolean)\n .slice(0, limit);\n}\n\nfunction normalizeDirections(value, existing = []) {\n const previous = new Map((Array.isArray(existing) ? existing : []).map(item => {\n const name = typeof item === 'string' ? item : item?.name;\n return [String(name || '').trim(), item];\n }));\n return normalizeStringList(value, 3).map(name => {\n const old = previous.get(name);\n if (old && typeof old === 'object') return { ...old, name };\n return { name, confidence: 1, evidence: ['用户手动编辑'] };\n });\n}\n\nfunction normalizeProfile(incoming, current = null) {\n const base = current && typeof current === 'object' ? current : {};\n const next = incoming && typeof incoming === 'object' ? incoming : {};\n const profile = {\n ...base,\n ...next,\n facts: {\n education: [], experiences: [], projects: [], skills: [], certificates: [],\n ...(base.facts || {}),\n ...(next.facts || {}),\n skills: normalizeStringList(next.facts?.skills ?? base.facts?.skills, 40)\n },\n primaryDirections: normalizeDirections(next.primaryDirections ?? base.primaryDirections, base.primaryDirections),\n secondaryDirections: normalizeStringList(next.secondaryDirections ?? base.secondaryDirections, 10),\n searchKeywords: normalizeStringList(next.searchKeywords ?? base.searchKeywords, 12),\n excludeDirections: normalizeStringList(next.excludeDirections ?? base.excludeDirections, 20),\n hardConstraints: {\n locations: [], employmentTypes: [], salary: '', experience: '', degree: '',\n ...(base.hardConstraints || {}),\n ...(next.hardConstraints || {})\n },\n summary: String(next.summary ?? base.summary ?? '').trim(),\n editedAt: Date.now()\n };\n profile.hardConstraints.locations = normalizeStringList(profile.hardConstraints.locations, 20);\n profile.hardConstraints.employmentTypes = normalizeStringList(profile.hardConstraints.employmentTypes, 10);\n profile.hardConstraints.salary = String(profile.hardConstraints.salary || '').trim();\n profile.hardConstraints.experience = String(profile.hardConstraints.experience || '').trim();\n profile.hardConstraints.degree = String(profile.hardConstraints.degree || '').trim();\n if (!profile.primaryDirections.length) throw new Error('职业画像至少需要一个主方向');\n if (!profile.searchKeywords.length) throw new Error('职业画像至少需要一个岗位搜索词');\n return profile;\n}\n\n\nfunction profileHasCore(profile) {\n return Boolean(\n normalizeStringList(profile?.primaryDirections, 3).length &&\n normalizeStringList(profile?.searchKeywords, 12).length\n );\n}\n\nfunction normalizeProfileDraft(incoming, fallback = null) {\n const base = fallback && typeof fallback === 'object' ? fallback : {};\n const next = incoming && typeof incoming === 'object' ? incoming : {};\n const chooseList = (key, limit = 30) => Object.prototype.hasOwnProperty.call(next, key)\n ? normalizeStringList(next[key], limit)\n : normalizeStringList(base[key], limit);\n const chooseText = key => Object.prototype.hasOwnProperty.call(next, key)\n ? String(next[key] || '').trim()\n : String(base[key] || '').trim();\n return {\n summary: chooseText('summary'),\n primaryDirections: chooseList('primaryDirections', 3),\n searchKeywords: chooseList('searchKeywords', 12),\n skills: chooseList('skills', 40),\n locations: chooseList('locations', 20),\n employmentTypes: chooseList('employmentTypes', 10),\n experience: chooseText('experience'),\n degree: chooseText('degree'),\n salary: chooseText('salary'),\n excludeDirections: chooseList('excludeDirections', 20),\n source: String(next.source || base.source || 'draft'),\n updatedAt: Number(next.updatedAt || Date.now())\n };\n}\n\nfunction profileDraftHasCore(draft) {\n return Boolean(\n normalizeStringList(draft?.primaryDirections, 3).length &&\n normalizeStringList(draft?.searchKeywords, 12).length\n );\n}\n\nfunction profileDraftHasAny(draft) {\n if (!draft || typeof draft !== 'object') return false;\n return Boolean(\n String(draft.summary || '').trim() ||\n normalizeStringList(draft.primaryDirections).length ||\n normalizeStringList(draft.searchKeywords).length ||\n normalizeStringList(draft.skills).length ||\n normalizeStringList(draft.locations).length ||\n normalizeStringList(draft.employmentTypes).length ||\n String(draft.experience || '').trim() ||\n String(draft.degree || '').trim() ||\n String(draft.salary || '').trim() ||\n normalizeStringList(draft.excludeDirections).length\n );\n}\n\nfunction profileToDraft(profile, source = 'generated') {\n const hard = profile?.hardConstraints || {};\n return normalizeProfileDraft({\n summary: profile?.summary || '',\n primaryDirections: normalizeStringList(profile?.primaryDirections, 3),\n searchKeywords: normalizeStringList(profile?.searchKeywords, 12),\n skills: normalizeStringList(profile?.facts?.skills, 40),\n locations: normalizeStringList(hard.locations, 20),\n employmentTypes: normalizeStringList(hard.employmentTypes, 10),\n experience: hard.experience || '',\n degree: hard.degree || '',\n salary: hard.salary || '',\n excludeDirections: normalizeStringList(profile?.excludeDirections, 20),\n source,\n updatedAt: Date.now()\n });\n}\n\nfunction profileFromDraft(draft, currentProfile = null) {\n const normalized = normalizeProfileDraft(draft);\n return normalizeProfile({\n ...(currentProfile || {}),\n summary: normalized.summary,\n primaryDirections: normalized.primaryDirections,\n searchKeywords: normalized.searchKeywords,\n excludeDirections: normalized.excludeDirections,\n facts: {\n ...(currentProfile?.facts || {}),\n skills: normalized.skills\n },\n hardConstraints: {\n ...(currentProfile?.hardConstraints || {}),\n locations: normalized.locations,\n employmentTypes: normalized.employmentTypes,\n experience: normalized.experience,\n degree: normalized.degree,\n salary: normalized.salary\n }\n }, currentProfile);\n}\n\n\nfunction clampNumber(value, min, max, fallback = min) {\n const number = Number(value);\n return Number.isFinite(number) ? Math.max(min, Math.min(max, number)) : fallback;\n}\n\nfunction normalizeDirectionKey(value) {\n return String(value || '')\n .toLowerCase()\n .replace(/实习生|工程师|开发|岗位|职位|校招|社招|应届/g, '')\n .replace(/[\\s,,/\\\\|·•()()【】\\[\\]_-]+/g, '')\n .trim();\n}\n\nfunction stableDirectionId(name, source = 'profile') {\n const input = `${source}:${normalizeDirectionKey(name) || String(name || '').trim().toLowerCase()}`;\n let hash = 2166136261;\n for (let index = 0; index < input.length; index += 1) {\n hash ^= input.charCodeAt(index);\n hash = Math.imul(hash, 16777619);\n }\n return `direction_${(hash >>> 0).toString(36)}`;\n}\n\nconst DIRECTION_PRESETS = [\n {\n test: /前端|web|react|vue|javascript|typescript/i,\n keywords: ['前端开发实习生', 'Web前端实习生', 'React开发实习生', 'Vue开发实习生'],\n relevantSkills: ['JavaScript', 'TypeScript', 'HTML', 'CSS', 'React', 'Vue', 'Next.js', 'Vite', 'Webpack'],\n gapSkills: ['工程化', '性能优化', '组件库']\n },\n {\n test: /ai应用|人工智能应用|大模型|agent|rag|llm/i,\n keywords: ['AI应用开发实习生', 'AI Agent实习生', 'RAG应用开发实习生', '大模型应用开发实习生'],\n relevantSkills: ['AI Agent', 'RAG', 'Python', 'TypeScript', 'React', 'Node.js', 'OCR'],\n gapSkills: ['Python', '模型评测', '向量数据库']\n },\n {\n test: /全栈|full.?stack|前后端/i,\n keywords: ['全栈开发实习生', 'Web全栈实习生', '前后端开发实习生'],\n relevantSkills: ['JavaScript', 'TypeScript', 'React', 'Vue', 'Node.js', 'Express', 'MySQL'],\n gapSkills: ['后端接口', '数据库', '部署运维']\n },\n {\n test: /开发者工具|研发工具|桌面端|tauri|electron|工具开发/i,\n keywords: ['开发者工具实习生', '研发工具开发实习生', '桌面端开发实习生', '平台工具开发实习生'],\n relevantSkills: ['Tauri', 'Electron', 'TypeScript', 'React', 'Rust', 'Node.js', 'OCR'],\n gapSkills: ['跨平台工程化', '桌面端发布', '系统 API']\n },\n {\n test: /数据可视化|可视化|echarts|大屏/i,\n keywords: ['数据可视化实习生', '前端可视化实习生', 'ECharts开发实习生'],\n relevantSkills: ['ECharts', 'JavaScript', 'TypeScript', 'React', 'Vue', '数据处理'],\n gapSkills: ['图形性能优化', '复杂交互', '可视化工程化']\n },\n {\n test: /后端|java|node|服务端/i,\n keywords: ['后端开发实习生', 'Node.js开发实习生', 'Java开发实习生'],\n relevantSkills: ['Java', 'Node.js', 'Express', 'MySQL', 'Redis', 'Spring'],\n gapSkills: ['数据库', '缓存', '接口设计']\n }\n];\n\nfunction directionPreset(name) {\n return DIRECTION_PRESETS.find(item => item.test.test(String(name || ''))) || null;\n}\n\nfunction normalizedSkillSet(profile = {}) {\n return new Map(normalizeStringList(profile?.facts?.skills, 50).map(skill => [normalizeDirectionKey(skill), skill]));\n}\n\nfunction buildDirectionKeywords(name, profile = {}) {\n const allKeywords = normalizeStringList(profile?.searchKeywords, 30);\n const nameKey = normalizeDirectionKey(name);\n const preset = directionPreset(name);\n const matching = allKeywords.filter(keyword => {\n const key = normalizeDirectionKey(keyword);\n return key && (nameKey.includes(key) || key.includes(nameKey) || [...nameKey].filter(char => key.includes(char)).length >= Math.min(3, nameKey.length));\n });\n return uniq([name, ...matching, ...(preset?.keywords || [])]).filter(Boolean).slice(0, 8);\n}\n\nfunction buildDirectionEvidence(name, candidate = {}, profile = {}) {\n const skills = normalizeStringList(profile?.facts?.skills, 30);\n const skillMap = normalizedSkillSet(profile);\n const preset = directionPreset(name);\n const relevant = (preset?.relevantSkills || skills).filter(skill => {\n const key = normalizeDirectionKey(skill);\n return skillMap.has(key) || skills.some(current => normalizeDirectionKey(current).includes(key) || key.includes(normalizeDirectionKey(current)));\n });\n const matchedSkills = uniq(relevant.length ? relevant : skills.slice(0, 4)).slice(0, 5);\n const gaps = (preset?.gapSkills || []).filter(skill => {\n const key = normalizeDirectionKey(skill);\n return ![...skillMap.keys()].some(existing => existing.includes(key) || key.includes(existing));\n }).slice(0, 3);\n const evidence = normalizeStringList(candidate?.evidence, 3);\n const reason = evidence.length\n ? `职业画像中已提取到:${evidence.join(';')}`.slice(0, 160)\n : matchedSkills.length\n ? `与简历中的 ${matchedSkills.join('、')} 技能和项目经历匹配。`\n : `该方向来自职业画像中的主要求职方向,可继续人工调整。`;\n return { matchedSkills, gaps, reason };\n}\n\nfunction normalizeDirectionItem(item = {}, index = 0) {\n const name = String(item.name || item.title || '').trim().slice(0, 60);\n const source = item.source === 'custom' || item.custom ? 'custom' : 'profile';\n const id = String(item.id || stableDirectionId(name || `custom-${index}`, source));\n return {\n id,\n source,\n custom: source === 'custom',\n sourceName: String(item.sourceName || name).trim().slice(0, 60),\n name,\n enabled: item.enabled !== false,\n priority: Math.round(clampNumber(item.priority, 1, 99, index + 1)),\n score: Math.round(clampNumber(item.score, 0, 100, source === 'custom' ? 70 : Math.max(60, 88 - index * 7))),\n reason: String(item.reason || (source === 'custom' ? '用户自定义岗位方向。' : '根据职业画像推荐。')).trim().slice(0, 240),\n matchedSkills: normalizeStringList(item.matchedSkills, 8),\n gaps: normalizeStringList(item.gaps, 6),\n keywords: uniq(normalizeStringList(item.keywords, 12).length ? normalizeStringList(item.keywords, 12) : [name]).slice(0, 12),\n updatedAt: Number(item.updatedAt || Date.now())\n };\n}\n\nfunction profileDirectionSignature(profile = {}) {\n return JSON.stringify({\n directions: normalizeStringList(profile?.primaryDirections?.map(item => typeof item === 'string' ? item : item?.name), 6).map(normalizeDirectionKey),\n keywords: normalizeStringList(profile?.searchKeywords, 20).map(normalizeDirectionKey)\n });\n}\n\nfunction normalizeDirectionPlan(plan, profile = null, options = {}) {\n const items = (Array.isArray(plan?.items) ? plan.items : [])\n .map((item, index) => normalizeDirectionItem(item, index))\n .filter(item => item.name && item.keywords.length)\n .sort((left, right) => left.priority - right.priority || right.score - left.score)\n .slice(0, 12)\n .map((item, index) => ({ ...item, priority: index + 1 }));\n return {\n version: 1,\n items,\n confirmed: options.confirmed ?? Boolean(plan?.confirmed),\n updatedAt: Number(options.updatedAt || plan?.updatedAt || Date.now()),\n appliedAt: Number(options.appliedAt || plan?.appliedAt || 0),\n profileSignature: String(options.profileSignature || plan?.profileSignature || profileDirectionSignature(profile)).slice(0, 600)\n };\n}\n\nfunction buildDirectionPlan(profile = {}, currentPlan = null, options = {}) {\n const primary = Array.isArray(profile?.primaryDirections) ? profile.primaryDirections : [];\n const secondary = Array.isArray(profile?.secondaryDirections) ? profile.secondaryDirections : [];\n const searchKeywords = normalizeStringList(profile?.searchKeywords, 20);\n const candidates = [];\n for (const item of [...primary, ...secondary]) {\n const name = String(typeof item === 'string' ? item : item?.name || '').trim();\n if (!name || candidates.some(candidate => normalizeDirectionKey(candidate.name) === normalizeDirectionKey(name))) continue;\n candidates.push({ name, raw: typeof item === 'object' ? item : {} });\n }\n for (const keyword of searchKeywords) {\n if (candidates.length >= 6) break;\n if (!/开发|工程师|实习生|前端|后端|全栈|AI|人工智能|工具|可视化/i.test(keyword)) continue;\n if (candidates.some(candidate => normalizeDirectionKey(candidate.name) === normalizeDirectionKey(keyword))) continue;\n candidates.push({ name: keyword, raw: {} });\n }\n if (!candidates.length) candidates.push({ name: '目标岗位', raw: {} });\n\n const existing = new Map((currentPlan?.items || []).map(item => [String(item.id || ''), item]));\n const generated = candidates.slice(0, 6).map((candidate, index) => {\n const id = stableDirectionId(candidate.name, 'profile');\n const previous = existing.get(id);\n const confidence = Number(candidate.raw?.confidence);\n const score = Number.isFinite(confidence)\n ? Math.round(confidence <= 1 ? confidence * 100 : confidence)\n : Math.max(62, 90 - index * 7);\n const evidence = buildDirectionEvidence(candidate.name, candidate.raw, profile);\n return normalizeDirectionItem({\n id,\n source: 'profile',\n sourceName: candidate.name,\n name: options.preserveEdits && previous?.name ? previous.name : candidate.name,\n enabled: options.preserveSelections && previous ? previous.enabled : index < 3,\n priority: previous?.priority || index + 1,\n score,\n reason: evidence.reason,\n matchedSkills: evidence.matchedSkills,\n gaps: evidence.gaps,\n keywords: options.preserveEdits && previous?.keywords?.length\n ? previous.keywords\n : buildDirectionKeywords(candidate.name, profile)\n }, index);\n });\n const custom = options.preserveCustom === false ? [] : (currentPlan?.items || [])\n .filter(item => item?.source === 'custom' || item?.custom)\n .map((item, index) => normalizeDirectionItem(item, generated.length + index));\n return normalizeDirectionPlan({\n items: [...generated, ...custom],\n confirmed: options.confirmed ?? false,\n updatedAt: Date.now(),\n appliedAt: options.confirmed ? Date.now() : Number(currentPlan?.appliedAt || 0)\n }, profile, { confirmed: options.confirmed ?? false, updatedAt: Date.now() });\n}\n\nfunction selectedDirectionItems(plan = {}) {\n return (Array.isArray(plan?.items) ? plan.items : [])\n .map((item, index) => normalizeDirectionItem(item, index))\n .filter(item => item.enabled && item.name && item.keywords.length)\n .sort((left, right) => left.priority - right.priority || right.score - left.score);\n}\n\nfunction fallbackApplicantGreeting(job, profile) {\n const title = String(job?.title || '该岗位').trim();\n const skills = normalizeStringList(profile?.facts?.skills, 4);\n const direction = normalizeStringList(profile?.primaryDirections?.map(item => typeof item === 'string' ? item : item?.name), 2);\n const evidence = skills.length ? `我的简历中有${skills.slice(0, 3).join('、')}相关项目与实践` : (profile?.summary || direction.join('、'));\n const target = `贵公司的${title}`;\n return `您好,我想应聘${target}岗位。${evidence ? `${evidence},` : ''}对岗位的工作内容很感兴趣。方便的话希望进一步了解岗位和团队,谢谢。`\n .replace(/。{2,}/g, '。')\n .slice(0, 140);\n}\n\nfunction normalizeApplicantGreeting(result, job, profile) {\n const raw = String(result?.greeting || '').trim();\n const reversed = /看到你的简历|你的简历|很匹配我们|匹配我们|欢迎.*沟通|期待你|候选人|我们团队|我们公司|团队主要涉及|你很匹配|方便的话来聊聊/i.test(raw);\n const applicantVoice = /我想应聘|我希望应聘|我对.{0,20}(岗位|职位).{0,10}(感兴趣|有兴趣)|想进一步了解|希望进一步沟通/.test(raw);\n if (!raw || reversed || !applicantVoice) return fallbackApplicantGreeting(job, profile);\n return raw.slice(0, 160);\n}\n\nasync function analyzeJob(job) {\n const { profile, resumeText, config } = await storage.get(['profile', 'resumeText', 'config']);\n if (!profile) throw new Error('请先生成职业画像');\n const result = await callModel([\n {\n role: 'system',\n content: '你是为求职者服务的岗位匹配审查器,不是招聘方。用户是正在应聘岗位的求职者。任何能力、年限、项目和成果都不能超出简历事实。先判断学历、经验、地点等硬条件,再判断方向与技能。输出 JSON:{\"score\":0,\"decision\":\"recommend|cautious|reject\",\"hardBlocks\":[],\"matchedEvidence\":[],\"gaps\":[],\"risks\":[],\"reason\":\"\",\"greeting\":\"\"}。greeting 是求职者发给招聘方/HR 的第一人称求职招呼语,50-110字,应使用“您好,我想应聘贵公司的……”口吻。严禁写成招聘方口吻,严禁出现“看到你的简历”“你的经历很匹配我们”“欢迎进一步沟通”“我们团队”“候选人”等表述;不得承诺薪资、到岗、年限或不存在的能力。'\n },\n {\n role: 'user',\n content: `职业画像:${JSON.stringify(profile)}\n简历:${String(resumeText || '').slice(0, 15000)}\n岗位:${JSON.stringify(job)}`\n }\n ]);\n result.score = Math.max(0, Math.min(100, Number(result.score || 0)));\n result.greeting = normalizeApplicantGreeting(result, job, profile);\n if (Array.isArray(result.hardBlocks) && result.hardBlocks.length) result.decision = 'reject';\n if (result.score < Number(config?.minScore || 75) && result.decision === 'recommend') {\n result.decision = 'cautious';\n }\n return result;\n}\n\nfunction createTasks(profile, config, directionPlan) {\n const directions = selectedDirectionItems(directionPlan);\n const locations = config.targetLocations?.length ? config.targetLocations : [''];\n const employmentTypes = config.employmentTypes?.length ? config.employmentTypes : ['不限'];\n const tasks = [];\n const seen = new Set();\n for (const direction of directions) {\n for (const keyword of direction.keywords) {\n for (const location of locations) {\n for (const employmentType of employmentTypes) {\n const dedupeKey = [keyword, location, employmentType].map(value => String(value || '').trim().toLowerCase()).join('|');\n if (seen.has(dedupeKey)) continue;\n seen.add(dedupeKey);\n tasks.push({\n id: crypto.randomUUID(),\n directionId: direction.id,\n directionName: direction.name,\n directionPriority: direction.priority,\n directionScore: direction.score,\n keyword,\n location,\n employmentType,\n experience: config.experiences?.[0] || '',\n degree: config.degrees?.[0] || '',\n salary: config.salary || '不限',\n attempts: 0,\n status: 'pending',\n progress: 0,\n stageLabel: '等待开始',\n processed: 0,\n discovered: 0,\n analyzed: 0,\n failed: 0,\n createdAt: Date.now()\n });\n }\n }\n }\n }\n return tasks;\n}\n\n\nasync function dispatchNextAutoPending() {\n const { pending = [], workflow = {}, config = {} } = await storage.get(['pending', 'workflow', 'config']);\n if (config.executionMode !== 'auto') return { started: false, reason: 'not-auto' };\n if (workflow.pendingApplyId) return { started: false, reason: 'busy', pendingApplyId: workflow.pendingApplyId };\n const ranked = rerankPending(pending);\n const candidate = ranked.find(entry => entry.status === 'approved_queue');\n if (!candidate) {\n await storage.set({ pending: ranked });\n return { started: false, reason: 'empty' };\n }\n const next = rerankPending(ranked.map(entry => entry.id === candidate.id\n ? { ...entry, status: 'approved', approvedAt: entry.approvedAt || Date.now() }\n : entry));\n await storage.set({ pending: next });\n const run = await updateTaskRunByPending(candidate.id, {\n status: 'running',\n stage: 'queued',\n progress: 64,\n stageLabel: `优先投递 · 队列第 ${candidate.priorityRank || 1} 位`,\n error: '',\n retryable: true,\n completedAt: null\n });\n await patchWorkflow({\n running: true,\n paused: false,\n phase: 'apply',\n statusText: `按匹配优先级投递:${candidate.job?.title || '岗位'}(AI ${candidate.analysis?.score || 0} 分)`,\n pendingApplyId: candidate.id,\n activeRunId: run?.id || candidate.runId || null\n });\n await sendToBoss({ type: 'RUN' });\n return { started: true, item: candidate, queueDepth: next.filter(entry => entry.status === 'approved_queue').length };\n}\n\nasync function addPending(item) {\n const { pending = [], config = {} } = await storage.get(['pending', 'config']);\n let run = null;\n if (item.runId) {\n run = await upsertTaskRun({\n id: item.runId,\n job: item.job,\n analysis: item.analysis,\n searchTask: item.task,\n status: config.executionMode === 'auto' ? 'queued' : 'waiting_review',\n stage: config.executionMode === 'auto' ? 'queued' : 'waiting_review',\n progress: config.executionMode === 'auto' ? 64 : 60,\n stageLabel: config.executionMode === 'auto' ? '已进入自动排序队列' : '等待人工确认'\n });\n } else {\n run = await upsertTaskRun({\n job: item.job,\n analysis: item.analysis,\n searchTask: item.task,\n status: config.executionMode === 'auto' ? 'queued' : 'waiting_review',\n stage: config.executionMode === 'auto' ? 'queued' : 'waiting_review',\n progress: config.executionMode === 'auto' ? 64 : 60,\n stageLabel: config.executionMode === 'auto' ? '已进入自动排序队列' : '等待人工确认'\n });\n }\n const nextItem = {\n ...item,\n runId: run.id,\n id: item.id || crypto.randomUUID(),\n deliveryGreeting: String(item.deliveryGreeting || item.analysis?.greeting || '').trim(),\n status: config.executionMode === 'auto' ? 'approved_queue' : 'pending',\n createdAt: Date.now()\n };\n await upsertTaskRun({ id: run.id, pendingId: nextItem.id });\n const next = rerankPending([nextItem, ...pending.filter(entry => entry.id !== nextItem.id)]);\n await storage.set({ pending: next });\n const count = next.filter(entry => entry.status === 'pending').length;\n await storage.set({ stats: { ...(await storage.get('stats')).stats, pending: count } });\n return next.find(entry => entry.id === nextItem.id) || nextItem;\n}\n\nasync function approvePending(id, greeting = '') {\n const { pending = [] } = await storage.get('pending');\n const item = pending.find(entry => entry.id === id);\n if (!item) throw new Error('待确认岗位不存在');\n const lockedGreeting = String(greeting || item.deliveryGreeting || item.analysis?.greeting || '').trim();\n const updatedItem = {\n ...item,\n deliveryGreeting: lockedGreeting,\n analysis: { ...(item.analysis || {}), greeting: lockedGreeting },\n status: 'approved',\n approvedAt: Date.now()\n };\n const next = pending.map(entry => entry.id === id ? updatedItem : entry);\n await storage.set({ pending: next });\n const pendingCount = next.filter(entry => entry.status === 'pending').length;\n const { stats } = await storage.get('stats');\n await storage.set({ stats: { ...stats, pending: pendingCount } });\n const run = await updateTaskRunByPending(id, {\n status: 'running',\n stage: 'queued',\n progress: 64,\n stageLabel: '等待投递',\n error: '',\n retryable: true,\n completedAt: null\n });\n await patchWorkflow({\n running: true,\n paused: false,\n phase: 'apply',\n statusText: `准备以求职者身份沟通:${item.job?.title || '岗位'}`,\n pendingApplyId: id,\n activeRunId: run?.id || item.runId || null\n });\n await sendToBoss({ type: 'RUN' });\n setTimeout(() => sendToBoss({ type: 'RUN' }).catch(() => {}), 700);\n return updatedItem;\n}\n\nasync function approveAllPending() {\n const { pending = [] } = await storage.get('pending');\n const candidates = rerankPending(pending).filter(entry => entry.status === 'pending');\n if (!candidates.length) return { count: 0 };\n const ids = new Set(candidates.map(entry => entry.id));\n const next = pending.map(entry => ids.has(entry.id)\n ? { ...entry, status: 'approved_queue', approvedAt: Date.now() }\n : entry);\n const first = candidates[0];\n await storage.set({ pending: next });\n const { stats } = await storage.get('stats');\n await storage.set({ stats: { ...stats, pending: 0 } });\n for (const [index, candidate] of candidates.entries()) {\n await updateTaskRunByPending(candidate.id, {\n status: index === 0 ? 'running' : 'queued',\n stage: 'queued',\n progress: 64,\n stageLabel: index === 0 ? '准备投递' : '批量队列等待中',\n error: '',\n retryable: true,\n completedAt: null\n });\n }\n await patchWorkflow({\n running: true,\n paused: false,\n phase: 'apply',\n statusText: `批量投递 1/${candidates.length}:${first.job?.title || '岗位'}`,\n pendingApplyId: first.id,\n activeRunId: first.runId || null\n });\n await sendToBoss({ type: 'RUN' });\n return { count: candidates.length };\n}\n\nasync function rejectAllPending() {\n const { pending = [] } = await storage.get('pending');\n const count = pending.filter(entry => entry.status === 'pending').length;\n const next = pending.map(entry => entry.status === 'pending'\n ? { ...entry, status: 'rejected', rejectedAt: Date.now() }\n : entry);\n await storage.set({ pending: next });\n const { stats } = await storage.get('stats');\n await storage.set({ stats: { ...stats, pending: 0 } });\n for (const item of pending.filter(entry => entry.status === 'pending')) {\n await updateTaskRunByPending(item.id, { status: 'ignored', stage: 'ignored', progress: 100, stageLabel: '已忽略', retryable: false });\n }\n return { count };\n}\n\nasync function rejectPending(id) {\n const { pending = [] } = await storage.get('pending');\n const next = pending.map(entry => entry.id === id ? { ...entry, status: 'rejected', rejectedAt: Date.now() } : entry);\n await storage.set({ pending: next });\n const count = next.filter(entry => entry.status === 'pending').length;\n const { stats } = await storage.get('stats');\n await storage.set({ stats: { ...stats, pending: count } });\n await updateTaskRunByPending(id, { status: 'ignored', stage: 'ignored', progress: 100, stageLabel: '已忽略', retryable: false });\n}\n\n\nasync function skipPendingTask(id, reason = '该岗位需要外部网申,已自动跳过', stageLabel = '外部网申岗位已跳过') {\n const { pending = [], stats = {}, config = {}, chatTransition = null } = await storage.get(['pending', 'stats', 'config', 'chatTransition']);\n const completedAt = Date.now();\n let skippedItem = null;\n let next = pending.map(entry => {\n if (entry.id !== id) return entry;\n skippedItem = { ...entry, status: 'skipped', completedAt, error: reason };\n return skippedItem;\n });\n const updatedStats = {\n ...stats,\n pending: next.filter(entry => entry.status === 'pending').length\n };\n await storage.set({ pending: next, stats: updatedStats, chatTransition: chatTransition?.pendingId === id ? null : chatTransition });\n const run = await updateTaskRunByPending(id, {\n status: 'skipped', stage: 'skipped', progress: 100,\n stageLabel, error: reason, retryable: false, completedAt\n });\n await writeEvent('info', stageLabel, {\n id,\n runId: run?.id || skippedItem?.runId || '',\n job: skippedItem?.job,\n reason\n });\n\n next = rerankPending(next);\n const queued = next.find(entry => entry.status === 'approved_queue');\n if (queued) {\n next = next.map(entry => entry.id === queued.id ? { ...entry, status: 'approved' } : entry);\n await storage.set({ pending: next });\n const queuedRun = await updateTaskRunByPending(queued.id, {\n status: 'running', stage: 'queued', progress: 64,\n stageLabel: '准备投递', error: '', completedAt: null\n });\n await patchWorkflow({\n pendingApplyId: queued.id,\n activeRunId: queuedRun?.id || queued.runId || null,\n running: true,\n paused: false,\n phase: 'apply',\n statusText: `已跳过外部网申岗位,继续投递:${queued.job?.title || '岗位'}`\n });\n } else {\n await patchWorkflow({\n pendingApplyId: null,\n activeRunId: null,\n running: true,\n paused: false,\n phase: 'search',\n statusText: '外部网申岗位已跳过,继续搜索可直接沟通岗位'\n });\n }\n setTimeout(() => sendToBoss({ type: 'RUN' }).catch(() => {}), Math.max(3000, Math.min(10000, Number(config.betweenJobsSeconds || 5) * 1000)));\n return { item: skippedItem, queued: queued || null };\n}\n\nasync function handleBridgeCommands() {\n try {\n const response = await bridge('/commands');\n for (const command of response.commands || []) {\n if (command.type === 'start') {\n await patchWorkflow({ running: true, paused: false, statusText: '由 OpenClaw 启动' });\n await sendToBoss({ type: 'RUN' });\n } else if (command.type === 'pause') {\n await patchWorkflow({ paused: true, statusText: '由 OpenClaw 暂停' });\n } else if (command.type === 'stop') {\n await patchWorkflow({ running: false, paused: true, phase: 'idle', statusText: '由 OpenClaw 停止' });\n }\n }\n } catch {\n // 桌面桥接未运行时保持浏览器扩展独立可用。\n }\n}\n\nchrome.runtime.onInstalled.addListener(init);\nchrome.runtime.onStartup.addListener(init);\ninit();\n\nchrome.alarms.onAlarm.addListener(async alarm => {\n if (alarm.name !== 'jobclaw-tick') return;\n const { workflow } = await storage.get('workflow');\n if (workflow?.running && !workflow.paused) sendToBoss({ type: 'RUN' }).catch(() => {});\n handleBridgeCommands();\n});\n\nchrome.runtime.onMessage.addListener((message, sender, reply) => {\n (async () => {\n switch (message?.type) {\n case 'TRUSTED_CHAT_INPUT': {\n const tabId = sender?.tab?.id;\n const url = String(sender?.tab?.url || '');\n if (!/^https:\\/\\/(?:www|app)\\.zhipin\\.com\\//i.test(url)) throw new Error('可信输入只能用于当前 BOSS 页面');\n const result = await trustedChatInput(tabId, message);\n reply({ ok: true, ...result });\n break;\n }\n case 'GET_STATE':\n reply({ ok: true, state: publicState(await storage.all()) });\n break;\n case 'SAVE_CONFIG': {\n const { config } = await storage.get('config');\n const incoming = message.config || {};\n const oldKey = config?.model?.apiKey || '';\n incoming.model = {\n ...(config?.model || {}),\n ...(incoming.model || {}),\n apiKey: incoming.model?.apiKey && incoming.model.apiKey !== '***'\n ? incoming.model.apiKey\n : oldKey\n };\n incoming.executionMode = incoming.executionMode === 'auto' ? 'auto' : 'review';\n incoming.dailyTarget = Math.max(1, Math.min(300, Number(incoming.dailyTarget || config?.dailyTarget || 150)));\n incoming.discoveryLimit = 0;\n incoming.model.baseUrl = String(incoming.model.baseUrl || 'https://api.deepseek.com').trim();\n incoming.model.model = String(incoming.model.model || 'deepseek-v4-pro').trim();\n await storage.set({ config: { ...(config || {}), ...incoming } });\n await writeEvent('info', '设置已保存');\n reply({ ok: true });\n break;\n }\n case 'SET_RESUME':\n await storage.set({ resumeText: String(message.text || '') });\n await writeEvent('info', '简历文本已保存', { length: String(message.text || '').length });\n reply({ ok: true });\n break;\n case 'SET_RESUME_SOURCE': {\n const file = message.file || null;\n if (!file?.name || !file?.dataUrl) throw new Error('简历原文件数据不完整');\n if (String(file.dataUrl).length > 26 * 1024 * 1024) throw new Error('简历原文件过大');\n const source = {\n name: String(file.name),\n type: String(file.type || ''),\n size: Number(file.size || 0),\n lastModified: Number(file.lastModified || 0),\n dataUrl: String(file.dataUrl),\n savedAt: Date.now()\n };\n await storage.set({ resumeSourceFile: source });\n await writeEvent('info', '简历原文件已保留', { name: source.name, size: source.size });\n reply({ ok: true, file: { name: source.name, type: source.type, size: source.size, stored: true } });\n break;\n }\n case 'PARSE_RESUME_PDF': {\n const { resumeSourceFile } = await storage.get('resumeSourceFile');\n if (!resumeSourceFile?.dataUrl) throw new Error('请先选择 PDF 简历');\n if (!String(resumeSourceFile.name || '').toLowerCase().endsWith('.pdf') && resumeSourceFile.type !== 'application/pdf') {\n throw new Error('当前保留的文件不是 PDF');\n }\n try {\n const result = await bridge('/parse-resume', {\n name: resumeSourceFile.name,\n type: resumeSourceFile.type,\n dataUrl: resumeSourceFile.dataUrl\n });\n if (result?.text) await writeEvent('info', 'PDF 本机深度识别完成', { method: result.method, length: result.text.length });\n reply({ ok: Boolean(result?.ok), result, error: result?.error || '' });\n } catch (error) {\n reply({ ok: false, error: `桌面桥接不可用:${error.message}` });\n }\n break;\n }\n case 'SET_IMAGE':\n await storage.set({ resumeImage: message.dataUrl || null });\n await writeEvent('info', message.dataUrl ? '简历图片已保存' : '简历图片已移除');\n reply({ ok: true });\n break;\n case 'BUILD_LOCAL_PROFILE':\n case 'ENSURE_PROFILE_DRAFT': {\n const { resumeText, profile: currentProfile, profileDraft: currentDraft } = await storage.get(['resumeText', 'profile', 'profileDraft']);\n if (profileDraftHasCore(currentDraft) && profileHasCore(currentProfile)) {\n reply({ ok: true, profile: currentProfile, profileDraft: currentDraft, skipped: true });\n break;\n }\n if (profileHasCore(currentProfile)) {\n const profileDraft = profileDraftHasAny(currentDraft)\n ? normalizeProfileDraft(currentDraft, profileToDraft(currentProfile, 'profile-repair'))\n : profileToDraft(currentProfile, 'profile-repair');\n await storage.set({ profileDraft });\n reply({ ok: true, profile: currentProfile, profileDraft, repaired: true });\n break;\n }\n const profile = buildLocalProfile(resumeText || '');\n profile.generation.mode = 'local-recovery';\n profile.generation.label = '本地初稿';\n profile.generation.warning = '已从保存的简历自动恢复可编辑初稿。';\n const profileDraft = profileToDraft(profile, 'local-recovery');\n const { directionPlan: currentDirectionPlan } = await storage.get('directionPlan');\n const directionPlan = buildDirectionPlan(profile, currentDirectionPlan, {\n confirmed: false,\n preserveSelections: true,\n preserveEdits: true,\n preserveCustom: true\n });\n await storage.set({ profile, profileDraft, directionPlan });\n await writeEvent('info', '已从已保存简历恢复可编辑画像', { directions: profile.primaryDirections });\n reply({ ok: true, profile, profileDraft, directionPlan, generation: profile.generation });\n break;\n }\n case 'BUILD_PROFILE': {\n const { resumeText, directionPlan: currentDirectionPlan } = await storage.get(['resumeText', 'directionPlan']);\n const profile = await buildProfile(resumeText || '');\n const profileDraft = profileToDraft(profile, profile.generation?.mode || 'generated');\n const directionPlan = buildDirectionPlan(profile, currentDirectionPlan, {\n confirmed: false,\n preserveSelections: true,\n preserveEdits: true,\n preserveCustom: true\n });\n await storage.set({ profile, profileDraft, directionPlan });\n const generation = profile.generation || {};\n const local = generation.mode === 'local-fallback';\n const eventTitle = !local\n ? (generation.mode === 'ai-compact-retry' ? 'AI 精简重试成功,职业画像已生成' : '职业画像已生成')\n : generation.aiStatus === 'service-error'\n ? 'AI 请求失败,已生成本地可编辑画像'\n : generation.aiStatus === 'config-missing'\n ? 'AI 尚未配置,已生成本地可编辑画像'\n : 'AI 输出未通过校验,已生成本地可编辑画像';\n await writeEvent(local ? 'warning' : 'info', eventTitle,\n { directions: profile.primaryDirections, generation });\n reply({ ok: true, profile, profileDraft, directionPlan, generation, warning: generation.warning || '' });\n break;\n }\n case 'SAVE_PROFILE_DRAFT': {\n const { profileDraft: currentDraft } = await storage.get('profileDraft');\n const profileDraft = normalizeProfileDraft(message.profileDraft, currentDraft);\n await storage.set({ profileDraft });\n reply({ ok: true, profileDraft });\n break;\n }\n case 'SAVE_PROFILE': {\n const { profile: currentProfile, config = {}, profileDraft: currentDraft, directionPlan: currentDirectionPlan } = await storage.get(['profile', 'config', 'profileDraft', 'directionPlan']);\n const profile = normalizeProfile(message.profile, currentProfile);\n const profileDraft = profileToDraft(profile, 'manual-save');\n const keepDirectionConfirmation = Boolean(\n currentDirectionPlan?.confirmed\n && profileDirectionSignature(currentProfile || {}) === profileDirectionSignature(profile)\n );\n const directionPlan = buildDirectionPlan(profile, currentDirectionPlan, {\n confirmed: keepDirectionConfirmation,\n preserveSelections: true,\n preserveEdits: true,\n preserveCustom: true\n });\n const hard = profile.hardConstraints || {};\n const nextConfig = {\n ...config,\n targetLocations: hard.locations?.length ? hard.locations : (config.targetLocations || []),\n employmentTypes: hard.employmentTypes?.length ? hard.employmentTypes : (config.employmentTypes || ['不限']),\n experiences: hard.experience ? [hard.experience] : (config.experiences || []),\n degrees: hard.degree ? [hard.degree] : (config.degrees || []),\n salary: hard.salary || config.salary || '不限'\n };\n await storage.set({ profile, profileDraft, directionPlan, config: nextConfig });\n await writeEvent('info', '职业画像已手动保存', { directions: profile.primaryDirections });\n reply({ ok: true, profile, profileDraft, directionPlan, config: nextConfig });\n break;\n }\n case 'REBUILD_DIRECTION_PLAN': {\n const stored = await storage.get(['profile', 'profileDraft', 'directionPlan']);\n let profile = stored.profile;\n if (!profileHasCore(profile) && profileDraftHasCore(stored.profileDraft)) {\n profile = profileFromDraft(stored.profileDraft, profile);\n await storage.set({ profile });\n }\n if (!profileHasCore(profile)) throw new Error('请先生成职业画像');\n const directionPlan = buildDirectionPlan(profile, stored.directionPlan, {\n confirmed: false,\n preserveSelections: false,\n preserveEdits: false,\n preserveCustom: true\n });\n await storage.set({ directionPlan });\n await writeEvent('info', '已重新生成岗位方向推荐', {\n directions: directionPlan.items.map(item => item.name),\n enabled: directionPlan.items.filter(item => item.enabled).length\n });\n reply({ ok: true, directionPlan });\n break;\n }\n case 'SAVE_DIRECTION_PLAN': {\n const stored = await storage.get(['profile', 'config', 'workflow', 'directionPlan']);\n if (!profileHasCore(stored.profile)) throw new Error('请先生成职业画像');\n const directionPlan = normalizeDirectionPlan(message.directionPlan, stored.profile, {\n confirmed: true,\n updatedAt: Date.now(),\n appliedAt: Date.now()\n });\n const selected = selectedDirectionItems(directionPlan);\n if (!selected.length) throw new Error('至少选择一个要投递的岗位方向');\n const tasks = createTasks(stored.profile, stored.config || DEFAULTS.config, directionPlan);\n if (!tasks.length) throw new Error('所选岗位方向没有可用搜索词,请先补充关键词');\n await storage.set({ directionPlan });\n await writeEvent('info', '投递岗位方向已保存', {\n directions: selected.map(item => item.name),\n taskCount: tasks.length,\n appliesNextRun: Boolean(stored.workflow?.running)\n });\n reply({\n ok: true,\n directionPlan,\n selectedCount: selected.length,\n taskCount: tasks.length,\n appliesNextRun: Boolean(stored.workflow?.running)\n });\n break;\n }\n case 'TEST_AI': {\n const text = await callModel([\n { role: 'system', content: '只回复“连接正常”。' },\n { role: 'user', content: '测试连接' }\n ], false);\n reply({ ok: true, text });\n break;\n }\n case 'PROBE_BOSS': {\n const tab = await activeBossTab();\n if (!tab) throw new Error('请先打开并登录 BOSS 直聘');\n const result = await ensureBossReceiver(tab);\n reply({ ok: true, result: { ...result, tabId: tab.id, title: tab.title || '', url: tab.url || '' } });\n break;\n }\n case 'START': {\n const stored = await storage.get(['profile', 'profileDraft', 'resumeText', 'config', 'directionPlan']);\n let profile = stored.profile;\n const config = stored.config;\n if (!profileHasCore(profile) && profileDraftHasCore(stored.profileDraft)) {\n profile = profileFromDraft(stored.profileDraft, profile);\n await storage.set({ profile });\n }\n if (!profileHasCore(profile) && String(stored.resumeText || '').trim().length >= 30) {\n profile = buildLocalProfile(stored.resumeText || '');\n profile.generation.mode = 'local-recovery';\n profile.generation.label = '本地初稿';\n const profileDraft = profileToDraft(profile, 'start-recovery');\n await storage.set({ profile, profileDraft });\n }\n if (!profileHasCore(profile)) throw new Error('请先生成职业画像');\n let directionPlan = stored.directionPlan;\n if (!directionPlan?.items?.length) {\n directionPlan = buildDirectionPlan(profile, null, { confirmed: false });\n await storage.set({ directionPlan });\n }\n directionPlan = normalizeDirectionPlan(directionPlan, profile);\n if (!directionPlan.confirmed) throw new Error('请先在“简历 → 职业画像”中选择要投递的岗位方向并保存');\n if (!selectedDirectionItems(directionPlan).length) throw new Error('至少选择一个要投递的岗位方向');\n const tasks = createTasks(profile, config, directionPlan);\n if (!tasks.length) throw new Error('所选岗位方向没有可执行的搜索词,请补充方向关键词');\n const tab = await activeBossTab();\n if (!tab) throw new Error('请先打开并登录 BOSS 直聘');\n await ensureBossReceiver(tab);\n const workflow = {\n ...DEFAULTS.workflow,\n running: true,\n paused: false,\n phase: 'search',\n statusText: '正在启动岗位采集',\n tasks,\n activeRunId: null\n };\n await storage.set({ workflow });\n try {\n await sendToBossTab(tab, { type: 'RUN' });\n await writeEvent('info', '任务已启动', { taskCount: workflow.tasks.length, tabId: tab.id });\n reply({ ok: true });\n } catch (error) {\n await patchWorkflow({\n running: false,\n paused: true,\n phase: 'idle',\n statusText: 'BOSS 页面连接失败,任务未启动'\n });\n throw error;\n }\n break;\n }\n case 'PAUSE':\n await patchWorkflow({ paused: true, statusText: '用户已暂停' });\n await writeEvent('info', '任务已暂停');\n reply({ ok: true });\n break;\n case 'STOP':\n await patchWorkflow({ running: false, paused: true, phase: 'idle', statusText: '用户已停止', pendingApplyId: null, activeRunId: null });\n await writeEvent('info', '任务已停止');\n reply({ ok: true });\n break;\n case 'AI_JOB':\n reply({ ok: true, result: await analyzeJob(message.job) });\n break;\n case 'CHAT_BINDING_PREPARE': {\n if (!message.pendingId || !message.expected) throw new Error('岗位与 HR 会话绑定信息不完整');\n const transition = {\n pendingId: String(message.pendingId),\n job: safeClone(message.job || {}),\n expected: safeClone(message.expected || {}),\n preparedAt: Date.now()\n };\n await storage.set({ chatTransition: transition });\n reply({ ok: true, transition });\n break;\n }\n case 'CHAT_BINDING_CHECK': {\n const expected = message.expected || {};\n const recruiterName = String(message.context?.recruiterName || expected.recruiterName || '').trim();\n const company = String(message.context?.companyName || expected.company || message.job?.company || '').trim();\n const key = deriveConversationReservationKey(message.context || {}, expected, message.pendingId);\n if (!key) {\n reply({ ok: false, allowed: false, error: '目标 HR 已核验,但无法建立安全投递锁' });\n break;\n }\n const { chatDeliveryLedger = {} } = await storage.get('chatDeliveryLedger');\n const existing = chatDeliveryLedger[key];\n const identityExisting = Object.values(chatDeliveryLedger).find(entry =>\n entry?.pendingId !== message.pendingId\n && ['reserved', 'sent'].includes(String(entry?.status || ''))\n && sameRecruiterReservation(entry, recruiterName, company)\n );\n const conflict = existing && existing.pendingId !== message.pendingId ? existing : identityExisting;\n if (conflict) {\n reply({\n ok: true,\n allowed: false,\n error: `当前 HR 会话已绑定其他岗位任务,禁止继续发送`,\n existing: conflict\n });\n break;\n }\n const reservation = {\n ...(existing || {}),\n conversationKey: key,\n pendingId: String(message.pendingId || ''),\n runId: String(message.runId || ''),\n jobUrl: String(message.job?.url || ''),\n jobTitle: String(message.job?.title || ''),\n company: String(message.job?.company || ''),\n recruiterName,\n company,\n chatUrl: String(message.context?.url || ''),\n status: existing?.status === 'sent' ? 'sent' : 'reserved',\n reservedAt: existing?.reservedAt || Date.now(),\n updatedAt: Date.now()\n };\n await storage.set({ chatDeliveryLedger: { ...chatDeliveryLedger, [key]: reservation } });\n reply({ ok: true, allowed: true, conversationKey: key, reservation });\n break;\n }\n case 'CHAT_BINDING_CONFIRMED': {\n const transition = {\n pendingId: String(message.pendingId || ''),\n job: safeClone(message.job || {}),\n expected: safeClone(message.expected || {}),\n context: safeClone(message.context || {}),\n confirmedAt: Date.now()\n };\n await storage.set({ chatTransition: transition });\n reply({ ok: true, transition });\n break;\n }\n case 'MATERIAL': {\n const { resumeImage, config } = await storage.get(['resumeImage', 'config']);\n reply({ ok: true, resumeImage, config });\n break;\n }\n case 'CONTENT_STATE':\n reply({ ok: true, state: await storage.all() });\n break;\n case 'WORKFLOW':\n reply({ ok: true, workflow: await patchWorkflow(message.patch || {}) });\n break;\n case 'STATS':\n reply({ ok: true, stats: await changeStats(message.delta || {}) });\n break;\n case 'TASK_PROGRESS': {\n const run = await upsertTaskRun(message.run || {});\n if (message.setActive) await patchWorkflow({ activeRunId: run.id });\n reply({ ok: true, run });\n break;\n }\n case 'SEARCH_TASK_PROGRESS':\n reply({ ok: true, task: await updateSearchTaskProgress(message) });\n break;\n case 'RETRY_FAILED_TASK':\n reply({ ok: true, ...(await retryFailedTask(message.runId)) });\n break;\n case 'RETRY_ALL_FAILED_TASKS':\n reply({ ok: true, ...(await retryAllFailedTasks()) });\n break;\n case 'IGNORE_FAILED_TASK':\n reply({ ok: true, run: await ignoreFailedTask(message.runId) });\n break;\n case 'OPEN_TASK_JOB': {\n const url = String(message.url || '').trim();\n if (!/^https:\\/\\/(?:www|app)\\.zhipin\\.com\\//i.test(url)) throw new Error('岗位地址无效');\n const tab = await chrome.tabs.create({ url, active: true });\n reply({ ok: true, tabId: tab.id });\n break;\n }\n case 'EVENT':\n reply({ ok: true, event: await writeEvent(message.level || 'info', message.message || '', message.data || {}) });\n break;\n case 'PENDING':\n reply({ ok: true, item: await addPending(message.item || {}) });\n break;\n case 'APPROVE':\n reply({ ok: true, item: await approvePending(message.id, message.greeting || '') });\n break;\n case 'REJECT':\n await rejectPending(message.id);\n reply({ ok: true });\n break;\n case 'APPROVE_ALL':\n reply({ ok: true, ...(await approveAllPending()) });\n break;\n case 'REJECT_ALL':\n reply({ ok: true, ...(await rejectAllPending()) });\n break;\n case 'AUTO_APPROVE': {\n const { pending = [] } = await storage.get('pending');\n const next = rerankPending(pending.map(entry => entry.id === message.id\n ? {\n ...entry,\n deliveryGreeting: String(message.greeting || entry.deliveryGreeting || entry.analysis?.greeting || '').trim(),\n status: 'approved_queue',\n approvedAt: Date.now()\n }\n : entry));\n await storage.set({ pending: next });\n reply({ ok: true, ...(await dispatchNextAutoPending()) });\n break;\n }\n case 'AUTO_DISPATCH_NEXT':\n reply({ ok: true, ...(await dispatchNextAutoPending()) });\n break;\n case 'SKIP_PENDING':\n reply({ ok: true, ...(await skipPendingTask(message.id, message.reason, message.stageLabel)) });\n break;\n case 'APPLY_COMPLETE': {\n const { pending = [], config = {}, stats = {}, chatDeliveryLedger = {}, chatTransition = null } = await storage.get(['pending', 'config', 'stats', 'chatDeliveryLedger', 'chatTransition']);\n const completedAt = Date.now();\n let completedItem = null;\n let next = pending.map(entry => {\n if (entry.id !== message.id) return entry;\n completedItem = { ...entry, status: message.ok ? 'sent' : 'failed', completedAt, error: message.error || '' };\n return completedItem;\n });\n const updatedStats = {\n ...stats,\n pending: next.filter(entry => entry.status === 'pending').length,\n sent: Number(stats?.sent || 0) + (message.ok ? 1 : 0),\n failed: Number(stats?.failed || 0) + (message.ok ? 0 : 1)\n };\n const conversationKey = String(message.conversation?.key || '');\n let nextLedger = chatDeliveryLedger;\n if (conversationKey && chatDeliveryLedger[conversationKey]?.pendingId === message.id) {\n nextLedger = {\n ...chatDeliveryLedger,\n [conversationKey]: {\n ...chatDeliveryLedger[conversationKey],\n status: message.ok ? 'sent' : 'failed',\n sentAt: message.ok ? completedAt : (chatDeliveryLedger[conversationKey].sentAt || null),\n failedAt: message.ok ? null : completedAt,\n error: message.error || '',\n updatedAt: completedAt\n }\n };\n }\n const nextTransition = chatTransition?.pendingId === message.id ? null : chatTransition;\n await storage.set({ pending: next, stats: updatedStats, chatDeliveryLedger: nextLedger, chatTransition: nextTransition });\n const completedRun = await updateTaskRunByPending(message.id, message.ok\n ? {\n status: 'success', stage: 'success', progress: 100, stageLabel: '投递成功', error: '',\n retryable: false, completedAt, result: message.result || 'sent'\n }\n : {\n status: 'failed', stage: message.stage || 'failed', progress: 100,\n stageLabel: message.stageLabel || '投递失败', error: message.error || '投递失败',\n retryable: message.retryable !== false, retryRequestedAt: 0, completedAt\n });\n await writeEvent(message.ok ? 'success' : 'error', message.ok ? '求职投递成功' : '求职投递失败', {\n id: message.id,\n runId: completedRun?.id || completedItem?.runId || '',\n job: completedItem?.job,\n stage: message.stage || '',\n error: message.error || ''\n });\n\n const validationPending = message.ok\n && config.executionMode === 'auto'\n && config.requireSingleJobValidation !== false\n && !Number(config.singleJobValidationCompletedAt || 0);\n if (validationPending) {\n const nextConfig = { ...config, singleJobValidationCompletedAt: completedAt };\n await storage.set({ config: nextConfig });\n await patchWorkflow({\n running: false,\n paused: true,\n pendingApplyId: null,\n activeRunId: null,\n phase: 'idle',\n statusText: '单条验收已通过并自动暂停;确认聊天文字和附件后可继续批量'\n });\n await writeEvent('success', '首次单条投递验收通过', {\n id: message.id,\n runId: completedRun?.id || completedItem?.runId || '',\n job: completedItem?.job,\n completedAt\n });\n reply({ ok: true, validationCompleted: true, pausedForValidation: true });\n break;\n }\n\n if (updatedStats.sent >= Number(config.dailyTarget || 150)) {\n await patchWorkflow({ running: false, paused: true, pendingApplyId: null, activeRunId: null, phase: 'idle', statusText: `已完成今日 ${updatedStats.sent} 次成功投递` });\n reply({ ok: true, targetReached: true });\n break;\n }\n\n // 任何文字未确认场景都暂停当前队列,但只有输入框中确实存在完整招呼语时,\n // 才提示“草稿已保留”。输入框为空时必须明确提示写入失败,不能误导用户。\n if (!message.ok && message.pauseQueue) {\n const draftPresent = message.preserveDraft === true || message.draftPresent === true;\n await patchWorkflow({\n running: false,\n paused: true,\n pendingApplyId: null,\n activeRunId: completedRun?.id || completedItem?.runId || null,\n phase: 'apply',\n statusText: draftPresent\n ? '发送未确认,已暂停队列;当前招呼语仍在输入框'\n : '文字没有写入或没有发送,已暂停队列并停留在当前 HR 页面'\n });\n await writeEvent('warning', draftPresent\n ? '自动投递已暂停,当前招呼语仍保留在输入框'\n : '自动投递已暂停,聊天输入框中没有完整招呼语', {\n id: message.id,\n runId: completedRun?.id || completedItem?.runId || '',\n job: completedItem?.job,\n draftPresent,\n editorDiagnostics: message.editorDiagnostics || null,\n error: message.error || ''\n });\n reply({ ok: true, pausedForDraft: draftPresent, pausedForSendFailure: true });\n break;\n }\n\n next = rerankPending(next);\n const queued = next.find(entry => entry.status === 'approved_queue');\n if (queued) {\n next = next.map(entry => entry.id === queued.id ? { ...entry, status: 'approved' } : entry);\n await storage.set({ pending: next });\n const remaining = next.filter(entry => entry.status === 'approved_queue').length;\n const queuedRun = await updateTaskRunByPending(queued.id, {\n status: 'running', stage: 'queued', progress: 64, stageLabel: '准备投递', error: '', completedAt: null\n });\n await patchWorkflow({\n pendingApplyId: queued.id,\n activeRunId: queuedRun?.id || queued.runId || null,\n running: true,\n paused: false,\n phase: 'apply',\n statusText: `继续批量投递:${queued.job?.title || '岗位'}(剩余 ${remaining})`\n });\n const pacingMs = Math.max(3000, Math.min(30000, Number(config.betweenJobsSeconds || 12) * 1000));\n await patchWorkflow({ statusText: `当前岗位处理完成,${Math.round(pacingMs / 1000)} 秒后继续批量投递` });\n setTimeout(() => sendToBoss({ type: 'RUN' }).catch(() => {}), pacingMs);\n } else {\n const pacingMs = Math.max(3000, Math.min(30000, Number(config.betweenJobsSeconds || 12) * 1000));\n await patchWorkflow({ pendingApplyId: null, activeRunId: null, phase: 'search', statusText: message.ok ? `文字投递已确认,${Math.round(pacingMs / 1000)} 秒后继续筛选` : `当前岗位失败,${Math.round(pacingMs / 1000)} 秒后继续筛选` });\n setTimeout(() => sendToBoss({ type: 'RUN' }).catch(() => {}), pacingMs);\n }\n reply({ ok: true });\n break;\n }\n case 'BRIDGE_STATUS':\n try { reply({ ok: true, result: await bridge('/status') }); }\n catch (error) { reply({ ok: false, error: error.message }); }\n break;\n case 'BRIDGE_REPORT':\n try { reply({ ok: true, result: await bridge('/report') }); }\n catch (error) { reply({ ok: false, error: error.message }); }\n break;\n case 'BRIDGE_COMMAND':\n try { reply({ ok: true, result: await bridge('/command', { type: message.command }) }); }\n catch (error) { reply({ ok: false, error: error.message }); }\n break;\n default:\n reply({ ok: false, error: 'unknown message' });\n }\n })().catch(async error => {\n await writeEvent('error', '后台处理失败', { type: message?.type, error: error.message });\n reply({ ok: false, error: error.message });\n });\n return true;\n});\n"}, "7e303eedb66fd66f0fe17c3e1630a2b248a4d214": {"t": 1785758655.0987291, "n": 135960, "h": "95b879215480f056", "c": "import { DEFAULTS, safeClone, today, uniq } from './common.js';\n\nconst BRIDGE_ENDPOINTS = ['http://127.0.0.1:17899', 'http://localhost:17899'];\nconst EXPECTED_CONTENT_VERSION = '1.2.37';\nconst CONTENT_SCRIPT_FILE = 'content-v37.js';\nconst storage = {\n get: keys => chrome.storage.local.get(keys),\n all: () => chrome.storage.local.get(null),\n set: patch => chrome.storage.local.set(patch)\n};\n\nfunction normalizeConversationIdentity(value) {\n return String(value || '')\n .replace(/\\s+/g, '')\n .replace(/有限责任公司|股份有限公司|有限公司|招聘者|招聘方|人事行政|人事|hr|在线|刚刚活跃|活跃/gi, '')\n .replace(/[()()【】\\[\\]<>《》,,。.::;;_\\-—·•||]/g, '')\n .trim()\n .toLowerCase();\n}\n\nfunction deriveConversationReservationKey(context = {}, expected = {}, pendingId = '') {\n const recruiterName = String(context.recruiterName || expected.recruiterName || '').trim();\n const company = String(context.companyName || expected.company || '').trim();\n const recruiterKey = normalizeConversationIdentity(recruiterName).slice(0, 80);\n const companyKey = normalizeConversationIdentity(company).slice(0, 100);\n if (recruiterKey) return `hr:${recruiterKey}${companyKey ? `|company:${companyKey}` : ''}`;\n\n const token = String(context.urlToken || '').trim();\n if (/conversationid=|chatid=|relationid=|bossid=|uid=/i.test(token)) return `chat:${token}`;\n const observed = normalizeConversationIdentity(`${context.headerText || ''} ${context.selectedText || ''}`).slice(0, 160);\n if (observed) return `observed:${observed}`;\n return pendingId ? `task:${String(pendingId)}` : '';\n}\n\nfunction sameRecruiterReservation(entry = {}, recruiterName = '', company = '') {\n const leftRecruiter = normalizeConversationIdentity(entry.recruiterName);\n const rightRecruiter = normalizeConversationIdentity(recruiterName);\n if (!leftRecruiter || !rightRecruiter || leftRecruiter !== rightRecruiter) return false;\n const leftCompany = normalizeConversationIdentity(entry.company);\n const rightCompany = normalizeConversationIdentity(company);\n // 两边公司都明确且不一致时,允许同名 HR;任一侧公司缺失时按安全策略视为同一会话。\n return !(leftCompany && rightCompany && leftCompany !== rightCompany);\n}\n\nconst debuggerLocks = new Map();\n\nconst OFFSCREEN_DOCUMENT_PATH = 'offscreen.html';\nlet offscreenCreation = null;\n\nasync function ensureClipboardOffscreen() {\n if (!chrome.offscreen?.createDocument) return false;\n const url = chrome.runtime.getURL(OFFSCREEN_DOCUMENT_PATH);\n if (chrome.runtime.getContexts) {\n const contexts = await chrome.runtime.getContexts({\n contextTypes: ['OFFSCREEN_DOCUMENT'],\n documentUrls: [url]\n }).catch(() => []);\n if (contexts?.length) return true;\n }\n if (!offscreenCreation) {\n offscreenCreation = chrome.offscreen.createDocument({\n url: OFFSCREEN_DOCUMENT_PATH,\n reasons: ['CLIPBOARD'],\n justification: '将求职招呼语以真实粘贴方式写入 BOSS 聊天编辑器'\n }).then(() => true).catch(error => {\n const message = String(error?.message || error || '');\n if (/single offscreen|already exists|existing offscreen/i.test(message)) return true;\n throw error;\n }).finally(() => { offscreenCreation = null; });\n }\n return offscreenCreation;\n}\n\nasync function writeClipboardText(text) {\n const value = String(text || '');\n if (!value) return false;\n try {\n const ready = await ensureClipboardOffscreen();\n if (!ready) return false;\n const response = await chrome.runtime.sendMessage({ type: 'JOBCLAW_CLIPBOARD_WRITE', text: value });\n return response?.ok === true;\n } catch {\n return false;\n }\n}\n\nfunction validPoint(point) {\n const x = Number(point?.x);\n const y = Number(point?.y);\n if (!Number.isFinite(x) || !Number.isFinite(y) || x < 0 || y < 0) {\n throw new Error('聊天输入坐标无效');\n }\n return { x, y };\n}\n\nasync function debuggerBringToFront(target) {\n await chrome.debugger.sendCommand(target, 'Page.bringToFront').catch(() => {});\n await chrome.debugger.sendCommand(target, 'Runtime.evaluate', {\n expression: 'window.focus(); true',\n returnByValue: true\n }).catch(() => {});\n}\n\nasync function debuggerNodeAtPoint(target, point) {\n const { x, y } = validPoint(point);\n const result = await chrome.debugger.sendCommand(target, 'DOM.getNodeForLocation', {\n x, y,\n includeUserAgentShadowDOM: true,\n ignorePointerEventsNone: true\n }).catch(() => null);\n return {\n nodeId: Number(result?.nodeId || 0),\n backendNodeId: Number(result?.backendNodeId || 0),\n frameId: String(result?.frameId || '')\n };\n}\n\nasync function debuggerResolveNodeObject(target, node = {}) {\n const args = {};\n if (Number(node.nodeId || 0)) args.nodeId = Number(node.nodeId);\n else if (Number(node.backendNodeId || 0)) args.backendNodeId = Number(node.backendNodeId);\n else return '';\n const resolved = await chrome.debugger.sendCommand(target, 'DOM.resolveNode', args).catch(() => null);\n return String(resolved?.object?.objectId || '');\n}\n\nasync function debuggerReadObjectText(target, objectId = '') {\n if (!objectId) return '';\n const result = await chrome.debugger.sendCommand(target, 'Runtime.callFunctionOn', {\n objectId,\n functionDeclaration: `function() {\n if ('value' in this && !this.isContentEditable) return String(this.value || '');\n return String(this.innerText || this.textContent || '');\n }`,\n returnByValue: true,\n silent: true\n }).catch(() => null);\n return String(result?.result?.value || '');\n}\n\nasync function debuggerReadNodeText(target, node = {}) {\n const objectId = await debuggerResolveNodeObject(target, node);\n return debuggerReadObjectText(target, objectId);\n}\n\nasync function debuggerFindEditableObject(target, message = {}) {\n const selector = String(message.selector || '').trim();\n const point = validPoint(message.point);\n const expression = `(() => {\n const exactSelector = ${JSON.stringify(selector)};\n const point = ${JSON.stringify(point)};\n const editableSelector = 'textarea,input[type=\"text\"],input:not([type]),[contenteditable]:not([contenteditable=\"false\"]),[role=\"textbox\"],[data-slate-editor=\"true\"],[data-lexical-editor=\"true\"]';\n const isEditable = node => {\n if (!node || node.nodeType !== 1) return false;\n const tag = String(node.tagName || '').toLowerCase();\n if (tag === 'textarea') return true;\n if (tag === 'input') {\n const type = String(node.getAttribute('type') || 'text').toLowerCase();\n return ['text', 'search', ''].includes(type);\n }\n const mode = String(node.getAttribute('contenteditable') || '').toLowerCase();\n return node.isContentEditable\n || Boolean(mode && !['false', 'inherit', 'off'].includes(mode))\n || node.getAttribute('role') === 'textbox'\n || node.getAttribute('data-slate-editor') === 'true'\n || node.getAttribute('data-lexical-editor') === 'true';\n };\n const deepQuery = (root, query) => {\n if (!root) return null;\n try {\n const direct = root.querySelector?.(query);\n if (direct) return direct;\n } catch {}\n let elements = [];\n try { elements = [...(root.querySelectorAll?.('*') || [])]; } catch {}\n for (const element of elements) {\n if (element.shadowRoot) {\n const found = deepQuery(element.shadowRoot, query);\n if (found) return found;\n }\n }\n return null;\n };\n const nearestEditable = node => {\n let current = node;\n for (let depth = 0; current && depth < 16; depth += 1) {\n if (isEditable(current)) return current;\n let nested = null;\n try { nested = current.querySelector?.(editableSelector); } catch {}\n if (nested && isEditable(nested)) return nested;\n const root = current.getRootNode?.();\n current = current.parentElement || (root && root.host) || null;\n }\n return null;\n };\n let node = exactSelector ? deepQuery(document, exactSelector) : null;\n node = nearestEditable(node) || node;\n if (!isEditable(node)) {\n let pointNode = null;\n try { pointNode = document.elementFromPoint(point.x, point.y); } catch {}\n node = nearestEditable(pointNode);\n }\n if (!isEditable(node)) return null;\n try { node.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'instant' }); } catch {}\n try { node.focus({ preventScroll: true }); } catch { try { node.focus(); } catch {} }\n if (node.isContentEditable) {\n try {\n const selection = window.getSelection();\n const range = document.createRange();\n range.selectNodeContents(node);\n range.collapse(false);\n selection.removeAllRanges();\n selection.addRange(range);\n } catch {}\n }\n return node;\n })()`;\n const result = await chrome.debugger.sendCommand(target, 'Runtime.evaluate', {\n expression,\n returnByValue: false,\n awaitPromise: false,\n includeCommandLineAPI: false\n }).catch(() => null);\n const objectId = String(result?.result?.objectId || '');\n if (!objectId) return { objectId: '', nodeId: 0, point, selector };\n const requested = await chrome.debugger.sendCommand(target, 'DOM.requestNode', { objectId }).catch(() => null);\n const nodeId = Number(requested?.nodeId || 0);\n return { objectId, nodeId, point, selector };\n}\n\nasync function debuggerFocusObject(target, objectId = '') {\n if (!objectId) return null;\n const result = await chrome.debugger.sendCommand(target, 'Runtime.callFunctionOn', {\n objectId,\n functionDeclaration: `function() {\n try { this.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'instant' }); } catch {}\n try { this.focus({ preventScroll: true }); } catch { try { this.focus(); } catch {} }\n if (this.isContentEditable) {\n try {\n const selection = window.getSelection();\n const range = document.createRange();\n range.selectNodeContents(this);\n range.collapse(false);\n selection.removeAllRanges();\n selection.addRange(range);\n } catch {}\n }\n const rect = this.getBoundingClientRect();\n return { left: rect.left, top: rect.top, width: rect.width, height: rect.height, tag: this.tagName || '', id: this.id || '' };\n }`,\n returnByValue: true,\n silent: true\n }).catch(() => null);\n return result?.result?.value || null;\n}\n\nasync function debuggerPasteClipboard(target) {\n await chrome.debugger.sendCommand(target, 'Input.dispatchKeyEvent', {\n type: 'rawKeyDown',\n key: 'v',\n code: 'KeyV',\n windowsVirtualKeyCode: 86,\n nativeVirtualKeyCode: 86,\n modifiers: 4,\n commands: ['Paste']\n });\n await chrome.debugger.sendCommand(target, 'Input.dispatchKeyEvent', {\n type: 'keyUp',\n key: 'v',\n code: 'KeyV',\n windowsVirtualKeyCode: 86,\n nativeVirtualKeyCode: 86,\n modifiers: 4\n });\n}\n\nasync function withTabDebugger(tabId, operation) {\n if (!Number.isInteger(Number(tabId))) throw new Error('无法确定当前 BOSS 标签页');\n const id = Number(tabId);\n const previous = debuggerLocks.get(id) || Promise.resolve();\n let release;\n const current = new Promise(resolve => { release = resolve; });\n const queueEntry = previous.then(() => current);\n debuggerLocks.set(id, queueEntry);\n await previous;\n const target = { tabId: id };\n let attached = false;\n try {\n await chrome.debugger.attach(target, '1.3');\n attached = true;\n await debuggerBringToFront(target);\n return await operation(target);\n } catch (error) {\n const message = String(error?.message || error || '');\n if (/Another debugger is already attached|Cannot attach to this target|DevTools/i.test(message)) {\n throw new Error('Chrome 调试通道正被 DevTools 或其他工具占用,请关闭该 BOSS 页面的开发者工具后重试');\n }\n throw error;\n } finally {\n if (attached) await chrome.debugger.detach(target).catch(() => {});\n release?.();\n if (debuggerLocks.get(id) === queueEntry) debuggerLocks.delete(id);\n }\n}\n\nasync function debuggerMouseClick(target, point) {\n const { x, y } = validPoint(point);\n await chrome.debugger.sendCommand(target, 'Input.dispatchMouseEvent', {\n type: 'mouseMoved', x, y, button: 'none', buttons: 0\n });\n await chrome.debugger.sendCommand(target, 'Input.dispatchMouseEvent', {\n type: 'mousePressed', x, y, button: 'left', buttons: 1, clickCount: 1\n });\n await chrome.debugger.sendCommand(target, 'Input.dispatchMouseEvent', {\n type: 'mouseReleased', x, y, button: 'left', buttons: 0, clickCount: 1\n });\n}\n\nasync function debuggerDocumentNode(target) {\n const result = await chrome.debugger.sendCommand(target, 'DOM.getDocument', {\n depth: -1,\n pierce: true\n });\n const nodeId = Number(result?.root?.nodeId || 0);\n if (!nodeId) throw new Error('无法读取当前 BOSS 页面 DOM');\n return nodeId;\n}\n\nasync function debuggerNodeBySelector(target, selector) {\n const normalized = String(selector || '').trim();\n if (!normalized) return 0;\n const rootNodeId = await debuggerDocumentNode(target);\n const result = await chrome.debugger.sendCommand(target, 'DOM.querySelector', {\n nodeId: rootNodeId,\n selector: normalized\n });\n return Number(result?.nodeId || 0);\n}\n\nasync function debuggerFocusTarget(target, message = {}) {\n await debuggerBringToFront(target);\n\n // 必须优先锁定 content script 临时标记的真实编辑节点。旧版先取坐标节点,\n // BOSS 页面上经常命中编辑器外壳/透明层,导致看似有光标但文字没有进入编辑器。\n const exact = await debuggerFindEditableObject(target, message).catch(() => ({ objectId: '', nodeId: 0 }));\n if (exact.objectId) {\n const rect = await debuggerFocusObject(target, exact.objectId);\n if (rect && Number(rect.width) > 0 && Number(rect.height) > 0) {\n const clickPoint = {\n x: Math.round(Number(rect.left) + Math.max(8, Math.min(Number(rect.width) - 8, Number(rect.width) * 0.5))),\n y: Math.round(Number(rect.top) + Math.max(8, Math.min(Number(rect.height) - 8, Number(rect.height) * 0.5)))\n };\n await debuggerMouseClick(target, clickPoint).catch(() => {});\n await debuggerFocusObject(target, exact.objectId);\n return { method: 'exact-selector-object-focus+click', ...exact, point: clickPoint, rect };\n }\n return { method: 'exact-selector-object-focus', ...exact, rect };\n }\n\n const pointNode = await debuggerNodeAtPoint(target, message.point).catch(() => ({ nodeId: 0, backendNodeId: 0 }));\n if (pointNode.nodeId || pointNode.backendNodeId) {\n const args = pointNode.nodeId ? { nodeId: pointNode.nodeId } : { backendNodeId: pointNode.backendNodeId };\n await chrome.debugger.sendCommand(target, 'DOM.focus', args).catch(() => {});\n await debuggerMouseClick(target, message.point).catch(() => {});\n const objectId = await debuggerResolveNodeObject(target, pointNode).catch(() => '');\n return { method: 'point-node-focus+click', ...pointNode, objectId };\n }\n\n await debuggerMouseClick(target, message.point);\n return { method: 'coordinate-click', nodeId: 0, objectId: '' };\n}\n\nasync function debuggerClickTarget(target, message = {}) {\n const selector = String(message.selector || '').trim();\n if (selector) {\n const nodeId = await debuggerNodeBySelector(target, selector);\n if (nodeId) {\n await chrome.debugger.sendCommand(target, 'DOM.scrollIntoViewIfNeeded', { nodeId }).catch(() => {});\n const model = await chrome.debugger.sendCommand(target, 'DOM.getBoxModel', { nodeId });\n const quad = model?.model?.content || model?.model?.border || [];\n if (Array.isArray(quad) && quad.length >= 8) {\n const xs = [quad[0], quad[2], quad[4], quad[6]].map(Number);\n const ys = [quad[1], quad[3], quad[5], quad[7]].map(Number);\n await debuggerMouseClick(target, {\n x: xs.reduce((sum, value) => sum + value, 0) / xs.length,\n y: ys.reduce((sum, value) => sum + value, 0) / ys.length\n });\n return { method: 'dom-box-click', nodeId };\n }\n }\n }\n await debuggerMouseClick(target, message.point);\n return { method: 'coordinate-click', nodeId: 0 };\n}\n\nasync function debuggerReadTargetText(target, selector) {\n const normalized = String(selector || '').trim();\n if (!normalized) return '';\n const expression = `(() => {\n const element = document.querySelector(${JSON.stringify(normalized)});\n if (!element) return '';\n if ('value' in element) return String(element.value || '');\n return String(element.innerText || element.textContent || '');\n })()`;\n const result = await chrome.debugger.sendCommand(target, 'Runtime.evaluate', {\n expression,\n returnByValue: true,\n awaitPromise: false\n });\n return String(result?.result?.value || '');\n}\n\nasync function debuggerSelectAll(target) {\n await chrome.debugger.sendCommand(target, 'Input.dispatchKeyEvent', {\n type: 'rawKeyDown', key: 'a', code: 'KeyA', windowsVirtualKeyCode: 65,\n nativeVirtualKeyCode: 65, modifiers: 4, commands: ['SelectAll']\n });\n await chrome.debugger.sendCommand(target, 'Input.dispatchKeyEvent', {\n type: 'keyUp', key: 'a', code: 'KeyA', windowsVirtualKeyCode: 65,\n nativeVirtualKeyCode: 65, modifiers: 4\n });\n}\n\nasync function debuggerPressEnter(target) {\n // 与 Selenium/Playwright 的真实 Enter 等价:rawKeyDown 携带回车文本后再 keyUp。\n await chrome.debugger.sendCommand(target, 'Input.dispatchKeyEvent', {\n type: 'rawKeyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13,\n nativeVirtualKeyCode: 13, text: '\\r', unmodifiedText: '\\r'\n });\n await chrome.debugger.sendCommand(target, 'Input.dispatchKeyEvent', {\n type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13,\n nativeVirtualKeyCode: 13\n });\n}\n\nasync function debuggerTypeText(target, text) {\n const value = String(text || '');\n for (const character of value) {\n await chrome.debugger.sendCommand(target, 'Input.dispatchKeyEvent', {\n type: 'char',\n text: character,\n unmodifiedText: character\n });\n await new Promise(resolve => setTimeout(resolve, 12));\n }\n}\n\nasync function mainWorldWriteChatText(tabId, selector, text) {\n const normalizedSelector = String(selector || '').trim();\n const desired = String(text || '');\n if (!normalizedSelector || !desired.trim()) throw new Error('聊天输入目标或招呼语为空');\n const results = await chrome.scripting.executeScript({\n target: { tabId: Number(tabId) },\n world: 'MAIN',\n func: (exactSelector, nextText) => {\n const normalizeText = value => String(value || '').replace(/\\s+/g, '').trim();\n const isEditable = node => {\n if (!node || typeof node.matches !== 'function') return false;\n const tag = String(node.tagName || '').toLowerCase();\n if (tag === 'textarea') return true;\n if (tag === 'input') {\n const type = String(node.getAttribute('type') || 'text').toLowerCase();\n return ['text', 'search', ''].includes(type);\n }\n const editableMode = String(node.getAttribute('contenteditable') || '').toLowerCase();\n return node.isContentEditable\n || Boolean(editableMode && !['false', 'inherit', 'off'].includes(editableMode))\n || node.getAttribute('role') === 'textbox'\n || node.getAttribute('data-slate-editor') === 'true'\n || node.getAttribute('data-lexical-editor') === 'true';\n };\n const deepQuery = (rootNode, query) => {\n if (!rootNode) return null;\n try {\n const direct = rootNode.querySelector?.(query);\n if (direct) return direct;\n } catch {}\n let elements = [];\n try { elements = [...(rootNode.querySelectorAll?.('*') || [])]; } catch {}\n for (const element of elements) {\n if (element.shadowRoot) {\n const found = deepQuery(element.shadowRoot, query);\n if (found) return found;\n }\n }\n return null;\n };\n const root = deepQuery(document, exactSelector);\n if (!root) return { ok: false, reason: 'selector-not-found' };\n const editor = isEditable(root)\n ? root\n : [...root.querySelectorAll('textarea,input[type=\"text\"],input:not([type]),[contenteditable]:not([contenteditable=\"false\"]),[role=\"textbox\"],[data-slate-editor=\"true\"],[data-lexical-editor=\"true\"]')].find(isEditable);\n if (!editor) return { ok: false, reason: 'editable-not-found', rootTag: String(root.tagName || '') };\n\n const readValue = () => {\n if ('value' in editor && !editor.isContentEditable) return String(editor.value || '');\n return String(editor.innerText || editor.textContent || '');\n };\n const dispatchInput = (inputType, data) => {\n try {\n editor.dispatchEvent(new InputEvent('input', {\n bubbles: true,\n cancelable: false,\n composed: true,\n inputType,\n data\n }));\n } catch {\n editor.dispatchEvent(new Event('input', { bubbles: true, composed: true }));\n }\n };\n const dispatchBeforeInput = (inputType, data) => {\n try {\n return editor.dispatchEvent(new InputEvent('beforeinput', {\n bubbles: true,\n cancelable: true,\n composed: true,\n inputType,\n data\n }));\n } catch {\n return true;\n }\n };\n\n editor.scrollIntoView?.({ block: 'nearest', inline: 'nearest', behavior: 'instant' });\n try { HTMLElement.prototype.focus.call(editor); } catch { editor.focus?.(); }\n const previous = readValue();\n const tag = String(editor.tagName || '').toLowerCase();\n let method = '';\n\n if ((tag === 'textarea' || tag === 'input') && 'value' in editor) {\n const proto = tag === 'textarea' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;\n const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;\n dispatchBeforeInput('deleteByCut', null);\n if (setter) setter.call(editor, ''); else editor.value = '';\n dispatchInput('deleteByCut', null);\n const tracker = editor._valueTracker;\n if (tracker?.setValue) tracker.setValue(previous);\n dispatchBeforeInput('insertText', nextText);\n if (setter) setter.call(editor, nextText); else editor.value = nextText;\n dispatchInput('insertText', nextText);\n editor.dispatchEvent(new Event('change', { bubbles: true, composed: true }));\n method = 'main-native-value-setter';\n } else {\n const selection = window.getSelection?.();\n if (selection && document.createRange) {\n const range = document.createRange();\n range.selectNodeContents(editor);\n selection.removeAllRanges();\n selection.addRange(range);\n }\n dispatchBeforeInput('deleteContentBackward', null);\n let inserted = false;\n try {\n const transfer = new DataTransfer();\n transfer.setData('text/plain', nextText);\n const pasteEvent = new ClipboardEvent('paste', {\n bubbles: true,\n cancelable: true,\n composed: true,\n clipboardData: transfer\n });\n editor.dispatchEvent(pasteEvent);\n inserted = normalizeText(readValue()).includes(normalizeText(nextText));\n } catch {\n inserted = false;\n }\n if (!inserted) {\n try {\n inserted = Boolean(document.execCommand?.('insertText', false, nextText));\n } catch {\n inserted = false;\n }\n }\n if (!inserted || !normalizeText(readValue()).includes(normalizeText(nextText))) {\n try {\n editor.replaceChildren(document.createTextNode(nextText));\n } catch {\n editor.textContent = nextText;\n }\n }\n dispatchInput('insertText', nextText);\n editor.dispatchEvent(new Event('change', { bubbles: true, composed: true }));\n try {\n editor.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true, composed: true, data: nextText }));\n } catch {}\n method = inserted ? 'main-exec-command' : 'main-contenteditable-replace';\n }\n\n try { HTMLElement.prototype.focus.call(editor); } catch { editor.focus?.(); }\n const actual = readValue();\n const compactExpected = normalizeText(nextText);\n const compactActual = normalizeText(actual);\n return {\n ok: Boolean(compactActual && compactActual.includes(compactExpected)),\n method,\n actual,\n tag,\n id: String(editor.id || ''),\n className: String(editor.className || '').slice(0, 180),\n contenteditable: String(editor.getAttribute?.('contenteditable') || ''),\n role: String(editor.getAttribute?.('role') || '')\n };\n },\n args: [normalizedSelector, desired]\n });\n return results?.[0]?.result || { ok: false, reason: 'main-world-no-result' };\n}\n\nasync function trustedChatInput(tabId, message = {}) {\n const action = String(message.action || '');\n if (action === 'replaceText' || action === 'replaceTextAndEnter') {\n const text = String(message.text || '');\n if (!text.trim()) throw new Error('待输入的招呼语为空');\n const compactExpected = text.replace(/\\s+/g, '');\n\n // 写入和 Enter 必须在同一次 debugger 会话、同一个精确编辑节点上完成。\n // 这是 Selenium sendKeys / Playwright locator.type + Enter 的同类浏览器级路径,\n // 避免旧版“聚焦了外壳、文字没有进入真实编辑器”的问题。\n return withTabDebugger(tabId, async target => {\n let focus = await debuggerFocusTarget(target, message);\n if (!focus.objectId) throw new Error('没有定位到 BOSS 真实聊天编辑节点');\n\n const readExact = async () => debuggerReadObjectText(target, focus.objectId);\n const refocus = async () => {\n const refreshed = await debuggerFocusTarget(target, message);\n if (refreshed.objectId) focus = refreshed;\n if (!focus.objectId) throw new Error('BOSS 聊天编辑节点已失效');\n return focus;\n };\n const matches = value => {\n const compact = String(value || '').replace(/\\s+/g, '');\n return Boolean(compact && (compact === compactExpected || compact.includes(compactExpected)));\n };\n\n let actual = '';\n let writeMethod = '';\n\n // 1) 精确对象聚焦后使用 CDP Input.insertText,最接近真实键盘输入。\n await debuggerSelectAll(target);\n await chrome.debugger.sendCommand(target, 'Input.insertText', { text });\n await new Promise(resolve => setTimeout(resolve, 700));\n actual = await readExact();\n writeMethod = 'exact-cdp-insert-text';\n\n // 2) 某些富文本编辑器只响应原生 paste。\n if (!matches(actual) && await writeClipboardText(text)) {\n await refocus();\n await debuggerSelectAll(target);\n await debuggerPasteClipboard(target);\n await new Promise(resolve => setTimeout(resolve, 900));\n actual = await readExact();\n writeMethod = 'exact-cdp-clipboard-paste';\n }\n\n // 3) React/Vue 受控编辑器可能需要主世界 setter/beforeinput/input。\n if (!matches(actual)) {\n const mainWrite = await mainWorldWriteChatText(tabId, message.selector, text).catch(error => ({\n ok: false,\n reason: String(error?.message || error || 'main-world-write-failed')\n }));\n await refocus();\n await new Promise(resolve => setTimeout(resolve, 650));\n actual = await readExact();\n writeMethod = mainWrite?.method || 'main-world-write';\n }\n\n // 4) 最后兜底为逐字符可信键盘输入,仍锁定同一个真实节点。\n if (!matches(actual)) {\n await refocus();\n await debuggerSelectAll(target);\n await debuggerTypeText(target, text);\n await new Promise(resolve => setTimeout(resolve, 1000));\n actual = await readExact();\n writeMethod = 'exact-cdp-keyboard-type';\n }\n\n if (!matches(actual)) {\n throw new Error(`文字没有进入 BOSS 真实聊天编辑器;实际长度=${String(actual || '').length}`);\n }\n\n if (action === 'replaceTextAndEnter') {\n await refocus();\n // 给 BOSS 受控状态一次提交渲染机会,再发送真实 Enter。\n await new Promise(resolve => setTimeout(resolve, 520));\n await debuggerPressEnter(target);\n }\n\n return {\n ok: true,\n action,\n length: text.length,\n insertedBeforeSend: true,\n writeMethod,\n actualLength: String(actual || '').length,\n focusMethod: focus.method,\n editor: {\n nodeId: Number(focus.nodeId || 0),\n selector: String(message.selector || '')\n }\n };\n });\n }\n\n return withTabDebugger(tabId, async target => {\n if (action === 'pressEnter') {\n const focus = await debuggerFocusTarget(target, message);\n if (!focus.objectId) throw new Error('没有定位到 BOSS 真实聊天编辑节点');\n await new Promise(resolve => setTimeout(resolve, 320));\n await debuggerPressEnter(target);\n return { ok: true, action, focusMethod: focus.method };\n }\n if (action === 'click') {\n const click = await debuggerClickTarget(target, message);\n return { ok: true, action, clickMethod: click.method };\n }\n throw new Error('不支持的可信输入动作');\n });\n}\n\nasync function init() {\n const current = await storage.all();\n const patch = {};\n for (const [key, value] of Object.entries(DEFAULTS)) {\n if (current[key] === undefined) patch[key] = value;\n }\n if (!current.ui9ModeMigration) {\n const currentConfig = current.config || {};\n patch.config = {\n ...DEFAULTS.config,\n ...currentConfig,\n executionMode: currentConfig.executionMode === 'auto' ? 'auto' : 'review',\n dailyTarget: Number(currentConfig.dailyTarget || 0) === 40 ? 150 : Number(currentConfig.dailyTarget || 150)\n };\n patch.ui9ModeMigration = true;\n }\n if (!current.ui10UnlimitedV4Migration) {\n const baseConfig = patch.config || current.config || {};\n const baseModel = { ...DEFAULTS.config.model, ...(baseConfig.model || {}) };\n const baseUrl = String(baseModel.baseUrl || DEFAULTS.config.model.baseUrl);\n const modelName = String(baseModel.model || '').trim();\n const isDeepSeek = /deepseek\\.com/i.test(baseUrl);\n const legacyDeepSeekModel = !modelName || ['deepseek-chat', 'deepseek-reasoner', 'deepseek-v4'].includes(modelName);\n patch.config = {\n ...DEFAULTS.config,\n ...baseConfig,\n discoveryLimit: 0,\n model: {\n ...baseModel,\n model: isDeepSeek && legacyDeepSeekModel ? 'deepseek-v4-pro' : (modelName || 'deepseek-v4-pro')\n }\n };\n patch.ui10UnlimitedV4Migration = true;\n }\n if (!current.ui20ReliableSendMigration) {\n const baseConfig = patch.config || current.config || {};\n patch.config = {\n ...DEFAULTS.config,\n ...baseConfig,\n betweenJobsSeconds: Math.max(8, Math.min(30, Number(baseConfig.betweenJobsSeconds || 8))),\n attachmentDelaySeconds: Math.max(1, Math.min(10, Number(baseConfig.attachmentDelaySeconds || 2)))\n };\n patch.ui20ReliableSendMigration = true;\n }\n if (!current.ui21ConversationBindingMigration) {\n patch.chatTransition = null;\n patch.chatDeliveryLedger = current.chatDeliveryLedger && typeof current.chatDeliveryLedger === 'object'\n ? current.chatDeliveryLedger\n : {};\n patch.ui21ConversationBindingMigration = true;\n }\n if (!current.ui22StrictTranscriptMigration) {\n const baseConfig = patch.config || current.config || {};\n patch.config = {\n ...DEFAULTS.config,\n ...baseConfig,\n betweenJobsSeconds: Math.max(12, Math.min(30, Number(baseConfig.betweenJobsSeconds || 12))),\n attachmentDelaySeconds: Math.max(4, Math.min(10, Number(baseConfig.attachmentDelaySeconds || 4)))\n };\n patch.ui22StrictTranscriptMigration = true;\n }\n if (!current.ui24VerifiedConversationMigration) {\n patch.chatTransition = null;\n patch.ui24VerifiedConversationMigration = true;\n }\n if (!current.ui27TrustedInputMigration) {\n const baseWorkflow = patch.workflow || current.workflow || {};\n patch.workflow = {\n ...DEFAULTS.workflow,\n ...baseWorkflow,\n running: false,\n paused: true,\n phase: 'idle',\n pendingApplyId: null,\n activeRunId: null,\n statusText: '可信发送引擎已升级,请重新开始任务'\n };\n patch.chatTransition = null;\n patch.ui27TrustedInputMigration = true;\n }\n if (!current.ui12ProfileDraftMigration) {\n let migratedProfile = current.profile && typeof current.profile === 'object' ? current.profile : null;\n let migratedDraft = current.profileDraft && typeof current.profileDraft === 'object'\n ? normalizeProfileDraft(current.profileDraft)\n : null;\n if (!profileDraftHasAny(migratedDraft) && profileHasCore(migratedProfile)) {\n migratedDraft = profileToDraft(migratedProfile, 'migration');\n }\n if (!profileDraftHasCore(migratedDraft) && String(current.resumeText || '').trim().length >= 30) {\n try {\n migratedProfile = buildLocalProfile(current.resumeText || '');\n migratedProfile.generation.mode = 'local-recovery';\n migratedProfile.generation.label = '本地初稿';\n migratedProfile.generation.warning = '已从保存的简历恢复初稿。';\n migratedDraft = profileToDraft(migratedProfile, 'recovery');\n patch.profile = migratedProfile;\n } catch {\n // 简历内容不足时只保留现有数据,不生成空画像。\n }\n }\n if (profileDraftHasAny(migratedDraft)) patch.profileDraft = migratedDraft;\n patch.ui12ProfileDraftMigration = true;\n }\n if (!current.ui37DirectionPlanMigration) {\n const profileForPlan = patch.profile || current.profile || null;\n const existingPlan = current.directionPlan && typeof current.directionPlan === 'object'\n ? current.directionPlan\n : null;\n if (profileHasCore(profileForPlan)) {\n patch.directionPlan = buildDirectionPlan(profileForPlan, existingPlan, {\n confirmed: Boolean(existingPlan?.confirmed),\n preserveSelections: true,\n preserveEdits: true,\n preserveCustom: true\n });\n } else if (existingPlan) {\n patch.directionPlan = normalizeDirectionPlan(existingPlan, profileForPlan);\n }\n patch.ui37DirectionPlanMigration = true;\n }\n if (!current.ui18TaskProgressMigration) {\n const migratedPending = Array.isArray(current.pending) ? current.pending.map(entry => ({ ...entry })) : [];\n const migratedRuns = Array.isArray(current.taskRuns) ? current.taskRuns.map(run => normalizeTaskRun(run, run)) : [];\n for (let index = 0; index < migratedPending.length; index += 1) {\n const item = migratedPending[index];\n let run = migratedRuns.find(entry => entry.id === item.runId || entry.pendingId === item.id);\n if (!run) {\n const statusMap = {\n pending: ['waiting_review', 'waiting_review', 60],\n approved: ['running', 'queued', 64],\n approved_queue: ['queued', 'queued', 64],\n sent: ['success', 'success', 100],\n failed: ['failed', 'failed', 100],\n rejected: ['ignored', 'ignored', 100]\n };\n const [status, stage, progress] = statusMap[item.status] || ['skipped', 'skipped', 100];\n run = normalizeTaskRun({\n id: crypto.randomUUID(),\n pendingId: item.id,\n job: item.job,\n analysis: item.analysis,\n searchTask: item.task,\n status,\n stage,\n progress,\n error: item.error || '',\n retryable: item.status === 'failed',\n createdAt: item.createdAt || Date.now(),\n completedAt: ['sent', 'failed', 'rejected'].includes(item.status) ? (item.completedAt || item.rejectedAt || Date.now()) : null\n });\n migratedRuns.push(run);\n }\n migratedPending[index] = { ...item, runId: run.id };\n }\n const workflow = { ...DEFAULTS.workflow, ...(current.workflow || {}) };\n workflow.tasks = (workflow.tasks || []).map((task, index) => ({\n ...task,\n id: task.id || crypto.randomUUID(),\n status: task.status || (index < Number(workflow.taskIndex || 0) ? 'completed' : index === Number(workflow.taskIndex || 0) && workflow.running ? 'running' : 'pending'),\n progress: Number.isFinite(Number(task.progress)) ? Number(task.progress) : (index < Number(workflow.taskIndex || 0) ? 100 : 0),\n stageLabel: task.stageLabel || (index < Number(workflow.taskIndex || 0) ? '已完成' : '等待开始'),\n processed: Number(task.processed || 0),\n discovered: Number(task.discovered || 0),\n analyzed: Number(task.analyzed || 0),\n failed: Number(task.failed || 0)\n }));\n patch.pending = migratedPending;\n patch.taskRuns = migratedRuns;\n patch.workflow = workflow;\n patch.ui18TaskProgressMigration = true;\n }\n if (!current.ui20GreetingLockMigration) {\n const basePending = Array.isArray(patch.pending) ? patch.pending : (Array.isArray(current.pending) ? current.pending : []);\n patch.pending = basePending.map(item => ({\n ...item,\n deliveryGreeting: String(item.deliveryGreeting || item.analysis?.greeting || '').trim()\n }));\n patch.ui20GreetingLockMigration = true;\n }\n if (!current.ui30AtomicSendSortMigration) {\n const basePending = Array.isArray(patch.pending) ? patch.pending : (Array.isArray(current.pending) ? current.pending : []);\n patch.pending = rerankPending(basePending);\n const baseWorkflow = patch.workflow || current.workflow || {};\n patch.workflow = {\n ...DEFAULTS.workflow,\n ...baseWorkflow,\n running: false,\n paused: true,\n pendingApplyId: null,\n activeRunId: null,\n statusText: '投递引擎与岗位自动排序已升级,请重新开始'\n };\n patch.chatTransition = null;\n patch.ui30AtomicSendSortMigration = true;\n }\n if (!current.ui31ConversationSelectionMigration) {\n const basePending = Array.isArray(patch.pending) ? patch.pending : (Array.isArray(current.pending) ? current.pending : []);\n patch.pending = rerankPending(basePending);\n const baseWorkflow = patch.workflow || current.workflow || {};\n patch.workflow = {\n ...DEFAULTS.workflow,\n ...baseWorkflow,\n running: false,\n paused: true,\n pendingApplyId: null,\n activeRunId: null,\n statusText: 'HR 会话选择与身份核对已升级,请重新开始'\n };\n const ledger = current.chatDeliveryLedger && typeof current.chatDeliveryLedger === 'object' ? current.chatDeliveryLedger : {};\n patch.chatDeliveryLedger = Object.fromEntries(Object.entries(ledger).filter(([, entry]) => entry?.status === 'sent'));\n patch.chatTransition = null;\n patch.ui31ConversationSelectionMigration = true;\n }\n if (!current.ui35ConversationKeyMigration) {\n const baseWorkflow = patch.workflow || current.workflow || {};\n patch.workflow = {\n ...DEFAULTS.workflow,\n ...baseWorkflow,\n running: false,\n paused: true,\n pendingApplyId: null,\n activeRunId: null,\n statusText: 'HR 会话锁已升级,请重新开始'\n };\n const ledger = current.chatDeliveryLedger && typeof current.chatDeliveryLedger === 'object' ? current.chatDeliveryLedger : {};\n patch.chatDeliveryLedger = Object.fromEntries(Object.entries(ledger).filter(([, entry]) => entry?.status === 'sent'));\n patch.chatTransition = null;\n patch.ui35ConversationKeyMigration = true;\n }\n if (Object.keys(patch).length) await storage.set(patch);\n const { stats } = await storage.get('stats');\n if (!stats || stats.date !== today()) {\n await storage.set({ stats: { ...DEFAULTS.stats, date: today() } });\n }\n await refreshBossTabsForRuntimeVersion();\n await chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => {});\n chrome.alarms.create('jobclaw-tick', { periodInMinutes: 1 });\n}\n\nfunction publicState(all) {\n const state = safeClone(all);\n if (state?.config?.model) {\n state.config.model.apiKey = state.config.model.apiKey ? '***' : '';\n }\n if (state?.resumeImage) state.resumeImage = '[已保存]';\n if (state?.resumeSourceFile) {\n const { name, type, size, lastModified } = state.resumeSourceFile;\n state.resumeSourceFile = { name, type, size, lastModified, stored: true };\n }\n return state;\n}\n\nasync function writeEvent(level, message, data = {}) {\n const { events = [] } = await storage.get('events');\n const event = { id: crypto.randomUUID(), ts: Date.now(), level, message, data };\n events.unshift(event);\n await storage.set({ events: events.slice(0, 300) });\n bridge('/sync', { event }).catch(() => {});\n return event;\n}\n\nasync function patchWorkflow(patch) {\n const { workflow } = await storage.get('workflow');\n const next = { ...DEFAULTS.workflow, ...(workflow || {}), ...(patch || {}) };\n await storage.set({ workflow: next });\n return next;\n}\n\nasync function changeStats(delta = {}) {\n const { stats } = await storage.get('stats');\n const next = { ...DEFAULTS.stats, ...(stats || {}), date: today() };\n for (const [key, value] of Object.entries(delta)) {\n next[key] = Math.max(0, Number(next[key] || 0) + Number(value || 0));\n }\n await storage.set({ stats: next });\n return next;\n}\n\n\nconst TERMINAL_RUN_STATUSES = new Set(['success', 'failed', 'ignored', 'skipped']);\nconst TASK_STAGE_META = {\n discovered: ['已发现岗位', 12],\n collect_detail: ['读取岗位详情', 24],\n ai_analyze: ['AI 匹配分析', 42],\n ai_complete: ['AI 分析完成', 56],\n waiting_review: ['等待人工确认', 60],\n queued: ['等待投递', 64],\n retry_queued: ['等待重新投递', 66],\n open_job: ['打开岗位页面', 70],\n open_chat: ['打开沟通窗口', 78],\n verify_chat_target: ['核对 HR 与岗位', 82],\n fill_message: ['填写求职招呼语', 86],\n send_message: ['发送求职招呼语', 90],\n verify_message: ['确认文字已发送', 94],\n send_resume: ['发送简历附件', 97],\n verify_result: ['确认投递结果', 98],\n success: ['投递成功', 100],\n failed: ['投递失败', 100],\n ignored: ['已忽略', 100],\n skipped: ['未达到推荐条件', 100]\n};\n\nfunction taskStageMeta(stage, label = '', progress = null) {\n const meta = TASK_STAGE_META[stage] || [label || stage || '处理中', 0];\n return {\n label: String(label || meta[0] || '处理中'),\n progress: Math.max(0, Math.min(100, Number(progress ?? meta[1] ?? 0)))\n };\n}\n\nfunction runJobKey(job = {}) {\n return String(job.jobId || job.encryptJobId || job.url || [job.title, job.company, job.location].filter(Boolean).join('|') || '').trim();\n}\n\nfunction normalizeTaskRun(input = {}, existing = {}) {\n const now = Date.now();\n const stage = String(input.stage || existing.stage || 'discovered');\n const meta = taskStageMeta(stage, input.stageLabel, input.progress);\n const status = String(input.status || existing.status || 'running');\n const terminal = TERMINAL_RUN_STATUSES.has(status);\n return {\n ...existing,\n ...input,\n id: input.id || existing.id || crypto.randomUUID(),\n jobKey: input.jobKey || existing.jobKey || runJobKey(input.job || existing.job || {}),\n status,\n stage,\n stageLabel: meta.label,\n progress: meta.progress,\n createdAt: existing.createdAt || input.createdAt || now,\n startedAt: existing.startedAt || input.startedAt || (status === 'running' ? now : null),\n updatedAt: now,\n completedAt: terminal ? (input.completedAt || existing.completedAt || now) : null,\n retryCount: Math.max(0, Number(input.retryCount ?? existing.retryCount ?? 0)),\n error: String(input.error ?? existing.error ?? ''),\n retryable: input.retryable ?? existing.retryable ?? Boolean(input.pendingId || existing.pendingId)\n };\n}\n\nasync function upsertTaskRun(patch = {}) {\n const { taskRuns = [] } = await storage.get('taskRuns');\n const jobKey = patch.jobKey || runJobKey(patch.job || {});\n const index = taskRuns.findIndex(run => run.id === patch.id || (jobKey && run.jobKey === jobKey && !TERMINAL_RUN_STATUSES.has(run.status)));\n const existing = index >= 0 ? taskRuns[index] : {};\n const nextRun = normalizeTaskRun({ ...patch, jobKey }, existing);\n const next = index >= 0\n ? taskRuns.map((run, runIndex) => runIndex === index ? nextRun : run)\n : [nextRun, ...taskRuns];\n await storage.set({ taskRuns: next });\n return nextRun;\n}\n\nasync function updateTaskRunByPending(pendingId, patch = {}) {\n if (!pendingId) return null;\n const { pending = [], taskRuns = [] } = await storage.get(['pending', 'taskRuns']);\n const item = pending.find(entry => entry.id === pendingId);\n const run = taskRuns.find(entry => entry.id === item?.runId || entry.pendingId === pendingId);\n if (!run && !item) return null;\n return upsertTaskRun({\n id: run?.id || item?.runId,\n pendingId,\n job: item?.job || run?.job,\n analysis: item?.analysis || run?.analysis,\n searchTask: item?.task || run?.searchTask,\n ...patch\n });\n}\n\nasync function updateSearchTaskProgress(message = {}) {\n const { workflow } = await storage.get('workflow');\n const nextWorkflow = { ...DEFAULTS.workflow, ...(workflow || {}) };\n const tasks = Array.isArray(nextWorkflow.tasks) ? [...nextWorkflow.tasks] : [];\n const index = tasks.findIndex(task => task.id === message.taskId);\n const fallbackIndex = Number.isInteger(message.taskIndex) ? message.taskIndex : -1;\n const targetIndex = index >= 0 ? index : fallbackIndex;\n if (targetIndex < 0 || targetIndex >= tasks.length) return null;\n const current = tasks[targetIndex] || {};\n const progress = Math.max(0, Math.min(100, Number(message.progress ?? current.progress ?? 0)));\n tasks[targetIndex] = {\n ...current,\n status: message.status || current.status || 'pending',\n stageLabel: message.stageLabel || current.stageLabel || '',\n progress,\n processed: Math.max(0, Number(message.processed ?? current.processed ?? 0)),\n discovered: Math.max(0, Number(message.discovered ?? current.discovered ?? 0)),\n analyzed: Math.max(0, Number(message.analyzed ?? current.analyzed ?? 0)),\n failed: Math.max(0, Number(message.failed ?? current.failed ?? 0)),\n updatedAt: Date.now(),\n completedAt: message.status === 'completed' ? Date.now() : current.completedAt || null\n };\n nextWorkflow.tasks = tasks;\n await storage.set({ workflow: nextWorkflow });\n return tasks[targetIndex];\n}\n\nasync function retryFailedTask(runId) {\n const { taskRuns = [], pending = [], workflow = {} } = await storage.get(['taskRuns', 'pending', 'workflow']);\n const run = taskRuns.find(entry => entry.id === runId);\n if (!run) throw new Error('失败任务不存在');\n if (run.status === 'success') throw new Error('该岗位已经投递成功,不会重复投递');\n if (workflow.running && (workflow.activeRunId === run.id || workflow.pendingApplyId === run.pendingId)) {\n throw new Error('该失败任务正在重试,请等待当前结果');\n }\n if (Number(run.retryRequestedAt || 0) > Date.now() - 15000) {\n throw new Error('该任务刚刚提交过重试,请稍后查看结果');\n }\n let item = pending.find(entry => entry.id === run.pendingId || entry.runId === run.id);\n if (!item && run.job && run.analysis) {\n item = {\n id: crypto.randomUUID(),\n runId: run.id,\n job: run.job,\n analysis: run.analysis,\n deliveryGreeting: String(run.analysis?.greeting || '').trim(),\n task: run.searchTask || {},\n status: 'approved',\n createdAt: Date.now(),\n approvedAt: Date.now()\n };\n }\n if (!item) throw new Error('该历史任务缺少岗位或招呼语信息,无法直接重试');\n if (item.status === 'sent') throw new Error('该岗位已经投递成功,不会重复投递');\n item = {\n ...item,\n deliveryGreeting: String(item.deliveryGreeting || item.analysis?.greeting || '').trim(),\n status: 'approved', error: '', approvedAt: Date.now(), runId: run.id\n };\n const nextPending = pending.some(entry => entry.id === item.id)\n ? pending.map(entry => entry.id === item.id ? item : entry)\n : [item, ...pending];\n await storage.set({ pending: rerankPending(nextPending) });\n await upsertTaskRun({\n id: run.id,\n pendingId: item.id,\n job: item.job,\n analysis: item.analysis,\n searchTask: item.task,\n status: 'running',\n stage: 'retry_queued',\n progress: 66,\n stageLabel: '等待重新投递',\n error: '',\n retryable: true,\n retryCount: Number(run.retryCount || 0) + 1,\n retryRequestedAt: Date.now(),\n completedAt: null\n });\n await patchWorkflow({\n running: true,\n paused: false,\n phase: 'apply',\n statusText: `正在重新投递:${item.job?.title || '岗位'}`,\n pendingApplyId: item.id,\n activeRunId: run.id\n });\n try {\n await sendToBoss({ type: 'RUN' });\n setTimeout(() => sendToBoss({ type: 'RUN' }).catch(() => {}), 700);\n } catch (error) {\n await upsertTaskRun({ id: run.id, status: 'failed', stage: 'failed', progress: 100, stageLabel: '重新投递启动失败', error: error.message, retryable: true });\n await patchWorkflow({ running: false, paused: true, phase: 'idle', pendingApplyId: null, activeRunId: null, statusText: '重新投递启动失败' });\n throw error;\n }\n return { runId: run.id, pendingId: item.id };\n}\n\nasync function retryAllFailedTasks() {\n const { taskRuns = [], pending = [], workflow = {} } = await storage.get(['taskRuns', 'pending', 'workflow']);\n if (workflow.running && workflow.activeRunId) {\n throw new Error('当前已有投递任务正在执行,请等待完成后再批量重试');\n }\n const failedRuns = taskRuns.filter(run => run.status === 'failed'\n && run.retryable !== false\n && Number(run.retryRequestedAt || 0) <= Date.now() - 15000);\n if (!failedRuns.length) return { count: 0 };\n const nextPending = [...pending];\n const queue = [];\n for (const run of failedRuns) {\n if (run.status === 'success') continue;\n let item = nextPending.find(entry => entry.id === run.pendingId || entry.runId === run.id);\n if (!item && run.job && run.analysis) {\n item = {\n id: crypto.randomUUID(), runId: run.id, job: run.job, analysis: run.analysis,\n deliveryGreeting: String(run.analysis?.greeting || '').trim(),\n task: run.searchTask || {}, createdAt: Date.now()\n };\n nextPending.unshift(item);\n }\n if (!item || item.status === 'sent') continue;\n item.deliveryGreeting = String(item.deliveryGreeting || item.analysis?.greeting || '').trim();\n item.status = queue.length ? 'approved_queue' : 'approved';\n item.approvedAt = Date.now();\n item.error = '';\n const pendingIndex = nextPending.findIndex(entry => entry.id === item.id);\n if (pendingIndex >= 0) nextPending[pendingIndex] = { ...item };\n queue.push({ run, item });\n }\n if (!queue.length) return { count: 0 };\n await storage.set({ pending: rerankPending(nextPending) });\n for (const [index, entry] of queue.entries()) {\n await upsertTaskRun({\n id: entry.run.id,\n pendingId: entry.item.id,\n status: index === 0 ? 'running' : 'queued',\n stage: 'retry_queued',\n progress: 66,\n stageLabel: index === 0 ? '正在重新投递' : '等待重新投递',\n error: '',\n retryable: true,\n retryCount: Number(entry.run.retryCount || 0) + 1,\n retryRequestedAt: Date.now(),\n completedAt: null\n });\n }\n const first = queue[0];\n await patchWorkflow({\n running: true,\n paused: false,\n phase: 'apply',\n statusText: `重新投递 1/${queue.length}:${first.item.job?.title || '岗位'}`,\n pendingApplyId: first.item.id,\n activeRunId: first.run.id\n });\n try {\n await sendToBoss({ type: 'RUN' });\n } catch (error) {\n for (const entry of queue) {\n await upsertTaskRun({ id: entry.run.id, status: 'failed', stage: 'failed', progress: 100, stageLabel: '批量重试启动失败', error: error.message, retryable: true });\n }\n await patchWorkflow({ running: false, paused: true, phase: 'idle', pendingApplyId: null, activeRunId: null, statusText: '批量重试启动失败' });\n throw error;\n }\n return { count: queue.length };\n}\n\nasync function ignoreFailedTask(runId) {\n const { taskRuns = [], pending = [] } = await storage.get(['taskRuns', 'pending']);\n const run = taskRuns.find(entry => entry.id === runId);\n if (!run) throw new Error('失败任务不存在');\n const nextPending = pending.map(entry => entry.id === run.pendingId || entry.runId === run.id\n ? { ...entry, status: 'rejected', rejectedAt: Date.now() }\n : entry);\n await storage.set({ pending: nextPending });\n return upsertTaskRun({ id: run.id, status: 'ignored', stage: 'ignored', progress: 100, stageLabel: '已忽略', retryable: false });\n}\n\nconst BOSS_URL_PATTERN = /^https:\\/\\/(?:www|app)\\.zhipin\\.com\\//i;\nconst NO_RECEIVER_PATTERN = /Could not establish connection|Receiving end does not exist|message port closed|No tab with id/i;\nconst wait = ms => new Promise(resolve => setTimeout(resolve, ms));\n\nfunction receiverMatchesExpectedVersion(probe) {\n return Boolean(probe?.ok && probe.contentVersion === EXPECTED_CONTENT_VERSION && probe.contentFile === CONTENT_SCRIPT_FILE);\n}\n\nasync function waitForTabReady(tabId, timeout = 16000) {\n if (!chrome.tabs?.get) {\n await wait(900);\n return null;\n }\n const startedAt = Date.now();\n while (Date.now() - startedAt < timeout) {\n const tab = await chrome.tabs.get(tabId).catch(() => null);\n if (tab?.status === 'complete') return tab;\n await wait(180);\n }\n return chrome.tabs.get(tabId).catch(() => null);\n}\n\nasync function reloadBossTab(tab, reason = 'runtime-refresh') {\n if (!tab?.id || !chrome.tabs?.reload) return tab;\n await chrome.tabs.reload(tab.id, { bypassCache: true });\n const ready = await waitForTabReady(tab.id);\n await writeEvent('info', 'BOSS 页面助手已自动刷新', { reason, tabId: tab.id, contentVersion: EXPECTED_CONTENT_VERSION }).catch(() => {});\n return ready || tab;\n}\n\nasync function refreshBossTabsForRuntimeVersion() {\n const key = 'bossContentRuntimeVersion';\n const current = await storage.get(key);\n if (current?.[key] === EXPECTED_CONTENT_VERSION) return;\n await storage.set({ [key]: EXPECTED_CONTENT_VERSION });\n if (!chrome.tabs?.query || !chrome.tabs?.reload) return;\n const tabs = await chrome.tabs.query({ url: ['https://www.zhipin.com/*', 'https://app.zhipin.com/*'] }).catch(() => []);\n for (const tab of tabs || []) {\n if (!tab?.id) continue;\n await chrome.tabs.reload(tab.id, { bypassCache: true }).catch(() => {});\n }\n}\n\nasync function activeBossTab() {\n const tabs = await chrome.tabs.query({ url: ['https://www.zhipin.com/*', 'https://app.zhipin.com/*'] });\n return tabs.find(tab => tab.active) || tabs[0] || null;\n}\n\nfunction bossConnectionError(error) {\n const message = String(error?.message || error || '');\n if (NO_RECEIVER_PATTERN.test(message)) {\n return 'BOSS 页面连接尚未就绪,JobClaw 已尝试自动修复。请刷新当前 BOSS 页面后再点一次开始。';\n }\n if (/Cannot access contents of url|The extensions gallery cannot be scripted|Missing host permission/i.test(message)) {\n return '当前页面无法接入 JobClaw。请确认打开的是 BOSS 直聘岗位页或沟通页。';\n }\n return message || 'BOSS 页面连接失败';\n}\n\nasync function probeBossReceiver(tab) {\n return chrome.tabs.sendMessage(tab.id, { type: 'PROBE', expectedContentVersion: EXPECTED_CONTENT_VERSION });\n}\n\nasync function injectBossContent(tab) {\n if (!chrome.scripting?.executeScript) throw new Error('扩展缺少页面注入能力,请重新加载最新版 JobClaw');\n await chrome.scripting.executeScript({\n target: { tabId: tab.id },\n files: [CONTENT_SCRIPT_FILE]\n });\n}\n\nasync function ensureBossReceiver(tab) {\n if (!tab?.id) throw new Error('请先打开并登录 BOSS 直聘');\n if (!BOSS_URL_PATTERN.test(String(tab.url || ''))) {\n throw new Error('请切换到 BOSS 直聘岗位页或沟通页');\n }\n\n let probe = null;\n try {\n probe = await probeBossReceiver(tab);\n if (receiverMatchesExpectedVersion(probe)) return probe;\n } catch (error) {\n if (!NO_RECEIVER_PATTERN.test(String(error?.message || error))) {\n throw new Error(bossConnectionError(error));\n }\n }\n\n // 能收到响应但版本不一致,说明当前标签页仍保留旧内容脚本。必须整页刷新,\n // 不能继续在同一页面叠加注入,否则旧 RUN 监听器仍可能执行。\n if (probe?.ok && !receiverMatchesExpectedVersion(probe)) {\n tab = await reloadBossTab(tab, `stale-${probe.contentVersion || 'legacy'}`);\n }\n\n for (let attempt = 0; attempt < 5; attempt += 1) {\n await wait(180 + attempt * 140);\n try {\n const currentProbe = await probeBossReceiver(tab);\n if (receiverMatchesExpectedVersion(currentProbe)) return currentProbe;\n if (currentProbe?.ok && !receiverMatchesExpectedVersion(currentProbe) && attempt === 0) {\n tab = await reloadBossTab(tab, `mismatch-${currentProbe.contentVersion || 'legacy'}`);\n }\n } catch (error) {\n if (!NO_RECEIVER_PATTERN.test(String(error?.message || error))) {\n throw new Error(bossConnectionError(error));\n }\n if (attempt === 1) {\n try { await injectBossContent(tab); } catch (injectError) { throw new Error(bossConnectionError(injectError)); }\n }\n }\n }\n throw new Error(`BOSS 页面助手版本未就绪(需要 ${EXPECTED_CONTENT_VERSION})。JobClaw 已自动刷新页面,请再点一次开始。`);\n}\n\nasync function sendToBossTab(tab, message) {\n await ensureBossReceiver(tab);\n try {\n return await chrome.tabs.sendMessage(tab.id, message);\n } catch (error) {\n if (!NO_RECEIVER_PATTERN.test(String(error?.message || error))) {\n throw new Error(bossConnectionError(error));\n }\n tab = await reloadBossTab(tab, 'message-receiver-lost');\n await ensureBossReceiver(tab);\n try {\n return await chrome.tabs.sendMessage(tab.id, message);\n } catch (retryError) {\n throw new Error(bossConnectionError(retryError));\n }\n }\n}\n\nasync function sendToBoss(message) {\n const tab = await activeBossTab();\n if (!tab) throw new Error('请先打开并登录 BOSS 直聘');\n return sendToBossTab(tab, message);\n}\n\nasync function bridge(path, body) {\n const failures = [];\n const timeoutMs = path === '/parse-resume' ? 190000 : 8000;\n for (const base of BRIDGE_ENDPOINTS) {\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), timeoutMs);\n try {\n const response = await fetch(`${base}${path}`, {\n method: body ? 'POST' : 'GET',\n headers: { 'Content-Type': 'application/json' },\n body: body ? JSON.stringify(body) : undefined,\n cache: 'no-store',\n credentials: 'omit',\n signal: controller.signal\n });\n const payload = await response.json().catch(() => ({}));\n if (!response.ok) throw new Error(payload.error || `增强识别组件返回 HTTP ${response.status}`);\n return payload;\n } catch (error) {\n failures.push(error?.name === 'AbortError' ? '连接超时' : String(error?.message || error));\n } finally {\n clearTimeout(timeout);\n }\n }\n const detail = failures.find(message => message && message !== 'Failed to fetch');\n throw new Error(detail || '增强识别组件未启动');\n}\n\nfunction extractJson(raw) {\n let text = String(raw || '').trim()\n .replace(/^```(?:json)?/i, '')\n .replace(/```$/i, '')\n .trim();\n const start = text.indexOf('{');\n const end = text.lastIndexOf('}');\n if (start >= 0 && end > start) text = text.slice(start, end + 1);\n text = text.replace(/,\\s*([}\\]])/g, '$1');\n return JSON.parse(text);\n}\n\nfunction aiError(code, message, details = {}) {\n const error = new Error(message);\n error.code = code;\n Object.assign(error, details);\n return error;\n}\n\nfunction aiFailureKind(error) {\n const code = String(error?.code || '');\n const message = String(error?.message || '');\n if (code === 'AI_CONFIG' || /API Key|未配置|401|unauthorized/i.test(message)) return 'config-missing';\n if (['AI_NETWORK', 'AI_HTTP', 'AI_TIMEOUT'].includes(code) || /HTTP|fetch|网络|超时|服务不可用/i.test(message)) return 'service-error';\n return 'output-invalid';\n}\n\nfunction isRetryableAiOutputError(error) {\n return ['AI_TRUNCATED', 'AI_EMPTY', 'AI_INVALID_JSON', 'AI_PROFILE_INCOMPLETE'].includes(String(error?.code || ''));\n}\n\nasync function requestModel(url, payload, apiKey, timeoutMs = 90000) {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n try {\n return await fetch(url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`\n },\n body: JSON.stringify(payload),\n signal: controller.signal\n });\n } catch (error) {\n if (error?.name === 'AbortError') throw aiError('AI_TIMEOUT', 'AI 请求超时');\n throw aiError('AI_NETWORK', `AI 网络请求失败:${error?.message || '连接异常'}`);\n } finally {\n clearTimeout(timer);\n }\n}\n\nasync function callModel(messages, jsonMode = true, options = {}) {\n const { config } = await storage.get('config');\n const model = config?.model || {};\n if (!model.apiKey) throw aiError('AI_CONFIG', '请先在设置页填写 AI API Key');\n const url = `${String(model.baseUrl || 'https://api.deepseek.com').replace(/\\/$/, '')}/chat/completions`;\n const payload = {\n model: model.model || 'deepseek-v4-pro',\n messages,\n temperature: Number(options.temperature ?? model.temperature ?? 0.1),\n max_tokens: Number(options.maxTokens ?? 2800)\n };\n if (jsonMode) payload.response_format = { type: 'json_object' };\n\n let response = await requestModel(url, payload, model.apiKey, Number(options.timeoutMs || 90000));\n let bodyText = await response.text();\n\n // 某些兼容网关不支持 response_format,但仍能正常返回 JSON 文本。\n if (!response.ok && jsonMode && [400, 404, 422].includes(response.status) && /response[_ -]?format|json_object|unsupported/i.test(bodyText)) {\n const retryPayload = { ...payload };\n delete retryPayload.response_format;\n response = await requestModel(url, retryPayload, model.apiKey, Number(options.timeoutMs || 90000));\n bodyText = await response.text();\n }\n\n if (!response.ok) {\n throw aiError('AI_HTTP', `AI 请求失败 HTTP ${response.status}: ${bodyText.substring(0, 500)}`, { status: response.status });\n }\n\n let result;\n try {\n result = JSON.parse(bodyText);\n } catch {\n throw aiError('AI_INVALID_RESPONSE', 'AI 接口返回了无法识别的响应');\n }\n const choice = result.choices?.[0];\n const content = String(choice?.message?.content || '').trim();\n if (!content) throw aiError('AI_EMPTY', 'AI 返回为空');\n if (choice.finish_reason === 'length') {\n throw aiError('AI_TRUNCATED', 'AI 输出被截断', { finishReason: choice.finish_reason, partial: content });\n }\n if (!jsonMode) return content;\n try {\n return extractJson(content);\n } catch (error) {\n throw aiError('AI_INVALID_JSON', `AI 返回 JSON 不完整:${error?.message || '解析失败'}`, { partial: content });\n }\n}\n\nfunction cleanResumeText(value) {\n return String(value || '')\n .replace(/\\r/g, '\\n')\n .replace(/[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001F]/g, ' ')\n .replace(/[ \\t]+/g, ' ')\n .replace(/\\n{3,}/g, '\\n\\n')\n .trim();\n}\n\nfunction lineMatches(text, pattern, limit = 6) {\n return uniq(cleanResumeText(text)\n .split('\\n')\n .map(line => line.trim())\n .filter(line => line.length >= 4 && line.length <= 180 && pattern.test(line)))\n .slice(0, limit);\n}\n\nfunction extractSkills(text) {\n const source = cleanResumeText(text);\n const catalog = [\n ['JavaScript', /\\bjavascript\\b|\\bjs\\b/i],\n ['TypeScript', /\\btypescript\\b|\\bts\\b/i],\n ['HTML', /\\bhtml5?\\b/i],\n ['CSS', /\\bcss3?\\b|sass|less/i],\n ['Vue', /\\bvue(?:\\.js|3|2)?\\b/i],\n ['React', /\\breact(?:\\.js)?\\b/i],\n ['Next.js', /\\bnext\\.?js\\b/i],\n ['Node.js', /\\bnode\\.?js\\b/i],\n ['Express', /\\bexpress\\b/i],\n ['Vite', /\\bvite\\b/i],\n ['Webpack', /\\bwebpack\\b/i],\n ['Tailwind CSS', /\\btailwind\\b/i],\n ['ECharts', /\\becharts\\b/i],\n ['D3.js', /\\bd3(?:\\.js)?\\b/i],\n ['Tauri', /\\btauri\\b/i],\n ['Electron', /\\belectron\\b/i],\n ['Chrome Extension', /chrome\\s*(?:extension|扩展)|浏览器扩展/i],\n ['Python', /\\bpython\\b/i],\n ['Java', /\\bjava\\b/i],\n ['C/C++', /\\bc\\+\\+\\b|\\bc语言\\b/i],\n ['Go', /\\bgolang\\b|\\bgo语言\\b/i],\n ['SQL', /\\bsql\\b/i],\n ['MySQL', /\\bmysql\\b/i],\n ['PostgreSQL', /\\bpostgres(?:ql)?\\b/i],\n ['Redis', /\\bredis\\b/i],\n ['Spring Boot', /spring\\s*boot/i],\n ['Flask', /\\bflask\\b/i],\n ['Django', /\\bdjango\\b/i],\n ['FastAPI', /\\bfastapi\\b/i],\n ['Git', /\\bgit\\b|github/i],\n ['Docker', /\\bdocker\\b/i],\n ['Linux', /\\blinux\\b/i],\n ['Playwright', /\\bplaywright\\b/i],\n ['Selenium', /\\bselenium\\b/i],\n ['PyTorch', /\\bpytorch\\b/i],\n ['TensorFlow', /\\btensorflow\\b/i],\n ['OpenCV', /\\bopencv\\b/i],\n ['RAG', /\\brag\\b|检索增强生成/i],\n ['LLM', /\\bllm\\b|大语言模型|语言模型/i],\n ['AI Agent', /ai\\s*agent|智能体/i],\n ['OCR', /\\bocr\\b|文字识别/i],\n ['数据可视化', /数据可视化|可视化看板/i]\n ];\n return catalog.filter(([, pattern]) => pattern.test(source)).map(([name]) => name).slice(0, 40);\n}\n\nfunction extractDegree(text) {\n const source = cleanResumeText(text);\n if (/博士|ph\\.?d/i.test(source)) return '博士';\n if (/硕士|研究生|master/i.test(source)) return '硕士';\n if (/本科|学士|bachelor/i.test(source)) return '本科';\n if (/大专|专科|associate/i.test(source)) return '大专';\n return '不限';\n}\n\nfunction extractLocations(text) {\n const source = cleanResumeText(text);\n const cities = ['北京','上海','广州','深圳','杭州','成都','西安','南京','武汉','苏州','重庆','天津','长沙','郑州','青岛','厦门','合肥','济南','宁波','东莞','珠海','佛山','无锡','兰州','太原','南阳','东京','大阪'];\n return cities.filter(city => source.includes(city)).slice(0, 6);\n}\n\nfunction extractExplicitDirections(text) {\n const source = cleanResumeText(text);\n const matches = [];\n const pattern = /(?:求职意向|求职方向|目标岗位|意向岗位|应聘岗位|期望职位)\\s*[::]?\\s*([^\\n]{2,100})/ig;\n let match;\n while ((match = pattern.exec(source)) && matches.length < 3) {\n matches.push(...String(match[1] || '').split(/[,,、/|]/).map(item => item.trim()).filter(item => item.length >= 2 && item.length <= 28));\n }\n return uniq(matches).slice(0, 3);\n}\n\nfunction inferDirections(text, skills) {\n const source = cleanResumeText(text);\n const explicit = extractExplicitDirections(source);\n if (explicit.length) return explicit;\n const directions = [];\n const has = name => skills.includes(name);\n const internship = /实习|在校|应届|校招/i.test(source);\n if (/前端|web前端|网页开发/i.test(source) || ['JavaScript','TypeScript','Vue','React','HTML','CSS'].some(has)) {\n directions.push(internship ? '前端开发实习生' : '前端开发工程师');\n }\n if (/全栈/i.test(source) || ((has('React') || has('Vue')) && (has('Node.js') || has('Java') || has('Python')))) {\n directions.push(internship ? '全栈开发实习生' : '全栈开发工程师');\n }\n if (/ai应用|人工智能应用|智能体|rag|大模型|llm/i.test(source) || ['RAG','LLM','AI Agent'].some(has)) {\n directions.push(internship ? 'AI 应用开发实习生' : 'AI 应用开发工程师');\n }\n if (/数据可视化|echarts|d3/i.test(source) || has('数据可视化') || has('ECharts') || has('D3.js')) {\n directions.push('数据可视化开发工程师');\n }\n if (/后端|服务端/i.test(source) || ['Spring Boot','Java','Node.js','Python','Go'].filter(has).length >= 2) {\n directions.push(internship ? '后端开发实习生' : '后端开发工程师');\n }\n if (!directions.length) directions.push(internship ? '软件开发实习生' : '软件开发工程师');\n return uniq(directions).slice(0, 3);\n}\n\nfunction buildSearchKeywords(directions, skills) {\n const keywords = [...directions];\n const joined = directions.join(' ');\n if (/前端/.test(joined)) {\n keywords.push('前端开发', 'Web 前端', 'JavaScript 开发');\n if (skills.includes('React')) keywords.push('React 开发');\n if (skills.includes('Vue')) keywords.push('Vue 开发');\n if (skills.includes('TypeScript')) keywords.push('TypeScript 前端');\n }\n if (/全栈/.test(joined)) keywords.push('全栈开发', 'Web 全栈');\n if (/AI 应用/.test(joined)) {\n keywords.push('AI 应用开发');\n if (skills.includes('RAG')) keywords.push('RAG 应用开发');\n if (skills.includes('AI Agent')) keywords.push('AI Agent 开发');\n }\n if (/数据可视化/.test(joined)) keywords.push('数据可视化', '可视化前端');\n if (/后端/.test(joined)) keywords.push('后端开发', '服务端开发');\n return uniq(keywords).slice(0, 12);\n}\n\nfunction explainProfileFallbackReason(reason = '', kind = '') {\n const message = String(reason || '').trim();\n const resolvedKind = kind || aiFailureKind({ message });\n if (!message) return '';\n if (resolvedKind === 'config-missing') {\n return 'AI 尚未配置;本次未调用 AI,当前初稿全部来自本地规则。';\n }\n if (resolvedKind === 'service-error') {\n return 'AI 请求没有成功;本次画像未使用 AI,当前初稿全部来自本地规则。';\n }\n return 'AI 连接可用,但返回内容未通过完整性校验;系统已自动精简重试,仍未成功,因此本次初稿全部来自本地规则。';\n}\n\nfunction validateGeneratedProfile(profile) {\n if (!profile || typeof profile !== 'object' || Array.isArray(profile)) {\n throw aiError('AI_PROFILE_INCOMPLETE', 'AI 画像字段不完整');\n }\n const directions = normalizeStringList(profile.primaryDirections, 3);\n const keywords = normalizeStringList(profile.searchKeywords, 12);\n const summary = String(profile.summary || '').trim();\n if (!directions.length || !keywords.length || summary.length < 12) {\n throw aiError('AI_PROFILE_INCOMPLETE', 'AI 画像字段不完整');\n }\n if (!profile.facts || typeof profile.facts !== 'object' || Array.isArray(profile.facts)) {\n throw aiError('AI_PROFILE_INCOMPLETE', 'AI 画像字段不完整');\n }\n if (!profile.hardConstraints || typeof profile.hardConstraints !== 'object' || Array.isArray(profile.hardConstraints)) {\n throw aiError('AI_PROFILE_INCOMPLETE', 'AI 画像字段不完整');\n }\n return profile;\n}\n\nfunction validateCompactGeneratedProfile(profile) {\n if (!profile || typeof profile !== 'object' || Array.isArray(profile)) {\n throw aiError('AI_PROFILE_INCOMPLETE', 'AI 精简画像字段不完整');\n }\n const normalized = {\n summary: String(profile.summary || '').trim(),\n primaryDirections: normalizeStringList(profile.primaryDirections, 3),\n searchKeywords: normalizeStringList(profile.searchKeywords, 12),\n skills: normalizeStringList(profile.skills, 30),\n locations: normalizeStringList(profile.locations, 20),\n employmentTypes: normalizeStringList(profile.employmentTypes, 10),\n salary: String(profile.salary || '').trim(),\n experience: String(profile.experience || '').trim(),\n degree: String(profile.degree || '').trim(),\n excludeDirections: normalizeStringList(profile.excludeDirections, 20)\n };\n if (!normalized.primaryDirections.length || !normalized.searchKeywords.length || normalized.summary.length < 12) {\n throw aiError('AI_PROFILE_INCOMPLETE', 'AI 精简画像字段不完整');\n }\n return normalized;\n}\n\nfunction buildLocalProfile(resumeText, reason = '', failureKind = '') {\n const text = cleanResumeText(resumeText);\n if (text.length < 30) throw new Error('简历内容太少,暂时无法生成职业画像');\n const skills = extractSkills(text);\n const primaryDirections = inferDirections(text, skills);\n const searchKeywords = buildSearchKeywords(primaryDirections, skills);\n const degree = extractDegree(text);\n const locations = extractLocations(text);\n const internship = /实习|在校|应届|校招/i.test(text);\n const employmentTypes = internship ? ['实习', '校招'] : ['全职'];\n const experience = internship ? '在校/应届' : '按岗位要求匹配';\n const topSkills = skills.slice(0, 7);\n const summaryParts = [];\n if (degree !== '不限') summaryParts.push(`${degree}${internship ? '在读或应届' : '背景'}`);\n if (topSkills.length) summaryParts.push(`具备 ${topSkills.join('、')} 等技能或项目经验`);\n summaryParts.push(`主要关注 ${primaryDirections.join('、')} 方向`);\n\n const education = lineMatches(text, /大学|学院|本科|硕士|博士|教育经历|专业/i, 6);\n const experiences = lineMatches(text, /实习|工作经历|公司|负责|任职|助理|工程师/i, 8);\n const projects = lineMatches(text, /项目|系统|平台|工作台|GitHub|开发|实现|搭建|设计/i, 10);\n const certificates = lineMatches(text, /证书|CET|英语六级|英语四级|资格|获奖|奖项/i, 6);\n const resolvedKind = failureKind || (reason ? aiFailureKind({ message: reason }) : 'not-requested');\n\n return normalizeProfile({\n facts: { education, experiences, projects, skills, certificates },\n primaryDirections: primaryDirections.map(name => ({ name, confidence: 0.72, evidence: ['根据简历中的技能、项目和求职阶段生成'] })),\n secondaryDirections: [],\n searchKeywords,\n hardConstraints: {\n locations,\n employmentTypes,\n salary: '不限',\n experience,\n degree\n },\n excludeDirections: [],\n summary: `${summaryParts.join(',')}。`,\n generation: {\n mode: 'local-fallback',\n label: '本地规则初稿',\n aiStatus: resolvedKind,\n warning: explainProfileFallbackReason(reason, resolvedKind),\n technicalReason: String(reason || ''),\n generatedAt: Date.now()\n }\n });\n}\n\nfunction mergeProfileWithFallback(aiProfile, fallbackProfile, generation = null) {\n const ai = aiProfile && typeof aiProfile === 'object' ? aiProfile : {};\n const aiFacts = ai.facts && typeof ai.facts === 'object' ? ai.facts : {};\n const fallbackFacts = fallbackProfile.facts || {};\n const hard = ai.hardConstraints && typeof ai.hardConstraints === 'object' ? ai.hardConstraints : {};\n const fallbackHard = fallbackProfile.hardConstraints || {};\n return normalizeProfile({\n ...fallbackProfile,\n ...ai,\n facts: {\n ...fallbackFacts,\n ...aiFacts,\n education: Array.isArray(aiFacts.education) && aiFacts.education.length ? aiFacts.education : fallbackFacts.education,\n experiences: Array.isArray(aiFacts.experiences) && aiFacts.experiences.length ? aiFacts.experiences : fallbackFacts.experiences,\n projects: Array.isArray(aiFacts.projects) && aiFacts.projects.length ? aiFacts.projects : fallbackFacts.projects,\n skills: normalizeStringList(aiFacts.skills).length ? aiFacts.skills : fallbackFacts.skills,\n certificates: Array.isArray(aiFacts.certificates) && aiFacts.certificates.length ? aiFacts.certificates : fallbackFacts.certificates\n },\n primaryDirections: normalizeStringList(ai.primaryDirections, 3).length ? ai.primaryDirections : fallbackProfile.primaryDirections,\n searchKeywords: normalizeStringList(ai.searchKeywords, 12).length ? ai.searchKeywords : fallbackProfile.searchKeywords,\n hardConstraints: {\n ...fallbackHard,\n ...hard,\n locations: normalizeStringList(hard.locations).length ? hard.locations : fallbackHard.locations,\n employmentTypes: normalizeStringList(hard.employmentTypes).length ? hard.employmentTypes : fallbackHard.employmentTypes,\n salary: String(hard.salary || fallbackHard.salary || '不限'),\n experience: String(hard.experience || fallbackHard.experience || ''),\n degree: String(hard.degree || fallbackHard.degree || '不限')\n },\n summary: String(ai.summary || fallbackProfile.summary),\n generation: generation || {\n mode: 'ai-assisted',\n label: 'AI 完整画像',\n aiStatus: 'success',\n warning: 'AI 返回的完整 JSON 已通过校验;仅对可选空字段使用本地规则补齐。',\n generatedAt: Date.now()\n }\n });\n}\n\nfunction compactProfileToFull(compact, fallback) {\n return {\n facts: {\n ...(fallback.facts || {}),\n skills: compact.skills.length ? compact.skills : fallback.facts?.skills || []\n },\n primaryDirections: compact.primaryDirections,\n secondaryDirections: [],\n searchKeywords: compact.searchKeywords,\n hardConstraints: {\n ...(fallback.hardConstraints || {}),\n locations: compact.locations.length ? compact.locations : fallback.hardConstraints?.locations || [],\n employmentTypes: compact.employmentTypes.length ? compact.employmentTypes : fallback.hardConstraints?.employmentTypes || [],\n salary: compact.salary || fallback.hardConstraints?.salary || '不限',\n experience: compact.experience || fallback.hardConstraints?.experience || '',\n degree: compact.degree || fallback.hardConstraints?.degree || '不限'\n },\n excludeDirections: compact.excludeDirections,\n summary: compact.summary\n };\n}\n\nasync function buildProfile(resumeText) {\n const text = cleanResumeText(resumeText);\n const fallback = buildLocalProfile(text);\n const schema = '{\"facts\":{\"education\":[],\"experiences\":[],\"projects\":[],\"skills\":[],\"certificates\":[]},\"primaryDirections\":[{\"name\":\"\",\"confidence\":0,\"evidence\":[]}],\"secondaryDirections\":[],\"searchKeywords\":[],\"hardConstraints\":{\"locations\":[],\"employmentTypes\":[],\"salary\":\"\",\"experience\":\"\",\"degree\":\"\"},\"excludeDirections\":[],\"summary\":\"\"}';\n let firstError = null;\n try {\n const profile = validateGeneratedProfile(await callModel([\n {\n role: 'system',\n content: `你是严格的职业画像分析器。只能使用简历真实事实,不能根据项目业务场景推断用户职业。主方向最多3个,搜索词必须是真实岗位名称。数组必须精简,教育/经历/项目各最多4条,每条不超过80字,技能最多15个,摘要不超过180字。即使信息不完整,也必须给出可编辑初稿,禁止返回空内容。输出严格 JSON:${schema}`\n },\n { role: 'user', content: text.slice(0, 22000) }\n ], true, { maxTokens: 4200, temperature: 0.05 }));\n return mergeProfileWithFallback(profile, fallback);\n } catch (error) {\n firstError = error;\n }\n\n // 连接正常但完整画像输出过长、JSON 不完整或字段校验失败时,自动改用精简结构重试。\n if (isRetryableAiOutputError(firstError)) {\n const compactSchema = '{\"summary\":\"\",\"primaryDirections\":[],\"searchKeywords\":[],\"skills\":[],\"locations\":[],\"employmentTypes\":[],\"salary\":\"\",\"experience\":\"\",\"degree\":\"\",\"excludeDirections\":[]}';\n try {\n const compact = validateCompactGeneratedProfile(await callModel([\n {\n role: 'system',\n content: `你是求职职业画像分析器。只使用简历事实。请输出极简 JSON,不要解释,不要证据长句。摘要120字以内;主方向最多3个;搜索词最多10个;技能最多12个;其余字段简短。输出结构:${compactSchema}`\n },\n { role: 'user', content: text.slice(0, 18000) }\n ], true, { maxTokens: 2200, temperature: 0.05 }));\n return mergeProfileWithFallback(compactProfileToFull(compact, fallback), fallback, {\n mode: 'ai-compact-retry',\n label: 'AI 精简重试结果',\n aiStatus: 'success-after-retry',\n warning: 'AI 连接正常。首次完整画像输出未完成,系统已自动精简重试并成功采用 AI 结果。',\n technicalReason: String(firstError?.message || ''),\n generatedAt: Date.now()\n });\n } catch (retryError) {\n const combined = `${firstError?.message || 'AI 首次输出未通过'};精简重试:${retryError?.message || '失败'}`;\n return buildLocalProfile(text, combined, aiFailureKind(firstError));\n }\n }\n\n return buildLocalProfile(text, firstError?.message || 'AI 生成失败', aiFailureKind(firstError));\n}\n\n\nfunction normalizeStringList(value, limit = 30) {\n const values = Array.isArray(value) ? value : String(value || '').split(/[,,\\n]/);\n return uniq(values.map(item => typeof item === 'string' ? item.trim() : String(item?.name || item?.title || '').trim()))\n .filter(Boolean)\n .slice(0, limit);\n}\n\nfunction normalizeDirections(value, existing = []) {\n const previous = new Map((Array.isArray(existing) ? existing : []).map(item => {\n const name = typeof item === 'string' ? item : item?.name;\n return [String(name || '').trim(), item];\n }));\n return normalizeStringList(value, 3).map(name => {\n const old = previous.get(name);\n if (old && typeof old === 'object') return { ...old, name };\n return { name, confidence: 1, evidence: ['用户手动编辑'] };\n });\n}\n\nfunction normalizeProfile(incoming, current = null) {\n const base = current && typeof current === 'object' ? current : {};\n const next = incoming && typeof incoming === 'object' ? incoming : {};\n const profile = {\n ...base,\n ...next,\n facts: {\n education: [], experiences: [], projects: [], skills: [], certificates: [],\n ...(base.facts || {}),\n ...(next.facts || {}),\n skills: normalizeStringList(next.facts?.skills ?? base.facts?.skills, 40)\n },\n primaryDirections: normalizeDirections(next.primaryDirections ?? base.primaryDirections, base.primaryDirections),\n secondaryDirections: normalizeStringList(next.secondaryDirections ?? base.secondaryDirections, 10),\n searchKeywords: normalizeStringList(next.searchKeywords ?? base.searchKeywords, 12),\n excludeDirections: normalizeStringList(next.excludeDirections ?? base.excludeDirections, 20),\n hardConstraints: {\n locations: [], employmentTypes: [], salary: '', experience: '', degree: '',\n ...(base.hardConstraints || {}),\n ...(next.hardConstraints || {})\n },\n summary: String(next.summary ?? base.summary ?? '').trim(),\n editedAt: Date.now()\n };\n profile.hardConstraints.locations = normalizeStringList(profile.hardConstraints.locations, 20);\n profile.hardConstraints.employmentTypes = normalizeStringList(profile.hardConstraints.employmentTypes, 10);\n profile.hardConstraints.salary = String(profile.hardConstraints.salary || '').trim();\n profile.hardConstraints.experience = String(profile.hardConstraints.experience || '').trim();\n profile.hardConstraints.degree = String(profile.hardConstraints.degree || '').trim();\n if (!profile.primaryDirections.length) throw new Error('职业画像至少需要一个主方向');\n if (!profile.searchKeywords.length) throw new Error('职业画像至少需要一个岗位搜索词');\n return profile;\n}\n\n\nfunction profileHasCore(profile) {\n return Boolean(\n normalizeStringList(profile?.primaryDirections, 3).length &&\n normalizeStringList(profile?.searchKeywords, 12).length\n );\n}\n\nfunction normalizeProfileDraft(incoming, fallback = null) {\n const base = fallback && typeof fallback === 'object' ? fallback : {};\n const next = incoming && typeof incoming === 'object' ? incoming : {};\n const chooseList = (key, limit = 30) => Object.prototype.hasOwnProperty.call(next, key)\n ? normalizeStringList(next[key], limit)\n : normalizeStringList(base[key], limit);\n const chooseText = key => Object.prototype.hasOwnProperty.call(next, key)\n ? String(next[key] || '').trim()\n : String(base[key] || '').trim();\n return {\n summary: chooseText('summary'),\n primaryDirections: chooseList('primaryDirections', 3),\n searchKeywords: chooseList('searchKeywords', 12),\n skills: chooseList('skills', 40),\n locations: chooseList('locations', 20),\n employmentTypes: chooseList('employmentTypes', 10),\n experience: chooseText('experience'),\n degree: chooseText('degree'),\n salary: chooseText('salary'),\n excludeDirections: chooseList('excludeDirections', 20),\n source: String(next.source || base.source || 'draft'),\n updatedAt: Number(next.updatedAt || Date.now())\n };\n}\n\nfunction profileDraftHasCore(draft) {\n return Boolean(\n normalizeStringList(draft?.primaryDirections, 3).length &&\n normalizeStringList(draft?.searchKeywords, 12).length\n );\n}\n\nfunction profileDraftHasAny(draft) {\n if (!draft || typeof draft !== 'object') return false;\n return Boolean(\n String(draft.summary || '').trim() ||\n normalizeStringList(draft.primaryDirections).length ||\n normalizeStringList(draft.searchKeywords).length ||\n normalizeStringList(draft.skills).length ||\n normalizeStringList(draft.locations).length ||\n normalizeStringList(draft.employmentTypes).length ||\n String(draft.experience || '').trim() ||\n String(draft.degree || '').trim() ||\n String(draft.salary || '').trim() ||\n normalizeStringList(draft.excludeDirections).length\n );\n}\n\nfunction profileToDraft(profile, source = 'generated') {\n const hard = profile?.hardConstraints || {};\n return normalizeProfileDraft({\n summary: profile?.summary || '',\n primaryDirections: normalizeStringList(profile?.primaryDirections, 3),\n searchKeywords: normalizeStringList(profile?.searchKeywords, 12),\n skills: normalizeStringList(profile?.facts?.skills, 40),\n locations: normalizeStringList(hard.locations, 20),\n employmentTypes: normalizeStringList(hard.employmentTypes, 10),\n experience: hard.experience || '',\n degree: hard.degree || '',\n salary: hard.salary || '',\n excludeDirections: normalizeStringList(profile?.excludeDirections, 20),\n source,\n updatedAt: Date.now()\n });\n}\n\nfunction profileFromDraft(draft, currentProfile = null) {\n const normalized = normalizeProfileDraft(draft);\n return normalizeProfile({\n ...(currentProfile || {}),\n summary: normalized.summary,\n primaryDirections: normalized.primaryDirections,\n searchKeywords: normalized.searchKeywords,\n excludeDirections: normalized.excludeDirections,\n facts: {\n ...(currentProfile?.facts || {}),\n skills: normalized.skills\n },\n hardConstraints: {\n ...(currentProfile?.hardConstraints || {}),\n locations: normalized.locations,\n employmentTypes: normalized.employmentTypes,\n experience: normalized.experience,\n degree: normalized.degree,\n salary: normalized.salary\n }\n }, currentProfile);\n}\n\n\nfunction clampNumber(value, min, max, fallback = min) {\n const number = Number(value);\n return Number.isFinite(number) ? Math.max(min, Math.min(max, number)) : fallback;\n}\n\nfunction normalizeDirectionKey(value) {\n return String(value || '')\n .toLowerCase()\n .replace(/实习生|工程师|开发|岗位|职位|校招|社招|应届/g, '')\n .replace(/[\\s,,/\\\\|·•()()【】\\[\\]_-]+/g, '')\n .trim();\n}\n\nfunction stableDirectionId(name, source = 'profile') {\n const input = `${source}:${normalizeDirectionKey(name) || String(name || '').trim().toLowerCase()}`;\n let hash = 2166136261;\n for (let index = 0; index < input.length; index += 1) {\n hash ^= input.charCodeAt(index);\n hash = Math.imul(hash, 16777619);\n }\n return `direction_${(hash >>> 0).toString(36)}`;\n}\n\nconst DIRECTION_PRESETS = [\n {\n test: /前端|web|react|vue|javascript|typescript/i,\n keywords: ['前端开发实习生', 'Web前端实习生', 'React开发实习生', 'Vue开发实习生'],\n relevantSkills: ['JavaScript', 'TypeScript', 'HTML', 'CSS', 'React', 'Vue', 'Next.js', 'Vite', 'Webpack'],\n gapSkills: ['工程化', '性能优化', '组件库']\n },\n {\n test: /ai应用|人工智能应用|大模型|agent|rag|llm/i,\n keywords: ['AI应用开发实习生', 'AI Agent实习生', 'RAG应用开发实习生', '大模型应用开发实习生'],\n relevantSkills: ['AI Agent', 'RAG', 'Python', 'TypeScript', 'React', 'Node.js', 'OCR'],\n gapSkills: ['Python', '模型评测', '向量数据库']\n },\n {\n test: /全栈|full.?stack|前后端/i,\n keywords: ['全栈开发实习生', 'Web全栈实习生', '前后端开发实习生'],\n relevantSkills: ['JavaScript', 'TypeScript', 'React', 'Vue', 'Node.js', 'Express', 'MySQL'],\n gapSkills: ['后端接口', '数据库', '部署运维']\n },\n {\n test: /开发者工具|研发工具|桌面端|tauri|electron|工具开发/i,\n keywords: ['开发者工具实习生', '研发工具开发实习生', '桌面端开发实习生', '平台工具开发实习生'],\n relevantSkills: ['Tauri', 'Electron', 'TypeScript', 'React', 'Rust', 'Node.js', 'OCR'],\n gapSkills: ['跨平台工程化', '桌面端发布', '系统 API']\n },\n {\n test: /数据可视化|可视化|echarts|大屏/i,\n keywords: ['数据可视化实习生', '前端可视化实习生', 'ECharts开发实习生'],\n relevantSkills: ['ECharts', 'JavaScript', 'TypeScript', 'React', 'Vue', '数据处理'],\n gapSkills: ['图形性能优化', '复杂交互', '可视化工程化']\n },\n {\n test: /后端|java|node|服务端/i,\n keywords: ['后端开发实习生', 'Node.js开发实习生', 'Java开发实习生'],\n relevantSkills: ['Java', 'Node.js', 'Express', 'MySQL', 'Redis', 'Spring'],\n gapSkills: ['数据库', '缓存', '接口设计']\n }\n];\n\nfunction directionPreset(name) {\n return DIRECTION_PRESETS.find(item => item.test.test(String(name || ''))) || null;\n}\n\nfunction normalizedSkillSet(profile = {}) {\n return new Map(normalizeStringList(profile?.facts?.skills, 50).map(skill => [normalizeDirectionKey(skill), skill]));\n}\n\nfunction buildDirectionKeywords(name, profile = {}) {\n const allKeywords = normalizeStringList(profile?.searchKeywords, 30);\n const nameKey = normalizeDirectionKey(name);\n const preset = directionPreset(name);\n const matching = allKeywords.filter(keyword => {\n const key = normalizeDirectionKey(keyword);\n return key && (nameKey.includes(key) || key.includes(nameKey) || [...nameKey].filter(char => key.includes(char)).length >= Math.min(3, nameKey.length));\n });\n return uniq([name, ...matching, ...(preset?.keywords || [])]).filter(Boolean).slice(0, 8);\n}\n\nfunction buildDirectionEvidence(name, candidate = {}, profile = {}) {\n const skills = normalizeStringList(profile?.facts?.skills, 30);\n const skillMap = normalizedSkillSet(profile);\n const preset = directionPreset(name);\n const relevant = (preset?.relevantSkills || skills).filter(skill => {\n const key = normalizeDirectionKey(skill);\n return skillMap.has(key) || skills.some(current => normalizeDirectionKey(current).includes(key) || key.includes(normalizeDirectionKey(current)));\n });\n const matchedSkills = uniq(relevant.length ? relevant : skills.slice(0, 4)).slice(0, 5);\n const gaps = (preset?.gapSkills || []).filter(skill => {\n const key = normalizeDirectionKey(skill);\n return ![...skillMap.keys()].some(existing => existing.includes(key) || key.includes(existing));\n }).slice(0, 3);\n const evidence = normalizeStringList(candidate?.evidence, 3);\n const reason = evidence.length\n ? `职业画像中已提取到:${evidence.join(';')}`.slice(0, 160)\n : matchedSkills.length\n ? `与简历中的 ${matchedSkills.join('、')} 技能和项目经历匹配。`\n : `该方向来自职业画像中的主要求职方向,可继续人工调整。`;\n return { matchedSkills, gaps, reason };\n}\n\nfunction normalizeDirectionItem(item = {}, index = 0) {\n const name = String(item.name || item.title || '').trim().slice(0, 60);\n const source = item.source === 'custom' || item.custom ? 'custom' : 'profile';\n const id = String(item.id || stableDirectionId(name || `custom-${index}`, source));\n return {\n id,\n source,\n custom: source === 'custom',\n sourceName: String(item.sourceName || name).trim().slice(0, 60),\n name,\n enabled: item.enabled !== false,\n priority: Math.round(clampNumber(item.priority, 1, 99, index + 1)),\n score: Math.round(clampNumber(item.score, 0, 100, source === 'custom' ? 70 : Math.max(60, 88 - index * 7))),\n reason: String(item.reason || (source === 'custom' ? '用户自定义岗位方向。' : '根据职业画像推荐。')).trim().slice(0, 240),\n matchedSkills: normalizeStringList(item.matchedSkills, 8),\n gaps: normalizeStringList(item.gaps, 6),\n keywords: uniq(normalizeStringList(item.keywords, 12).length ? normalizeStringList(item.keywords, 12) : [name]).slice(0, 12),\n updatedAt: Number(item.updatedAt || Date.now())\n };\n}\n\nfunction profileDirectionSignature(profile = {}) {\n return JSON.stringify({\n directions: normalizeStringList(profile?.primaryDirections?.map(item => typeof item === 'string' ? item : item?.name), 6).map(normalizeDirectionKey),\n keywords: normalizeStringList(profile?.searchKeywords, 20).map(normalizeDirectionKey)\n });\n}\n\nfunction normalizeDirectionPlan(plan, profile = null, options = {}) {\n const items = (Array.isArray(plan?.items) ? plan.items : [])\n .map((item, index) => normalizeDirectionItem(item, index))\n .filter(item => item.name && item.keywords.length)\n .sort((left, right) => left.priority - right.priority || right.score - left.score)\n .slice(0, 12)\n .map((item, index) => ({ ...item, priority: index + 1 }));\n return {\n version: 1,\n items,\n confirmed: options.confirmed ?? Boolean(plan?.confirmed),\n updatedAt: Number(options.updatedAt || plan?.updatedAt || Date.now()),\n appliedAt: Number(options.appliedAt || plan?.appliedAt || 0),\n profileSignature: String(options.profileSignature || plan?.profileSignature || profileDirectionSignature(profile)).slice(0, 600)\n };\n}\n\nfunction buildDirectionPlan(profile = {}, currentPlan = null, options = {}) {\n const primary = Array.isArray(profile?.primaryDirections) ? profile.primaryDirections : [];\n const secondary = Array.isArray(profile?.secondaryDirections) ? profile.secondaryDirections : [];\n const searchKeywords = normalizeStringList(profile?.searchKeywords, 20);\n const candidates = [];\n for (const item of [...primary, ...secondary]) {\n const name = String(typeof item === 'string' ? item : item?.name || '').trim();\n if (!name || candidates.some(candidate => normalizeDirectionKey(candidate.name) === normalizeDirectionKey(name))) continue;\n candidates.push({ name, raw: typeof item === 'object' ? item : {} });\n }\n for (const keyword of searchKeywords) {\n if (candidates.length >= 6) break;\n if (!/开发|工程师|实习生|前端|后端|全栈|AI|人工智能|工具|可视化/i.test(keyword)) continue;\n if (candidates.some(candidate => normalizeDirectionKey(candidate.name) === normalizeDirectionKey(keyword))) continue;\n candidates.push({ name: keyword, raw: {} });\n }\n if (!candidates.length) candidates.push({ name: '目标岗位', raw: {} });\n\n const existing = new Map((currentPlan?.items || []).map(item => [String(item.id || ''), item]));\n const generated = candidates.slice(0, 6).map((candidate, index) => {\n const id = stableDirectionId(candidate.name, 'profile');\n const previous = existing.get(id);\n const confidence = Number(candidate.raw?.confidence);\n const score = Number.isFinite(confidence)\n ? Math.round(confidence <= 1 ? confidence * 100 : confidence)\n : Math.max(62, 90 - index * 7);\n const evidence = buildDirectionEvidence(candidate.name, candidate.raw, profile);\n return normalizeDirectionItem({\n id,\n source: 'profile',\n sourceName: candidate.name,\n name: options.preserveEdits && previous?.name ? previous.name : candidate.name,\n enabled: options.preserveSelections && previous ? previous.enabled : index < 3,\n priority: previous?.priority || index + 1,\n score,\n reason: evidence.reason,\n matchedSkills: evidence.matchedSkills,\n gaps: evidence.gaps,\n keywords: options.preserveEdits && previous?.keywords?.length\n ? previous.keywords\n : buildDirectionKeywords(candidate.name, profile)\n }, index);\n });\n const custom = options.preserveCustom === false ? [] : (currentPlan?.items || [])\n .filter(item => item?.source === 'custom' || item?.custom)\n .map((item, index) => normalizeDirectionItem(item, generated.length + index));\n return normalizeDirectionPlan({\n items: [...generated, ...custom],\n confirmed: options.confirmed ?? false,\n updatedAt: Date.now(),\n appliedAt: options.confirmed ? Date.now() : Number(currentPlan?.appliedAt || 0)\n }, profile, { confirmed: options.confirmed ?? false, updatedAt: Date.now() });\n}\n\nfunction selectedDirectionItems(plan = {}) {\n return (Array.isArray(plan?.items) ? plan.items : [])\n .map((item, index) => normalizeDirectionItem(item, index))\n .filter(item => item.enabled && item.name && item.keywords.length)\n .sort((left, right) => left.priority - right.priority || right.score - left.score);\n}\n\nfunction fallbackApplicantGreeting(job, profile) {\n const title = String(job?.title || '该岗位').trim();\n const skills = normalizeStringList(profile?.facts?.skills, 4);\n const direction = normalizeStringList(profile?.primaryDirections?.map(item => typeof item === 'string' ? item : item?.name), 2);\n const evidence = skills.length ? `我的简历中有${skills.slice(0, 3).join('、')}相关项目与实践` : (profile?.summary || direction.join('、'));\n const target = `贵公司的${title}`;\n return `您好,我想应聘${target}岗位。${evidence ? `${evidence},` : ''}对岗位的工作内容很感兴趣。方便的话希望进一步了解岗位和团队,谢谢。`\n .replace(/。{2,}/g, '。')\n .slice(0, 140);\n}\n\nfunction normalizeApplicantGreeting(result, job, profile) {\n const raw = String(result?.greeting || '').trim();\n const reversed = /看到你的简历|你的简历|很匹配我们|匹配我们|欢迎.*沟通|期待你|候选人|我们团队|我们公司|团队主要涉及|你很匹配|方便的话来聊聊/i.test(raw);\n const applicantVoice = /我想应聘|我希望应聘|我对.{0,20}(岗位|职位).{0,10}(感兴趣|有兴趣)|想进一步了解|希望进一步沟通/.test(raw);\n if (!raw || reversed || !applicantVoice) return fallbackApplicantGreeting(job, profile);\n return raw.slice(0, 160);\n}\n\nasync function analyzeJob(job) {\n const { profile, resumeText, config } = await storage.get(['profile', 'resumeText', 'config']);\n if (!profile) throw new Error('请先生成职业画像');\n const result = await callModel([\n {\n role: 'system',\n content: '你是为求职者服务的岗位匹配审查器,不是招聘方。用户是正在应聘岗位的求职者。任何能力、年限、项目和成果都不能超出简历事实。先判断学历、经验、地点等硬条件,再判断方向与技能。输出 JSON:{\"score\":0,\"decision\":\"recommend|cautious|reject\",\"hardBlocks\":[],\"matchedEvidence\":[],\"gaps\":[],\"risks\":[],\"reason\":\"\",\"greeting\":\"\"}。greeting 是求职者发给招聘方/HR 的第一人称求职招呼语,50-110字,应使用“您好,我想应聘贵公司的……”口吻。严禁写成招聘方口吻,严禁出现“看到你的简历”“你的经历很匹配我们”“欢迎进一步沟通”“我们团队”“候选人”等表述;不得承诺薪资、到岗、年限或不存在的能力。'\n },\n {\n role: 'user',\n content: `职业画像:${JSON.stringify(profile)}\n简历:${String(resumeText || '').slice(0, 15000)}\n岗位:${JSON.stringify(job)}`\n }\n ]);\n result.score = Math.max(0, Math.min(100, Number(result.score || 0)));\n result.greeting = normalizeApplicantGreeting(result, job, profile);\n if (Array.isArray(result.hardBlocks) && result.hardBlocks.length) result.decision = 'reject';\n if (result.score < Number(config?.minScore || 75) && result.decision === 'recommend') {\n result.decision = 'cautious';\n }\n return result;\n}\n\nfunction createTasks(profile, config, directionPlan) {\n const directions = selectedDirectionItems(directionPlan);\n const locations = config.targetLocations?.length ? config.targetLocations : [''];\n const employmentTypes = config.employmentTypes?.length ? config.employmentTypes : ['不限'];\n const tasks = [];\n const seen = new Set();\n for (const direction of directions) {\n for (const keyword of direction.keywords) {\n for (const location of locations) {\n for (const employmentType of employmentTypes) {\n const dedupeKey = [keyword, location, employmentType].map(value => String(value || '').trim().toLowerCase()).join('|');\n if (seen.has(dedupeKey)) continue;\n seen.add(dedupeKey);\n tasks.push({\n id: crypto.randomUUID(),\n directionId: direction.id,\n directionName: direction.name,\n directionPriority: direction.priority,\n directionScore: direction.score,\n keyword,\n location,\n employmentType,\n experience: config.experiences?.[0] || '',\n degree: config.degrees?.[0] || '',\n salary: config.salary || '不限',\n attempts: 0,\n status: 'pending',\n progress: 0,\n stageLabel: '等待开始',\n processed: 0,\n discovered: 0,\n analyzed: 0,\n failed: 0,\n createdAt: Date.now()\n });\n }\n }\n }\n }\n return tasks;\n}\n\n\nfunction salaryPriority(job = {}) {\n const source = `${job.salary || ''} ${job.cardText || ''}`;\n const numbers = [...source.matchAll(/(\\d+(?:\\.\\d+)?)/g)].map(match => Number(match[1])).filter(Number.isFinite);\n if (!numbers.length) return 0;\n let midpoint = numbers.length >= 2 ? (numbers[0] + numbers[1]) / 2 : numbers[0];\n if (/元\\/天|元每天|\\/天/.test(source)) midpoint *= 22 / 1000;\n if (/元\\/月|元每月/.test(source)) midpoint /= 1000;\n return Math.max(0, Math.min(180, Math.round(midpoint * 6)));\n}\n\nfunction freshnessPriority(job = {}) {\n const source = `${job.publishTime || ''} ${job.cardText || ''}`;\n if (/刚刚|最新|今日|今天|分钟前|小时内/.test(source)) return 120;\n if (/昨天|1天前/.test(source)) return 80;\n const days = Number(source.match(/(\\d+)\\s*天前/)?.[1] || 0);\n if (days > 0) return Math.max(0, 70 - days * 8);\n return 0;\n}\n\nfunction computeJobPriority(item = {}) {\n const analysis = item.analysis || {};\n const job = item.job || {};\n const hardBlocks = Array.isArray(analysis.hardBlocks) ? analysis.hardBlocks.length : 0;\n const risks = Array.isArray(analysis.risks) ? analysis.risks.length : 0;\n const gaps = Array.isArray(analysis.gaps) ? analysis.gaps.length : 0;\n const score = Math.max(0, Math.min(100, Number(analysis.score || 0)));\n let priority = score * 100;\n if (analysis.decision === 'recommend') priority += 900;\n if (analysis.decision === 'cautious') priority -= 350;\n if (analysis.decision === 'reject') priority -= 5000;\n priority -= hardBlocks * 2400;\n priority -= risks * 90;\n priority -= gaps * 45;\n priority += salaryPriority(job);\n priority += freshnessPriority(job);\n if (/外部网申|立即网申|去网申/.test(`${job.applicationMode || ''} ${job.cardText || ''}`)) priority -= 6000;\n priority -= Number(item.retryCount || 0) * 120;\n return Math.round(priority);\n}\n\nfunction pendingStatusRank(status) {\n return {\n approved: 0,\n approved_queue: 1,\n pending: 2,\n failed: 3,\n sent: 4,\n skipped: 5,\n rejected: 6,\n ignored: 7\n }[status] ?? 8;\n}\n\nfunction rerankPending(items = []) {\n const enriched = items.map(entry => ({\n ...entry,\n priorityScore: computeJobPriority(entry)\n }));\n enriched.sort((a, b) => {\n const statusDiff = pendingStatusRank(a.status) - pendingStatusRank(b.status);\n if (statusDiff) return statusDiff;\n const priorityDiff = Number(b.priorityScore || 0) - Number(a.priorityScore || 0);\n if (priorityDiff) return priorityDiff;\n const scoreDiff = Number(b.analysis?.score || 0) - Number(a.analysis?.score || 0);\n if (scoreDiff) return scoreDiff;\n return Number(a.createdAt || 0) - Number(b.createdAt || 0);\n });\n let queueRank = 0;\n return enriched.map(entry => {\n if (['approved', 'approved_queue', 'pending'].includes(entry.status)) queueRank += 1;\n return {\n ...entry,\n priorityRank: ['approved', 'approved_queue', 'pending'].includes(entry.status) ? queueRank : null\n };\n });\n}\n\nasync function dispatchNextAutoPending() {\n const { pending = [], workflow = {}, config = {} } = await storage.get(['pending', 'workflow', 'config']);\n if (config.executionMode !== 'auto') return { started: false, reason: 'not-auto' };\n if (workflow.pendingApplyId) return { started: false, reason: 'busy', pendingApplyId: workflow.pendingApplyId };\n const ranked = rerankPending(pending);\n const candidate = ranked.find(entry => entry.status === 'approved_queue');\n if (!candidate) {\n await storage.set({ pending: ranked });\n return { started: false, reason: 'empty' };\n }\n const next = rerankPending(ranked.map(entry => entry.id === candidate.id\n ? { ...entry, status: 'approved', approvedAt: entry.approvedAt || Date.now() }\n : entry));\n await storage.set({ pending: next });\n const run = await updateTaskRunByPending(candidate.id, {\n status: 'running',\n stage: 'queued',\n progress: 64,\n stageLabel: `优先投递 · 队列第 ${candidate.priorityRank || 1} 位`,\n error: '',\n retryable: true,\n completedAt: null\n });\n await patchWorkflow({\n running: true,\n paused: false,\n phase: 'apply',\n statusText: `按匹配优先级投递:${candidate.job?.title || '岗位'}(AI ${candidate.analysis?.score || 0} 分)`,\n pendingApplyId: candidate.id,\n activeRunId: run?.id || candidate.runId || null\n });\n await sendToBoss({ type: 'RUN' });\n return { started: true, item: candidate, queueDepth: next.filter(entry => entry.status === 'approved_queue').length };\n}\n\nasync function addPending(item) {\n const { pending = [], config = {} } = await storage.get(['pending', 'config']);\n let run = null;\n if (item.runId) {\n run = await upsertTaskRun({\n id: item.runId,\n job: item.job,\n analysis: item.analysis,\n searchTask: item.task,\n status: config.executionMode === 'auto' ? 'queued' : 'waiting_review',\n stage: config.executionMode === 'auto' ? 'queued' : 'waiting_review',\n progress: config.executionMode === 'auto' ? 64 : 60,\n stageLabel: config.executionMode === 'auto' ? '已进入自动排序队列' : '等待人工确认'\n });\n } else {\n run = await upsertTaskRun({\n job: item.job,\n analysis: item.analysis,\n searchTask: item.task,\n status: config.executionMode === 'auto' ? 'queued' : 'waiting_review',\n stage: config.executionMode === 'auto' ? 'queued' : 'waiting_review',\n progress: config.executionMode === 'auto' ? 64 : 60,\n stageLabel: config.executionMode === 'auto' ? '已进入自动排序队列' : '等待人工确认'\n });\n }\n const nextItem = {\n ...item,\n runId: run.id,\n id: item.id || crypto.randomUUID(),\n deliveryGreeting: String(item.deliveryGreeting || item.analysis?.greeting || '').trim(),\n status: config.executionMode === 'auto' ? 'approved_queue' : 'pending',\n createdAt: Date.now()\n };\n await upsertTaskRun({ id: run.id, pendingId: nextItem.id });\n const next = rerankPending([nextItem, ...pending.filter(entry => entry.id !== nextItem.id)]);\n await storage.set({ pending: next });\n const count = next.filter(entry => entry.status === 'pending').length;\n await storage.set({ stats: { ...(await storage.get('stats')).stats, pending: count } });\n return next.find(entry => entry.id === nextItem.id) || nextItem;\n}\n\nasync function approvePending(id, greeting = '') {\n const { pending = [] } = await storage.get('pending');\n const item = pending.find(entry => entry.id === id);\n if (!item) throw new Error('待确认岗位不存在');\n const lockedGreeting = String(greeting || item.deliveryGreeting || item.analysis?.greeting || '').trim();\n const updatedItem = {\n ...item,\n deliveryGreeting: lockedGreeting,\n analysis: { ...(item.analysis || {}), greeting: lockedGreeting },\n status: 'approved',\n approvedAt: Date.now()\n };\n const next = pending.map(entry => entry.id === id ? updatedItem : entry);\n await storage.set({ pending: next });\n const pendingCount = next.filter(entry => entry.status === 'pending').length;\n const { stats } = await storage.get('stats');\n await storage.set({ stats: { ...stats, pending: pendingCount } });\n const run = await updateTaskRunByPending(id, {\n status: 'running',\n stage: 'queued',\n progress: 64,\n stageLabel: '等待投递',\n error: '',\n retryable: true,\n completedAt: null\n });\n await patchWorkflow({\n running: true,\n paused: false,\n phase: 'apply',\n statusText: `准备以求职者身份沟通:${item.job?.title || '岗位'}`,\n pendingApplyId: id,\n activeRunId: run?.id || item.runId || null\n });\n await sendToBoss({ type: 'RUN' });\n setTimeout(() => sendToBoss({ type: 'RUN' }).catch(() => {}), 700);\n return updatedItem;\n}\n\nasync function approveAllPending() {\n const { pending = [] } = await storage.get('pending');\n const candidates = rerankPending(pending).filter(entry => entry.status === 'pending');\n if (!candidates.length) return { count: 0 };\n const ids = new Set(candidates.map(entry => entry.id));\n const next = pending.map(entry => ids.has(entry.id)\n ? { ...entry, status: 'approved_queue', approvedAt: Date.now() }\n : entry);\n const first = candidates[0];\n await storage.set({ pending: next });\n const { stats } = await storage.get('stats');\n await storage.set({ stats: { ...stats, pending: 0 } });\n for (const [index, candidate] of candidates.entries()) {\n await updateTaskRunByPending(candidate.id, {\n status: index === 0 ? 'running' : 'queued',\n stage: 'queued',\n progress: 64,\n stageLabel: index === 0 ? '准备投递' : '批量队列等待中',\n error: '',\n retryable: true,\n completedAt: null\n });\n }\n await patchWorkflow({\n running: true,\n paused: false,\n phase: 'apply',\n statusText: `批量投递 1/${candidates.length}:${first.job?.title || '岗位'}`,\n pendingApplyId: first.id,\n activeRunId: first.runId || null\n });\n await sendToBoss({ type: 'RUN' });\n return { count: candidates.length };\n}\n\nasync function rejectAllPending() {\n const { pending = [] } = await storage.get('pending');\n const count = pending.filter(entry => entry.status === 'pending').length;\n const next = pending.map(entry => entry.status === 'pending'\n ? { ...entry, status: 'rejected', rejectedAt: Date.now() }\n : entry);\n await storage.set({ pending: next });\n const { stats } = await storage.get('stats');\n await storage.set({ stats: { ...stats, pending: 0 } });\n for (const item of pending.filter(entry => entry.status === 'pending')) {\n await updateTaskRunByPending(item.id, { status: 'ignored', stage: 'ignored', progress: 100, stageLabel: '已忽略', retryable: false });\n }\n return { count };\n}\n\nasync function rejectPending(id) {\n const { pending = [] } = await storage.get('pending');\n const next = pending.map(entry => entry.id === id ? { ...entry, status: 'rejected', rejectedAt: Date.now() } : entry);\n await storage.set({ pending: next });\n const count = next.filter(entry => entry.status === 'pending').length;\n const { stats } = await storage.get('stats');\n await storage.set({ stats: { ...stats, pending: count } });\n await updateTaskRunByPending(id, { status: 'ignored', stage: 'ignored', progress: 100, stageLabel: '已忽略', retryable: false });\n}\n\n\nasync function skipPendingTask(id, reason = '该岗位需要外部网申,已自动跳过', stageLabel = '外部网申岗位已跳过') {\n const { pending = [], stats = {}, config = {}, chatTransition = null } = await storage.get(['pending', 'stats', 'config', 'chatTransition']);\n const completedAt = Date.now();\n let skippedItem = null;\n let next = pending.map(entry => {\n if (entry.id !== id) return entry;\n skippedItem = { ...entry, status: 'skipped', completedAt, error: reason };\n return skippedItem;\n });\n const updatedStats = {\n ...stats,\n pending: next.filter(entry => entry.status === 'pending').length\n };\n await storage.set({ pending: next, stats: updatedStats, chatTransition: chatTransition?.pendingId === id ? null : chatTransition });\n const run = await updateTaskRunByPending(id, {\n status: 'skipped', stage: 'skipped', progress: 100,\n stageLabel, error: reason, retryable: false, completedAt\n });\n await writeEvent('info', stageLabel, {\n id,\n runId: run?.id || skippedItem?.runId || '',\n job: skippedItem?.job,\n reason\n });\n\n next = rerankPending(next);\n const queued = next.find(entry => entry.status === 'approved_queue');\n if (queued) {\n next = next.map(entry => entry.id === queued.id ? { ...entry, status: 'approved' } : entry);\n await storage.set({ pending: next });\n const queuedRun = await updateTaskRunByPending(queued.id, {\n status: 'running', stage: 'queued', progress: 64,\n stageLabel: '准备投递', error: '', completedAt: null\n });\n await patchWorkflow({\n pendingApplyId: queued.id,\n activeRunId: queuedRun?.id || queued.runId || null,\n running: true,\n paused: false,\n phase: 'apply',\n statusText: `已跳过外部网申岗位,继续投递:${queued.job?.title || '岗位'}`\n });\n } else {\n await patchWorkflow({\n pendingApplyId: null,\n activeRunId: null,\n running: true,\n paused: false,\n phase: 'search',\n statusText: '外部网申岗位已跳过,继续搜索可直接沟通岗位'\n });\n }\n setTimeout(() => sendToBoss({ type: 'RUN' }).catch(() => {}), Math.max(3000, Math.min(10000, Number(config.betweenJobsSeconds || 5) * 1000)));\n return { item: skippedItem, queued: queued || null };\n}\n\nasync function handleBridgeCommands() {\n try {\n const response = await bridge('/commands');\n for (const command of response.commands || []) {\n if (command.type === 'start') {\n await patchWorkflow({ running: true, paused: false, statusText: '由 OpenClaw 启动' });\n await sendToBoss({ type: 'RUN' });\n } else if (command.type === 'pause') {\n await patchWorkflow({ paused: true, statusText: '由 OpenClaw 暂停' });\n } else if (command.type === 'stop') {\n await patchWorkflow({ running: false, paused: true, phase: 'idle', statusText: '由 OpenClaw 停止' });\n }\n }\n } catch {\n // 桌面桥接未运行时保持浏览器扩展独立可用。\n }\n}\n\nchrome.runtime.onInstalled.addListener(init);\nchrome.runtime.onStartup.addListener(init);\ninit();\n\nchrome.alarms.onAlarm.addListener(async alarm => {\n if (alarm.name !== 'jobclaw-tick') return;\n const { workflow } = await storage.get('workflow');\n if (workflow?.running && !workflow.paused) sendToBoss({ type: 'RUN' }).catch(() => {});\n handleBridgeCommands();\n});\n\nchrome.runtime.onMessage.addListener((message, sender, reply) => {\n (async () => {\n switch (message?.type) {\n case 'TRUSTED_CHAT_INPUT': {\n const tabId = sender?.tab?.id;\n const url = String(sender?.tab?.url || '');\n if (!/^https:\\/\\/(?:www|app)\\.zhipin\\.com\\//i.test(url)) throw new Error('可信输入只能用于当前 BOSS 页面');\n const result = await trustedChatInput(tabId, message);\n reply({ ok: true, ...result });\n break;\n }\n case 'GET_STATE':\n reply({ ok: true, state: publicState(await storage.all()) });\n break;\n case 'SAVE_CONFIG': {\n const { config } = await storage.get('config');\n const incoming = message.config || {};\n const oldKey = config?.model?.apiKey || '';\n incoming.model = {\n ...(config?.model || {}),\n ...(incoming.model || {}),\n apiKey: incoming.model?.apiKey && incoming.model.apiKey !== '***'\n ? incoming.model.apiKey\n : oldKey\n };\n incoming.executionMode = incoming.executionMode === 'auto' ? 'auto' : 'review';\n incoming.dailyTarget = Math.max(1, Math.min(300, Number(incoming.dailyTarget || config?.dailyTarget || 150)));\n incoming.discoveryLimit = 0;\n incoming.model.baseUrl = String(incoming.model.baseUrl || 'https://api.deepseek.com').trim();\n incoming.model.model = String(incoming.model.model || 'deepseek-v4-pro').trim();\n await storage.set({ config: { ...(config || {}), ...incoming } });\n await writeEvent('info', '设置已保存');\n reply({ ok: true });\n break;\n }\n case 'SET_RESUME':\n await storage.set({ resumeText: String(message.text || '') });\n await writeEvent('info', '简历文本已保存', { length: String(message.text || '').length });\n reply({ ok: true });\n break;\n case 'SET_RESUME_SOURCE': {\n const file = message.file || null;\n if (!file?.name || !file?.dataUrl) throw new Error('简历原文件数据不完整');\n if (String(file.dataUrl).length > 26 * 1024 * 1024) throw new Error('简历原文件过大');\n const source = {\n name: String(file.name),\n type: String(file.type || ''),\n size: Number(file.size || 0),\n lastModified: Number(file.lastModified || 0),\n dataUrl: String(file.dataUrl),\n savedAt: Date.now()\n };\n await storage.set({ resumeSourceFile: source });\n await writeEvent('info', '简历原文件已保留', { name: source.name, size: source.size });\n reply({ ok: true, file: { name: source.name, type: source.type, size: source.size, stored: true } });\n break;\n }\n case 'PARSE_RESUME_PDF': {\n const { resumeSourceFile } = await storage.get('resumeSourceFile');\n if (!resumeSourceFile?.dataUrl) throw new Error('请先选择 PDF 简历');\n if (!String(resumeSourceFile.name || '').toLowerCase().endsWith('.pdf') && resumeSourceFile.type !== 'application/pdf') {\n throw new Error('当前保留的文件不是 PDF');\n }\n try {\n const result = await bridge('/parse-resume', {\n name: resumeSourceFile.name,\n type: resumeSourceFile.type,\n dataUrl: resumeSourceFile.dataUrl\n });\n if (result?.text) await writeEvent('info', 'PDF 本机深度识别完成', { method: result.method, length: result.text.length });\n reply({ ok: Boolean(result?.ok), result, error: result?.error || '' });\n } catch (error) {\n reply({ ok: false, error: `桌面桥接不可用:${error.message}` });\n }\n break;\n }\n case 'SET_IMAGE':\n await storage.set({ resumeImage: message.dataUrl || null });\n await writeEvent('info', message.dataUrl ? '简历图片已保存' : '简历图片已移除');\n reply({ ok: true });\n break;\n case 'BUILD_LOCAL_PROFILE':\n case 'ENSURE_PROFILE_DRAFT': {\n const { resumeText, profile: currentProfile, profileDraft: currentDraft } = await storage.get(['resumeText', 'profile', 'profileDraft']);\n if (profileDraftHasCore(currentDraft) && profileHasCore(currentProfile)) {\n reply({ ok: true, profile: currentProfile, profileDraft: currentDraft, skipped: true });\n break;\n }\n if (profileHasCore(currentProfile)) {\n const profileDraft = profileDraftHasAny(currentDraft)\n ? normalizeProfileDraft(currentDraft, profileToDraft(currentProfile, 'profile-repair'))\n : profileToDraft(currentProfile, 'profile-repair');\n await storage.set({ profileDraft });\n reply({ ok: true, profile: currentProfile, profileDraft, repaired: true });\n break;\n }\n const profile = buildLocalProfile(resumeText || '');\n profile.generation.mode = 'local-recovery';\n profile.generation.label = '本地初稿';\n profile.generation.warning = '已从保存的简历自动恢复可编辑初稿。';\n const profileDraft = profileToDraft(profile, 'local-recovery');\n const { directionPlan: currentDirectionPlan } = await storage.get('directionPlan');\n const directionPlan = buildDirectionPlan(profile, currentDirectionPlan, {\n confirmed: false,\n preserveSelections: true,\n preserveEdits: true,\n preserveCustom: true\n });\n await storage.set({ profile, profileDraft, directionPlan });\n await writeEvent('info', '已从已保存简历恢复可编辑画像', { directions: profile.primaryDirections });\n reply({ ok: true, profile, profileDraft, directionPlan, generation: profile.generation });\n break;\n }\n case 'BUILD_PROFILE': {\n const { resumeText, directionPlan: currentDirectionPlan } = await storage.get(['resumeText', 'directionPlan']);\n const profile = await buildProfile(resumeText || '');\n const profileDraft = profileToDraft(profile, profile.generation?.mode || 'generated');\n const directionPlan = buildDirectionPlan(profile, currentDirectionPlan, {\n confirmed: false,\n preserveSelections: true,\n preserveEdits: true,\n preserveCustom: true\n });\n await storage.set({ profile, profileDraft, directionPlan });\n const generation = profile.generation || {};\n const local = generation.mode === 'local-fallback';\n const eventTitle = !local\n ? (generation.mode === 'ai-compact-retry' ? 'AI 精简重试成功,职业画像已生成' : '职业画像已生成')\n : generation.aiStatus === 'service-error'\n ? 'AI 请求失败,已生成本地可编辑画像'\n : generation.aiStatus === 'config-missing'\n ? 'AI 尚未配置,已生成本地可编辑画像'\n : 'AI 输出未通过校验,已生成本地可编辑画像';\n await writeEvent(local ? 'warning' : 'info', eventTitle,\n { directions: profile.primaryDirections, generation });\n reply({ ok: true, profile, profileDraft, directionPlan, generation, warning: generation.warning || '' });\n break;\n }\n case 'SAVE_PROFILE_DRAFT': {\n const { profileDraft: currentDraft } = await storage.get('profileDraft');\n const profileDraft = normalizeProfileDraft(message.profileDraft, currentDraft);\n await storage.set({ profileDraft });\n reply({ ok: true, profileDraft });\n break;\n }\n case 'SAVE_PROFILE': {\n const { profile: currentProfile, config = {}, profileDraft: currentDraft, directionPlan: currentDirectionPlan } = await storage.get(['profile', 'config', 'profileDraft', 'directionPlan']);\n const profile = normalizeProfile(message.profile, currentProfile);\n const profileDraft = profileToDraft(profile, 'manual-save');\n const keepDirectionConfirmation = Boolean(\n currentDirectionPlan?.confirmed\n && profileDirectionSignature(currentProfile || {}) === profileDirectionSignature(profile)\n );\n const directionPlan = buildDirectionPlan(profile, currentDirectionPlan, {\n confirmed: keepDirectionConfirmation,\n preserveSelections: true,\n preserveEdits: true,\n preserveCustom: true\n });\n const hard = profile.hardConstraints || {};\n const nextConfig = {\n ...config,\n targetLocations: hard.locations?.length ? hard.locations : (config.targetLocations || []),\n employmentTypes: hard.employmentTypes?.length ? hard.employmentTypes : (config.employmentTypes || ['不限']),\n experiences: hard.experience ? [hard.experience] : (config.experiences || []),\n degrees: hard.degree ? [hard.degree] : (config.degrees || []),\n salary: hard.salary || config.salary || '不限'\n };\n await storage.set({ profile, profileDraft, directionPlan, config: nextConfig });\n await writeEvent('info', '职业画像已手动保存', { directions: profile.primaryDirections });\n reply({ ok: true, profile, profileDraft, directionPlan, config: nextConfig });\n break;\n }\n case 'REBUILD_DIRECTION_PLAN': {\n const stored = await storage.get(['profile', 'profileDraft', 'directionPlan']);\n let profile = stored.profile;\n if (!profileHasCore(profile) && profileDraftHasCore(stored.profileDraft)) {\n profile = profileFromDraft(stored.profileDraft, profile);\n await storage.set({ profile });\n }\n if (!profileHasCore(profile)) throw new Error('请先生成职业画像');\n const directionPlan = buildDirectionPlan(profile, stored.directionPlan, {\n confirmed: false,\n preserveSelections: false,\n preserveEdits: false,\n preserveCustom: true\n });\n await storage.set({ directionPlan });\n await writeEvent('info', '已重新生成岗位方向推荐', {\n directions: directionPlan.items.map(item => item.name),\n enabled: directionPlan.items.filter(item => item.enabled).length\n });\n reply({ ok: true, directionPlan });\n break;\n }\n case 'SAVE_DIRECTION_PLAN': {\n const stored = await storage.get(['profile', 'config', 'workflow', 'directionPlan']);\n if (!profileHasCore(stored.profile)) throw new Error('请先生成职业画像');\n const directionPlan = normalizeDirectionPlan(message.directionPlan, stored.profile, {\n confirmed: true,\n updatedAt: Date.now(),\n appliedAt: Date.now()\n });\n const selected = selectedDirectionItems(directionPlan);\n if (!selected.length) throw new Error('至少选择一个要投递的岗位方向');\n const tasks = createTasks(stored.profile, stored.config || DEFAULTS.config, directionPlan);\n if (!tasks.length) throw new Error('所选岗位方向没有可用搜索词,请先补充关键词');\n await storage.set({ directionPlan });\n await writeEvent('info', '投递岗位方向已保存', {\n directions: selected.map(item => item.name),\n taskCount: tasks.length,\n appliesNextRun: Boolean(stored.workflow?.running)\n });\n reply({\n ok: true,\n directionPlan,\n selectedCount: selected.length,\n taskCount: tasks.length,\n appliesNextRun: Boolean(stored.workflow?.running)\n });\n break;\n }\n case 'TEST_AI': {\n const text = await callModel([\n { role: 'system', content: '只回复“连接正常”。' },\n { role: 'user', content: '测试连接' }\n ], false);\n reply({ ok: true, text });\n break;\n }\n case 'PROBE_BOSS': {\n const tab = await activeBossTab();\n if (!tab) throw new Error('请先打开并登录 BOSS 直聘');\n const result = await ensureBossReceiver(tab);\n reply({ ok: true, result: { ...result, tabId: tab.id, title: tab.title || '', url: tab.url || '' } });\n break;\n }\n case 'START': {\n const stored = await storage.get(['profile', 'profileDraft', 'resumeText', 'config', 'directionPlan']);\n let profile = stored.profile;\n const config = stored.config;\n if (!profileHasCore(profile) && profileDraftHasCore(stored.profileDraft)) {\n profile = profileFromDraft(stored.profileDraft, profile);\n await storage.set({ profile });\n }\n if (!profileHasCore(profile) && String(stored.resumeText || '').trim().length >= 30) {\n profile = buildLocalProfile(stored.resumeText || '');\n profile.generation.mode = 'local-recovery';\n profile.generation.label = '本地初稿';\n const profileDraft = profileToDraft(profile, 'start-recovery');\n await storage.set({ profile, profileDraft });\n }\n if (!profileHasCore(profile)) throw new Error('请先生成职业画像');\n let directionPlan = stored.directionPlan;\n if (!directionPlan?.items?.length) {\n directionPlan = buildDirectionPlan(profile, null, { confirmed: false });\n await storage.set({ directionPlan });\n }\n directionPlan = normalizeDirectionPlan(directionPlan, profile);\n if (!directionPlan.confirmed) throw new Error('请先在“简历 → 职业画像”中选择要投递的岗位方向并保存');\n if (!selectedDirectionItems(directionPlan).length) throw new Error('至少选择一个要投递的岗位方向');\n const tasks = createTasks(profile, config, directionPlan);\n if (!tasks.length) throw new Error('所选岗位方向没有可执行的搜索词,请补充方向关键词');\n const tab = await activeBossTab();\n if (!tab) throw new Error('请先打开并登录 BOSS 直聘');\n await ensureBossReceiver(tab);\n const workflow = {\n ...DEFAULTS.workflow,\n running: true,\n paused: false,\n phase: 'search',\n statusText: '正在启动岗位采集',\n tasks,\n activeRunId: null\n };\n await storage.set({ workflow });\n try {\n await sendToBossTab(tab, { type: 'RUN' });\n await writeEvent('info', '任务已启动', { taskCount: workflow.tasks.length, tabId: tab.id });\n reply({ ok: true });\n } catch (error) {\n await patchWorkflow({\n running: false,\n paused: true,\n phase: 'idle',\n statusText: 'BOSS 页面连接失败,任务未启动'\n });\n throw error;\n }\n break;\n }\n case 'PAUSE':\n await patchWorkflow({ paused: true, statusText: '用户已暂停' });\n await writeEvent('info', '任务已暂停');\n reply({ ok: true });\n break;\n case 'STOP':\n await patchWorkflow({ running: false, paused: true, phase: 'idle', statusText: '用户已停止', pendingApplyId: null, activeRunId: null });\n await writeEvent('info', '任务已停止');\n reply({ ok: true });\n break;\n case 'AI_JOB':\n reply({ ok: true, result: await analyzeJob(message.job) });\n break;\n case 'CHAT_BINDING_PREPARE': {\n if (!message.pendingId || !message.expected) throw new Error('岗位与 HR 会话绑定信息不完整');\n const transition = {\n pendingId: String(message.pendingId),\n job: safeClone(message.job || {}),\n expected: safeClone(message.expected || {}),\n preparedAt: Date.now()\n };\n await storage.set({ chatTransition: transition });\n reply({ ok: true, transition });\n break;\n }\n case 'CHAT_BINDING_CHECK': {\n const expected = message.expected || {};\n const recruiterName = String(message.context?.recruiterName || expected.recruiterName || '').trim();\n const company = String(message.context?.companyName || expected.company || message.job?.company || '').trim();\n const key = deriveConversationReservationKey(message.context || {}, expected, message.pendingId);\n if (!key) {\n reply({ ok: false, allowed: false, error: '目标 HR 已核验,但无法建立安全投递锁' });\n break;\n }\n const { chatDeliveryLedger = {} } = await storage.get('chatDeliveryLedger');\n const existing = chatDeliveryLedger[key];\n const identityExisting = Object.values(chatDeliveryLedger).find(entry =>\n entry?.pendingId !== message.pendingId\n && ['reserved', 'sent'].includes(String(entry?.status || ''))\n && sameRecruiterReservation(entry, recruiterName, company)\n );\n const conflict = existing && existing.pendingId !== message.pendingId ? existing : identityExisting;\n if (conflict) {\n reply({\n ok: true,\n allowed: false,\n error: `当前 HR 会话已绑定其他岗位任务,禁止继续发送`,\n existing: conflict\n });\n break;\n }\n const reservation = {\n ...(existing || {}),\n conversationKey: key,\n pendingId: String(message.pendingId || ''),\n runId: String(message.runId || ''),\n jobUrl: String(message.job?.url || ''),\n jobTitle: String(message.job?.title || ''),\n company: String(message.job?.company || ''),\n recruiterName,\n company,\n chatUrl: String(message.context?.url || ''),\n status: existing?.status === 'sent' ? 'sent' : 'reserved',\n reservedAt: existing?.reservedAt || Date.now(),\n updatedAt: Date.now()\n };\n await storage.set({ chatDeliveryLedger: { ...chatDeliveryLedger, [key]: reservation } });\n reply({ ok: true, allowed: true, conversationKey: key, reservation });\n break;\n }\n case 'CHAT_BINDING_CONFIRMED': {\n const transition = {\n pendingId: String(message.pendingId || ''),\n job: safeClone(message.job || {}),\n expected: safeClone(message.expected || {}),\n context: safeClone(message.context || {}),\n confirmedAt: Date.now()\n };\n await storage.set({ chatTransition: transition });\n reply({ ok: true, transition });\n break;\n }\n case 'MATERIAL': {\n const { resumeImage, config } = await storage.get(['resumeImage', 'config']);\n reply({ ok: true, resumeImage, config });\n break;\n }\n case 'CONTENT_STATE':\n reply({ ok: true, state: await storage.all() });\n break;\n case 'WORKFLOW':\n reply({ ok: true, workflow: await patchWorkflow(message.patch || {}) });\n break;\n case 'STATS':\n reply({ ok: true, stats: await changeStats(message.delta || {}) });\n break;\n case 'TASK_PROGRESS': {\n const run = await upsertTaskRun(message.run || {});\n if (message.setActive) await patchWorkflow({ activeRunId: run.id });\n reply({ ok: true, run });\n break;\n }\n case 'SEARCH_TASK_PROGRESS':\n reply({ ok: true, task: await updateSearchTaskProgress(message) });\n break;\n case 'RETRY_FAILED_TASK':\n reply({ ok: true, ...(await retryFailedTask(message.runId)) });\n break;\n case 'RETRY_ALL_FAILED_TASKS':\n reply({ ok: true, ...(await retryAllFailedTasks()) });\n break;\n case 'IGNORE_FAILED_TASK':\n reply({ ok: true, run: await ignoreFailedTask(message.runId) });\n break;\n case 'OPEN_TASK_JOB': {\n const url = String(message.url || '').trim();\n if (!/^https:\\/\\/(?:www|app)\\.zhipin\\.com\\//i.test(url)) throw new Error('岗位地址无效');\n const tab = await chrome.tabs.create({ url, active: true });\n reply({ ok: true, tabId: tab.id });\n break;\n }\n case 'EVENT':\n reply({ ok: true, event: await writeEvent(message.level || 'info', message.message || '', message.data || {}) });\n break;\n case 'PENDING':\n reply({ ok: true, item: await addPending(message.item || {}) });\n break;\n case 'APPROVE':\n reply({ ok: true, item: await approvePending(message.id, message.greeting || '') });\n break;\n case 'REJECT':\n await rejectPending(message.id);\n reply({ ok: true });\n break;\n case 'APPROVE_ALL':\n reply({ ok: true, ...(await approveAllPending()) });\n break;\n case 'REJECT_ALL':\n reply({ ok: true, ...(await rejectAllPending()) });\n break;\n case 'AUTO_APPROVE': {\n const { pending = [] } = await storage.get('pending');\n const next = rerankPending(pending.map(entry => entry.id === message.id\n ? {\n ...entry,\n deliveryGreeting: String(message.greeting || entry.deliveryGreeting || entry.analysis?.greeting || '').trim(),\n status: 'approved_queue',\n approvedAt: Date.now()\n }\n : entry));\n await storage.set({ pending: next });\n reply({ ok: true, ...(await dispatchNextAutoPending()) });\n break;\n }\n case 'AUTO_DISPATCH_NEXT':\n reply({ ok: true, ...(await dispatchNextAutoPending()) });\n break;\n case 'SKIP_PENDING':\n reply({ ok: true, ...(await skipPendingTask(message.id, message.reason, message.stageLabel)) });\n break;\n case 'APPLY_COMPLETE': {\n const { pending = [], config = {}, stats = {}, chatDeliveryLedger = {}, chatTransition = null } = await storage.get(['pending', 'config', 'stats', 'chatDeliveryLedger', 'chatTransition']);\n const completedAt = Date.now();\n let completedItem = null;\n let next = pending.map(entry => {\n if (entry.id !== message.id) return entry;\n completedItem = { ...entry, status: message.ok ? 'sent' : 'failed', completedAt, error: message.error || '' };\n return completedItem;\n });\n const updatedStats = {\n ...stats,\n pending: next.filter(entry => entry.status === 'pending').length,\n sent: Number(stats?.sent || 0) + (message.ok ? 1 : 0),\n failed: Number(stats?.failed || 0) + (message.ok ? 0 : 1)\n };\n const conversationKey = String(message.conversation?.key || '');\n let nextLedger = chatDeliveryLedger;\n if (conversationKey && chatDeliveryLedger[conversationKey]?.pendingId === message.id) {\n nextLedger = {\n ...chatDeliveryLedger,\n [conversationKey]: {\n ...chatDeliveryLedger[conversationKey],\n status: message.ok ? 'sent' : 'failed',\n sentAt: message.ok ? completedAt : (chatDeliveryLedger[conversationKey].sentAt || null),\n failedAt: message.ok ? null : completedAt,\n error: message.error || '',\n updatedAt: completedAt\n }\n };\n }\n const nextTransition = chatTransition?.pendingId === message.id ? null : chatTransition;\n await storage.set({ pending: next, stats: updatedStats, chatDeliveryLedger: nextLedger, chatTransition: nextTransition });\n const completedRun = await updateTaskRunByPending(message.id, message.ok\n ? {\n status: 'success', stage: 'success', progress: 100, stageLabel: '投递成功', error: '',\n retryable: false, completedAt, result: message.result || 'sent'\n }\n : {\n status: 'failed', stage: message.stage || 'failed', progress: 100,\n stageLabel: message.stageLabel || '投递失败', error: message.error || '投递失败',\n retryable: message.retryable !== false, retryRequestedAt: 0, completedAt\n });\n await writeEvent(message.ok ? 'success' : 'error', message.ok ? '求职投递成功' : '求职投递失败', {\n id: message.id,\n runId: completedRun?.id || completedItem?.runId || '',\n job: completedItem?.job,\n stage: message.stage || '',\n error: message.error || ''\n });\n\n if (updatedStats.sent >= Number(config.dailyTarget || 150)) {\n await patchWorkflow({ running: false, paused: true, pendingApplyId: null, activeRunId: null, phase: 'idle', statusText: `已完成今日 ${updatedStats.sent} 次成功投递` });\n reply({ ok: true, targetReached: true });\n break;\n }\n\n // 任何文字未确认场景都暂停当前队列,但只有输入框中确实存在完整招呼语时,\n // 才提示“草稿已保留”。输入框为空时必须明确提示写入失败,不能误导用户。\n if (!message.ok && message.pauseQueue) {\n const draftPresent = message.preserveDraft === true || message.draftPresent === true;\n await patchWorkflow({\n running: false,\n paused: true,\n pendingApplyId: null,\n activeRunId: completedRun?.id || completedItem?.runId || null,\n phase: 'apply',\n statusText: draftPresent\n ? '发送未确认,已暂停队列;当前招呼语仍在输入框'\n : '文字没有写入或没有发送,已暂停队列并停留在当前 HR 页面'\n });\n await writeEvent('warning', draftPresent\n ? '自动投递已暂停,当前招呼语仍保留在输入框'\n : '自动投递已暂停,聊天输入框中没有完整招呼语', {\n id: message.id,\n runId: completedRun?.id || completedItem?.runId || '',\n job: completedItem?.job,\n draftPresent,\n editorDiagnostics: message.editorDiagnostics || null,\n error: message.error || ''\n });\n reply({ ok: true, pausedForDraft: draftPresent, pausedForSendFailure: true });\n break;\n }\n\n next = rerankPending(next);\n const queued = next.find(entry => entry.status === 'approved_queue');\n if (queued) {\n next = next.map(entry => entry.id === queued.id ? { ...entry, status: 'approved' } : entry);\n await storage.set({ pending: next });\n const remaining = next.filter(entry => entry.status === 'approved_queue').length;\n const queuedRun = await updateTaskRunByPending(queued.id, {\n status: 'running', stage: 'queued', progress: 64, stageLabel: '准备投递', error: '', completedAt: null\n });\n await patchWorkflow({\n pendingApplyId: queued.id,\n activeRunId: queuedRun?.id || queued.runId || null,\n running: true,\n paused: false,\n phase: 'apply',\n statusText: `继续批量投递:${queued.job?.title || '岗位'}(剩余 ${remaining})`\n });\n const pacingMs = Math.max(3000, Math.min(30000, Number(config.betweenJobsSeconds || 12) * 1000));\n await patchWorkflow({ statusText: `当前岗位处理完成,${Math.round(pacingMs / 1000)} 秒后继续批量投递` });\n setTimeout(() => sendToBoss({ type: 'RUN' }).catch(() => {}), pacingMs);\n } else {\n const pacingMs = Math.max(3000, Math.min(30000, Number(config.betweenJobsSeconds || 12) * 1000));\n await patchWorkflow({ pendingApplyId: null, activeRunId: null, phase: 'search', statusText: message.ok ? `文字投递已确认,${Math.round(pacingMs / 1000)} 秒后继续筛选` : `当前岗位失败,${Math.round(pacingMs / 1000)} 秒后继续筛选` });\n setTimeout(() => sendToBoss({ type: 'RUN' }).catch(() => {}), pacingMs);\n }\n reply({ ok: true });\n break;\n }\n case 'BRIDGE_STATUS':\n try { reply({ ok: true, result: await bridge('/status') }); }\n catch (error) { reply({ ok: false, error: error.message }); }\n break;\n case 'BRIDGE_REPORT':\n try { reply({ ok: true, result: await bridge('/report') }); }\n catch (error) { reply({ ok: false, error: error.message }); }\n break;\n case 'BRIDGE_COMMAND':\n try { reply({ ok: true, result: await bridge('/command', { type: message.command }) }); }\n catch (error) { reply({ ok: false, error: error.message }); }\n break;\n default:\n reply({ ok: false, error: 'unknown message' });\n }\n })().catch(async error => {\n await writeEvent('error', '后台处理失败', { type: message?.type, error: error.message });\n reply({ ok: false, error: error.message });\n });\n return true;\n});\n"}, "50acc0b6102f67c00c1ef1ce8cd500e3a9386a31": {"t": 1786067827.4746614, "n": 157588, "h": "b62afbe9c8346314", "c": "import { DEFAULTS, safeClone, today, uniq } from './common.js';\nimport { normalizeConversationIdentity, deriveConversationReservationKey, sameRecruiterReservation } from './lib/conversation-identity.js';\nimport { TERMINAL_RUN_STATUSES, taskStageMeta } from './lib/task-state.js';\nimport { rerankPending } from './lib/job-priority.js';\nimport { computeRateLimitDecision, evaluateStrategy, normalizeStrategy, recordRateAction, recordSafetyOutcome, resetSafetyCircuit, strictHardBlocks } from './lib/safety-control.js';\nimport { companyCacheKey, companyVerificationExpired, heuristicCompanyVerification, mergeCompanyVerification } from './lib/company-verifier.js';\nimport { createHistoryEntry, findDuplicate } from './lib/deduplication.js';\nimport { normalizeRelease } from './lib/update-checker.js';\n\nconst BRIDGE_ENDPOINTS = ['http://127.0.0.1:17899', 'http://localhost:17899'];\nconst NATIVE_BRIDGE_HOST = 'com.jobclaw.bridge';\nlet lastBridgeSnapshotAt = 0;\nlet bridgeUnavailableUntil = 0;\nlet bridgeLastError = '';\nconst EXPECTED_CONTENT_VERSION = '1.7.0';\nconst CONTENT_SCRIPT_FILE = 'content-v37.js';\nconst storage = {\n get: keys => chrome.storage.local.get(keys),\n all: () => chrome.storage.local.get(null),\n set: patch => chrome.storage.local.set(patch)\n};\n\nconst debuggerLocks = new Map();\n\nconst OFFSCREEN_DOCUMENT_PATH = 'offscreen.html';\nlet offscreenCreation = null;\n\nasync function ensureClipboardOffscreen() {\n if (!chrome.offscreen?.createDocument) return false;\n const url = chrome.runtime.getURL(OFFSCREEN_DOCUMENT_PATH);\n if (chrome.runtime.getContexts) {\n const contexts = await chrome.runtime.getContexts({\n contextTypes: ['OFFSCREEN_DOCUMENT'],\n documentUrls: [url]\n }).catch(() => []);\n if (contexts?.length) return true;\n }\n if (!offscreenCreation) {\n offscreenCreation = chrome.offscreen.createDocument({\n url: OFFSCREEN_DOCUMENT_PATH,\n reasons: ['CLIPBOARD'],\n justification: '将求职招呼语以真实粘贴方式写入 BOSS 聊天编辑器'\n }).then(() => true).catch(error => {\n const message = String(error?.message || error || '');\n if (/single offscreen|already exists|existing offscreen/i.test(message)) return true;\n throw error;\n }).finally(() => { offscreenCreation = null; });\n }\n return offscreenCreation;\n}\n\nasync function writeClipboardText(text) {\n const value = String(text || '');\n if (!value) return false;\n try {\n const ready = await ensureClipboardOffscreen();\n if (!ready) return false;\n const response = await chrome.runtime.sendMessage({ type: 'JOBCLAW_CLIPBOARD_WRITE', text: value });\n return response?.ok === true;\n } catch {\n return false;\n }\n}\n\nfunction validPoint(point) {\n const x = Number(point?.x);\n const y = Number(point?.y);\n if (!Number.isFinite(x) || !Number.isFinite(y) || x < 0 || y < 0) {\n throw new Error('聊天输入坐标无效');\n }\n return { x, y };\n}\n\nasync function debuggerBringToFront(target) {\n await chrome.debugger.sendCommand(target, 'Page.bringToFront').catch(() => {});\n await chrome.debugger.sendCommand(target, 'Runtime.evaluate', {\n expression: 'window.focus(); true',\n returnByValue: true\n }).catch(() => {});\n}\n\nasync function debuggerNodeAtPoint(target, point) {\n const { x, y } = validPoint(point);\n const result = await chrome.debugger.sendCommand(target, 'DOM.getNodeForLocation', {\n x, y,\n includeUserAgentShadowDOM: true,\n ignorePointerEventsNone: true\n }).catch(() => null);\n return {\n nodeId: Number(result?.nodeId || 0),\n backendNodeId: Number(result?.backendNodeId || 0),\n frameId: String(result?.frameId || '')\n };\n}\n\nasync function debuggerResolveNodeObject(target, node = {}) {\n const args = {};\n if (Number(node.nodeId || 0)) args.nodeId = Number(node.nodeId);\n else if (Number(node.backendNodeId || 0)) args.backendNodeId = Number(node.backendNodeId);\n else return '';\n const resolved = await chrome.debugger.sendCommand(target, 'DOM.resolveNode', args).catch(() => null);\n return String(resolved?.object?.objectId || '');\n}\n\nasync function debuggerReadObjectText(target, objectId = '') {\n if (!objectId) return '';\n const result = await chrome.debugger.sendCommand(target, 'Runtime.callFunctionOn', {\n objectId,\n functionDeclaration: `function() {\n if ('value' in this && !this.isContentEditable) return String(this.value || '');\n return String(this.innerText || this.textContent || '');\n }`,\n returnByValue: true,\n silent: true\n }).catch(() => null);\n return String(result?.result?.value || '');\n}\n\nasync function debuggerReadNodeText(target, node = {}) {\n const objectId = await debuggerResolveNodeObject(target, node);\n return debuggerReadObjectText(target, objectId);\n}\n\nasync function debuggerFindEditableObject(target, message = {}) {\n const selector = String(message.selector || '').trim();\n const point = validPoint(message.point);\n const expression = `(() => {\n const exactSelector = ${JSON.stringify(selector)};\n const point = ${JSON.stringify(point)};\n const editableSelector = 'textarea,input[type=\"text\"],input:not([type]),[contenteditable]:not([contenteditable=\"false\"]),[role=\"textbox\"],[data-slate-editor=\"true\"],[data-lexical-editor=\"true\"]';\n const isEditable = node => {\n if (!node || node.nodeType !== 1) return false;\n const tag = String(node.tagName || '').toLowerCase();\n if (tag === 'textarea') return true;\n if (tag === 'input') {\n const type = String(node.getAttribute('type') || 'text').toLowerCase();\n return ['text', 'search', ''].includes(type);\n }\n const mode = String(node.getAttribute('contenteditable') || '').toLowerCase();\n return node.isContentEditable\n || Boolean(mode && !['false', 'inherit', 'off'].includes(mode))\n || node.getAttribute('role') === 'textbox'\n || node.getAttribute('data-slate-editor') === 'true'\n || node.getAttribute('data-lexical-editor') === 'true';\n };\n const deepQuery = (root, query) => {\n if (!root) return null;\n try {\n const direct = root.querySelector?.(query);\n if (direct) return direct;\n } catch {}\n let elements = [];\n try { elements = [...(root.querySelectorAll?.('*') || [])]; } catch {}\n for (const element of elements) {\n if (element.shadowRoot) {\n const found = deepQuery(element.shadowRoot, query);\n if (found) return found;\n }\n }\n return null;\n };\n const nearestEditable = node => {\n let current = node;\n for (let depth = 0; current && depth < 16; depth += 1) {\n if (isEditable(current)) return current;\n let nested = null;\n try { nested = current.querySelector?.(editableSelector); } catch {}\n if (nested && isEditable(nested)) return nested;\n const root = current.getRootNode?.();\n current = current.parentElement || (root && root.host) || null;\n }\n return null;\n };\n let node = exactSelector ? deepQuery(document, exactSelector) : null;\n node = nearestEditable(node) || node;\n if (!isEditable(node)) {\n let pointNode = null;\n try { pointNode = document.elementFromPoint(point.x, point.y); } catch {}\n node = nearestEditable(pointNode);\n }\n if (!isEditable(node)) return null;\n try { node.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'instant' }); } catch {}\n try { node.focus({ preventScroll: true }); } catch { try { node.focus(); } catch {} }\n if (node.isContentEditable) {\n try {\n const selection = window.getSelection();\n const range = document.createRange();\n range.selectNodeContents(node);\n range.collapse(false);\n selection.removeAllRanges();\n selection.addRange(range);\n } catch {}\n }\n return node;\n })()`;\n const result = await chrome.debugger.sendCommand(target, 'Runtime.evaluate', {\n expression,\n returnByValue: false,\n awaitPromise: false,\n includeCommandLineAPI: false\n }).catch(() => null);\n const objectId = String(result?.result?.objectId || '');\n if (!objectId) return { objectId: '', nodeId: 0, point, selector };\n const requested = await chrome.debugger.sendCommand(target, 'DOM.requestNode', { objectId }).catch(() => null);\n const nodeId = Number(requested?.nodeId || 0);\n return { objectId, nodeId, point, selector };\n}\n\nasync function debuggerFocusObject(target, objectId = '') {\n if (!objectId) return null;\n const result = await chrome.debugger.sendCommand(target, 'Runtime.callFunctionOn', {\n objectId,\n functionDeclaration: `function() {\n try { this.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'instant' }); } catch {}\n try { this.focus({ preventScroll: true }); } catch { try { this.focus(); } catch {} }\n if (this.isContentEditable) {\n try {\n const selection = window.getSelection();\n const range = document.createRange();\n range.selectNodeContents(this);\n range.collapse(false);\n selection.removeAllRanges();\n selection.addRange(range);\n } catch {}\n }\n const rect = this.getBoundingClientRect();\n return { left: rect.left, top: rect.top, width: rect.width, height: rect.height, tag: this.tagName || '', id: this.id || '' };\n }`,\n returnByValue: true,\n silent: true\n }).catch(() => null);\n return result?.result?.value || null;\n}\n\nasync function debuggerPasteClipboard(target) {\n await chrome.debugger.sendCommand(target, 'Input.dispatchKeyEvent', {\n type: 'rawKeyDown',\n key: 'v',\n code: 'KeyV',\n windowsVirtualKeyCode: 86,\n nativeVirtualKeyCode: 86,\n modifiers: 4,\n commands: ['Paste']\n });\n await chrome.debugger.sendCommand(target, 'Input.dispatchKeyEvent', {\n type: 'keyUp',\n key: 'v',\n code: 'KeyV',\n windowsVirtualKeyCode: 86,\n nativeVirtualKeyCode: 86,\n modifiers: 4\n });\n}\n\nasync function withTabDebugger(tabId, operation) {\n if (!Number.isInteger(Number(tabId))) throw new Error('无法确定当前 BOSS 标签页');\n const id = Number(tabId);\n const previous = debuggerLocks.get(id) || Promise.resolve();\n let release;\n const current = new Promise(resolve => { release = resolve; });\n const queueEntry = previous.then(() => current);\n debuggerLocks.set(id, queueEntry);\n await previous;\n const target = { tabId: id };\n let attached = false;\n try {\n await chrome.debugger.attach(target, '1.3');\n attached = true;\n await debuggerBringToFront(target);\n return await operation(target);\n } catch (error) {\n const message = String(error?.message || error || '');\n if (/Another debugger is already attached|Cannot attach to this target|DevTools/i.test(message)) {\n throw new Error('Chrome 调试通道正被 DevTools 或其他工具占用,请关闭该 BOSS 页面的开发者工具后重试');\n }\n throw error;\n } finally {\n if (attached) await chrome.debugger.detach(target).catch(() => {});\n release?.();\n if (debuggerLocks.get(id) === queueEntry) debuggerLocks.delete(id);\n }\n}\n\nasync function debuggerMouseClick(target, point) {\n const { x, y } = validPoint(point);\n await chrome.debugger.sendCommand(target, 'Input.dispatchMouseEvent', {\n type: 'mouseMoved', x, y, button: 'none', buttons: 0\n });\n await chrome.debugger.sendCommand(target, 'Input.dispatchMouseEvent', {\n type: 'mousePressed', x, y, button: 'left', buttons: 1, clickCount: 1\n });\n await chrome.debugger.sendCommand(target, 'Input.dispatchMouseEvent', {\n type: 'mouseReleased', x, y, button: 'left', buttons: 0, clickCount: 1\n });\n}\n\nasync function debuggerDocumentNode(target) {\n const result = await chrome.debugger.sendCommand(target, 'DOM.getDocument', {\n depth: -1,\n pierce: true\n });\n const nodeId = Number(result?.root?.nodeId || 0);\n if (!nodeId) throw new Error('无法读取当前 BOSS 页面 DOM');\n return nodeId;\n}\n\nasync function debuggerNodeBySelector(target, selector) {\n const normalized = String(selector || '').trim();\n if (!normalized) return 0;\n const rootNodeId = await debuggerDocumentNode(target);\n const result = await chrome.debugger.sendCommand(target, 'DOM.querySelector', {\n nodeId: rootNodeId,\n selector: normalized\n });\n return Number(result?.nodeId || 0);\n}\n\nasync function debuggerFocusTarget(target, message = {}) {\n await debuggerBringToFront(target);\n\n // 必须优先锁定 content script 临时标记的真实编辑节点。旧版先取坐标节点,\n // BOSS 页面上经常命中编辑器外壳/透明层,导致看似有光标但文字没有进入编辑器。\n const exact = await debuggerFindEditableObject(target, message).catch(() => ({ objectId: '', nodeId: 0 }));\n if (exact.objectId) {\n const rect = await debuggerFocusObject(target, exact.objectId);\n if (rect && Number(rect.width) > 0 && Number(rect.height) > 0) {\n const clickPoint = {\n x: Math.round(Number(rect.left) + Math.max(8, Math.min(Number(rect.width) - 8, Number(rect.width) * 0.5))),\n y: Math.round(Number(rect.top) + Math.max(8, Math.min(Number(rect.height) - 8, Number(rect.height) * 0.5)))\n };\n await debuggerMouseClick(target, clickPoint).catch(() => {});\n await debuggerFocusObject(target, exact.objectId);\n return { method: 'exact-selector-object-focus+click', ...exact, point: clickPoint, rect };\n }\n return { method: 'exact-selector-object-focus', ...exact, rect };\n }\n\n const pointNode = await debuggerNodeAtPoint(target, message.point).catch(() => ({ nodeId: 0, backendNodeId: 0 }));\n if (pointNode.nodeId || pointNode.backendNodeId) {\n const args = pointNode.nodeId ? { nodeId: pointNode.nodeId } : { backendNodeId: pointNode.backendNodeId };\n await chrome.debugger.sendCommand(target, 'DOM.focus', args).catch(() => {});\n await debuggerMouseClick(target, message.point).catch(() => {});\n const objectId = await debuggerResolveNodeObject(target, pointNode).catch(() => '');\n return { method: 'point-node-focus+click', ...pointNode, objectId };\n }\n\n await debuggerMouseClick(target, message.point);\n return { method: 'coordinate-click', nodeId: 0, objectId: '' };\n}\n\nasync function debuggerClickTarget(target, message = {}) {\n const selector = String(message.selector || '').trim();\n if (selector) {\n const nodeId = await debuggerNodeBySelector(target, selector);\n if (nodeId) {\n await chrome.debugger.sendCommand(target, 'DOM.scrollIntoViewIfNeeded', { nodeId }).catch(() => {});\n const model = await chrome.debugger.sendCommand(target, 'DOM.getBoxModel', { nodeId });\n const quad = model?.model?.content || model?.model?.border || [];\n if (Array.isArray(quad) && quad.length >= 8) {\n const xs = [quad[0], quad[2], quad[4], quad[6]].map(Number);\n const ys = [quad[1], quad[3], quad[5], quad[7]].map(Number);\n await debuggerMouseClick(target, {\n x: xs.reduce((sum, value) => sum + value, 0) / xs.length,\n y: ys.reduce((sum, value) => sum + value, 0) / ys.length\n });\n return { method: 'dom-box-click', nodeId };\n }\n }\n }\n await debuggerMouseClick(target, message.point);\n return { method: 'coordinate-click', nodeId: 0 };\n}\n\nasync function debuggerReadTargetText(target, selector) {\n const normalized = String(selector || '').trim();\n if (!normalized) return '';\n const expression = `(() => {\n const element = document.querySelector(${JSON.stringify(normalized)});\n if (!element) return '';\n if ('value' in element) return String(element.value || '');\n return String(element.innerText || element.textContent || '');\n })()`;\n const result = await chrome.debugger.sendCommand(target, 'Runtime.evaluate', {\n expression,\n returnByValue: true,\n awaitPromise: false\n });\n return String(result?.result?.value || '');\n}\n\nasync function debuggerSelectAll(target) {\n await chrome.debugger.sendCommand(target, 'Input.dispatchKeyEvent', {\n type: 'rawKeyDown', key: 'a', code: 'KeyA', windowsVirtualKeyCode: 65,\n nativeVirtualKeyCode: 65, modifiers: 4, commands: ['SelectAll']\n });\n await chrome.debugger.sendCommand(target, 'Input.dispatchKeyEvent', {\n type: 'keyUp', key: 'a', code: 'KeyA', windowsVirtualKeyCode: 65,\n nativeVirtualKeyCode: 65, modifiers: 4\n });\n}\n\nasync function debuggerPressEnter(target) {\n // 与 Selenium/Playwright 的真实 Enter 等价:rawKeyDown 携带回车文本后再 keyUp。\n await chrome.debugger.sendCommand(target, 'Input.dispatchKeyEvent', {\n type: 'rawKeyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13,\n nativeVirtualKeyCode: 13, text: '\\r', unmodifiedText: '\\r'\n });\n await chrome.debugger.sendCommand(target, 'Input.dispatchKeyEvent', {\n type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13,\n nativeVirtualKeyCode: 13\n });\n}\n\nasync function debuggerTypeText(target, text) {\n const value = String(text || '');\n for (const character of value) {\n await chrome.debugger.sendCommand(target, 'Input.dispatchKeyEvent', {\n type: 'char',\n text: character,\n unmodifiedText: character\n });\n await new Promise(resolve => setTimeout(resolve, 12));\n }\n}\n\nasync function mainWorldWriteChatText(tabId, selector, text) {\n const normalizedSelector = String(selector || '').trim();\n const desired = String(text || '');\n if (!normalizedSelector || !desired.trim()) throw new Error('聊天输入目标或招呼语为空');\n const results = await chrome.scripting.executeScript({\n target: { tabId: Number(tabId) },\n world: 'MAIN',\n func: (exactSelector, nextText) => {\n const normalizeText = value => String(value || '').replace(/\\s+/g, '').trim();\n const isEditable = node => {\n if (!node || typeof node.matches !== 'function') return false;\n const tag = String(node.tagName || '').toLowerCase();\n if (tag === 'textarea') return true;\n if (tag === 'input') {\n const type = String(node.getAttribute('type') || 'text').toLowerCase();\n return ['text', 'search', ''].includes(type);\n }\n const editableMode = String(node.getAttribute('contenteditable') || '').toLowerCase();\n return node.isContentEditable\n || Boolean(editableMode && !['false', 'inherit', 'off'].includes(editableMode))\n || node.getAttribute('role') === 'textbox'\n || node.getAttribute('data-slate-editor') === 'true'\n || node.getAttribute('data-lexical-editor') === 'true';\n };\n const deepQuery = (rootNode, query) => {\n if (!rootNode) return null;\n try {\n const direct = rootNode.querySelector?.(query);\n if (direct) return direct;\n } catch {}\n let elements = [];\n try { elements = [...(rootNode.querySelectorAll?.('*') || [])]; } catch {}\n for (const element of elements) {\n if (element.shadowRoot) {\n const found = deepQuery(element.shadowRoot, query);\n if (found) return found;\n }\n }\n return null;\n };\n const root = deepQuery(document, exactSelector);\n if (!root) return { ok: false, reason: 'selector-not-found' };\n const editor = isEditable(root)\n ? root\n : [...root.querySelectorAll('textarea,input[type=\"text\"],input:not([type]),[contenteditable]:not([contenteditable=\"false\"]),[role=\"textbox\"],[data-slate-editor=\"true\"],[data-lexical-editor=\"true\"]')].find(isEditable);\n if (!editor) return { ok: false, reason: 'editable-not-found', rootTag: String(root.tagName || '') };\n\n const readValue = () => {\n if ('value' in editor && !editor.isContentEditable) return String(editor.value || '');\n return String(editor.innerText || editor.textContent || '');\n };\n const dispatchInput = (inputType, data) => {\n try {\n editor.dispatchEvent(new InputEvent('input', {\n bubbles: true,\n cancelable: false,\n composed: true,\n inputType,\n data\n }));\n } catch {\n editor.dispatchEvent(new Event('input', { bubbles: true, composed: true }));\n }\n };\n const dispatchBeforeInput = (inputType, data) => {\n try {\n return editor.dispatchEvent(new InputEvent('beforeinput', {\n bubbles: true,\n cancelable: true,\n composed: true,\n inputType,\n data\n }));\n } catch {\n return true;\n }\n };\n\n editor.scrollIntoView?.({ block: 'nearest', inline: 'nearest', behavior: 'instant' });\n try { HTMLElement.prototype.focus.call(editor); } catch { editor.focus?.(); }\n const previous = readValue();\n const tag = String(editor.tagName || '').toLowerCase();\n let method = '';\n\n if ((tag === 'textarea' || tag === 'input') && 'value' in editor) {\n const proto = tag === 'textarea' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;\n const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;\n dispatchBeforeInput('deleteByCut', null);\n if (setter) setter.call(editor, ''); else editor.value = '';\n dispatchInput('deleteByCut', null);\n const tracker = editor._valueTracker;\n if (tracker?.setValue) tracker.setValue(previous);\n dispatchBeforeInput('insertText', nextText);\n if (setter) setter.call(editor, nextText); else editor.value = nextText;\n dispatchInput('insertText', nextText);\n editor.dispatchEvent(new Event('change', { bubbles: true, composed: true }));\n method = 'main-native-value-setter';\n } else {\n const selection = window.getSelection?.();\n if (selection && document.createRange) {\n const range = document.createRange();\n range.selectNodeContents(editor);\n selection.removeAllRanges();\n selection.addRange(range);\n }\n dispatchBeforeInput('deleteContentBackward', null);\n let inserted = false;\n try {\n const transfer = new DataTransfer();\n transfer.setData('text/plain', nextText);\n const pasteEvent = new ClipboardEvent('paste', {\n bubbles: true,\n cancelable: true,\n composed: true,\n clipboardData: transfer\n });\n editor.dispatchEvent(pasteEvent);\n inserted = normalizeText(readValue()).includes(normalizeText(nextText));\n } catch {\n inserted = false;\n }\n if (!inserted) {\n try {\n inserted = Boolean(document.execCommand?.('insertText', false, nextText));\n } catch {\n inserted = false;\n }\n }\n if (!inserted || !normalizeText(readValue()).includes(normalizeText(nextText))) {\n try {\n editor.replaceChildren(document.createTextNode(nextText));\n } catch {\n editor.textContent = nextText;\n }\n }\n dispatchInput('insertText', nextText);\n editor.dispatchEvent(new Event('change', { bubbles: true, composed: true }));\n try {\n editor.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true, composed: true, data: nextText }));\n } catch {}\n method = inserted ? 'main-exec-command' : 'main-contenteditable-replace';\n }\n\n try { HTMLElement.prototype.focus.call(editor); } catch { editor.focus?.(); }\n const actual = readValue();\n const compactExpected = normalizeText(nextText);\n const compactActual = normalizeText(actual);\n return {\n ok: Boolean(compactActual && compactActual.includes(compactExpected)),\n method,\n actual,\n tag,\n id: String(editor.id || ''),\n className: String(editor.className || '').slice(0, 180),\n contenteditable: String(editor.getAttribute?.('contenteditable') || ''),\n role: String(editor.getAttribute?.('role') || '')\n };\n },\n args: [normalizedSelector, desired]\n });\n return results?.[0]?.result || { ok: false, reason: 'main-world-no-result' };\n}\n\nasync function trustedChatInput(tabId, message = {}) {\n const action = String(message.action || '');\n if (action === 'replaceText' || action === 'replaceTextAndEnter') {\n const text = String(message.text || '');\n if (!text.trim()) throw new Error('待输入的招呼语为空');\n const compactExpected = text.replace(/\\s+/g, '');\n\n // 写入和 Enter 必须在同一次 debugger 会话、同一个精确编辑节点上完成。\n // 这是 Selenium sendKeys / Playwright locator.type + Enter 的同类浏览器级路径,\n // 避免旧版“聚焦了外壳、文字没有进入真实编辑器”的问题。\n return withTabDebugger(tabId, async target => {\n let focus = await debuggerFocusTarget(target, message);\n if (!focus.objectId) throw new Error('没有定位到 BOSS 真实聊天编辑节点');\n\n const readExact = async () => debuggerReadObjectText(target, focus.objectId);\n const refocus = async () => {\n const refreshed = await debuggerFocusTarget(target, message);\n if (refreshed.objectId) focus = refreshed;\n if (!focus.objectId) throw new Error('BOSS 聊天编辑节点已失效');\n return focus;\n };\n const matches = value => {\n const compact = String(value || '').replace(/\\s+/g, '');\n return Boolean(compact && (compact === compactExpected || compact.includes(compactExpected)));\n };\n\n let actual = '';\n let writeMethod = '';\n\n // 1) 精确对象聚焦后使用 CDP Input.insertText,最接近真实键盘输入。\n await debuggerSelectAll(target);\n await chrome.debugger.sendCommand(target, 'Input.insertText', { text });\n await new Promise(resolve => setTimeout(resolve, 700));\n actual = await readExact();\n writeMethod = 'exact-cdp-insert-text';\n\n // 2) 某些富文本编辑器只响应原生 paste。\n if (!matches(actual) && await writeClipboardText(text)) {\n await refocus();\n await debuggerSelectAll(target);\n await debuggerPasteClipboard(target);\n await new Promise(resolve => setTimeout(resolve, 900));\n actual = await readExact();\n writeMethod = 'exact-cdp-clipboard-paste';\n }\n\n // 3) React/Vue 受控编辑器可能需要主世界 setter/beforeinput/input。\n if (!matches(actual)) {\n const mainWrite = await mainWorldWriteChatText(tabId, message.selector, text).catch(error => ({\n ok: false,\n reason: String(error?.message || error || 'main-world-write-failed')\n }));\n await refocus();\n await new Promise(resolve => setTimeout(resolve, 650));\n actual = await readExact();\n writeMethod = mainWrite?.method || 'main-world-write';\n }\n\n // 4) 最后兜底为逐字符可信键盘输入,仍锁定同一个真实节点。\n if (!matches(actual)) {\n await refocus();\n await debuggerSelectAll(target);\n await debuggerTypeText(target, text);\n await new Promise(resolve => setTimeout(resolve, 1000));\n actual = await readExact();\n writeMethod = 'exact-cdp-keyboard-type';\n }\n\n if (!matches(actual)) {\n throw new Error(`文字没有进入 BOSS 真实聊天编辑器;实际长度=${String(actual || '').length}`);\n }\n\n if (action === 'replaceTextAndEnter') {\n await refocus();\n // 给 BOSS 受控状态一次提交渲染机会,再发送真实 Enter。\n await new Promise(resolve => setTimeout(resolve, 520));\n await debuggerPressEnter(target);\n }\n\n return {\n ok: true,\n action,\n length: text.length,\n insertedBeforeSend: true,\n writeMethod,\n actualLength: String(actual || '').length,\n focusMethod: focus.method,\n editor: {\n nodeId: Number(focus.nodeId || 0),\n selector: String(message.selector || '')\n }\n };\n });\n }\n\n return withTabDebugger(tabId, async target => {\n if (action === 'pressEnter') {\n const focus = await debuggerFocusTarget(target, message);\n if (!focus.objectId) throw new Error('没有定位到 BOSS 真实聊天编辑节点');\n await new Promise(resolve => setTimeout(resolve, 320));\n await debuggerPressEnter(target);\n return { ok: true, action, focusMethod: focus.method };\n }\n if (action === 'click') {\n const click = await debuggerClickTarget(target, message);\n return { ok: true, action, clickMethod: click.method };\n }\n throw new Error('不支持的可信输入动作');\n });\n}\n\nasync function init() {\n const current = await storage.all();\n const patch = {};\n for (const [key, value] of Object.entries(DEFAULTS)) {\n if (current[key] === undefined) patch[key] = value;\n }\n if (!current.ui9ModeMigration) {\n const currentConfig = current.config || {};\n patch.config = {\n ...DEFAULTS.config,\n ...currentConfig,\n executionMode: currentConfig.executionMode === 'auto' ? 'auto' : 'review',\n dailyTarget: Number(currentConfig.dailyTarget || 0) === 40 ? 150 : Number(currentConfig.dailyTarget || 150)\n };\n patch.ui9ModeMigration = true;\n }\n if (!current.ui10UnlimitedV4Migration) {\n const baseConfig = patch.config || current.config || {};\n const baseModel = { ...DEFAULTS.config.model, ...(baseConfig.model || {}) };\n const baseUrl = String(baseModel.baseUrl || DEFAULTS.config.model.baseUrl);\n const modelName = String(baseModel.model || '').trim();\n const isDeepSeek = /deepseek\\.com/i.test(baseUrl);\n const legacyDeepSeekModel = !modelName || ['deepseek-chat', 'deepseek-reasoner', 'deepseek-v4'].includes(modelName);\n patch.config = {\n ...DEFAULTS.config,\n ...baseConfig,\n discoveryLimit: 0,\n model: {\n ...baseModel,\n model: isDeepSeek && legacyDeepSeekModel ? 'deepseek-v4-pro' : (modelName || 'deepseek-v4-pro')\n }\n };\n patch.ui10UnlimitedV4Migration = true;\n }\n if (!current.ui20ReliableSendMigration) {\n const baseConfig = patch.config || current.config || {};\n patch.config = {\n ...DEFAULTS.config,\n ...baseConfig,\n betweenJobsSeconds: Math.max(8, Math.min(30, Number(baseConfig.betweenJobsSeconds || 8))),\n attachmentDelaySeconds: Math.max(1, Math.min(10, Number(baseConfig.attachmentDelaySeconds || 2)))\n };\n patch.ui20ReliableSendMigration = true;\n }\n if (!current.ui21ConversationBindingMigration) {\n patch.chatTransition = null;\n patch.chatDeliveryLedger = current.chatDeliveryLedger && typeof current.chatDeliveryLedger === 'object'\n ? current.chatDeliveryLedger\n : {};\n patch.ui21ConversationBindingMigration = true;\n }\n if (!current.ui22StrictTranscriptMigration) {\n const baseConfig = patch.config || current.config || {};\n patch.config = {\n ...DEFAULTS.config,\n ...baseConfig,\n betweenJobsSeconds: Math.max(12, Math.min(30, Number(baseConfig.betweenJobsSeconds || 12))),\n attachmentDelaySeconds: Math.max(4, Math.min(10, Number(baseConfig.attachmentDelaySeconds || 4)))\n };\n patch.ui22StrictTranscriptMigration = true;\n }\n if (!current.ui24VerifiedConversationMigration) {\n patch.chatTransition = null;\n patch.ui24VerifiedConversationMigration = true;\n }\n if (!current.ui27TrustedInputMigration) {\n const baseWorkflow = patch.workflow || current.workflow || {};\n patch.workflow = {\n ...DEFAULTS.workflow,\n ...baseWorkflow,\n running: false,\n paused: true,\n phase: 'idle',\n pendingApplyId: null,\n activeRunId: null,\n statusText: '可信发送引擎已升级,请重新开始任务'\n };\n patch.chatTransition = null;\n patch.ui27TrustedInputMigration = true;\n }\n if (!current.ui12ProfileDraftMigration) {\n let migratedProfile = current.profile && typeof current.profile === 'object' ? current.profile : null;\n let migratedDraft = current.profileDraft && typeof current.profileDraft === 'object'\n ? normalizeProfileDraft(current.profileDraft)\n : null;\n if (!profileDraftHasAny(migratedDraft) && profileHasCore(migratedProfile)) {\n migratedDraft = profileToDraft(migratedProfile, 'migration');\n }\n if (!profileDraftHasCore(migratedDraft) && String(current.resumeText || '').trim().length >= 30) {\n try {\n migratedProfile = buildLocalProfile(current.resumeText || '');\n migratedProfile.generation.mode = 'local-recovery';\n migratedProfile.generation.label = '本地初稿';\n migratedProfile.generation.warning = '已从保存的简历恢复初稿。';\n migratedDraft = profileToDraft(migratedProfile, 'recovery');\n patch.profile = migratedProfile;\n } catch {\n // 简历内容不足时只保留现有数据,不生成空画像。\n }\n }\n if (profileDraftHasAny(migratedDraft)) patch.profileDraft = migratedDraft;\n patch.ui12ProfileDraftMigration = true;\n }\n if (!current.ui37DirectionPlanMigration) {\n const profileForPlan = patch.profile || current.profile || null;\n const existingPlan = current.directionPlan && typeof current.directionPlan === 'object'\n ? current.directionPlan\n : null;\n if (profileHasCore(profileForPlan)) {\n patch.directionPlan = buildDirectionPlan(profileForPlan, existingPlan, {\n confirmed: Boolean(existingPlan?.confirmed),\n preserveSelections: true,\n preserveEdits: true,\n preserveCustom: true\n });\n } else if (existingPlan) {\n patch.directionPlan = normalizeDirectionPlan(existingPlan, profileForPlan);\n }\n patch.ui37DirectionPlanMigration = true;\n }\n if (!current.ui18TaskProgressMigration) {\n const migratedPending = Array.isArray(current.pending) ? current.pending.map(entry => ({ ...entry })) : [];\n const migratedRuns = Array.isArray(current.taskRuns) ? current.taskRuns.map(run => normalizeTaskRun(run, run)) : [];\n for (let index = 0; index < migratedPending.length; index += 1) {\n const item = migratedPending[index];\n let run = migratedRuns.find(entry => entry.id === item.runId || entry.pendingId === item.id);\n if (!run) {\n const statusMap = {\n pending: ['waiting_review', 'waiting_review', 60],\n approved: ['running', 'queued', 64],\n approved_queue: ['queued', 'queued', 64],\n sent: ['success', 'success', 100],\n failed: ['failed', 'failed', 100],\n rejected: ['ignored', 'ignored', 100]\n };\n const [status, stage, progress] = statusMap[item.status] || ['skipped', 'skipped', 100];\n run = normalizeTaskRun({\n id: crypto.randomUUID(),\n pendingId: item.id,\n job: item.job,\n analysis: item.analysis,\n searchTask: item.task,\n status,\n stage,\n progress,\n error: item.error || '',\n retryable: item.status === 'failed',\n createdAt: item.createdAt || Date.now(),\n completedAt: ['sent', 'failed', 'rejected'].includes(item.status) ? (item.completedAt || item.rejectedAt || Date.now()) : null\n });\n migratedRuns.push(run);\n }\n migratedPending[index] = { ...item, runId: run.id };\n }\n const workflow = { ...DEFAULTS.workflow, ...(current.workflow || {}) };\n workflow.tasks = (workflow.tasks || []).map((task, index) => ({\n ...task,\n id: task.id || crypto.randomUUID(),\n status: task.status || (index < Number(workflow.taskIndex || 0) ? 'completed' : index === Number(workflow.taskIndex || 0) && workflow.running ? 'running' : 'pending'),\n progress: Number.isFinite(Number(task.progress)) ? Number(task.progress) : (index < Number(workflow.taskIndex || 0) ? 100 : 0),\n stageLabel: task.stageLabel || (index < Number(workflow.taskIndex || 0) ? '已完成' : '等待开始'),\n processed: Number(task.processed || 0),\n discovered: Number(task.discovered || 0),\n analyzed: Number(task.analyzed || 0),\n failed: Number(task.failed || 0)\n }));\n patch.pending = migratedPending;\n patch.taskRuns = migratedRuns;\n patch.workflow = workflow;\n patch.ui18TaskProgressMigration = true;\n }\n if (!current.ui20GreetingLockMigration) {\n const basePending = Array.isArray(patch.pending) ? patch.pending : (Array.isArray(current.pending) ? current.pending : []);\n patch.pending = basePending.map(item => ({\n ...item,\n deliveryGreeting: String(item.deliveryGreeting || item.analysis?.greeting || '').trim()\n }));\n patch.ui20GreetingLockMigration = true;\n }\n if (!current.ui30AtomicSendSortMigration) {\n const basePending = Array.isArray(patch.pending) ? patch.pending : (Array.isArray(current.pending) ? current.pending : []);\n patch.pending = rerankPending(basePending);\n const baseWorkflow = patch.workflow || current.workflow || {};\n patch.workflow = {\n ...DEFAULTS.workflow,\n ...baseWorkflow,\n running: false,\n paused: true,\n pendingApplyId: null,\n activeRunId: null,\n statusText: '投递引擎与岗位自动排序已升级,请重新开始'\n };\n patch.chatTransition = null;\n patch.ui30AtomicSendSortMigration = true;\n }\n if (!current.ui31ConversationSelectionMigration) {\n const basePending = Array.isArray(patch.pending) ? patch.pending : (Array.isArray(current.pending) ? current.pending : []);\n patch.pending = rerankPending(basePending);\n const baseWorkflow = patch.workflow || current.workflow || {};\n patch.workflow = {\n ...DEFAULTS.workflow,\n ...baseWorkflow,\n running: false,\n paused: true,\n pendingApplyId: null,\n activeRunId: null,\n statusText: 'HR 会话选择与身份核对已升级,请重新开始'\n };\n const ledger = current.chatDeliveryLedger && typeof current.chatDeliveryLedger === 'object' ? current.chatDeliveryLedger : {};\n patch.chatDeliveryLedger = Object.fromEntries(Object.entries(ledger).filter(([, entry]) => entry?.status === 'sent'));\n patch.chatTransition = null;\n patch.ui31ConversationSelectionMigration = true;\n }\n if (!current.ui35ConversationKeyMigration) {\n const baseWorkflow = patch.workflow || current.workflow || {};\n patch.workflow = {\n ...DEFAULTS.workflow,\n ...baseWorkflow,\n running: false,\n paused: true,\n pendingApplyId: null,\n activeRunId: null,\n statusText: 'HR 会话锁已升级,请重新开始'\n };\n const ledger = current.chatDeliveryLedger && typeof current.chatDeliveryLedger === 'object' ? current.chatDeliveryLedger : {};\n patch.chatDeliveryLedger = Object.fromEntries(Object.entries(ledger).filter(([, entry]) => entry?.status === 'sent'));\n patch.chatTransition = null;\n patch.ui35ConversationKeyMigration = true;\n }\n if (!current.v130SingleValidationMigration) {\n const baseConfig = patch.config || current.config || {};\n const alreadySuccessful = Number(current.stats?.sent || 0) > 0\n || Number(baseConfig.singleJobValidationCompletedAt || 0) > 0;\n patch.config = {\n ...DEFAULTS.config,\n ...baseConfig,\n requireSingleJobValidation: baseConfig.requireSingleJobValidation !== false,\n singleJobValidationCompletedAt: alreadySuccessful\n ? Number(baseConfig.singleJobValidationCompletedAt || Date.now())\n : 0\n };\n patch.v130SingleValidationMigration = true;\n }\n if (!current.v170SafetyIntelligenceMigration) {\n const baseConfig = patch.config || current.config || {};\n const legacyTarget = Number(baseConfig.dailyTarget || 0);\n const legacyDiscovery = Number(baseConfig.discoveryLimit || 0);\n patch.config = {\n ...DEFAULTS.config,\n ...baseConfig,\n batchStrategy: normalizeStrategy(baseConfig.batchStrategy),\n dryRun: Boolean(baseConfig.dryRun),\n dailyTarget: legacyTarget <= 0 ? 30 : Math.min(150, legacyTarget),\n discoveryLimit: legacyDiscovery <= 0 ? 150 : Math.min(800, legacyDiscovery),\n betweenJobsSeconds: Math.max(6, Number(baseConfig.betweenJobsSeconds || 9)),\n maxPerCompanyPerDay: Math.max(1, Math.min(12, Number(baseConfig.maxPerCompanyPerDay || 3))),\n maxConsecutiveFailures: Math.max(1, Math.min(10, Number(baseConfig.maxConsecutiveFailures || 3))),\n jitterSeconds: Math.max(0, Math.min(15, Number(baseConfig.jitterSeconds ?? 3))),\n companyVerificationEnabled: baseConfig.companyVerificationEnabled !== false,\n companyVerificationProvider: String(baseConfig.companyVerificationProvider || 'bridge'),\n companyVerificationCacheDays: Math.max(1, Math.min(90, Number(baseConfig.companyVerificationCacheDays || 14))),\n blockUnknownCompanies: Boolean(baseConfig.blockUnknownCompanies),\n updateCheckEnabled: baseConfig.updateCheckEnabled !== false,\n rateLimits: { ...DEFAULTS.config.rateLimits, ...(baseConfig.rateLimits || {}) }\n };\n patch.safetyState = { ...DEFAULTS.safetyState, ...(current.safetyState || {}) };\n patch.companyVerificationCache = current.companyVerificationCache && typeof current.companyVerificationCache === 'object' ? current.companyVerificationCache : {};\n patch.deliveryHistory = Array.isArray(current.deliveryHistory) ? current.deliveryHistory : [];\n patch.updateInfo = { ...DEFAULTS.updateInfo, ...(current.updateInfo || {}), currentVersion: '1.7.0' };\n patch.workflow = {\n ...DEFAULTS.workflow,\n ...(patch.workflow || current.workflow || {}),\n running: false,\n paused: true,\n pendingApplyId: null,\n activeRunId: null,\n statusText: 'v1.7 安全投递引擎已升级 请检查限速和企业核验设置后重新开始'\n };\n patch.v170SafetyIntelligenceMigration = true;\n }\n if (!current.v170FormalReleaseMigration) {\n const baseConfig = patch.config || current.config || {};\n const migratedStrategy = normalizeStrategy(baseConfig.batchStrategy);\n patch.config = {\n ...DEFAULTS.config,\n ...baseConfig,\n batchStrategy: migratedStrategy,\n massApplyAnalysis: ['fast', 'ai'].includes(baseConfig.massApplyAnalysis) ? baseConfig.massApplyAnalysis : 'fast',\n pacingPreset: ['conservative', 'standard', 'efficient', 'custom'].includes(baseConfig.pacingPreset) ? baseConfig.pacingPreset : 'standard',\n dailyTarget: Math.max(1, Math.min(150, Number(baseConfig.dailyTarget || 30))),\n discoveryLimit: Math.max(1, Math.min(800, Number(baseConfig.discoveryLimit || 150))),\n betweenJobsSeconds: Math.max(6, Math.min(120, Number(baseConfig.betweenJobsSeconds || 9))),\n attachmentDelaySeconds: Math.max(1.5, Math.min(15, Number(baseConfig.attachmentDelaySeconds || 3))),\n maxPerCompanyPerDay: Math.max(1, Math.min(12, Number(baseConfig.maxPerCompanyPerDay || 3))),\n queueWarmup: Math.max(1, Math.min(10, Number(baseConfig.queueWarmup || 4))),\n dailyReportEnabled: baseConfig.dailyReportEnabled !== false,\n dailyReportTime: /^\\d{2}:\\d{2}$/.test(String(baseConfig.dailyReportTime || '')) ? String(baseConfig.dailyReportTime) : '20:30',\n dailyReportNotification: baseConfig.dailyReportNotification !== false,\n rateLimits: { ...DEFAULTS.config.rateLimits, ...(baseConfig.rateLimits || {}) }\n };\n patch.safetyState = { ...DEFAULTS.safetyState, ...(current.safetyState || {}) };\n patch.v170FormalReleaseMigration = true;\n }\n if (Object.keys(patch).length) await storage.set(patch);\n const { stats } = await storage.get('stats');\n if (!stats || stats.date !== today()) {\n await storage.set({ stats: { ...DEFAULTS.stats, date: today() } });\n }\n await refreshBossTabsForRuntimeVersion();\n await chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => {});\n chrome.alarms.create('jobclaw-tick', { periodInMinutes: 1 });\n chrome.alarms.create('jobclaw-update-check', { periodInMinutes: 360 });\n checkForUpdates(false).catch(() => {});\n syncBridgeSnapshot(true).catch(() => {});\n}\n\nfunction publicState(all) {\n const state = safeClone(all);\n if (state?.config?.model) {\n state.config.model.apiKey = state.config.model.apiKey ? '***' : '';\n }\n if (state?.resumeImage) state.resumeImage = '[已保存]';\n if (state?.resumeSourceFile) {\n const { name, type, size, lastModified } = state.resumeSourceFile;\n state.resumeSourceFile = { name, type, size, lastModified, stored: true };\n }\n return state;\n}\n\nasync function writeEvent(level, message, data = {}) {\n const { events = [] } = await storage.get('events');\n const event = { id: crypto.randomUUID(), ts: Date.now(), level, message, data };\n events.unshift(event);\n await storage.set({ events: events.slice(0, 300) });\n bridge('/sync', { event }).catch(() => {});\n return event;\n}\n\nasync function patchWorkflow(patch) {\n const { workflow } = await storage.get('workflow');\n const next = { ...DEFAULTS.workflow, ...(workflow || {}), ...(patch || {}) };\n await storage.set({ workflow: next });\n return next;\n}\n\nasync function changeStats(delta = {}) {\n const { stats } = await storage.get('stats');\n const next = { ...DEFAULTS.stats, ...(stats || {}), date: today() };\n for (const [key, value] of Object.entries(delta)) {\n next[key] = Math.max(0, Number(next[key] || 0) + Number(value || 0));\n }\n await storage.set({ stats: next });\n return next;\n}\n\n\nfunction runJobKey(job = {}) {\n return String(job.jobId || job.encryptJobId || job.url || [job.title, job.company, job.location].filter(Boolean).join('|') || '').trim();\n}\n\nfunction normalizeTaskRun(input = {}, existing = {}) {\n const now = Date.now();\n const stage = String(input.stage || existing.stage || 'discovered');\n const meta = taskStageMeta(stage, input.stageLabel, input.progress);\n const status = String(input.status || existing.status || 'running');\n const terminal = TERMINAL_RUN_STATUSES.has(status);\n return {\n ...existing,\n ...input,\n id: input.id || existing.id || crypto.randomUUID(),\n jobKey: input.jobKey || existing.jobKey || runJobKey(input.job || existing.job || {}),\n status,\n stage,\n stageLabel: meta.label,\n progress: meta.progress,\n createdAt: existing.createdAt || input.createdAt || now,\n startedAt: existing.startedAt || input.startedAt || (status === 'running' ? now : null),\n updatedAt: now,\n completedAt: terminal ? (input.completedAt || existing.completedAt || now) : null,\n retryCount: Math.max(0, Number(input.retryCount ?? existing.retryCount ?? 0)),\n error: String(input.error ?? existing.error ?? ''),\n retryable: input.retryable ?? existing.retryable ?? Boolean(input.pendingId || existing.pendingId)\n };\n}\n\nasync function upsertTaskRun(patch = {}) {\n const { taskRuns = [] } = await storage.get('taskRuns');\n const jobKey = patch.jobKey || runJobKey(patch.job || {});\n const index = taskRuns.findIndex(run => run.id === patch.id || (jobKey && run.jobKey === jobKey && !TERMINAL_RUN_STATUSES.has(run.status)));\n const existing = index >= 0 ? taskRuns[index] : {};\n const nextRun = normalizeTaskRun({ ...patch, jobKey }, existing);\n const next = index >= 0\n ? taskRuns.map((run, runIndex) => runIndex === index ? nextRun : run)\n : [nextRun, ...taskRuns];\n await storage.set({ taskRuns: next });\n return nextRun;\n}\n\nasync function updateTaskRunByPending(pendingId, patch = {}) {\n if (!pendingId) return null;\n const { pending = [], taskRuns = [] } = await storage.get(['pending', 'taskRuns']);\n const item = pending.find(entry => entry.id === pendingId);\n const run = taskRuns.find(entry => entry.id === item?.runId || entry.pendingId === pendingId);\n if (!run && !item) return null;\n return upsertTaskRun({\n id: run?.id || item?.runId,\n pendingId,\n job: item?.job || run?.job,\n analysis: item?.analysis || run?.analysis,\n searchTask: item?.task || run?.searchTask,\n ...patch\n });\n}\n\nasync function updateSearchTaskProgress(message = {}) {\n const { workflow } = await storage.get('workflow');\n const nextWorkflow = { ...DEFAULTS.workflow, ...(workflow || {}) };\n const tasks = Array.isArray(nextWorkflow.tasks) ? [...nextWorkflow.tasks] : [];\n const index = tasks.findIndex(task => task.id === message.taskId);\n const fallbackIndex = Number.isInteger(message.taskIndex) ? message.taskIndex : -1;\n const targetIndex = index >= 0 ? index : fallbackIndex;\n if (targetIndex < 0 || targetIndex >= tasks.length) return null;\n const current = tasks[targetIndex] || {};\n const progress = Math.max(0, Math.min(100, Number(message.progress ?? current.progress ?? 0)));\n tasks[targetIndex] = {\n ...current,\n status: message.status || current.status || 'pending',\n stageLabel: message.stageLabel || current.stageLabel || '',\n progress,\n processed: Math.max(0, Number(message.processed ?? current.processed ?? 0)),\n discovered: Math.max(0, Number(message.discovered ?? current.discovered ?? 0)),\n analyzed: Math.max(0, Number(message.analyzed ?? current.analyzed ?? 0)),\n failed: Math.max(0, Number(message.failed ?? current.failed ?? 0)),\n updatedAt: Date.now(),\n completedAt: message.status === 'completed' ? Date.now() : current.completedAt || null\n };\n nextWorkflow.tasks = tasks;\n await storage.set({ workflow: nextWorkflow });\n return tasks[targetIndex];\n}\n\nasync function retryFailedTask(runId) {\n const { taskRuns = [], pending = [], workflow = {} } = await storage.get(['taskRuns', 'pending', 'workflow']);\n const run = taskRuns.find(entry => entry.id === runId);\n if (!run) throw new Error('失败任务不存在');\n if (run.status === 'success') throw new Error('该岗位已经投递成功,不会重复投递');\n if (workflow.running && (workflow.activeRunId === run.id || workflow.pendingApplyId === run.pendingId)) {\n throw new Error('该失败任务正在重试,请等待当前结果');\n }\n if (Number(run.retryRequestedAt || 0) > Date.now() - 15000) {\n throw new Error('该任务刚刚提交过重试,请稍后查看结果');\n }\n let item = pending.find(entry => entry.id === run.pendingId || entry.runId === run.id);\n if (!item && run.job && run.analysis) {\n item = {\n id: crypto.randomUUID(),\n runId: run.id,\n job: run.job,\n analysis: run.analysis,\n deliveryGreeting: String(run.analysis?.greeting || '').trim(),\n task: run.searchTask || {},\n status: 'approved',\n createdAt: Date.now(),\n approvedAt: Date.now()\n };\n }\n if (!item) throw new Error('该历史任务缺少岗位或招呼语信息,无法直接重试');\n if (item.status === 'sent') throw new Error('该岗位已经投递成功,不会重复投递');\n item = {\n ...item,\n deliveryGreeting: String(item.deliveryGreeting || item.analysis?.greeting || '').trim(),\n status: 'approved', error: '', approvedAt: Date.now(), runId: run.id\n };\n const nextPending = pending.some(entry => entry.id === item.id)\n ? pending.map(entry => entry.id === item.id ? item : entry)\n : [item, ...pending];\n await storage.set({ pending: rerankPending(nextPending) });\n await upsertTaskRun({\n id: run.id,\n pendingId: item.id,\n job: item.job,\n analysis: item.analysis,\n searchTask: item.task,\n status: 'running',\n stage: 'retry_queued',\n progress: 66,\n stageLabel: '等待重新投递',\n error: '',\n retryable: true,\n retryCount: Number(run.retryCount || 0) + 1,\n retryRequestedAt: Date.now(),\n completedAt: null\n });\n await patchWorkflow({\n running: true,\n paused: false,\n phase: 'apply',\n statusText: `正在重新投递:${item.job?.title || '岗位'}`,\n pendingApplyId: item.id,\n activeRunId: run.id\n });\n try {\n await sendToBoss({ type: 'RUN' });\n setTimeout(() => sendToBoss({ type: 'RUN' }).catch(() => {}), 700);\n } catch (error) {\n await upsertTaskRun({ id: run.id, status: 'failed', stage: 'failed', progress: 100, stageLabel: '重新投递启动失败', error: error.message, retryable: true });\n await patchWorkflow({ running: false, paused: true, phase: 'idle', pendingApplyId: null, activeRunId: null, statusText: '重新投递启动失败' });\n throw error;\n }\n return { runId: run.id, pendingId: item.id };\n}\n\nasync function retryAllFailedTasks() {\n const { taskRuns = [], pending = [], workflow = {} } = await storage.get(['taskRuns', 'pending', 'workflow']);\n if (workflow.running && workflow.activeRunId) {\n throw new Error('当前已有投递任务正在执行,请等待完成后再批量重试');\n }\n const failedRuns = taskRuns.filter(run => run.status === 'failed'\n && run.retryable !== false\n && Number(run.retryRequestedAt || 0) <= Date.now() - 15000);\n if (!failedRuns.length) return { count: 0 };\n const nextPending = [...pending];\n const queue = [];\n for (const run of failedRuns) {\n if (run.status === 'success') continue;\n let item = nextPending.find(entry => entry.id === run.pendingId || entry.runId === run.id);\n if (!item && run.job && run.analysis) {\n item = {\n id: crypto.randomUUID(), runId: run.id, job: run.job, analysis: run.analysis,\n deliveryGreeting: String(run.analysis?.greeting || '').trim(),\n task: run.searchTask || {}, createdAt: Date.now()\n };\n nextPending.unshift(item);\n }\n if (!item || item.status === 'sent') continue;\n item.deliveryGreeting = String(item.deliveryGreeting || item.analysis?.greeting || '').trim();\n item.status = queue.length ? 'approved_queue' : 'approved';\n item.approvedAt = Date.now();\n item.error = '';\n const pendingIndex = nextPending.findIndex(entry => entry.id === item.id);\n if (pendingIndex >= 0) nextPending[pendingIndex] = { ...item };\n queue.push({ run, item });\n }\n if (!queue.length) return { count: 0 };\n await storage.set({ pending: rerankPending(nextPending) });\n for (const [index, entry] of queue.entries()) {\n await upsertTaskRun({\n id: entry.run.id,\n pendingId: entry.item.id,\n status: index === 0 ? 'running' : 'queued',\n stage: 'retry_queued',\n progress: 66,\n stageLabel: index === 0 ? '正在重新投递' : '等待重新投递',\n error: '',\n retryable: true,\n retryCount: Number(entry.run.retryCount || 0) + 1,\n retryRequestedAt: Date.now(),\n completedAt: null\n });\n }\n const first = queue[0];\n await patchWorkflow({\n running: true,\n paused: false,\n phase: 'apply',\n statusText: `重新投递 1/${queue.length}:${first.item.job?.title || '岗位'}`,\n pendingApplyId: first.item.id,\n activeRunId: first.run.id\n });\n try {\n await sendToBoss({ type: 'RUN' });\n } catch (error) {\n for (const entry of queue) {\n await upsertTaskRun({ id: entry.run.id, status: 'failed', stage: 'failed', progress: 100, stageLabel: '批量重试启动失败', error: error.message, retryable: true });\n }\n await patchWorkflow({ running: false, paused: true, phase: 'idle', pendingApplyId: null, activeRunId: null, statusText: '批量重试启动失败' });\n throw error;\n }\n return { count: queue.length };\n}\n\nasync function ignoreFailedTask(runId) {\n const { taskRuns = [], pending = [] } = await storage.get(['taskRuns', 'pending']);\n const run = taskRuns.find(entry => entry.id === runId);\n if (!run) throw new Error('失败任务不存在');\n const nextPending = pending.map(entry => entry.id === run.pendingId || entry.runId === run.id\n ? { ...entry, status: 'rejected', rejectedAt: Date.now() }\n : entry);\n await storage.set({ pending: nextPending });\n return upsertTaskRun({ id: run.id, status: 'ignored', stage: 'ignored', progress: 100, stageLabel: '已忽略', retryable: false });\n}\n\nconst BOSS_URL_PATTERN = /^https:\\/\\/(?:www|app)\\.zhipin\\.com\\//i;\nconst NO_RECEIVER_PATTERN = /Could not establish connection|Receiving end does not exist|message port closed|No tab with id/i;\nconst wait = ms => new Promise(resolve => setTimeout(resolve, ms));\n\nfunction receiverMatchesExpectedVersion(probe) {\n return Boolean(probe?.ok && probe.contentVersion === EXPECTED_CONTENT_VERSION && probe.contentFile === CONTENT_SCRIPT_FILE);\n}\n\nasync function waitForTabReady(tabId, timeout = 16000) {\n if (!chrome.tabs?.get) {\n await wait(900);\n return null;\n }\n const startedAt = Date.now();\n while (Date.now() - startedAt < timeout) {\n const tab = await chrome.tabs.get(tabId).catch(() => null);\n if (tab?.status === 'complete') return tab;\n await wait(180);\n }\n return chrome.tabs.get(tabId).catch(() => null);\n}\n\nasync function reloadBossTab(tab, reason = 'runtime-refresh') {\n if (!tab?.id || !chrome.tabs?.reload) return tab;\n await chrome.tabs.reload(tab.id, { bypassCache: true });\n const ready = await waitForTabReady(tab.id);\n await writeEvent('info', 'BOSS 页面助手已自动刷新', { reason, tabId: tab.id, contentVersion: EXPECTED_CONTENT_VERSION }).catch(() => {});\n return ready || tab;\n}\n\nasync function refreshBossTabsForRuntimeVersion() {\n const key = 'bossContentRuntimeVersion';\n const current = await storage.get(key);\n if (current?.[key] === EXPECTED_CONTENT_VERSION) return;\n await storage.set({ [key]: EXPECTED_CONTENT_VERSION });\n if (!chrome.tabs?.query || !chrome.tabs?.reload) return;\n const tabs = await chrome.tabs.query({ url: ['https://www.zhipin.com/*', 'https://app.zhipin.com/*'] }).catch(() => []);\n for (const tab of tabs || []) {\n if (!tab?.id) continue;\n await chrome.tabs.reload(tab.id, { bypassCache: true }).catch(() => {});\n }\n}\n\nasync function activeBossTab() {\n const tabs = await chrome.tabs.query({ url: ['https://www.zhipin.com/*', 'https://app.zhipin.com/*'] });\n return tabs.find(tab => tab.active) || tabs[0] || null;\n}\n\nfunction bossConnectionError(error) {\n const message = String(error?.message || error || '');\n if (NO_RECEIVER_PATTERN.test(message)) {\n return 'BOSS 页面连接尚未就绪,JobClaw 已尝试自动修复。请刷新当前 BOSS 页面后再点一次开始。';\n }\n if (/Cannot access contents of url|The extensions gallery cannot be scripted|Missing host permission/i.test(message)) {\n return '当前页面无法接入 JobClaw。请确认打开的是 BOSS 直聘岗位页或沟通页。';\n }\n return message || 'BOSS 页面连接失败';\n}\n\nasync function probeBossReceiver(tab) {\n return chrome.tabs.sendMessage(tab.id, { type: 'PROBE', expectedContentVersion: EXPECTED_CONTENT_VERSION });\n}\n\nasync function injectBossContent(tab) {\n if (!chrome.scripting?.executeScript) throw new Error('扩展缺少页面注入能力,请重新加载最新版 JobClaw');\n await chrome.scripting.executeScript({\n target: { tabId: tab.id },\n files: [CONTENT_SCRIPT_FILE]\n });\n}\n\nasync function ensureBossReceiver(tab) {\n if (!tab?.id) throw new Error('请先打开并登录 BOSS 直聘');\n if (!BOSS_URL_PATTERN.test(String(tab.url || ''))) {\n throw new Error('请切换到 BOSS 直聘岗位页或沟通页');\n }\n\n let probe = null;\n try {\n probe = await probeBossReceiver(tab);\n if (receiverMatchesExpectedVersion(probe)) return probe;\n } catch (error) {\n if (!NO_RECEIVER_PATTERN.test(String(error?.message || error))) {\n throw new Error(bossConnectionError(error));\n }\n }\n\n // 能收到响应但版本不一致,说明当前标签页仍保留旧内容脚本。必须整页刷新,\n // 不能继续在同一页面叠加注入,否则旧 RUN 监听器仍可能执行。\n if (probe?.ok && !receiverMatchesExpectedVersion(probe)) {\n tab = await reloadBossTab(tab, `stale-${probe.contentVersion || 'legacy'}`);\n }\n\n for (let attempt = 0; attempt < 5; attempt += 1) {\n await wait(180 + attempt * 140);\n try {\n const currentProbe = await probeBossReceiver(tab);\n if (receiverMatchesExpectedVersion(currentProbe)) return currentProbe;\n if (currentProbe?.ok && !receiverMatchesExpectedVersion(currentProbe) && attempt === 0) {\n tab = await reloadBossTab(tab, `mismatch-${currentProbe.contentVersion || 'legacy'}`);\n }\n } catch (error) {\n if (!NO_RECEIVER_PATTERN.test(String(error?.message || error))) {\n throw new Error(bossConnectionError(error));\n }\n if (attempt === 1) {\n try { await injectBossContent(tab); } catch (injectError) { throw new Error(bossConnectionError(injectError)); }\n }\n }\n }\n throw new Error(`BOSS 页面助手版本未就绪(需要 ${EXPECTED_CONTENT_VERSION})。JobClaw 已自动刷新页面,请再点一次开始。`);\n}\n\nasync function sendToBossTab(tab, message) {\n await ensureBossReceiver(tab);\n try {\n return await chrome.tabs.sendMessage(tab.id, message);\n } catch (error) {\n if (!NO_RECEIVER_PATTERN.test(String(error?.message || error))) {\n throw new Error(bossConnectionError(error));\n }\n tab = await reloadBossTab(tab, 'message-receiver-lost');\n await ensureBossReceiver(tab);\n try {\n return await chrome.tabs.sendMessage(tab.id, message);\n } catch (retryError) {\n throw new Error(bossConnectionError(retryError));\n }\n }\n}\n\nasync function sendToBoss(message) {\n const tab = await activeBossTab();\n if (!tab) throw new Error('请先打开并登录 BOSS 直聘');\n return sendToBossTab(tab, message);\n}\n\nasync function nativeBridge(path, body) {\n if (!chrome.runtime?.sendNativeMessage) throw new Error('当前 Chrome 不支持 Native Messaging');\n const response = await chrome.runtime.sendNativeMessage(NATIVE_BRIDGE_HOST, { path, body: body ?? null });\n if (!response || response.ok === false) throw new Error(response?.error || '本地桥接没有返回结果');\n const payload = response.payload && typeof response.payload === 'object' ? response.payload : response;\n return { ...payload, _transport: 'native' };\n}\n\nasync function bridge(path, body) {\n const failures = [];\n const timeoutMs = path === '/parse-resume' ? 190000 : path === '/company/verify' ? 3500 : path === '/status' ? 3000 : 8000;\n for (const base of BRIDGE_ENDPOINTS) {\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), timeoutMs);\n try {\n const response = await fetch(`${base}${path}`, {\n method: body ? 'POST' : 'GET',\n headers: { 'Content-Type': 'application/json' },\n body: body ? JSON.stringify(body) : undefined,\n cache: 'no-store',\n credentials: 'omit',\n signal: controller.signal\n });\n const payload = await response.json().catch(() => ({}));\n if (!response.ok) throw new Error(payload.error || `本地桥接返回 HTTP ${response.status}`);\n bridgeUnavailableUntil = 0;\n bridgeLastError = '';\n return { ...payload, _transport: 'http', _endpoint: base };\n } catch (error) {\n failures.push(`${base} ${error?.name === 'AbortError' ? '连接超时' : String(error?.message || error)}`);\n } finally {\n clearTimeout(timeout);\n }\n }\n try {\n const payload = await nativeBridge(path, body);\n bridgeUnavailableUntil = 0;\n bridgeLastError = '';\n return payload;\n } catch (error) {\n failures.push(`Native Messaging ${String(error?.message || error)}`);\n bridgeLastError = String(error?.message || error);\n if (path === '/company/verify') bridgeUnavailableUntil = Date.now() + 5 * 60 * 1000;\n }\n throw new Error(`OpenClaw 桌面桥接未连接。请双击“安装桌面桥接-mac.command”完成安装或修复。${failures.length ? ` 诊断:${failures.join(';')}` : ''}`);\n}\n\nasync function diagnoseBridge() {\n const attempts = [];\n for (const base of BRIDGE_ENDPOINTS) {\n const startedAt = Date.now();\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), 2500);\n try {\n const response = await fetch(`${base}/status`, { cache: 'no-store', credentials: 'omit', signal: controller.signal });\n const payload = await response.json().catch(() => ({}));\n attempts.push({ transport: 'http', endpoint: base, ok: response.ok, elapsedMs: Date.now() - startedAt, status: response.status, payload });\n if (response.ok) return { ok: true, transport: 'http', runtimeId: chrome.runtime.id, attempts, status: payload };\n } catch (error) {\n attempts.push({ transport: 'http', endpoint: base, ok: false, elapsedMs: Date.now() - startedAt, error: error?.name === 'AbortError' ? '连接超时' : String(error?.message || error) });\n } finally {\n clearTimeout(timer);\n }\n }\n try {\n const status = await nativeBridge('/status');\n attempts.push({ transport: 'native', host: NATIVE_BRIDGE_HOST, ok: true });\n return { ok: true, transport: 'native', runtimeId: chrome.runtime.id, attempts, status };\n } catch (error) {\n attempts.push({ transport: 'native', host: NATIVE_BRIDGE_HOST, ok: false, error: String(error?.message || error) });\n }\n return {\n ok: false,\n runtimeId: chrome.runtime.id,\n expectedRuntimeId: 'dkfilgjiooigjbollljdionbnnofekkh',\n attempts,\n fix: '退出旧桥接后 双击项目根目录的“安装桌面桥接-mac.command” 安装完成后回到扩展点击检测连接',\n logPaths: ['~/.jobclaw/bridge.log', '~/.jobclaw/bridge-error.log']\n };\n}\n\nfunction bridgeSnapshot(all = {}) {\n const config = all.config || {};\n const pending = Array.isArray(all.pending) ? all.pending : [];\n const taskRuns = Array.isArray(all.taskRuns) ? all.taskRuns : [];\n return {\n ts: Date.now(),\n stats: { ...(all.stats || {}) },\n workflow: {\n running: Boolean(all.workflow?.running),\n paused: Boolean(all.workflow?.paused),\n phase: String(all.workflow?.phase || 'idle'),\n statusText: String(all.workflow?.statusText || '')\n },\n queue: {\n waiting: pending.filter(item => ['pending', 'approved_queue', 'approved'].includes(item.status)).length,\n sent: pending.filter(item => item.status === 'sent').length,\n failed: pending.filter(item => item.status === 'failed').length\n },\n recentRuns: taskRuns.filter(() => true).slice(0, 30).map(run => ({\n status: run.status,\n stageLabel: run.stageLabel,\n job: run.job ? { title: run.job.title || '', company: run.job.company || '' } : null,\n updatedAt: run.updatedAt || run.completedAt || run.createdAt || 0\n })),\n config: {\n batchStrategy: normalizeStrategy(config.batchStrategy),\n massApplyAnalysis: config.massApplyAnalysis || 'fast',\n pacingPreset: config.pacingPreset || 'standard',\n dailyTarget: Number(config.dailyTarget || 30),\n dailyReportEnabled: config.dailyReportEnabled !== false,\n dailyReportTime: String(config.dailyReportTime || '20:30'),\n dailyReportNotification: config.dailyReportNotification !== false\n }\n };\n}\n\nasync function syncBridgeSnapshot(force = false) {\n if (!force && Date.now() - lastBridgeSnapshotAt < 55 * 1000) return { skipped: true };\n lastBridgeSnapshotAt = Date.now();\n const snapshot = bridgeSnapshot(await storage.all());\n return bridge('/sync', { snapshot });\n}\n\nasync function enforceRateLimit(scope = 'discovery') {\n const { config = {}, safetyState = {} } = await storage.get(['config', 'safetyState']);\n const now = Date.now();\n const decision = computeRateLimitDecision(config, safetyState, scope, now);\n if (!decision.allowed) {\n await patchWorkflow({ running: false, paused: true, statusText: decision.reason || '安全熔断已开启' });\n return { ok: false, ...decision };\n }\n const reservedAt = now + Number(decision.waitMs || 0);\n const nextState = recordRateAction(safetyState, scope, reservedAt, Number(decision.waitMs || 0) > 0);\n await storage.set({ safetyState: nextState });\n return { ok: true, ...decision, reservedAt };\n}\n\nasync function applySafetyOutcome(outcome = {}) {\n const { config = {}, safetyState = {} } = await storage.get(['config', 'safetyState']);\n const next = recordSafetyOutcome(config, safetyState, outcome);\n await storage.set({ safetyState: next });\n if (next.circuitOpen) {\n await patchWorkflow({\n running: false,\n paused: true,\n phase: 'safety_stop',\n pendingApplyId: null,\n statusText: `安全熔断:${next.circuitReason || '连续任务失败'}`\n });\n await writeEvent('warning', '安全熔断已开启', { reason: next.circuitReason, failures: next.consecutiveFailures });\n chrome.notifications.create(`jobclaw-safety-${Date.now()}`, {\n type: 'basic',\n iconUrl: 'icon128.png',\n title: 'JobClaw 已安全暂停',\n message: next.circuitReason || '连续任务失败,请检查页面后手动恢复'\n }).catch(() => {});\n }\n return next;\n}\n\nasync function clearSafetyCircuit() {\n const { safetyState = {} } = await storage.get('safetyState');\n const next = resetSafetyCircuit(safetyState);\n await storage.set({ safetyState: next });\n await writeEvent('info', '安全熔断已重置');\n return next;\n}\n\nasync function verifyCompanyForJob(job = {}, force = false) {\n const { config = {}, companyVerificationCache = {} } = await storage.get(['config', 'companyVerificationCache']);\n const fallback = heuristicCompanyVerification(job);\n if (config.companyVerificationEnabled === false) {\n return { ...fallback, provider: 'disabled', status: 'disabled', verified: false, riskLevel: fallback.riskLevel === 'high' ? 'high' : 'unknown' };\n }\n const key = companyCacheKey(job.company);\n const cached = companyVerificationCache[key];\n if (!force && cached && !companyVerificationExpired(cached, config.companyVerificationCacheDays || 14)) {\n return { ...cached, cacheHit: true };\n }\n let providerResult = null;\n if (config.companyVerificationProvider !== 'local' && Date.now() < bridgeUnavailableUntil) {\n providerResult = {\n provider: 'bridge-cooldown',\n status: 'unavailable',\n verified: false,\n riskLevel: 'unknown',\n confidence: 0,\n signals: [`OpenClaw暂不可用 已在本地快速降级 ${bridgeLastError || ''}`.trim()],\n evidence: [],\n checkedAt: Date.now()\n };\n }\n if (config.companyVerificationProvider !== 'local' && !providerResult) {\n const throttle = await enforceRateLimit('company');\n if (throttle.ok && throttle.waitMs) await wait(throttle.waitMs);\n try {\n const response = await bridge('/company/verify', {\n provider: config.companyVerificationProvider || 'bridge',\n companyName: job.company || '',\n job: {\n title: job.title || '',\n company: job.company || '',\n location: job.location || '',\n description: job.description || job.cardText || '',\n url: job.url || ''\n }\n });\n providerResult = response?.result || response;\n } catch (error) {\n providerResult = {\n provider: 'bridge-unavailable',\n status: 'unavailable',\n verified: false,\n riskLevel: 'unknown',\n confidence: 0,\n signals: [`企业数据源暂不可用:${error.message}`],\n evidence: []\n };\n }\n }\n const merged = mergeCompanyVerification(providerResult || {}, fallback);\n const nextCache = { ...companyVerificationCache, [key]: merged };\n const entries = Object.entries(nextCache).sort((a, b) => Number(b[1]?.checkedAt || 0) - Number(a[1]?.checkedAt || 0)).slice(0, 500);\n await storage.set({ companyVerificationCache: Object.fromEntries(entries) });\n return { ...merged, cacheHit: false };\n}\n\nasync function preflightJob(job = {}) {\n const { config = {}, pending = [], deliveryHistory = [] } = await storage.get(['config', 'pending', 'deliveryHistory']);\n const duplicate = findDuplicate(job, {\n pending,\n history: deliveryHistory,\n maxPerCompanyPerDay: config.maxPerCompanyPerDay || 2,\n date: today()\n });\n if (duplicate.duplicate) {\n await changeStats({ duplicates: 1, blocked: 1 });\n return { ok: true, blocked: true, category: 'duplicate', reason: duplicate.reason, duplicate };\n }\n const company = await verifyCompanyForJob(job);\n const blockUnknown = Boolean(config.blockUnknownCompanies);\n const blocked = company.riskLevel === 'high' || (blockUnknown && company.riskLevel === 'unknown');\n await changeStats(blocked ? { blocked: 1 } : company.verified ? { verified: 1 } : {});\n return {\n ok: true,\n blocked,\n category: blocked ? 'company-risk' : 'accepted',\n reason: blocked ? (company.signals?.[0] || '企业核验未通过') : '',\n company,\n duplicate\n };\n}\n\nasync function checkForUpdates(force = false) {\n const currentVersion = chrome.runtime.getManifest().version;\n const { config = {}, updateInfo = {} } = await storage.get(['config', 'updateInfo']);\n if (config.updateCheckEnabled === false && !force) return updateInfo;\n if (!force && Number(updateInfo.checkedAt || 0) && Date.now() - Number(updateInfo.checkedAt) < 6 * 60 * 60 * 1000) return updateInfo;\n try {\n const response = await fetch('https://api.github.com/repos/Chrisbetheking/job-claw/releases/latest', {\n headers: { Accept: 'application/vnd.github+json' },\n cache: 'no-store',\n credentials: 'omit'\n });\n if (response.status === 404) {\n const next = { ...DEFAULTS.updateInfo, currentVersion, checkedAt: Date.now(), error: '仓库暂未发布正式 Release' };\n await storage.set({ updateInfo: next });\n return next;\n }\n if (!response.ok) throw new Error(`GitHub Release HTTP ${response.status}`);\n const next = normalizeRelease(await response.json(), currentVersion);\n const wasAvailable = Boolean(updateInfo.available && updateInfo.latestVersion === next.latestVersion);\n await storage.set({ updateInfo: next });\n if (next.available && !wasAvailable) {\n chrome.notifications.create(`jobclaw-update-${next.latestVersion}`, {\n type: 'basic',\n iconUrl: 'icon128.png',\n title: `JobClaw ${next.latestVersion} 可更新`,\n message: next.name || '发现新的正式版本'\n }).catch(() => {});\n }\n return next;\n } catch (error) {\n const next = { ...updateInfo, currentVersion, checkedAt: Date.now(), error: error.message || '更新检查失败' };\n await storage.set({ updateInfo: next });\n return next;\n }\n}\n\nfunction extractJson(raw) {\n let text = String(raw || '').trim()\n .replace(/^```(?:json)?/i, '')\n .replace(/```$/i, '')\n .trim();\n const start = text.indexOf('{');\n const end = text.lastIndexOf('}');\n if (start >= 0 && end > start) text = text.slice(start, end + 1);\n text = text.replace(/,\\s*([}\\]])/g, '$1');\n return JSON.parse(text);\n}\n\nfunction aiError(code, message, details = {}) {\n const error = new Error(message);\n error.code = code;\n Object.assign(error, details);\n return error;\n}\n\nfunction aiFailureKind(error) {\n const code = String(error?.code || '');\n const message = String(error?.message || '');\n if (code === 'AI_CONFIG' || /API Key|未配置|401|unauthorized/i.test(message)) return 'config-missing';\n if (['AI_NETWORK', 'AI_HTTP', 'AI_TIMEOUT'].includes(code) || /HTTP|fetch|网络|超时|服务不可用/i.test(message)) return 'service-error';\n return 'output-invalid';\n}\n\nfunction isRetryableAiOutputError(error) {\n return ['AI_TRUNCATED', 'AI_EMPTY', 'AI_INVALID_JSON', 'AI_PROFILE_INCOMPLETE'].includes(String(error?.code || ''));\n}\n\nasync function requestModel(url, payload, apiKey, timeoutMs = 90000) {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n try {\n return await fetch(url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`\n },\n body: JSON.stringify(payload),\n signal: controller.signal\n });\n } catch (error) {\n if (error?.name === 'AbortError') throw aiError('AI_TIMEOUT', 'AI 请求超时');\n throw aiError('AI_NETWORK', `AI 网络请求失败:${error?.message || '连接异常'}`);\n } finally {\n clearTimeout(timer);\n }\n}\n\nasync function callModel(messages, jsonMode = true, options = {}) {\n const { config } = await storage.get('config');\n const model = config?.model || {};\n if (!model.apiKey) throw aiError('AI_CONFIG', '请先在设置页填写 AI API Key');\n const url = `${String(model.baseUrl || 'https://api.deepseek.com').replace(/\\/$/, '')}/chat/completions`;\n const payload = {\n model: model.model || 'deepseek-v4-pro',\n messages,\n temperature: Number(options.temperature ?? model.temperature ?? 0.1),\n max_tokens: Number(options.maxTokens ?? 2800)\n };\n if (jsonMode) payload.response_format = { type: 'json_object' };\n\n let response = await requestModel(url, payload, model.apiKey, Number(options.timeoutMs || 90000));\n let bodyText = await response.text();\n\n // 某些兼容网关不支持 response_format,但仍能正常返回 JSON 文本。\n if (!response.ok && jsonMode && [400, 404, 422].includes(response.status) && /response[_ -]?format|json_object|unsupported/i.test(bodyText)) {\n const retryPayload = { ...payload };\n delete retryPayload.response_format;\n response = await requestModel(url, retryPayload, model.apiKey, Number(options.timeoutMs || 90000));\n bodyText = await response.text();\n }\n\n if (!response.ok) {\n throw aiError('AI_HTTP', `AI 请求失败 HTTP ${response.status}: ${bodyText.substring(0, 500)}`, { status: response.status });\n }\n\n let result;\n try {\n result = JSON.parse(bodyText);\n } catch {\n throw aiError('AI_INVALID_RESPONSE', 'AI 接口返回了无法识别的响应');\n }\n const choice = result.choices?.[0];\n const content = String(choice?.message?.content || '').trim();\n if (!content) throw aiError('AI_EMPTY', 'AI 返回为空');\n if (choice.finish_reason === 'length') {\n throw aiError('AI_TRUNCATED', 'AI 输出被截断', { finishReason: choice.finish_reason, partial: content });\n }\n if (!jsonMode) return content;\n try {\n return extractJson(content);\n } catch (error) {\n throw aiError('AI_INVALID_JSON', `AI 返回 JSON 不完整:${error?.message || '解析失败'}`, { partial: content });\n }\n}\n\nfunction cleanResumeText(value) {\n return String(value || '')\n .replace(/\\r/g, '\\n')\n .replace(/[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001F]/g, ' ')\n .replace(/[ \\t]+/g, ' ')\n .replace(/\\n{3,}/g, '\\n\\n')\n .trim();\n}\n\nfunction lineMatches(text, pattern, limit = 6) {\n return uniq(cleanResumeText(text)\n .split('\\n')\n .map(line => line.trim())\n .filter(line => line.length >= 4 && line.length <= 180 && pattern.test(line)))\n .slice(0, limit);\n}\n\nfunction extractSkills(text) {\n const source = cleanResumeText(text);\n const catalog = [\n ['JavaScript', /\\bjavascript\\b|\\bjs\\b/i],\n ['TypeScript', /\\btypescript\\b|\\bts\\b/i],\n ['HTML', /\\bhtml5?\\b/i],\n ['CSS', /\\bcss3?\\b|sass|less/i],\n ['Vue', /\\bvue(?:\\.js|3|2)?\\b/i],\n ['React', /\\breact(?:\\.js)?\\b/i],\n ['Next.js', /\\bnext\\.?js\\b/i],\n ['Node.js', /\\bnode\\.?js\\b/i],\n ['Express', /\\bexpress\\b/i],\n ['Vite', /\\bvite\\b/i],\n ['Webpack', /\\bwebpack\\b/i],\n ['Tailwind CSS', /\\btailwind\\b/i],\n ['ECharts', /\\becharts\\b/i],\n ['D3.js', /\\bd3(?:\\.js)?\\b/i],\n ['Tauri', /\\btauri\\b/i],\n ['Electron', /\\belectron\\b/i],\n ['Chrome Extension', /chrome\\s*(?:extension|扩展)|浏览器扩展/i],\n ['Python', /\\bpython\\b/i],\n ['Java', /\\bjava\\b/i],\n ['C/C++', /\\bc\\+\\+\\b|\\bc语言\\b/i],\n ['Go', /\\bgolang\\b|\\bgo语言\\b/i],\n ['SQL', /\\bsql\\b/i],\n ['MySQL', /\\bmysql\\b/i],\n ['PostgreSQL', /\\bpostgres(?:ql)?\\b/i],\n ['Redis', /\\bredis\\b/i],\n ['Spring Boot', /spring\\s*boot/i],\n ['Flask', /\\bflask\\b/i],\n ['Django', /\\bdjango\\b/i],\n ['FastAPI', /\\bfastapi\\b/i],\n ['Git', /\\bgit\\b|github/i],\n ['Docker', /\\bdocker\\b/i],\n ['Linux', /\\blinux\\b/i],\n ['Playwright', /\\bplaywright\\b/i],\n ['Selenium', /\\bselenium\\b/i],\n ['PyTorch', /\\bpytorch\\b/i],\n ['TensorFlow', /\\btensorflow\\b/i],\n ['OpenCV', /\\bopencv\\b/i],\n ['RAG', /\\brag\\b|检索增强生成/i],\n ['LLM', /\\bllm\\b|大语言模型|语言模型/i],\n ['AI Agent', /ai\\s*agent|智能体/i],\n ['OCR', /\\bocr\\b|文字识别/i],\n ['数据可视化', /数据可视化|可视化看板/i]\n ];\n return catalog.filter(([, pattern]) => pattern.test(source)).map(([name]) => name).slice(0, 40);\n}\n\nfunction extractDegree(text) {\n const source = cleanResumeText(text);\n if (/博士|ph\\.?d/i.test(source)) return '博士';\n if (/硕士|研究生|master/i.test(source)) return '硕士';\n if (/本科|学士|bachelor/i.test(source)) return '本科';\n if (/大专|专科|associate/i.test(source)) return '大专';\n return '不限';\n}\n\nfunction extractLocations(text) {\n const source = cleanResumeText(text);\n const cities = ['北京','上海','广州','深圳','杭州','成都','西安','南京','武汉','苏州','重庆','天津','长沙','郑州','青岛','厦门','合肥','济南','宁波','东莞','珠海','佛山','无锡','兰州','太原','南阳','东京','大阪'];\n return cities.filter(city => source.includes(city)).slice(0, 6);\n}\n\nfunction extractExplicitDirections(text) {\n const source = cleanResumeText(text);\n const matches = [];\n const pattern = /(?:求职意向|求职方向|目标岗位|意向岗位|应聘岗位|期望职位)\\s*[::]?\\s*([^\\n]{2,100})/ig;\n let match;\n while ((match = pattern.exec(source)) && matches.length < 3) {\n matches.push(...String(match[1] || '').split(/[,,、/|]/).map(item => item.trim()).filter(item => item.length >= 2 && item.length <= 28));\n }\n return uniq(matches).slice(0, 3);\n}\n\nfunction inferDirections(text, skills) {\n const source = cleanResumeText(text);\n const explicit = extractExplicitDirections(source);\n if (explicit.length) return explicit;\n const directions = [];\n const has = name => skills.includes(name);\n const internship = /实习|在校|应届|校招/i.test(source);\n if (/前端|web前端|网页开发/i.test(source) || ['JavaScript','TypeScript','Vue','React','HTML','CSS'].some(has)) {\n directions.push(internship ? '前端开发实习生' : '前端开发工程师');\n }\n if (/全栈/i.test(source) || ((has('React') || has('Vue')) && (has('Node.js') || has('Java') || has('Python')))) {\n directions.push(internship ? '全栈开发实习生' : '全栈开发工程师');\n }\n if (/ai应用|人工智能应用|智能体|rag|大模型|llm/i.test(source) || ['RAG','LLM','AI Agent'].some(has)) {\n directions.push(internship ? 'AI 应用开发实习生' : 'AI 应用开发工程师');\n }\n if (/数据可视化|echarts|d3/i.test(source) || has('数据可视化') || has('ECharts') || has('D3.js')) {\n directions.push('数据可视化开发工程师');\n }\n if (/后端|服务端/i.test(source) || ['Spring Boot','Java','Node.js','Python','Go'].filter(has).length >= 2) {\n directions.push(internship ? '后端开发实习生' : '后端开发工程师');\n }\n if (!directions.length) directions.push(internship ? '软件开发实习生' : '软件开发工程师');\n return uniq(directions).slice(0, 3);\n}\n\nfunction buildSearchKeywords(directions, skills) {\n const keywords = [...directions];\n const joined = directions.join(' ');\n if (/前端/.test(joined)) {\n keywords.push('前端开发', 'Web 前端', 'JavaScript 开发');\n if (skills.includes('React')) keywords.push('React 开发');\n if (skills.includes('Vue')) keywords.push('Vue 开发');\n if (skills.includes('TypeScript')) keywords.push('TypeScript 前端');\n }\n if (/全栈/.test(joined)) keywords.push('全栈开发', 'Web 全栈');\n if (/AI 应用/.test(joined)) {\n keywords.push('AI 应用开发');\n if (skills.includes('RAG')) keywords.push('RAG 应用开发');\n if (skills.includes('AI Agent')) keywords.push('AI Agent 开发');\n }\n if (/数据可视化/.test(joined)) keywords.push('数据可视化', '可视化前端');\n if (/后端/.test(joined)) keywords.push('后端开发', '服务端开发');\n return uniq(keywords).slice(0, 12);\n}\n\nfunction explainProfileFallbackReason(reason = '', kind = '') {\n const message = String(reason || '').trim();\n const resolvedKind = kind || aiFailureKind({ message });\n if (!message) return '';\n if (resolvedKind === 'config-missing') {\n return 'AI 尚未配置;本次未调用 AI,当前初稿全部来自本地规则。';\n }\n if (resolvedKind === 'service-error') {\n return 'AI 请求没有成功;本次画像未使用 AI,当前初稿全部来自本地规则。';\n }\n return 'AI 连接可用,但返回内容未通过完整性校验;系统已自动精简重试,仍未成功,因此本次初稿全部来自本地规则。';\n}\n\nfunction validateGeneratedProfile(profile) {\n if (!profile || typeof profile !== 'object' || Array.isArray(profile)) {\n throw aiError('AI_PROFILE_INCOMPLETE', 'AI 画像字段不完整');\n }\n const directions = normalizeStringList(profile.primaryDirections, 3);\n const keywords = normalizeStringList(profile.searchKeywords, 12);\n const summary = String(profile.summary || '').trim();\n if (!directions.length || !keywords.length || summary.length < 12) {\n throw aiError('AI_PROFILE_INCOMPLETE', 'AI 画像字段不完整');\n }\n if (!profile.facts || typeof profile.facts !== 'object' || Array.isArray(profile.facts)) {\n throw aiError('AI_PROFILE_INCOMPLETE', 'AI 画像字段不完整');\n }\n if (!profile.hardConstraints || typeof profile.hardConstraints !== 'object' || Array.isArray(profile.hardConstraints)) {\n throw aiError('AI_PROFILE_INCOMPLETE', 'AI 画像字段不完整');\n }\n return profile;\n}\n\nfunction validateCompactGeneratedProfile(profile) {\n if (!profile || typeof profile !== 'object' || Array.isArray(profile)) {\n throw aiError('AI_PROFILE_INCOMPLETE', 'AI 精简画像字段不完整');\n }\n const normalized = {\n summary: String(profile.summary || '').trim(),\n primaryDirections: normalizeStringList(profile.primaryDirections, 3),\n searchKeywords: normalizeStringList(profile.searchKeywords, 12),\n skills: normalizeStringList(profile.skills, 30),\n locations: normalizeStringList(profile.locations, 20),\n employmentTypes: normalizeStringList(profile.employmentTypes, 10),\n salary: String(profile.salary || '').trim(),\n experience: String(profile.experience || '').trim(),\n degree: String(profile.degree || '').trim(),\n excludeDirections: normalizeStringList(profile.excludeDirections, 20)\n };\n if (!normalized.primaryDirections.length || !normalized.searchKeywords.length || normalized.summary.length < 12) {\n throw aiError('AI_PROFILE_INCOMPLETE', 'AI 精简画像字段不完整');\n }\n return normalized;\n}\n\nfunction buildLocalProfile(resumeText, reason = '', failureKind = '') {\n const text = cleanResumeText(resumeText);\n if (text.length < 30) throw new Error('简历内容太少,暂时无法生成职业画像');\n const skills = extractSkills(text);\n const primaryDirections = inferDirections(text, skills);\n const searchKeywords = buildSearchKeywords(primaryDirections, skills);\n const degree = extractDegree(text);\n const locations = extractLocations(text);\n const internship = /实习|在校|应届|校招/i.test(text);\n const employmentTypes = internship ? ['实习', '校招'] : ['全职'];\n const experience = internship ? '在校/应届' : '按岗位要求匹配';\n const topSkills = skills.slice(0, 7);\n const summaryParts = [];\n if (degree !== '不限') summaryParts.push(`${degree}${internship ? '在读或应届' : '背景'}`);\n if (topSkills.length) summaryParts.push(`具备 ${topSkills.join('、')} 等技能或项目经验`);\n summaryParts.push(`主要关注 ${primaryDirections.join('、')} 方向`);\n\n const education = lineMatches(text, /大学|学院|本科|硕士|博士|教育经历|专业/i, 6);\n const experiences = lineMatches(text, /实习|工作经历|公司|负责|任职|助理|工程师/i, 8);\n const projects = lineMatches(text, /项目|系统|平台|工作台|GitHub|开发|实现|搭建|设计/i, 10);\n const certificates = lineMatches(text, /证书|CET|英语六级|英语四级|资格|获奖|奖项/i, 6);\n const resolvedKind = failureKind || (reason ? aiFailureKind({ message: reason }) : 'not-requested');\n\n return normalizeProfile({\n facts: { education, experiences, projects, skills, certificates },\n primaryDirections: primaryDirections.map(name => ({ name, confidence: 0.72, evidence: ['根据简历中的技能、项目和求职阶段生成'] })),\n secondaryDirections: [],\n searchKeywords,\n hardConstraints: {\n locations,\n employmentTypes,\n salary: '不限',\n experience,\n degree\n },\n excludeDirections: [],\n summary: `${summaryParts.join(',')}。`,\n generation: {\n mode: 'local-fallback',\n label: '本地规则初稿',\n aiStatus: resolvedKind,\n warning: explainProfileFallbackReason(reason, resolvedKind),\n technicalReason: String(reason || ''),\n generatedAt: Date.now()\n }\n });\n}\n\nfunction mergeProfileWithFallback(aiProfile, fallbackProfile, generation = null) {\n const ai = aiProfile && typeof aiProfile === 'object' ? aiProfile : {};\n const aiFacts = ai.facts && typeof ai.facts === 'object' ? ai.facts : {};\n const fallbackFacts = fallbackProfile.facts || {};\n const hard = ai.hardConstraints && typeof ai.hardConstraints === 'object' ? ai.hardConstraints : {};\n const fallbackHard = fallbackProfile.hardConstraints || {};\n return normalizeProfile({\n ...fallbackProfile,\n ...ai,\n facts: {\n ...fallbackFacts,\n ...aiFacts,\n education: Array.isArray(aiFacts.education) && aiFacts.education.length ? aiFacts.education : fallbackFacts.education,\n experiences: Array.isArray(aiFacts.experiences) && aiFacts.experiences.length ? aiFacts.experiences : fallbackFacts.experiences,\n projects: Array.isArray(aiFacts.projects) && aiFacts.projects.length ? aiFacts.projects : fallbackFacts.projects,\n skills: normalizeStringList(aiFacts.skills).length ? aiFacts.skills : fallbackFacts.skills,\n certificates: Array.isArray(aiFacts.certificates) && aiFacts.certificates.length ? aiFacts.certificates : fallbackFacts.certificates\n },\n primaryDirections: normalizeStringList(ai.primaryDirections, 3).length ? ai.primaryDirections : fallbackProfile.primaryDirections,\n searchKeywords: normalizeStringList(ai.searchKeywords, 12).length ? ai.searchKeywords : fallbackProfile.searchKeywords,\n hardConstraints: {\n ...fallbackHard,\n ...hard,\n locations: normalizeStringList(hard.locations).length ? hard.locations : fallbackHard.locations,\n employmentTypes: normalizeStringList(hard.employmentTypes).length ? hard.employmentTypes : fallbackHard.employmentTypes,\n salary: String(hard.salary || fallbackHard.salary || '不限'),\n experience: String(hard.experience || fallbackHard.experience || ''),\n degree: String(hard.degree || fallbackHard.degree || '不限')\n },\n summary: String(ai.summary || fallbackProfile.summary),\n generation: generation || {\n mode: 'ai-assisted',\n label: 'AI 完整画像',\n aiStatus: 'success',\n warning: 'AI 返回的完整 JSON 已通过校验;仅对可选空字段使用本地规则补齐。',\n generatedAt: Date.now()\n }\n });\n}\n\nfunction compactProfileToFull(compact, fallback) {\n return {\n facts: {\n ...(fallback.facts || {}),\n skills: compact.skills.length ? compact.skills : fallback.facts?.skills || []\n },\n primaryDirections: compact.primaryDirections,\n secondaryDirections: [],\n searchKeywords: compact.searchKeywords,\n hardConstraints: {\n ...(fallback.hardConstraints || {}),\n locations: compact.locations.length ? compact.locations : fallback.hardConstraints?.locations || [],\n employmentTypes: compact.employmentTypes.length ? compact.employmentTypes : fallback.hardConstraints?.employmentTypes || [],\n salary: compact.salary || fallback.hardConstraints?.salary || '不限',\n experience: compact.experience || fallback.hardConstraints?.experience || '',\n degree: compact.degree || fallback.hardConstraints?.degree || '不限'\n },\n excludeDirections: compact.excludeDirections,\n summary: compact.summary\n };\n}\n\nasync function buildProfile(resumeText) {\n const text = cleanResumeText(resumeText);\n const fallback = buildLocalProfile(text);\n const schema = '{\"facts\":{\"education\":[],\"experiences\":[],\"projects\":[],\"skills\":[],\"certificates\":[]},\"primaryDirections\":[{\"name\":\"\",\"confidence\":0,\"evidence\":[]}],\"secondaryDirections\":[],\"searchKeywords\":[],\"hardConstraints\":{\"locations\":[],\"employmentTypes\":[],\"salary\":\"\",\"experience\":\"\",\"degree\":\"\"},\"excludeDirections\":[],\"summary\":\"\"}';\n let firstError = null;\n try {\n const profile = validateGeneratedProfile(await callModel([\n {\n role: 'system',\n content: `你是严格的职业画像分析器。只能使用简历真实事实,不能根据项目业务场景推断用户职业。主方向最多3个,搜索词必须是真实岗位名称。数组必须精简,教育/经历/项目各最多4条,每条不超过80字,技能最多15个,摘要不超过180字。即使信息不完整,也必须给出可编辑初稿,禁止返回空内容。输出严格 JSON:${schema}`\n },\n { role: 'user', content: text.slice(0, 22000) }\n ], true, { maxTokens: 4200, temperature: 0.05 }));\n return mergeProfileWithFallback(profile, fallback);\n } catch (error) {\n firstError = error;\n }\n\n // 连接正常但完整画像输出过长、JSON 不完整或字段校验失败时,自动改用精简结构重试。\n if (isRetryableAiOutputError(firstError)) {\n const compactSchema = '{\"summary\":\"\",\"primaryDirections\":[],\"searchKeywords\":[],\"skills\":[],\"locations\":[],\"employmentTypes\":[],\"salary\":\"\",\"experience\":\"\",\"degree\":\"\",\"excludeDirections\":[]}';\n try {\n const compact = validateCompactGeneratedProfile(await callModel([\n {\n role: 'system',\n content: `你是求职职业画像分析器。只使用简历事实。请输出极简 JSON,不要解释,不要证据长句。摘要120字以内;主方向最多3个;搜索词最多10个;技能最多12个;其余字段简短。输出结构:${compactSchema}`\n },\n { role: 'user', content: text.slice(0, 18000) }\n ], true, { maxTokens: 2200, temperature: 0.05 }));\n return mergeProfileWithFallback(compactProfileToFull(compact, fallback), fallback, {\n mode: 'ai-compact-retry',\n label: 'AI 精简重试结果',\n aiStatus: 'success-after-retry',\n warning: 'AI 连接正常。首次完整画像输出未完成,系统已自动精简重试并成功采用 AI 结果。',\n technicalReason: String(firstError?.message || ''),\n generatedAt: Date.now()\n });\n } catch (retryError) {\n const combined = `${firstError?.message || 'AI 首次输出未通过'};精简重试:${retryError?.message || '失败'}`;\n return buildLocalProfile(text, combined, aiFailureKind(firstError));\n }\n }\n\n return buildLocalProfile(text, firstError?.message || 'AI 生成失败', aiFailureKind(firstError));\n}\n\n\nfunction normalizeStringList(value, limit = 30) {\n const values = Array.isArray(value) ? value : String(value || '').split(/[,,\\n]/);\n return uniq(values.map(item => typeof item === 'string' ? item.trim() : String(item?.name || item?.title || '').trim()))\n .filter(Boolean)\n .slice(0, limit);\n}\n\nfunction normalizeDirections(value, existing = []) {\n const previous = new Map((Array.isArray(existing) ? existing : []).map(item => {\n const name = typeof item === 'string' ? item : item?.name;\n return [String(name || '').trim(), item];\n }));\n return normalizeStringList(value, 3).map(name => {\n const old = previous.get(name);\n if (old && typeof old === 'object') return { ...old, name };\n return { name, confidence: 1, evidence: ['用户手动编辑'] };\n });\n}\n\nfunction normalizeProfile(incoming, current = null) {\n const base = current && typeof current === 'object' ? current : {};\n const next = incoming && typeof incoming === 'object' ? incoming : {};\n const profile = {\n ...base,\n ...next,\n facts: {\n education: [], experiences: [], projects: [], skills: [], certificates: [],\n ...(base.facts || {}),\n ...(next.facts || {}),\n skills: normalizeStringList(next.facts?.skills ?? base.facts?.skills, 40)\n },\n primaryDirections: normalizeDirections(next.primaryDirections ?? base.primaryDirections, base.primaryDirections),\n secondaryDirections: normalizeStringList(next.secondaryDirections ?? base.secondaryDirections, 10),\n searchKeywords: normalizeStringList(next.searchKeywords ?? base.searchKeywords, 12),\n excludeDirections: normalizeStringList(next.excludeDirections ?? base.excludeDirections, 20),\n hardConstraints: {\n locations: [], employmentTypes: [], salary: '', experience: '', degree: '',\n ...(base.hardConstraints || {}),\n ...(next.hardConstraints || {})\n },\n summary: String(next.summary ?? base.summary ?? '').trim(),\n editedAt: Date.now()\n };\n profile.hardConstraints.locations = normalizeStringList(profile.hardConstraints.locations, 20);\n profile.hardConstraints.employmentTypes = normalizeStringList(profile.hardConstraints.employmentTypes, 10);\n profile.hardConstraints.salary = String(profile.hardConstraints.salary || '').trim();\n profile.hardConstraints.experience = String(profile.hardConstraints.experience || '').trim();\n profile.hardConstraints.degree = String(profile.hardConstraints.degree || '').trim();\n if (!profile.primaryDirections.length) throw new Error('职业画像至少需要一个主方向');\n if (!profile.searchKeywords.length) throw new Error('职业画像至少需要一个岗位搜索词');\n return profile;\n}\n\n\nfunction profileHasCore(profile) {\n return Boolean(\n normalizeStringList(profile?.primaryDirections, 3).length &&\n normalizeStringList(profile?.searchKeywords, 12).length\n );\n}\n\nfunction normalizeProfileDraft(incoming, fallback = null) {\n const base = fallback && typeof fallback === 'object' ? fallback : {};\n const next = incoming && typeof incoming === 'object' ? incoming : {};\n const chooseList = (key, limit = 30) => Object.prototype.hasOwnProperty.call(next, key)\n ? normalizeStringList(next[key], limit)\n : normalizeStringList(base[key], limit);\n const chooseText = key => Object.prototype.hasOwnProperty.call(next, key)\n ? String(next[key] || '').trim()\n : String(base[key] || '').trim();\n return {\n summary: chooseText('summary'),\n primaryDirections: chooseList('primaryDirections', 3),\n searchKeywords: chooseList('searchKeywords', 12),\n skills: chooseList('skills', 40),\n locations: chooseList('locations', 20),\n employmentTypes: chooseList('employmentTypes', 10),\n experience: chooseText('experience'),\n degree: chooseText('degree'),\n salary: chooseText('salary'),\n excludeDirections: chooseList('excludeDirections', 20),\n source: String(next.source || base.source || 'draft'),\n updatedAt: Number(next.updatedAt || Date.now())\n };\n}\n\nfunction profileDraftHasCore(draft) {\n return Boolean(\n normalizeStringList(draft?.primaryDirections, 3).length &&\n normalizeStringList(draft?.searchKeywords, 12).length\n );\n}\n\nfunction profileDraftHasAny(draft) {\n if (!draft || typeof draft !== 'object') return false;\n return Boolean(\n String(draft.summary || '').trim() ||\n normalizeStringList(draft.primaryDirections).length ||\n normalizeStringList(draft.searchKeywords).length ||\n normalizeStringList(draft.skills).length ||\n normalizeStringList(draft.locations).length ||\n normalizeStringList(draft.employmentTypes).length ||\n String(draft.experience || '').trim() ||\n String(draft.degree || '').trim() ||\n String(draft.salary || '').trim() ||\n normalizeStringList(draft.excludeDirections).length\n );\n}\n\nfunction profileToDraft(profile, source = 'generated') {\n const hard = profile?.hardConstraints || {};\n return normalizeProfileDraft({\n summary: profile?.summary || '',\n primaryDirections: normalizeStringList(profile?.primaryDirections, 3),\n searchKeywords: normalizeStringList(profile?.searchKeywords, 12),\n skills: normalizeStringList(profile?.facts?.skills, 40),\n locations: normalizeStringList(hard.locations, 20),\n employmentTypes: normalizeStringList(hard.employmentTypes, 10),\n experience: hard.experience || '',\n degree: hard.degree || '',\n salary: hard.salary || '',\n excludeDirections: normalizeStringList(profile?.excludeDirections, 20),\n source,\n updatedAt: Date.now()\n });\n}\n\nfunction profileFromDraft(draft, currentProfile = null) {\n const normalized = normalizeProfileDraft(draft);\n return normalizeProfile({\n ...(currentProfile || {}),\n summary: normalized.summary,\n primaryDirections: normalized.primaryDirections,\n searchKeywords: normalized.searchKeywords,\n excludeDirections: normalized.excludeDirections,\n facts: {\n ...(currentProfile?.facts || {}),\n skills: normalized.skills\n },\n hardConstraints: {\n ...(currentProfile?.hardConstraints || {}),\n locations: normalized.locations,\n employmentTypes: normalized.employmentTypes,\n experience: normalized.experience,\n degree: normalized.degree,\n salary: normalized.salary\n }\n }, currentProfile);\n}\n\n\nfunction clampNumber(value, min, max, fallback = min) {\n const number = Number(value);\n return Number.isFinite(number) ? Math.max(min, Math.min(max, number)) : fallback;\n}\n\nfunction normalizeDirectionKey(value) {\n return String(value || '')\n .toLowerCase()\n .replace(/实习生|工程师|开发|岗位|职位|校招|社招|应届/g, '')\n .replace(/[\\s,,/\\\\|·•()()【】\\[\\]_-]+/g, '')\n .trim();\n}\n\nfunction stableDirectionId(name, source = 'profile') {\n const input = `${source}:${normalizeDirectionKey(name) || String(name || '').trim().toLowerCase()}`;\n let hash = 2166136261;\n for (let index = 0; index < input.length; index += 1) {\n hash ^= input.charCodeAt(index);\n hash = Math.imul(hash, 16777619);\n }\n return `direction_${(hash >>> 0).toString(36)}`;\n}\n\nconst DIRECTION_PRESETS = [\n {\n test: /前端|web|react|vue|javascript|typescript/i,\n keywords: ['前端开发实习生', 'Web前端实习生', 'React开发实习生', 'Vue开发实习生'],\n relevantSkills: ['JavaScript', 'TypeScript', 'HTML', 'CSS', 'React', 'Vue', 'Next.js', 'Vite', 'Webpack'],\n gapSkills: ['工程化', '性能优化', '组件库']\n },\n {\n test: /ai应用|人工智能应用|大模型|agent|rag|llm/i,\n keywords: ['AI应用开发实习生', 'AI Agent实习生', 'RAG应用开发实习生', '大模型应用开发实习生'],\n relevantSkills: ['AI Agent', 'RAG', 'Python', 'TypeScript', 'React', 'Node.js', 'OCR'],\n gapSkills: ['Python', '模型评测', '向量数据库']\n },\n {\n test: /全栈|full.?stack|前后端/i,\n keywords: ['全栈开发实习生', 'Web全栈实习生', '前后端开发实习生'],\n relevantSkills: ['JavaScript', 'TypeScript', 'React', 'Vue', 'Node.js', 'Express', 'MySQL'],\n gapSkills: ['后端接口', '数据库', '部署运维']\n },\n {\n test: /开发者工具|研发工具|桌面端|tauri|electron|工具开发/i,\n keywords: ['开发者工具实习生', '研发工具开发实习生', '桌面端开发实习生', '平台工具开发实习生'],\n relevantSkills: ['Tauri', 'Electron', 'TypeScript', 'React', 'Rust', 'Node.js', 'OCR'],\n gapSkills: ['跨平台工程化', '桌面端发布', '系统 API']\n },\n {\n test: /数据可视化|可视化|echarts|大屏/i,\n keywords: ['数据可视化实习生', '前端可视化实习生', 'ECharts开发实习生'],\n relevantSkills: ['ECharts', 'JavaScript', 'TypeScript', 'React', 'Vue', '数据处理'],\n gapSkills: ['图形性能优化', '复杂交互', '可视化工程化']\n },\n {\n test: /后端|java|node|服务端/i,\n keywords: ['后端开发实习生', 'Node.js开发实习生', 'Java开发实习生'],\n relevantSkills: ['Java', 'Node.js', 'Express', 'MySQL', 'Redis', 'Spring'],\n gapSkills: ['数据库', '缓存', '接口设计']\n }\n];\n\nfunction directionPreset(name) {\n return DIRECTION_PRESETS.find(item => item.test.test(String(name || ''))) || null;\n}\n\nfunction normalizedSkillSet(profile = {}) {\n return new Map(normalizeStringList(profile?.facts?.skills, 50).map(skill => [normalizeDirectionKey(skill), skill]));\n}\n\nfunction buildDirectionKeywords(name, profile = {}) {\n const allKeywords = normalizeStringList(profile?.searchKeywords, 30);\n const nameKey = normalizeDirectionKey(name);\n const preset = directionPreset(name);\n const matching = allKeywords.filter(keyword => {\n const key = normalizeDirectionKey(keyword);\n return key && (nameKey.includes(key) || key.includes(nameKey) || [...nameKey].filter(char => key.includes(char)).length >= Math.min(3, nameKey.length));\n });\n return uniq([name, ...matching, ...(preset?.keywords || [])]).filter(Boolean).slice(0, 8);\n}\n\nfunction buildDirectionEvidence(name, candidate = {}, profile = {}) {\n const skills = normalizeStringList(profile?.facts?.skills, 30);\n const skillMap = normalizedSkillSet(profile);\n const preset = directionPreset(name);\n const relevant = (preset?.relevantSkills || skills).filter(skill => {\n const key = normalizeDirectionKey(skill);\n return skillMap.has(key) || skills.some(current => normalizeDirectionKey(current).includes(key) || key.includes(normalizeDirectionKey(current)));\n });\n const matchedSkills = uniq(relevant.length ? relevant : skills.slice(0, 4)).slice(0, 5);\n const gaps = (preset?.gapSkills || []).filter(skill => {\n const key = normalizeDirectionKey(skill);\n return ![...skillMap.keys()].some(existing => existing.includes(key) || key.includes(existing));\n }).slice(0, 3);\n const evidence = normalizeStringList(candidate?.evidence, 3);\n const reason = evidence.length\n ? `职业画像中已提取到:${evidence.join(';')}`.slice(0, 160)\n : matchedSkills.length\n ? `与简历中的 ${matchedSkills.join('、')} 技能和项目经历匹配。`\n : `该方向来自职业画像中的主要求职方向,可继续人工调整。`;\n return { matchedSkills, gaps, reason };\n}\n\nfunction normalizeDirectionItem(item = {}, index = 0) {\n const name = String(item.name || item.title || '').trim().slice(0, 60);\n const source = item.source === 'custom' || item.custom ? 'custom' : 'profile';\n const id = String(item.id || stableDirectionId(name || `custom-${index}`, source));\n return {\n id,\n source,\n custom: source === 'custom',\n sourceName: String(item.sourceName || name).trim().slice(0, 60),\n name,\n enabled: item.enabled !== false,\n priority: Math.round(clampNumber(item.priority, 1, 99, index + 1)),\n score: Math.round(clampNumber(item.score, 0, 100, source === 'custom' ? 70 : Math.max(60, 88 - index * 7))),\n reason: String(item.reason || (source === 'custom' ? '用户自定义岗位方向。' : '根据职业画像推荐。')).trim().slice(0, 240),\n matchedSkills: normalizeStringList(item.matchedSkills, 8),\n gaps: normalizeStringList(item.gaps, 6),\n keywords: uniq(normalizeStringList(item.keywords, 12).length ? normalizeStringList(item.keywords, 12) : [name]).slice(0, 12),\n updatedAt: Number(item.updatedAt || Date.now())\n };\n}\n\nfunction profileDirectionSignature(profile = {}) {\n return JSON.stringify({\n directions: normalizeStringList(profile?.primaryDirections?.map(item => typeof item === 'string' ? item : item?.name), 6).map(normalizeDirectionKey),\n keywords: normalizeStringList(profile?.searchKeywords, 20).map(normalizeDirectionKey)\n });\n}\n\nfunction normalizeDirectionPlan(plan, profile = null, options = {}) {\n const items = (Array.isArray(plan?.items) ? plan.items : [])\n .map((item, index) => normalizeDirectionItem(item, index))\n .filter(item => item.name && item.keywords.length)\n .sort((left, right) => left.priority - right.priority || right.score - left.score)\n .slice(0, 12)\n .map((item, index) => ({ ...item, priority: index + 1 }));\n return {\n version: 1,\n items,\n confirmed: options.confirmed ?? Boolean(plan?.confirmed),\n updatedAt: Number(options.updatedAt || plan?.updatedAt || Date.now()),\n appliedAt: Number(options.appliedAt || plan?.appliedAt || 0),\n profileSignature: String(options.profileSignature || plan?.profileSignature || profileDirectionSignature(profile)).slice(0, 600)\n };\n}\n\nfunction buildDirectionPlan(profile = {}, currentPlan = null, options = {}) {\n const primary = Array.isArray(profile?.primaryDirections) ? profile.primaryDirections : [];\n const secondary = Array.isArray(profile?.secondaryDirections) ? profile.secondaryDirections : [];\n const searchKeywords = normalizeStringList(profile?.searchKeywords, 20);\n const candidates = [];\n for (const item of [...primary, ...secondary]) {\n const name = String(typeof item === 'string' ? item : item?.name || '').trim();\n if (!name || candidates.some(candidate => normalizeDirectionKey(candidate.name) === normalizeDirectionKey(name))) continue;\n candidates.push({ name, raw: typeof item === 'object' ? item : {} });\n }\n for (const keyword of searchKeywords) {\n if (candidates.length >= 6) break;\n if (!/开发|工程师|实习生|前端|后端|全栈|AI|人工智能|工具|可视化/i.test(keyword)) continue;\n if (candidates.some(candidate => normalizeDirectionKey(candidate.name) === normalizeDirectionKey(keyword))) continue;\n candidates.push({ name: keyword, raw: {} });\n }\n if (!candidates.length) candidates.push({ name: '目标岗位', raw: {} });\n\n const existing = new Map((currentPlan?.items || []).map(item => [String(item.id || ''), item]));\n const generated = candidates.slice(0, 6).map((candidate, index) => {\n const id = stableDirectionId(candidate.name, 'profile');\n const previous = existing.get(id);\n const confidence = Number(candidate.raw?.confidence);\n const score = Number.isFinite(confidence)\n ? Math.round(confidence <= 1 ? confidence * 100 : confidence)\n : Math.max(62, 90 - index * 7);\n const evidence = buildDirectionEvidence(candidate.name, candidate.raw, profile);\n return normalizeDirectionItem({\n id,\n source: 'profile',\n sourceName: candidate.name,\n name: options.preserveEdits && previous?.name ? previous.name : candidate.name,\n enabled: options.preserveSelections && previous ? previous.enabled : index < 3,\n priority: previous?.priority || index + 1,\n score,\n reason: evidence.reason,\n matchedSkills: evidence.matchedSkills,\n gaps: evidence.gaps,\n keywords: options.preserveEdits && previous?.keywords?.length\n ? previous.keywords\n : buildDirectionKeywords(candidate.name, profile)\n }, index);\n });\n const custom = options.preserveCustom === false ? [] : (currentPlan?.items || [])\n .filter(item => item?.source === 'custom' || item?.custom)\n .map((item, index) => normalizeDirectionItem(item, generated.length + index));\n return normalizeDirectionPlan({\n items: [...generated, ...custom],\n confirmed: options.confirmed ?? false,\n updatedAt: Date.now(),\n appliedAt: options.confirmed ? Date.now() : Number(currentPlan?.appliedAt || 0)\n }, profile, { confirmed: options.confirmed ?? false, updatedAt: Date.now() });\n}\n\nfunction selectedDirectionItems(plan = {}) {\n return (Array.isArray(plan?.items) ? plan.items : [])\n .map((item, index) => normalizeDirectionItem(item, index))\n .filter(item => item.enabled && item.name && item.keywords.length)\n .sort((left, right) => left.priority - right.priority || right.score - left.score);\n}\n\nfunction fallbackApplicantGreeting(job, profile) {\n const title = String(job?.title || '该岗位').trim();\n const skills = normalizeStringList(profile?.facts?.skills, 4);\n const direction = normalizeStringList(profile?.primaryDirections?.map(item => typeof item === 'string' ? item : item?.name), 2);\n const evidence = skills.length ? `我的简历中有${skills.slice(0, 3).join('、')}相关项目与实践` : (profile?.summary || direction.join('、'));\n const target = `贵公司的${title}`;\n return `您好,我想应聘${target}岗位。${evidence ? `${evidence},` : ''}对岗位的工作内容很感兴趣。方便的话希望进一步了解岗位和团队,谢谢。`\n .replace(/。{2,}/g, '。')\n .slice(0, 140);\n}\n\nfunction normalizeApplicantGreeting(result, job, profile) {\n const raw = String(result?.greeting || '').trim();\n const reversed = /看到你的简历|你的简历|很匹配我们|匹配我们|欢迎.*沟通|期待你|候选人|我们团队|我们公司|团队主要涉及|你很匹配|方便的话来聊聊/i.test(raw);\n const applicantVoice = /我想应聘|我希望应聘|我对.{0,20}(岗位|职位).{0,10}(感兴趣|有兴趣)|想进一步了解|希望进一步沟通/.test(raw);\n if (!raw || reversed || !applicantVoice) return fallbackApplicantGreeting(job, profile);\n return raw.slice(0, 160);\n}\n\nfunction fastMassAnalysis(job, profile) {\n const jobText = [job?.title, job?.description, job?.cardText].filter(Boolean).join('\\n').toLowerCase();\n const skills = normalizeStringList(profile?.facts?.skills, 20);\n const directions = normalizeStringList(profile?.primaryDirections?.map(item => typeof item === 'string' ? item : item?.name), 8);\n const matchedEvidence = [];\n for (const skill of skills) {\n const token = String(skill || '').trim();\n if (token && jobText.includes(token.toLowerCase())) matchedEvidence.push(token);\n }\n const directionMatched = directions.some(item => item && jobText.includes(String(item).toLowerCase()));\n const score = Math.max(25, Math.min(88, 35 + matchedEvidence.length * 9 + (directionMatched ? 15 : 0)));\n return {\n score,\n decision: matchedEvidence.length || directionMatched ? 'recommend' : 'cautious',\n hardBlocks: [],\n strictHardBlocks: [],\n matchedEvidence: matchedEvidence.slice(0, 8),\n gaps: [],\n risks: [],\n reason: '安全海投快速模式 仅做基础排序 不因技能 年限 专业或学历差距直接拦截',\n greeting: fallbackApplicantGreeting(job, profile),\n analysisMode: 'local-mass-fast'\n };\n}\n\nasync function analyzeJob(job) {\n const { profile, resumeText, config } = await storage.get(['profile', 'resumeText', 'config']);\n if (!profile) throw new Error('请先生成职业画像');\n const massMode = normalizeStrategy(config?.batchStrategy) === 'mass';\n if (massMode && config?.massApplyAnalysis !== 'ai') return fastMassAnalysis(job, profile);\n const strategyInstruction = massMode\n ? '当前为安全海投模式。技能、技术栈、专业、学历偏差、经验年限不足和行业经历缺失通常只能放入gaps,不能直接判定reject。只有JD明确写出必须、仅限、不接受,且与用户真实条件发生不可改变冲突时,才写入hardBlocks。'\n : '当前为精准投递模式。可以综合匹配度给出recommend、cautious或reject,但技能缺口仍应与真正不可改变的硬性冲突区分。';\n const result = await callModel([\n {\n role: 'system',\n content: `你是为求职者服务的岗位匹配审查器,不是招聘方。用户是正在应聘岗位的求职者。任何能力、年限、项目和成果都不能超出简历事实。${strategyInstruction} 输出 JSON:{\"score\":0,\"decision\":\"recommend|cautious|reject\",\"hardBlocks\":[],\"matchedEvidence\":[],\"gaps\":[],\"risks\":[],\"reason\":\"\",\"greeting\":\"\"}。greeting 是求职者发给招聘方/HR 的第一人称求职招呼语,50-110字,应使用“您好,我想应聘贵公司的……”口吻。严禁写成招聘方口吻,严禁出现“看到你的简历”“你的经历很匹配我们”“欢迎进一步沟通”“我们团队”“候选人”等表述;不得承诺薪资、到岗、年限或不存在的能力。`\n },\n {\n role: 'user',\n content: `职业画像:${JSON.stringify(profile)}\n简历:${String(resumeText || '').slice(0, 15000)}\n岗位:${JSON.stringify(job)}`\n }\n ]);\n result.score = Math.max(0, Math.min(100, Number(result.score || 0)));\n result.greeting = normalizeApplicantGreeting(result, job, profile);\n result.hardBlocks = Array.isArray(result.hardBlocks) ? result.hardBlocks.map(item => String(item || '').trim()).filter(Boolean) : [];\n result.strictHardBlocks = strictHardBlocks(result.hardBlocks);\n if (result.strictHardBlocks.length) result.decision = 'reject';\n else if (massMode && result.decision === 'reject') result.decision = 'cautious';\n if (!massMode && result.score < Number(config?.minScore || 75) && result.decision === 'recommend') result.decision = 'cautious';\n return result;\n}\n\nfunction createTasks(profile, config, directionPlan) {\n const directions = selectedDirectionItems(directionPlan);\n const locations = config.targetLocations?.length ? config.targetLocations : [''];\n const employmentTypes = config.employmentTypes?.length ? config.employmentTypes : ['不限'];\n const tasks = [];\n const seen = new Set();\n for (const direction of directions) {\n for (const keyword of direction.keywords) {\n for (const location of locations) {\n for (const employmentType of employmentTypes) {\n const dedupeKey = [keyword, location, employmentType].map(value => String(value || '').trim().toLowerCase()).join('|');\n if (seen.has(dedupeKey)) continue;\n seen.add(dedupeKey);\n tasks.push({\n id: crypto.randomUUID(),\n directionId: direction.id,\n directionName: direction.name,\n directionPriority: direction.priority,\n directionScore: direction.score,\n keyword,\n location,\n employmentType,\n experience: config.experiences?.[0] || '',\n degree: config.degrees?.[0] || '',\n salary: config.salary || '不限',\n attempts: 0,\n status: 'pending',\n progress: 0,\n stageLabel: '等待开始',\n processed: 0,\n discovered: 0,\n analyzed: 0,\n failed: 0,\n createdAt: Date.now()\n });\n }\n }\n }\n }\n return tasks;\n}\n\n\nasync function dispatchNextAutoPending() {\n const { pending = [], workflow = {}, config = {} } = await storage.get(['pending', 'workflow', 'config']);\n if (config.executionMode !== 'auto') return { started: false, reason: 'not-auto' };\n if (config.dryRun) return { started: false, reason: 'dry-run' };\n if (workflow.pendingApplyId) return { started: false, reason: 'busy', pendingApplyId: workflow.pendingApplyId };\n const ranked = rerankPending(pending);\n const candidate = ranked.find(entry => entry.status === 'approved_queue');\n if (!candidate) {\n await storage.set({ pending: ranked });\n return { started: false, reason: 'empty' };\n }\n const next = rerankPending(ranked.map(entry => entry.id === candidate.id\n ? { ...entry, status: 'approved', approvedAt: entry.approvedAt || Date.now() }\n : entry));\n await storage.set({ pending: next });\n const run = await updateTaskRunByPending(candidate.id, {\n status: 'running',\n stage: 'queued',\n progress: 64,\n stageLabel: `优先投递 · 队列第 ${candidate.priorityRank || 1} 位`,\n error: '',\n retryable: true,\n completedAt: null\n });\n await patchWorkflow({\n running: true,\n paused: false,\n phase: 'apply',\n statusText: `${normalizeStrategy(config.batchStrategy) === 'mass' ? '安全海投' : '精准投递'}:${candidate.job?.title || '岗位'}(队列优先级 ${candidate.priorityRank || 1})`,\n pendingApplyId: candidate.id,\n activeRunId: run?.id || candidate.runId || null\n });\n await sendToBoss({ type: 'RUN' });\n return { started: true, item: candidate, queueDepth: next.filter(entry => entry.status === 'approved_queue').length };\n}\n\nasync function addPending(item) {\n const { pending = [], config = {} } = await storage.get(['pending', 'config']);\n let run = null;\n if (item.runId) {\n run = await upsertTaskRun({\n id: item.runId,\n job: item.job,\n analysis: item.analysis,\n searchTask: item.task,\n status: config.executionMode === 'auto' && !config.dryRun ? 'queued' : 'waiting_review',\n stage: config.executionMode === 'auto' && !config.dryRun ? 'queued' : 'waiting_review',\n progress: config.executionMode === 'auto' && !config.dryRun ? 64 : 60,\n stageLabel: config.dryRun ? '模拟运行等待确认' : (config.executionMode === 'auto' ? '已进入自动排序队列' : '等待人工确认')\n });\n } else {\n run = await upsertTaskRun({\n job: item.job,\n analysis: item.analysis,\n searchTask: item.task,\n status: config.executionMode === 'auto' && !config.dryRun ? 'queued' : 'waiting_review',\n stage: config.executionMode === 'auto' && !config.dryRun ? 'queued' : 'waiting_review',\n progress: config.executionMode === 'auto' && !config.dryRun ? 64 : 60,\n stageLabel: config.dryRun ? '模拟运行等待确认' : (config.executionMode === 'auto' ? '已进入自动排序队列' : '等待人工确认')\n });\n }\n const nextItem = {\n ...item,\n runId: run.id,\n id: item.id || crypto.randomUUID(),\n deliveryGreeting: String(item.deliveryGreeting || item.analysis?.greeting || '').trim(),\n status: config.executionMode === 'auto' && !config.dryRun ? 'approved_queue' : 'pending',\n createdAt: Date.now()\n };\n await upsertTaskRun({ id: run.id, pendingId: nextItem.id });\n const next = rerankPending([nextItem, ...pending.filter(entry => entry.id !== nextItem.id)]);\n await storage.set({ pending: next });\n const count = next.filter(entry => entry.status === 'pending').length;\n await storage.set({ stats: { ...(await storage.get('stats')).stats, pending: count } });\n return next.find(entry => entry.id === nextItem.id) || nextItem;\n}\n\nasync function approvePending(id, greeting = '') {\n const { pending = [], config = {}, stats = {} } = await storage.get(['pending', 'config', 'stats']);\n const item = pending.find(entry => entry.id === id);\n if (!item) throw new Error('待确认岗位不存在');\n const lockedGreeting = String(greeting || item.deliveryGreeting || item.analysis?.greeting || '').trim();\n if (config.dryRun) {\n const completedAt = Date.now();\n const next = pending.map(entry => entry.id === id ? {\n ...entry,\n deliveryGreeting: lockedGreeting,\n analysis: { ...(entry.analysis || {}), greeting: lockedGreeting },\n status: 'simulated',\n completedAt\n } : entry);\n await storage.set({\n pending: next,\n stats: { ...stats, pending: next.filter(entry => entry.status === 'pending').length, simulated: Number(stats.simulated || 0) + 1 }\n });\n await updateTaskRunByPending(id, { status: 'success', stage: 'simulated', progress: 100, stageLabel: '模拟投递已通过', retryable: false, completedAt, result: 'dry-run' });\n await writeEvent('success', '模拟投递已通过 未执行真实发送', { id, job: item.job });\n return next.find(entry => entry.id === id);\n }\n const updatedItem = {\n ...item,\n deliveryGreeting: lockedGreeting,\n analysis: { ...(item.analysis || {}), greeting: lockedGreeting },\n status: 'approved',\n approvedAt: Date.now()\n };\n const next = pending.map(entry => entry.id === id ? updatedItem : entry);\n await storage.set({ pending: next });\n const pendingCount = next.filter(entry => entry.status === 'pending').length;\n await storage.set({ stats: { ...stats, pending: pendingCount } });\n const run = await updateTaskRunByPending(id, {\n status: 'running',\n stage: 'queued',\n progress: 64,\n stageLabel: '等待投递',\n error: '',\n retryable: true,\n completedAt: null\n });\n await patchWorkflow({\n running: true,\n paused: false,\n phase: 'apply',\n statusText: `准备以求职者身份沟通:${item.job?.title || '岗位'}`,\n pendingApplyId: id,\n activeRunId: run?.id || item.runId || null\n });\n await sendToBoss({ type: 'RUN' });\n setTimeout(() => sendToBoss({ type: 'RUN' }).catch(() => {}), 700);\n return updatedItem;\n}\n\nasync function approveAllPending() {\n const { pending = [], config = {}, stats = {} } = await storage.get(['pending', 'config', 'stats']);\n const candidates = rerankPending(pending).filter(entry => entry.status === 'pending');\n if (!candidates.length) return { count: 0 };\n if (config.dryRun) {\n const completedAt = Date.now();\n const ids = new Set(candidates.map(entry => entry.id));\n const next = pending.map(entry => ids.has(entry.id) ? { ...entry, status: 'simulated', completedAt } : entry);\n await storage.set({ pending: next, stats: { ...stats, pending: 0, simulated: Number(stats.simulated || 0) + candidates.length } });\n for (const candidate of candidates) {\n await updateTaskRunByPending(candidate.id, { status: 'success', stage: 'simulated', progress: 100, stageLabel: '模拟投递已通过', retryable: false, completedAt, result: 'dry-run' });\n }\n await writeEvent('success', `已完成${candidates.length}个岗位的模拟投递 未执行真实发送`);\n return { count: candidates.length, simulated: true };\n }\n const ids = new Set(candidates.map(entry => entry.id));\n const next = pending.map(entry => ids.has(entry.id)\n ? { ...entry, status: 'approved_queue', approvedAt: Date.now() }\n : entry);\n const first = candidates[0];\n await storage.set({ pending: next });\n await storage.set({ stats: { ...stats, pending: 0 } });\n for (const [index, candidate] of candidates.entries()) {\n await updateTaskRunByPending(candidate.id, {\n status: index === 0 ? 'running' : 'queued',\n stage: 'queued',\n progress: 64,\n stageLabel: index === 0 ? '准备投递' : '批量队列等待中',\n error: '',\n retryable: true,\n completedAt: null\n });\n }\n await patchWorkflow({\n running: true,\n paused: false,\n phase: 'apply',\n statusText: `批量投递 1/${candidates.length}:${first.job?.title || '岗位'}`,\n pendingApplyId: first.id,\n activeRunId: first.runId || null\n });\n await sendToBoss({ type: 'RUN' });\n return { count: candidates.length };\n}\n\nasync function rejectAllPending() {\n const { pending = [] } = await storage.get('pending');\n const count = pending.filter(entry => entry.status === 'pending').length;\n const next = pending.map(entry => entry.status === 'pending'\n ? { ...entry, status: 'rejected', rejectedAt: Date.now() }\n : entry);\n await storage.set({ pending: next });\n const { stats } = await storage.get('stats');\n await storage.set({ stats: { ...stats, pending: 0 } });\n for (const item of pending.filter(entry => entry.status === 'pending')) {\n await updateTaskRunByPending(item.id, { status: 'ignored', stage: 'ignored', progress: 100, stageLabel: '已忽略', retryable: false });\n }\n return { count };\n}\n\nasync function rejectPending(id) {\n const { pending = [] } = await storage.get('pending');\n const next = pending.map(entry => entry.id === id ? { ...entry, status: 'rejected', rejectedAt: Date.now() } : entry);\n await storage.set({ pending: next });\n const count = next.filter(entry => entry.status === 'pending').length;\n const { stats } = await storage.get('stats');\n await storage.set({ stats: { ...stats, pending: count } });\n await updateTaskRunByPending(id, { status: 'ignored', stage: 'ignored', progress: 100, stageLabel: '已忽略', retryable: false });\n}\n\n\nasync function skipPendingTask(id, reason = '该岗位需要外部网申,已自动跳过', stageLabel = '外部网申岗位已跳过') {\n const { pending = [], stats = {}, config = {}, chatTransition = null } = await storage.get(['pending', 'stats', 'config', 'chatTransition']);\n const completedAt = Date.now();\n let skippedItem = null;\n let next = pending.map(entry => {\n if (entry.id !== id) return entry;\n skippedItem = { ...entry, status: 'skipped', completedAt, error: reason };\n return skippedItem;\n });\n const updatedStats = {\n ...stats,\n pending: next.filter(entry => entry.status === 'pending').length\n };\n await storage.set({ pending: next, stats: updatedStats, chatTransition: chatTransition?.pendingId === id ? null : chatTransition });\n const run = await updateTaskRunByPending(id, {\n status: 'skipped', stage: 'skipped', progress: 100,\n stageLabel, error: reason, retryable: false, completedAt\n });\n await writeEvent('info', stageLabel, {\n id,\n runId: run?.id || skippedItem?.runId || '',\n job: skippedItem?.job,\n reason\n });\n\n next = rerankPending(next);\n const queued = next.find(entry => entry.status === 'approved_queue');\n if (queued) {\n next = next.map(entry => entry.id === queued.id ? { ...entry, status: 'approved' } : entry);\n await storage.set({ pending: next });\n const queuedRun = await updateTaskRunByPending(queued.id, {\n status: 'running', stage: 'queued', progress: 64,\n stageLabel: '准备投递', error: '', completedAt: null\n });\n await patchWorkflow({\n pendingApplyId: queued.id,\n activeRunId: queuedRun?.id || queued.runId || null,\n running: true,\n paused: false,\n phase: 'apply',\n statusText: `已跳过外部网申岗位,继续投递:${queued.job?.title || '岗位'}`\n });\n } else {\n await patchWorkflow({\n pendingApplyId: null,\n activeRunId: null,\n running: true,\n paused: false,\n phase: 'search',\n statusText: '外部网申岗位已跳过,继续搜索可直接沟通岗位'\n });\n }\n setTimeout(() => sendToBoss({ type: 'RUN' }).catch(() => {}), Math.max(3000, Math.min(10000, Number(config.betweenJobsSeconds || 5) * 1000)));\n return { item: skippedItem, queued: queued || null };\n}\n\nasync function handleBridgeCommands() {\n try {\n const response = await bridge('/commands');\n for (const command of response.commands || []) {\n if (command.type === 'start') {\n await patchWorkflow({ running: true, paused: false, statusText: '由 OpenClaw 启动' });\n await sendToBoss({ type: 'RUN' });\n } else if (command.type === 'pause') {\n await patchWorkflow({ paused: true, statusText: '由 OpenClaw 暂停' });\n } else if (command.type === 'stop') {\n await patchWorkflow({ running: false, paused: true, phase: 'idle', statusText: '由 OpenClaw 停止' });\n }\n }\n } catch {\n // 桌面桥接未运行时保持浏览器扩展独立可用。\n }\n}\n\nchrome.runtime.onInstalled.addListener(init);\nchrome.runtime.onStartup.addListener(init);\ninit();\n\nchrome.alarms.onAlarm.addListener(async alarm => {\n if (alarm.name === 'jobclaw-update-check') {\n await checkForUpdates(false).catch(() => {});\n return;\n }\n if (alarm.name !== 'jobclaw-tick') return;\n const { workflow } = await storage.get('workflow');\n if (workflow?.running && !workflow.paused) sendToBoss({ type: 'RUN' }).catch(() => {});\n handleBridgeCommands();\n syncBridgeSnapshot(false).catch(() => {});\n});\n\nchrome.runtime.onMessage.addListener((message, sender, reply) => {\n (async () => {\n switch (message?.type) {\n case 'TRUSTED_CHAT_INPUT': {\n const tabId = sender?.tab?.id;\n const url = String(sender?.tab?.url || '');\n if (!/^https:\\/\\/(?:www|app)\\.zhipin\\.com\\//i.test(url)) throw new Error('可信输入只能用于当前 BOSS 页面');\n const result = await trustedChatInput(tabId, message);\n reply({ ok: true, ...result });\n break;\n }\n case 'RATE_LIMIT': {\n reply(await enforceRateLimit(message.scope || 'discovery'));\n break;\n }\n case 'SAFETY_OUTCOME': {\n const state = await applySafetyOutcome({ ok: message.ok !== false, reason: message.reason || '' });\n reply({ ok: true, state });\n break;\n }\n case 'RESET_SAFETY': {\n reply({ ok: true, state: await clearSafetyCircuit() });\n break;\n }\n case 'JOB_PREFLIGHT': {\n reply(await preflightJob(message.job || {}));\n break;\n }\n case 'VERIFY_COMPANY': {\n reply({ ok: true, result: await verifyCompanyForJob(message.job || {}, Boolean(message.force)) });\n break;\n }\n case 'EVALUATE_STRATEGY': {\n const { config = {} } = await storage.get('config');\n const result = evaluateStrategy({\n strategy: config.batchStrategy,\n score: message.score,\n minScore: config.minScore,\n riskLevel: message.riskLevel || 'unknown',\n verified: Boolean(message.verified),\n decision: message.decision || 'cautious',\n hardBlocks: message.hardBlocks || []\n });\n reply({ ok: true, result });\n break;\n }\n case 'CHECK_UPDATE': {\n reply({ ok: true, result: await checkForUpdates(Boolean(message.force)) });\n break;\n }\n case 'OPEN_UPDATE': {\n const { updateInfo = {} } = await storage.get('updateInfo');\n const url = String(message.url || updateInfo.url || 'https://github.com/Chrisbetheking/job-claw/releases');\n await chrome.tabs.create({ url });\n reply({ ok: true });\n break;\n }\n case 'GET_STATE':\n reply({ ok: true, state: publicState(await storage.all()) });\n break;\n case 'SAVE_CONFIG': {\n const { config } = await storage.get('config');\n const incoming = message.config || {};\n const oldKey = config?.model?.apiKey || '';\n incoming.model = {\n ...(config?.model || {}),\n ...(incoming.model || {}),\n apiKey: incoming.model?.apiKey && incoming.model.apiKey !== '***'\n ? incoming.model.apiKey\n : oldKey\n };\n incoming.executionMode = incoming.executionMode === 'auto' ? 'auto' : 'review';\n incoming.batchStrategy = normalizeStrategy(incoming.batchStrategy || config?.batchStrategy || 'precise');\n incoming.massApplyAnalysis = ['fast', 'ai'].includes(incoming.massApplyAnalysis) ? incoming.massApplyAnalysis : (config?.massApplyAnalysis || 'fast');\n incoming.pacingPreset = ['conservative', 'standard', 'efficient', 'custom'].includes(incoming.pacingPreset) ? incoming.pacingPreset : (config?.pacingPreset || 'standard');\n incoming.dryRun = Boolean(incoming.dryRun);\n incoming.dailyTarget = Math.max(1, Math.min(150, Number(incoming.dailyTarget || config?.dailyTarget || 30)));\n incoming.discoveryLimit = Math.max(1, Math.min(800, Number(incoming.discoveryLimit || config?.discoveryLimit || 150)));\n const presetDelay = { conservative: 15, standard: 9, efficient: 6 }[incoming.pacingPreset];\n incoming.betweenJobsSeconds = Math.max(6, Math.min(120, Number(presetDelay || incoming.betweenJobsSeconds || config?.betweenJobsSeconds || 9)));\n incoming.attachmentDelaySeconds = Math.max(1.5, Math.min(15, Number(incoming.attachmentDelaySeconds || config?.attachmentDelaySeconds || 3)));\n incoming.maxPerCompanyPerDay = Math.max(1, Math.min(12, Number(incoming.maxPerCompanyPerDay || config?.maxPerCompanyPerDay || 3)));\n incoming.queueWarmup = Math.max(1, Math.min(10, Number(incoming.queueWarmup || config?.queueWarmup || 4)));\n incoming.maxConsecutiveFailures = Math.max(1, Math.min(10, Number(incoming.maxConsecutiveFailures || config?.maxConsecutiveFailures || 3)));\n incoming.jitterSeconds = Math.max(0, Math.min(15, Number(incoming.jitterSeconds ?? config?.jitterSeconds ?? 3)));\n incoming.companyVerificationEnabled = incoming.companyVerificationEnabled !== false;\n incoming.companyVerificationProvider = String(incoming.companyVerificationProvider || config?.companyVerificationProvider || 'bridge');\n incoming.companyVerificationCacheDays = Math.max(1, Math.min(90, Number(incoming.companyVerificationCacheDays || config?.companyVerificationCacheDays || 14)));\n incoming.blockUnknownCompanies = Boolean(incoming.blockUnknownCompanies);\n incoming.updateCheckEnabled = incoming.updateCheckEnabled !== false;\n incoming.dailyReportEnabled = incoming.dailyReportEnabled !== false;\n incoming.dailyReportTime = /^\\d{2}:\\d{2}$/.test(String(incoming.dailyReportTime || '')) ? String(incoming.dailyReportTime) : (config?.dailyReportTime || '20:30');\n incoming.dailyReportNotification = incoming.dailyReportNotification !== false;\n incoming.rateLimits = { ...DEFAULTS.config.rateLimits, ...(config?.rateLimits || {}), ...(incoming.rateLimits || {}), deliveryMs: Math.max(6000, Number(incoming.betweenJobsSeconds || 9) * 1000), attachmentMs: Math.max(1500, Number(incoming.attachmentDelaySeconds || 3) * 1000) };\n incoming.model.baseUrl = String(incoming.model.baseUrl || 'https://api.deepseek.com').trim();\n incoming.model.model = String(incoming.model.model || 'deepseek-v4-pro').trim();\n await storage.set({ config: { ...(config || {}), ...incoming } });\n await writeEvent('info', '设置已保存');\n syncBridgeSnapshot(true).catch(() => {});\n reply({ ok: true });\n break;\n }\n case 'SET_RESUME':\n await storage.set({ resumeText: String(message.text || '') });\n await writeEvent('info', '简历文本已保存', { length: String(message.text || '').length });\n reply({ ok: true });\n break;\n case 'SET_RESUME_SOURCE': {\n const file = message.file || null;\n if (!file?.name || !file?.dataUrl) throw new Error('简历原文件数据不完整');\n if (String(file.dataUrl).length > 26 * 1024 * 1024) throw new Error('简历原文件过大');\n const source = {\n name: String(file.name),\n type: String(file.type || ''),\n size: Number(file.size || 0),\n lastModified: Number(file.lastModified || 0),\n dataUrl: String(file.dataUrl),\n savedAt: Date.now()\n };\n await storage.set({ resumeSourceFile: source });\n await writeEvent('info', '简历原文件已保留', { name: source.name, size: source.size });\n reply({ ok: true, file: { name: source.name, type: source.type, size: source.size, stored: true } });\n break;\n }\n case 'PARSE_RESUME_PDF': {\n const { resumeSourceFile } = await storage.get('resumeSourceFile');\n if (!resumeSourceFile?.dataUrl) throw new Error('请先选择 PDF 简历');\n if (!String(resumeSourceFile.name || '').toLowerCase().endsWith('.pdf') && resumeSourceFile.type !== 'application/pdf') {\n throw new Error('当前保留的文件不是 PDF');\n }\n try {\n const result = await bridge('/parse-resume', {\n name: resumeSourceFile.name,\n type: resumeSourceFile.type,\n dataUrl: resumeSourceFile.dataUrl\n });\n if (result?.text) await writeEvent('info', 'PDF 本机深度识别完成', { method: result.method, length: result.text.length });\n reply({ ok: Boolean(result?.ok), result, error: result?.error || '' });\n } catch (error) {\n reply({ ok: false, error: `桌面桥接不可用:${error.message}` });\n }\n break;\n }\n case 'SET_IMAGE':\n await storage.set({ resumeImage: message.dataUrl || null });\n await writeEvent('info', message.dataUrl ? '简历图片已保存' : '简历图片已移除');\n reply({ ok: true });\n break;\n case 'BUILD_LOCAL_PROFILE':\n case 'ENSURE_PROFILE_DRAFT': {\n const { resumeText, profile: currentProfile, profileDraft: currentDraft } = await storage.get(['resumeText', 'profile', 'profileDraft']);\n if (profileDraftHasCore(currentDraft) && profileHasCore(currentProfile)) {\n reply({ ok: true, profile: currentProfile, profileDraft: currentDraft, skipped: true });\n break;\n }\n if (profileHasCore(currentProfile)) {\n const profileDraft = profileDraftHasAny(currentDraft)\n ? normalizeProfileDraft(currentDraft, profileToDraft(currentProfile, 'profile-repair'))\n : profileToDraft(currentProfile, 'profile-repair');\n await storage.set({ profileDraft });\n reply({ ok: true, profile: currentProfile, profileDraft, repaired: true });\n break;\n }\n const profile = buildLocalProfile(resumeText || '');\n profile.generation.mode = 'local-recovery';\n profile.generation.label = '本地初稿';\n profile.generation.warning = '已从保存的简历自动恢复可编辑初稿。';\n const profileDraft = profileToDraft(profile, 'local-recovery');\n const { directionPlan: currentDirectionPlan } = await storage.get('directionPlan');\n const directionPlan = buildDirectionPlan(profile, currentDirectionPlan, {\n confirmed: false,\n preserveSelections: true,\n preserveEdits: true,\n preserveCustom: true\n });\n await storage.set({ profile, profileDraft, directionPlan });\n await writeEvent('info', '已从已保存简历恢复可编辑画像', { directions: profile.primaryDirections });\n reply({ ok: true, profile, profileDraft, directionPlan, generation: profile.generation });\n break;\n }\n case 'BUILD_PROFILE': {\n const { resumeText, directionPlan: currentDirectionPlan } = await storage.get(['resumeText', 'directionPlan']);\n const profile = await buildProfile(resumeText || '');\n const profileDraft = profileToDraft(profile, profile.generation?.mode || 'generated');\n const directionPlan = buildDirectionPlan(profile, currentDirectionPlan, {\n confirmed: false,\n preserveSelections: true,\n preserveEdits: true,\n preserveCustom: true\n });\n await storage.set({ profile, profileDraft, directionPlan });\n const generation = profile.generation || {};\n const local = generation.mode === 'local-fallback';\n const eventTitle = !local\n ? (generation.mode === 'ai-compact-retry' ? 'AI 精简重试成功,职业画像已生成' : '职业画像已生成')\n : generation.aiStatus === 'service-error'\n ? 'AI 请求失败,已生成本地可编辑画像'\n : generation.aiStatus === 'config-missing'\n ? 'AI 尚未配置,已生成本地可编辑画像'\n : 'AI 输出未通过校验,已生成本地可编辑画像';\n await writeEvent(local ? 'warning' : 'info', eventTitle,\n { directions: profile.primaryDirections, generation });\n reply({ ok: true, profile, profileDraft, directionPlan, generation, warning: generation.warning || '' });\n break;\n }\n case 'SAVE_PROFILE_DRAFT': {\n const { profileDraft: currentDraft } = await storage.get('profileDraft');\n const profileDraft = normalizeProfileDraft(message.profileDraft, currentDraft);\n await storage.set({ profileDraft });\n reply({ ok: true, profileDraft });\n break;\n }\n case 'SAVE_PROFILE': {\n const { profile: currentProfile, config = {}, profileDraft: currentDraft, directionPlan: currentDirectionPlan } = await storage.get(['profile', 'config', 'profileDraft', 'directionPlan']);\n const profile = normalizeProfile(message.profile, currentProfile);\n const profileDraft = profileToDraft(profile, 'manual-save');\n const keepDirectionConfirmation = Boolean(\n currentDirectionPlan?.confirmed\n && profileDirectionSignature(currentProfile || {}) === profileDirectionSignature(profile)\n );\n const directionPlan = buildDirectionPlan(profile, currentDirectionPlan, {\n confirmed: keepDirectionConfirmation,\n preserveSelections: true,\n preserveEdits: true,\n preserveCustom: true\n });\n const hard = profile.hardConstraints || {};\n const nextConfig = {\n ...config,\n targetLocations: hard.locations?.length ? hard.locations : (config.targetLocations || []),\n employmentTypes: hard.employmentTypes?.length ? hard.employmentTypes : (config.employmentTypes || ['不限']),\n experiences: hard.experience ? [hard.experience] : (config.experiences || []),\n degrees: hard.degree ? [hard.degree] : (config.degrees || []),\n salary: hard.salary || config.salary || '不限'\n };\n await storage.set({ profile, profileDraft, directionPlan, config: nextConfig });\n await writeEvent('info', '职业画像已手动保存', { directions: profile.primaryDirections });\n reply({ ok: true, profile, profileDraft, directionPlan, config: nextConfig });\n break;\n }\n case 'REBUILD_DIRECTION_PLAN': {\n const stored = await storage.get(['profile', 'profileDraft', 'directionPlan']);\n let profile = stored.profile;\n if (!profileHasCore(profile) && profileDraftHasCore(stored.profileDraft)) {\n profile = profileFromDraft(stored.profileDraft, profile);\n await storage.set({ profile });\n }\n if (!profileHasCore(profile)) throw new Error('请先生成职业画像');\n const directionPlan = buildDirectionPlan(profile, stored.directionPlan, {\n confirmed: false,\n preserveSelections: false,\n preserveEdits: false,\n preserveCustom: true\n });\n await storage.set({ directionPlan });\n await writeEvent('info', '已重新生成岗位方向推荐', {\n directions: directionPlan.items.map(item => item.name),\n enabled: directionPlan.items.filter(item => item.enabled).length\n });\n reply({ ok: true, directionPlan });\n break;\n }\n case 'SAVE_DIRECTION_PLAN': {\n const stored = await storage.get(['profile', 'config', 'workflow', 'directionPlan']);\n if (!profileHasCore(stored.profile)) throw new Error('请先生成职业画像');\n const directionPlan = normalizeDirectionPlan(message.directionPlan, stored.profile, {\n confirmed: true,\n updatedAt: Date.now(),\n appliedAt: Date.now()\n });\n const selected = selectedDirectionItems(directionPlan);\n if (!selected.length) throw new Error('至少选择一个要投递的岗位方向');\n const tasks = createTasks(stored.profile, stored.config || DEFAULTS.config, directionPlan);\n if (!tasks.length) throw new Error('所选岗位方向没有可用搜索词,请先补充关键词');\n await storage.set({ directionPlan });\n await writeEvent('info', '投递岗位方向已保存', {\n directions: selected.map(item => item.name),\n taskCount: tasks.length,\n appliesNextRun: Boolean(stored.workflow?.running)\n });\n reply({\n ok: true,\n directionPlan,\n selectedCount: selected.length,\n taskCount: tasks.length,\n appliesNextRun: Boolean(stored.workflow?.running)\n });\n break;\n }\n case 'TEST_AI': {\n const text = await callModel([\n { role: 'system', content: '只回复“连接正常”。' },\n { role: 'user', content: '测试连接' }\n ], false);\n reply({ ok: true, text });\n break;\n }\n case 'PROBE_BOSS': {\n const tab = await activeBossTab();\n if (!tab) throw new Error('请先打开并登录 BOSS 直聘');\n const result = await ensureBossReceiver(tab);\n reply({ ok: true, result: { ...result, tabId: tab.id, title: tab.title || '', url: tab.url || '' } });\n break;\n }\n case 'START': {\n const safety = await storage.get('safetyState');\n if (safety.safetyState?.circuitOpen) throw new Error(`安全熔断未重置:${safety.safetyState.circuitReason || '请先检查页面状态'}`);\n const stored = await storage.get(['profile', 'profileDraft', 'resumeText', 'config', 'directionPlan']);\n let profile = stored.profile;\n const config = stored.config;\n if (!profileHasCore(profile) && profileDraftHasCore(stored.profileDraft)) {\n profile = profileFromDraft(stored.profileDraft, profile);\n await storage.set({ profile });\n }\n if (!profileHasCore(profile) && String(stored.resumeText || '').trim().length >= 30) {\n profile = buildLocalProfile(stored.resumeText || '');\n profile.generation.mode = 'local-recovery';\n profile.generation.label = '本地初稿';\n const profileDraft = profileToDraft(profile, 'start-recovery');\n await storage.set({ profile, profileDraft });\n }\n if (!profileHasCore(profile)) throw new Error('请先生成职业画像');\n let directionPlan = stored.directionPlan;\n if (!directionPlan?.items?.length) {\n directionPlan = buildDirectionPlan(profile, null, { confirmed: false });\n await storage.set({ directionPlan });\n }\n directionPlan = normalizeDirectionPlan(directionPlan, profile);\n if (!directionPlan.confirmed) throw new Error('请先在“简历 → 职业画像”中选择要投递的岗位方向并保存');\n if (!selectedDirectionItems(directionPlan).length) throw new Error('至少选择一个要投递的岗位方向');\n const tasks = createTasks(profile, config, directionPlan);\n if (!tasks.length) throw new Error('所选岗位方向没有可执行的搜索词,请补充方向关键词');\n const tab = await activeBossTab();\n if (!tab) throw new Error('请先打开并登录 BOSS 直聘');\n await ensureBossReceiver(tab);\n const workflow = {\n ...DEFAULTS.workflow,\n running: true,\n paused: false,\n phase: 'search',\n statusText: '正在启动岗位采集',\n tasks,\n activeRunId: null\n };\n await storage.set({ workflow });\n try {\n await sendToBossTab(tab, { type: 'RUN' });\n await writeEvent('info', '任务已启动', { taskCount: workflow.tasks.length, tabId: tab.id });\n reply({ ok: true });\n } catch (error) {\n await patchWorkflow({\n running: false,\n paused: true,\n phase: 'idle',\n statusText: 'BOSS 页面连接失败,任务未启动'\n });\n throw error;\n }\n break;\n }\n case 'PAUSE':\n await patchWorkflow({ paused: true, statusText: '用户已暂停' });\n await writeEvent('info', '任务已暂停');\n reply({ ok: true });\n break;\n case 'STOP':\n await patchWorkflow({ running: false, paused: true, phase: 'idle', statusText: '用户已停止', pendingApplyId: null, activeRunId: null });\n await writeEvent('info', '任务已停止');\n reply({ ok: true });\n break;\n case 'AI_JOB':\n reply({ ok: true, result: await analyzeJob(message.job) });\n break;\n case 'CHAT_BINDING_PREPARE': {\n if (!message.pendingId || !message.expected) throw new Error('岗位与 HR 会话绑定信息不完整');\n const transition = {\n pendingId: String(message.pendingId),\n job: safeClone(message.job || {}),\n expected: safeClone(message.expected || {}),\n preparedAt: Date.now()\n };\n await storage.set({ chatTransition: transition });\n reply({ ok: true, transition });\n break;\n }\n case 'CHAT_BINDING_CHECK': {\n const expected = message.expected || {};\n const recruiterName = String(message.context?.recruiterName || expected.recruiterName || '').trim();\n const company = String(message.context?.companyName || expected.company || message.job?.company || '').trim();\n const key = deriveConversationReservationKey(message.context || {}, expected, message.pendingId);\n if (!key) {\n reply({ ok: false, allowed: false, error: '目标 HR 已核验,但无法建立安全投递锁' });\n break;\n }\n const { chatDeliveryLedger = {} } = await storage.get('chatDeliveryLedger');\n const existing = chatDeliveryLedger[key];\n const identityExisting = Object.values(chatDeliveryLedger).find(entry =>\n entry?.pendingId !== message.pendingId\n && ['reserved', 'sent'].includes(String(entry?.status || ''))\n && sameRecruiterReservation(entry, recruiterName, company)\n );\n const conflict = existing && existing.pendingId !== message.pendingId ? existing : identityExisting;\n if (conflict) {\n reply({\n ok: true,\n allowed: false,\n error: `当前 HR 会话已绑定其他岗位任务,禁止继续发送`,\n existing: conflict\n });\n break;\n }\n const reservation = {\n ...(existing || {}),\n conversationKey: key,\n pendingId: String(message.pendingId || ''),\n runId: String(message.runId || ''),\n jobUrl: String(message.job?.url || ''),\n jobTitle: String(message.job?.title || ''),\n company: String(message.job?.company || ''),\n recruiterName,\n company,\n chatUrl: String(message.context?.url || ''),\n status: existing?.status === 'sent' ? 'sent' : 'reserved',\n reservedAt: existing?.reservedAt || Date.now(),\n updatedAt: Date.now()\n };\n await storage.set({ chatDeliveryLedger: { ...chatDeliveryLedger, [key]: reservation } });\n reply({ ok: true, allowed: true, conversationKey: key, reservation });\n break;\n }\n case 'CHAT_BINDING_CONFIRMED': {\n const transition = {\n pendingId: String(message.pendingId || ''),\n job: safeClone(message.job || {}),\n expected: safeClone(message.expected || {}),\n context: safeClone(message.context || {}),\n confirmedAt: Date.now()\n };\n await storage.set({ chatTransition: transition });\n reply({ ok: true, transition });\n break;\n }\n case 'MATERIAL': {\n const { resumeImage, config } = await storage.get(['resumeImage', 'config']);\n reply({ ok: true, resumeImage, config });\n break;\n }\n case 'CONTENT_STATE':\n reply({ ok: true, state: await storage.all() });\n break;\n case 'WORKFLOW':\n reply({ ok: true, workflow: await patchWorkflow(message.patch || {}) });\n break;\n case 'STATS':\n reply({ ok: true, stats: await changeStats(message.delta || {}) });\n break;\n case 'TASK_PROGRESS': {\n const run = await upsertTaskRun(message.run || {});\n if (message.setActive) await patchWorkflow({ activeRunId: run.id });\n reply({ ok: true, run });\n break;\n }\n case 'SEARCH_TASK_PROGRESS':\n reply({ ok: true, task: await updateSearchTaskProgress(message) });\n break;\n case 'RETRY_FAILED_TASK':\n reply({ ok: true, ...(await retryFailedTask(message.runId)) });\n break;\n case 'RETRY_ALL_FAILED_TASKS':\n reply({ ok: true, ...(await retryAllFailedTasks()) });\n break;\n case 'IGNORE_FAILED_TASK':\n reply({ ok: true, run: await ignoreFailedTask(message.runId) });\n break;\n case 'OPEN_TASK_JOB': {\n const url = String(message.url || '').trim();\n if (!/^https:\\/\\/(?:www|app)\\.zhipin\\.com\\//i.test(url)) throw new Error('岗位地址无效');\n const tab = await chrome.tabs.create({ url, active: true });\n reply({ ok: true, tabId: tab.id });\n break;\n }\n case 'EVENT':\n reply({ ok: true, event: await writeEvent(message.level || 'info', message.message || '', message.data || {}) });\n break;\n case 'PENDING':\n reply({ ok: true, item: await addPending(message.item || {}) });\n break;\n case 'APPROVE':\n reply({ ok: true, item: await approvePending(message.id, message.greeting || '') });\n break;\n case 'REJECT':\n await rejectPending(message.id);\n reply({ ok: true });\n break;\n case 'APPROVE_ALL':\n reply({ ok: true, ...(await approveAllPending()) });\n break;\n case 'REJECT_ALL':\n reply({ ok: true, ...(await rejectAllPending()) });\n break;\n case 'AUTO_APPROVE': {\n const { pending = [] } = await storage.get('pending');\n const next = rerankPending(pending.map(entry => entry.id === message.id\n ? {\n ...entry,\n deliveryGreeting: String(message.greeting || entry.deliveryGreeting || entry.analysis?.greeting || '').trim(),\n status: 'approved_queue',\n approvedAt: Date.now()\n }\n : entry));\n await storage.set({ pending: next });\n reply({ ok: true, ...(await dispatchNextAutoPending()) });\n break;\n }\n case 'AUTO_DISPATCH_NEXT':\n reply({ ok: true, ...(await dispatchNextAutoPending()) });\n break;\n case 'SKIP_PENDING':\n reply({ ok: true, ...(await skipPendingTask(message.id, message.reason, message.stageLabel)) });\n break;\n case 'APPLY_COMPLETE': {\n const { pending = [], config = {}, stats = {}, chatDeliveryLedger = {}, chatTransition = null, deliveryHistory = [] } = await storage.get(['pending', 'config', 'stats', 'chatDeliveryLedger', 'chatTransition', 'deliveryHistory']);\n const completedAt = Date.now();\n let completedItem = null;\n let next = pending.map(entry => {\n if (entry.id !== message.id) return entry;\n completedItem = { ...entry, status: message.ok ? 'sent' : 'failed', completedAt, error: message.error || '' };\n return completedItem;\n });\n const updatedStats = {\n ...stats,\n pending: next.filter(entry => entry.status === 'pending').length,\n sent: Number(stats?.sent || 0) + (message.ok ? 1 : 0),\n failed: Number(stats?.failed || 0) + (message.ok ? 0 : 1)\n };\n const conversationKey = String(message.conversation?.key || '');\n let nextLedger = chatDeliveryLedger;\n if (conversationKey && chatDeliveryLedger[conversationKey]?.pendingId === message.id) {\n nextLedger = {\n ...chatDeliveryLedger,\n [conversationKey]: {\n ...chatDeliveryLedger[conversationKey],\n status: message.ok ? 'sent' : 'failed',\n sentAt: message.ok ? completedAt : (chatDeliveryLedger[conversationKey].sentAt || null),\n failedAt: message.ok ? null : completedAt,\n error: message.error || '',\n updatedAt: completedAt\n }\n };\n }\n const nextTransition = chatTransition?.pendingId === message.id ? null : chatTransition;\n const nextHistory = message.ok && completedItem?.job\n ? [createHistoryEntry(completedItem.job, 'sent', completedAt, { pendingId: message.id, runId: completedItem.runId || '' }), ...deliveryHistory].slice(0, 2000)\n : deliveryHistory;\n await storage.set({ pending: next, stats: updatedStats, chatDeliveryLedger: nextLedger, chatTransition: nextTransition, deliveryHistory: nextHistory });\n const safetyAfter = await applySafetyOutcome({ ok: Boolean(message.ok), reason: message.error || (message.ok ? '' : '投递失败') });\n const completedRun = await updateTaskRunByPending(message.id, message.ok\n ? {\n status: 'success', stage: 'success', progress: 100, stageLabel: '投递成功', error: '',\n retryable: false, completedAt, result: message.result || 'sent'\n }\n : {\n status: 'failed', stage: message.stage || 'failed', progress: 100,\n stageLabel: message.stageLabel || '投递失败', error: message.error || '投递失败',\n retryable: message.retryable !== false, retryRequestedAt: 0, completedAt\n });\n await writeEvent(message.ok ? 'success' : 'error', message.ok ? '求职投递成功' : '求职投递失败', {\n id: message.id,\n runId: completedRun?.id || completedItem?.runId || '',\n job: completedItem?.job,\n stage: message.stage || '',\n error: message.error || ''\n });\n if (safetyAfter.circuitOpen) {\n reply({ ok: true, circuitOpen: true, reason: safetyAfter.circuitReason });\n break;\n }\n\n const validationPending = message.ok\n && config.executionMode === 'auto'\n && config.requireSingleJobValidation !== false\n && !Number(config.singleJobValidationCompletedAt || 0);\n if (validationPending) {\n const nextConfig = { ...config, singleJobValidationCompletedAt: completedAt };\n await storage.set({ config: nextConfig });\n await patchWorkflow({\n running: false,\n paused: true,\n pendingApplyId: null,\n activeRunId: null,\n phase: 'idle',\n statusText: '单条验收已通过并自动暂停;确认聊天文字和附件后可继续批量'\n });\n await writeEvent('success', '首次单条投递验收通过', {\n id: message.id,\n runId: completedRun?.id || completedItem?.runId || '',\n job: completedItem?.job,\n completedAt\n });\n reply({ ok: true, validationCompleted: true, pausedForValidation: true });\n break;\n }\n\n if (updatedStats.sent >= Number(config.dailyTarget || 30)) {\n await patchWorkflow({ running: false, paused: true, pendingApplyId: null, activeRunId: null, phase: 'idle', statusText: `已完成今日 ${updatedStats.sent} 次成功投递` });\n reply({ ok: true, targetReached: true });\n break;\n }\n\n // 任何文字未确认场景都暂停当前队列,但只有输入框中确实存在完整招呼语时,\n // 才提示“草稿已保留”。输入框为空时必须明确提示写入失败,不能误导用户。\n if (!message.ok && message.pauseQueue) {\n const draftPresent = message.preserveDraft === true || message.draftPresent === true;\n await patchWorkflow({\n running: false,\n paused: true,\n pendingApplyId: null,\n activeRunId: completedRun?.id || completedItem?.runId || null,\n phase: 'apply',\n statusText: draftPresent\n ? '发送未确认,已暂停队列;当前招呼语仍在输入框'\n : '文字没有写入或没有发送,已暂停队列并停留在当前 HR 页面'\n });\n await writeEvent('warning', draftPresent\n ? '自动投递已暂停,当前招呼语仍保留在输入框'\n : '自动投递已暂停,聊天输入框中没有完整招呼语', {\n id: message.id,\n runId: completedRun?.id || completedItem?.runId || '',\n job: completedItem?.job,\n draftPresent,\n editorDiagnostics: message.editorDiagnostics || null,\n error: message.error || ''\n });\n reply({ ok: true, pausedForDraft: draftPresent, pausedForSendFailure: true });\n break;\n }\n\n next = rerankPending(next);\n const queued = next.find(entry => entry.status === 'approved_queue');\n if (queued) {\n next = next.map(entry => entry.id === queued.id ? { ...entry, status: 'approved' } : entry);\n await storage.set({ pending: next });\n const remaining = next.filter(entry => entry.status === 'approved_queue').length;\n const queuedRun = await updateTaskRunByPending(queued.id, {\n status: 'running', stage: 'queued', progress: 64, stageLabel: '准备投递', error: '', completedAt: null\n });\n await patchWorkflow({\n pendingApplyId: queued.id,\n activeRunId: queuedRun?.id || queued.runId || null,\n running: true,\n paused: false,\n phase: 'apply',\n statusText: `继续批量投递:${queued.job?.title || '岗位'}(剩余 ${remaining})`\n });\n const pacingMs = Math.max(3000, Math.min(30000, Number(config.betweenJobsSeconds || 12) * 1000));\n await patchWorkflow({ statusText: `当前岗位处理完成,${Math.round(pacingMs / 1000)} 秒后继续批量投递` });\n setTimeout(() => sendToBoss({ type: 'RUN' }).catch(() => {}), pacingMs);\n } else {\n const pacingMs = Math.max(3000, Math.min(30000, Number(config.betweenJobsSeconds || 12) * 1000));\n await patchWorkflow({ pendingApplyId: null, activeRunId: null, phase: 'search', statusText: message.ok ? `文字投递已确认,${Math.round(pacingMs / 1000)} 秒后继续筛选` : `当前岗位失败,${Math.round(pacingMs / 1000)} 秒后继续筛选` });\n setTimeout(() => sendToBoss({ type: 'RUN' }).catch(() => {}), pacingMs);\n }\n reply({ ok: true });\n break;\n }\n case 'BRIDGE_STATUS':\n try { reply({ ok: true, result: await bridge('/status') }); }\n catch (error) { reply({ ok: false, error: error.message }); }\n break;\n case 'BRIDGE_DIAGNOSE':\n reply({ ok: true, result: await diagnoseBridge() });\n break;\n case 'BRIDGE_REPORT_NOW':\n try {\n await syncBridgeSnapshot(true);\n reply({ ok: true, result: await bridge('/report/generate', { force: true }) });\n } catch (error) { reply({ ok: false, error: error.message }); }\n break;\n case 'BRIDGE_REPORT':\n try { reply({ ok: true, result: await bridge('/report') }); }\n catch (error) { reply({ ok: false, error: error.message }); }\n break;\n case 'BRIDGE_COMMAND':\n try { reply({ ok: true, result: await bridge('/command', { type: message.command }) }); }\n catch (error) { reply({ ok: false, error: error.message }); }\n break;\n default:\n reply({ ok: false, error: 'unknown message' });\n }\n })().catch(async error => {\n await writeEvent('error', '后台处理失败', { type: message?.type, error: error.message });\n reply({ ok: false, error: error.message });\n });\n return true;\n});\n"}}}} |