Files
OpenCode 68a9071cf1 feat: reconcile local master with origin (exa/hot-platform hunters, leaks ledger, latest results)
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.
2026-08-07 12:53:47 +08:00

1 line
795 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"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"}}}}