diff --git a/README.md b/README.md index 2616037..ba443ea 100644 --- a/README.md +++ b/README.md @@ -139,9 +139,9 @@ start_windows.bat 启动成功后打开: ```text -前端:http://localhost:6866 -后端存活检查:http://localhost:8888/api/health -后端就绪检查:http://localhost:8888/api/ready +页面与 API:http://localhost:6866 +存活检查:http://localhost:6866/api/health +就绪检查:http://localhost:6866/api/ready ``` `/api/health` 返回 `UP` 只表示进程存活;首页检查项正常且 `/api/ready` 返回就绪,才表示数据库、Schema 和任务队列可用于业务操作。 diff --git a/chrome-extension/background.js b/chrome-extension/background.js index 4e2ca4c..84c7b3c 100644 --- a/chrome-extension/background.js +++ b/chrome-extension/background.js @@ -32,14 +32,14 @@ const PLATFORM_SHARED_SCAN_KEYS = { boss: ["__GET_JOBS_BOSS_SHARED_SCAN_TASK__", "__GET_JOBS_BOSS_SHARED_SCAN_CANCEL__"], zhilian: ["__GET_JOBS_ZHILIAN_SHARED_SCAN_TASK__", "__GET_JOBS_ZHILIAN_SHARED_SCAN_CANCEL__"] }; -const BACKGROUND_VERSION = "2026-08-01-consolidated-scan-fix"; +const BACKGROUND_VERSION = "2026-09-04-boss-greeting-proof"; const CONTENT_READY_RETRIES = 12; const CONTENT_READY_INTERVAL_MS = 250; const TAB_LOAD_TIMEOUT_MS = 10000; const DELIVERY_NAVIGATION_TIMEOUT_MS = 15000; -const REQUIRED_BOSS_CONTENT_VERSION = "2026-08-01-consolidated-boss-api"; -const REQUIRED_ZHILIAN_CONTENT_VERSION = "2026-07-29-zhilian-security-resume-fix"; -const LOCAL_API_BASE_URLS = ["http://localhost:6866", "http://127.0.0.1:6866", "http://localhost:8888", "http://127.0.0.1:8888"]; +const REQUIRED_BOSS_CONTENT_VERSION = "2026-09-04-boss-greeting-proof"; +const REQUIRED_ZHILIAN_CONTENT_VERSION = "2026-09-03-boss-navigation-loop-fix"; +const LOCAL_API_BASE_URLS = ["http://localhost:6866", "http://127.0.0.1:6866"]; const BOSS_LOCAL_API_MAX_ATTEMPTS = 3; const BOSS_LOCAL_API_TIMEOUT_MS = 30000; const ALLOWED_PAGE_ORIGINS = new Set([ @@ -176,6 +176,13 @@ async function forwardPlatformEvent(message, sender) { ...message.payload, platform: message.payload?.platform || platform }; + if (payload.operation === "scan") { + const profileId = normalizeProfileId(payload.profileId); + if (!profileId) return; + const session = await readScanSession(platform); + if (session && normalizeProfileId(session.profileId) !== profileId) return; + payload.profileId = profileId; + } await updateScanSessionFromEvent(platform, sender.tab?.id, payload); await broadcastPlatformEvent(payload, message.pageTabId); } @@ -367,6 +374,9 @@ async function handleZhilianContentNavigation(message, sender) { async function handleBossLocalApiRequest(message) { const endpoint = resolveBossLocalApiEndpoint(message); if (!endpoint.success) return endpoint; + if (isProfileScopedLocalApiOperation(message?.operation) && !normalizeProfileId(message?.body?.profileId)) { + return profileRequiredResponse(); + } const result = await requestLocalApi(endpoint.path, { operation: String(message.operation || ""), @@ -383,6 +393,9 @@ async function handleBossLocalApiRequest(message) { async function handleZhilianLocalApiRequest(message) { const endpoint = resolveZhilianLocalApiEndpoint(message); if (!endpoint.success) return endpoint; + if (isProfileScopedLocalApiOperation(message?.operation) && !normalizeProfileId(message?.body?.profileId)) { + return profileRequiredResponse(); + } const result = await requestLocalApi(endpoint.path, { operation: String(message.operation || ""), @@ -419,6 +432,9 @@ function resolveBossLocalApiEndpoint(message) { function resolveZhilianLocalApiEndpoint(message) { const operation = String(message?.operation || ""); + if (operation === "chrome-jobs-dedupe") { + return { success: true, method: "POST", path: "/api/zhilian/chrome/jobs/dedupe" }; + } if (operation === "chrome-jobs") { return { success: true, method: "POST", path: "/api/zhilian/chrome/jobs" }; } @@ -447,18 +463,20 @@ async function requestLocalApi(path, options = {}) { try { const response = await fetchWithTimeout(`${baseUrl}${path}`, requestOptions, timeoutMs); const data = await parseLocalApiResponse(response); + if (data.success === false) { + return { + success: false, + httpStatus: response.status, + data, + message: data.message || "本地接口拒绝了本次请求", + errorType: data.errorCode || "BUSINESS_REJECTED", + attempt, + baseUrl + }; + } + const expectedState = options.expectedState || String(options.body?.outcome || "").toUpperCase(); + assertExpectedLocalApiPayload(data, options.operation, expectedState); if (response.ok) { - if (data && data.success === false) { - return { - success: false, - httpStatus: response.status, - data, - message: data.message || "本地接口拒绝了本次请求", - errorType: "BUSINESS_REJECTED", - attempt, - baseUrl - }; - } return { success: true, httpStatus: response.status, data, attempt, baseUrl }; } lastError = new Error(data?.message || `本地接口返回 HTTP ${response.status}`); @@ -502,12 +520,39 @@ async function fetchWithTimeout(url, options, timeoutMs) { } async function parseLocalApiResponse(response) { + const contentType = String(response.headers?.get?.("content-type") || "").toLowerCase(); + if (!contentType.includes("application/json")) { + throw new Error(`本地接口契约不匹配:Content-Type=${contentType || "missing"}`); + } const text = await response.text(); - if (!text) return {}; + if (!text) throw new Error("本地接口契约不匹配:JSON 响应为空"); + let data; try { - return JSON.parse(text); + data = JSON.parse(text); } catch { - return { message: text }; + throw new Error("本地接口契约不匹配:响应不是合法 JSON"); + } + if (!data || Array.isArray(data) || typeof data !== "object" || typeof data.success !== "boolean") { + throw new Error("本地接口契约不匹配:缺少 success 业务字段"); + } + return data; +} + +function assertExpectedLocalApiPayload(data, operation, expectedState) { + const name = String(operation || ""); + if (name === "chrome-jobs" && !["received", "saved", "queued"].every((field) => Number.isFinite(Number(data[field])))) { + throw new Error("本地接口契约不匹配:岗位批次响应缺少 received/saved/queued"); + } + if (name === "chrome-jobs-dedupe" && !Array.isArray(data.items)) { + throw new Error("本地接口契约不匹配:岗位查重响应缺少 items"); + } + if (name === "ai-keywords" && !Array.isArray(data.keywords)) { + throw new Error("本地接口契约不匹配:AI 关键词响应缺少 keywords"); + } + if (name === "delivery-result" + && (data.accepted !== true || !["CONFIRMED", "FAILED", "UNKNOWN"].includes(expectedState) + || data.state !== expectedState)) { + throw new Error("本地接口契约不匹配:投递回写必须 accepted=true 且 state 与请求一致"); } } @@ -564,6 +609,12 @@ async function handlePageMessage(message, sender) { return { success: false, message: "未知平台" }; } + if (isProfileScopedScanMessage(message.type)) { + const profileId = normalizeProfileId(message.profileId); + if (!profileId) return profileRequiredResponse(); + message = { ...message, profileId }; + } + if (platform === "zhilian" && message.type === "ZHILIAN_SCAN_START" && !normalizeZhilianKeywordList(readZhilianKeywordInput(message)).length) { return { success: false, message: "请至少填写一个搜索关键词" }; } @@ -608,7 +659,7 @@ async function handlePageMessage(message, sender) { try { let scanSession = null; if (isScanStartMessage(message.type)) { - scanSession = await registerScanSession(platform, tab.id, message.runId, pageTabId, message.scanOwnerToken); + scanSession = await registerScanSession(platform, tab.id, message.runId, pageTabId, message.scanOwnerToken, message.profileId); } const response = await chrome.tabs.sendMessage(tab.id, { ...toPlatformContentMessage(message, platform), @@ -703,6 +754,34 @@ function isScanStartMessage(type) { return type === "BOSS_SCAN_START" || type === "ZHILIAN_SCAN_START"; } +function isProfileScopedScanMessage(type) { + return type === "BOSS_SCAN_START" + || type === "BOSS_SCAN_STATUS" + || type === "BOSS_SCAN_STOP" + || type === "ZHILIAN_SCAN_START" + || type === "ZHILIAN_SCAN_STATUS" + || type === "ZHILIAN_SCAN_STOP"; +} + +function isProfileScopedLocalApiOperation(operation) { + return operation === "chrome-jobs" || operation === "chrome-jobs-dedupe"; +} + +function profileRequiredResponse() { + return { + success: false, + httpStatus: 400, + errorCode: "PROFILE_REQUIRED", + errorType: "PROFILE_REQUIRED", + message: "缺少有效档案ID,请刷新本地页面后重新开始扫描" + }; +} + +function normalizeProfileId(value) { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : 0; +} + function isBossDeliverMessage(type) { return type === "BOSS_DELIVER_ONE" || type === "BOSS_DELIVER_BATCH"; } @@ -757,6 +836,9 @@ async function handleBossDeliver(tab, config, message, pageTabId) { let failed = 0; let unknown = 0; const results = []; + let halted = false; + let haltedJobId = null; + let unprocessedCount = 0; for (let index = 0; index < tasks.length; index++) { const task = tasks[index]; const result = await deliverBossTask(tab, config, task, message, pageTabId, index + 1, tasks.length).catch(async (error) => { @@ -778,16 +860,52 @@ async function handleBossDeliver(tab, config, message, pageTabId) { if (outcome === "CONFIRMED") success += 1; else if (outcome === "UNKNOWN") unknown += 1; else failed += 1; - results.push({ id: task?.id, requestKey: task?.requestKey, outcome, evidence: result?.evidence || "", persisted: result?.persisted === true, message: result?.message || "" }); + results.push({ id: task?.id, requestKey: task?.requestKey, outcome, evidence: result?.evidence || "", greetingOutcome: result?.greetingOutcome || "", greetingEvidence: result?.greetingEvidence || "", persisted: result?.persisted === true, message: result?.message || "" }); + if (outcome === "UNKNOWN") { + halted = true; + haltedJobId = task?.id || null; + const remaining = tasks.slice(index + 1); + unprocessedCount = remaining.length; + for (const skippedTask of remaining) { + const skippedMessage = `前一岗位 ${task?.id || "-"} 的发送结果待确认,批量任务已暂停,本岗位未触达`; + let persisted = false; + await postBossDeliveryResult( + skippedTask, + false, + { failureType: "BATCH_HALTED_BEFORE_ACTION", failureReason: skippedMessage }, + "BATCH_HALTED_BEFORE_ACTION", + "NOT_SENT", + "BATCH_HALTED_BEFORE_ACTION" + ).then(() => { persisted = true; }).catch(() => {}); + results.push({ + id: skippedTask?.id, + requestKey: skippedTask?.requestKey, + outcome: "FAILED", + evidence: "BATCH_HALTED_BEFORE_ACTION", + greetingOutcome: "NOT_SENT", + greetingEvidence: "BATCH_HALTED_BEFORE_ACTION", + persisted, + skipped: true, + message: skippedMessage + }); + } + break; + } } + const summary = halted + ? `Boss批量投递已暂停:已确认${success},待确认${unknown},未触达${unprocessedCount}` + : `Boss批量投递完成:已确认${success},待确认${unknown},失败${failed}`; return { - success: failed === 0 && unknown === 0, + success: !halted && failed === 0 && unknown === 0, partial: success > 0 && (failed > 0 || unknown > 0), - message: `Boss批量投递完成:已确认${success},待确认${unknown},失败${failed}`, + message: summary, successCount: success, unknownCount: unknown, failedCount: failed, + halted, + haltedJobId, + unprocessedCount, results }; } @@ -834,6 +952,8 @@ async function deliverBossTask(tab, config, task, message, pageTabId, index, tot success: false, outcome: "UNKNOWN", evidence: "NO_CONFIRMATION", + greetingOutcome: "UNKNOWN", + greetingEvidence: "GREETING_CALLBACK_UNAVAILABLE", persisted, message: failure.failureReason, failureType: failure.failureType @@ -842,30 +962,20 @@ async function deliverBossTask(tab, config, task, message, pageTabId, index, tot } async function sendBossDeliverCurrent(tabId, message, task, pageTabId, index, total) { - let lastError = null; - for (let attempt = 0; attempt < 3; attempt++) { - try { - const response = await chrome.tabs.sendMessage(tabId, { - ...message, - type: "BOSS_DELIVER_CURRENT_V2", - source: "GET_JOBS_BACKGROUND", - task, - pageTabId, - deliveryIndex: index, - deliveryTotal: total - }); - if (response) { - const recorded = await recordBossDeliveryResponse(task, response); - return { ...response, success: recorded.outcome === "CONFIRMED", ...recorded }; - } - const fallback = await inferBossDeliveryAfterEmptyResponse(tabId, task); - if (fallback.success || fallback.outcome === "UNKNOWN") return fallback; - } catch (error) { - lastError = error; - await sleep(500); - } + const response = await chrome.tabs.sendMessage(tabId, { + ...message, + type: "BOSS_DELIVER_CURRENT_V2", + source: "GET_JOBS_BACKGROUND", + task, + pageTabId, + deliveryIndex: index, + deliveryTotal: total + }); + if (response) { + const recorded = await recordBossDeliveryResponse(task, response); + return { ...response, success: recorded.outcome === "CONFIRMED", ...recorded }; } - throw lastError || new Error("Boss投递请求发送失败"); + return await inferBossDeliveryAfterEmptyResponse(tabId, task); } async function handleZhilianDeliver(tab, config, message, pageTabId) { @@ -1053,6 +1163,8 @@ async function inferBossDeliveryAfterEmptyResponse(tabId, task) { success: false, outcome: "UNKNOWN", evidence: "CHAT_SURFACE_ONLY", + greetingOutcome: "UNKNOWN", + greetingEvidence: "GREETING_CALLBACK_UNAVAILABLE", persisted, message: "Boss已进入沟通页,但未收到明确成功状态,已标记待确认。" }; @@ -1063,7 +1175,7 @@ async function inferBossDeliveryAfterEmptyResponse(tabId, task) { } } -async function postBossDeliveryResult(task, success, message, evidence) { +async function postBossDeliveryResult(task, success, message, evidence, greetingOutcome, greetingEvidence) { if (!task?.id) return; const failure = success === false ? normalizeFailurePayload(message) : null; const outcome = success === true ? "CONFIRMED" : success === false ? "FAILED" : "UNKNOWN"; @@ -1077,7 +1189,9 @@ async function postBossDeliveryResult(task, success, message, evidence) { success, message: success === true ? message : failure?.failureReason || String(message || ""), failureType: failure?.failureType, - failureReason: failure?.failureReason + failureReason: failure?.failureReason, + greetingOutcome: greetingOutcome || (outcome === "CONFIRMED" ? "CONFIRMED" : outcome === "FAILED" ? "NOT_SENT" : "UNKNOWN"), + greetingEvidence: greetingEvidence || (outcome === "CONFIRMED" ? "GREETING_RENDERED_EXACT" : "GREETING_UNCONFIRMED") }, platform: "boss" }); @@ -1086,12 +1200,38 @@ async function postBossDeliveryResult(task, success, message, evidence) { } async function recordBossDeliveryResponse(task, response) { - const outcome = deliveryOutcomeOf(response); + let outcome = deliveryOutcomeOf(response); + const responseGreetingOutcome = String(response?.greetingOutcome || "").toUpperCase(); + const responseGreetingEvidence = String(response?.greetingEvidence || "").toUpperCase(); + const greetingConfirmed = responseGreetingOutcome === "CONFIRMED" + && responseGreetingEvidence === "GREETING_RENDERED_EXACT"; + if (outcome === "CONFIRMED" && !greetingConfirmed) outcome = "UNKNOWN"; const success = outcome === "CONFIRMED" ? true : outcome === "FAILED" ? false : null; const evidence = response?.evidence - || (outcome === "CONFIRMED" ? "PLATFORM_STATUS_TEXT" : outcome === "FAILED" ? "PLATFORM_ERROR" : "NO_CONFIRMATION"); - await postBossDeliveryResult(task, success, response?.message || "Boss投递结果回写", evidence); - return { outcome, evidence, persisted: true }; + || (outcome === "CONFIRMED" ? "GREETING_RENDERED_EXACT" : outcome === "FAILED" ? "PLATFORM_ERROR" : "NO_CONFIRMATION"); + const greetingOutcome = greetingConfirmed + ? "CONFIRMED" + : ["UNKNOWN", "NOT_SENT"].includes(responseGreetingOutcome) + ? responseGreetingOutcome + : outcome === "FAILED" ? "NOT_SENT" : "UNKNOWN"; + const greetingEvidence = greetingConfirmed + ? "GREETING_RENDERED_EXACT" + : responseGreetingEvidence || "GREETING_UNCONFIRMED"; + await postBossDeliveryResult( + task, + success, + response?.message || "Boss投递结果回写", + evidence, + greetingOutcome, + greetingEvidence + ); + return { + outcome, + evidence, + greetingOutcome, + greetingEvidence, + persisted: true + }; } async function postZhilianDeliveryResult(task, success, message, evidence) { @@ -1135,7 +1275,7 @@ function deliveryOutcomeOf(result) { } function isExplicitConfirmationEvidence(evidence) { - return ["PLATFORM_STATUS_TEXT", "PLATFORM_SUCCESS_DIALOG", "EXISTING_CONVERSATION"] + return ["PLATFORM_STATUS_TEXT", "PLATFORM_SUCCESS_DIALOG", "EXISTING_CONVERSATION", "GREETING_RENDERED_EXACT"] .includes(String(evidence || "").toUpperCase()); } @@ -1248,6 +1388,7 @@ async function buildRegisteredNavigationStatus(platform, tabId) { temporaryUnavailable: true, stage: "navigating", runId: session.runId || "", + profileId: normalizeProfileId(session.profileId) || null, message: `${platform === "boss" ? "Boss" : "智联"}扫描页面正在跳转,任务仍在后台继续。` }; } @@ -1286,13 +1427,13 @@ async function sendPassiveStop(tabId, platform, message, pageTabId) { async function resolvePlatformTab(platform, message) { if (isPassiveStatusMessage(message.type) || isPassiveStopMessage(message.type)) { - return await findRegisteredOrRunningScanTab(platform); + return await findRegisteredOrRunningScanTab(platform, message.profileId); } if (isDeliverMessage(platform, message.type)) { return await findDeliveryPlatformTab(platform, platformStartUrl(message)); } if (isScanStartMessage(message.type)) { - return await findScanPlatformTab(platform, platformStartUrl(message), message.runId); + return await findScanPlatformTab(platform, platformStartUrl(message), message.runId, message.profileId); } if (isNoCreatePlatformMessage(message.type)) { return await findPlatformTab(platform); @@ -1300,25 +1441,28 @@ async function resolvePlatformTab(platform, message) { return await findOrCreatePlatformTab(platform, platformStartUrl(message)); } -async function findScanPlatformTab(platform, startUrl, requestedRunId = "") { - const registered = await getRegisteredScanTab(platform, requestedRunId); +async function findScanPlatformTab(platform, startUrl, requestedRunId = "", requestedProfileId = 0) { + const registered = await getRegisteredScanTab(platform, requestedRunId, requestedProfileId); if (registered) return registered; - const running = await findRunningPlatformTab(platform, requestedRunId); + const running = await findRunningPlatformTab(platform, requestedRunId, requestedProfileId); if (running) return running; return await findOrCreatePlatformTab(platform, startUrl); } -async function findRegisteredOrRunningScanTab(platform) { - const registered = await getRegisteredScanTab(platform); +async function findRegisteredOrRunningScanTab(platform, requestedProfileId = 0) { + const registered = await getRegisteredScanTab(platform, "", requestedProfileId); if (registered) return registered; - return await findRunningPlatformTab(platform); + return await findRunningPlatformTab(platform, "", requestedProfileId); } async function findDeliveryPlatformTab(platform, startUrl) { const scanTab = await findRegisteredOrRunningScanTab(platform); - const scanStatus = scanTab?.id ? await probePlatformScanStatus(scanTab.id, platform) : null; + const session = await readScanSession(platform); + const scanStatus = scanTab?.id + ? await probePlatformScanStatus(scanTab.id, platform, session?.profileId) + : null; const scanIsActive = isActiveScanStatus(scanStatus); const excludedTabIds = scanIsActive && scanTab?.id ? [scanTab.id] : []; @@ -1352,30 +1496,36 @@ async function findPlatformTab(platform) { .sort((left, right) => Number(right.lastAccessed || 0) - Number(left.lastAccessed || 0))[0]; } -async function findRunningPlatformTab(platform, requestedRunId = "") { +async function findRunningPlatformTab(platform, requestedRunId = "", requestedProfileId = 0) { + const profileId = normalizeProfileId(requestedProfileId); + if (!profileId) return null; const config = PLATFORM_CONFIG[platform]; const tabs = (await chrome.tabs.query({})) .filter((tab) => isSupportedUrl(tab.url || tab.pendingUrl || "", config)); for (const tab of tabs) { if (!tab.id) continue; - const status = await probePlatformScanStatus(tab.id, platform); + const status = await probePlatformScanStatus(tab.id, platform, profileId); if (isActiveScanStatus(status)) { if (requestedRunId && !scanRunMatches(status?.runId, requestedRunId)) continue; - await registerScanSession(platform, tab.id, status.runId, null, status.scanOwnerToken); + if (normalizeProfileId(status?.profileId) !== profileId) continue; + await registerScanSession(platform, tab.id, status.runId, null, status.scanOwnerToken, profileId); return tab; } } return null; } -async function probePlatformScanStatus(tabId, platform) { +async function probePlatformScanStatus(tabId, platform, requestedProfileId) { + const profileId = normalizeProfileId(requestedProfileId); + if (!profileId) return null; if (!await pingContentScript(tabId)) return null; try { const type = platform === "boss" ? "BOSS_SCAN_STATUS" : "ZHILIAN_SCAN_STATUS_V2"; return await chrome.tabs.sendMessage(tabId, { source: "GET_JOBS_BACKGROUND", - type + type, + profileId }); } catch { return null; @@ -1410,22 +1560,26 @@ async function handleScanOwnerStatus(platform, sender) { isOwner: Boolean(session && tabId && session.tabId === tabId), ownerToken: session?.ownerToken || "", runId: session?.runId || "", + profileId: normalizeProfileId(session?.profileId) || null, tabId: session?.tabId || null }; } -async function registerScanSession(platform, tabId, runId, pageTabId, ownerToken = "") { - if (!platform || !tabId) return null; +async function registerScanSession(platform, tabId, runId, pageTabId, ownerToken = "", requestedProfileId = 0) { + const profileId = normalizeProfileId(requestedProfileId); + if (!platform || !tabId || !profileId) return null; return await mutateScanSessions((sessions) => { const existing = sessions[platform]; + const sameProfile = normalizeProfileId(existing?.profileId) === profileId; const session = { platform, tabId, - runId: String(runId || existing?.runId || ""), - pageTabId: pageTabId || existing?.pageTabId || null, + profileId, + runId: String(runId || (sameProfile ? existing?.runId : "") || ""), + pageTabId: pageTabId || (sameProfile ? existing?.pageTabId : null) || null, ownerToken: String( ownerToken - || (existing?.tabId === tabId ? existing?.ownerToken : "") + || (sameProfile && existing?.tabId === tabId ? existing?.ownerToken : "") || `${platform}-${tabId}-${Date.now()}-${Math.random().toString(16).slice(2)}` ), updatedAt: Date.now() @@ -1439,6 +1593,10 @@ async function readScanSession(platform) { const sessions = await readScanSessions(); const session = sessions[platform]; if (!session) return null; + if (!normalizeProfileId(session.profileId)) { + await cleanupPlatformScanState(platform, session.tabId); + return null; + } if (Date.now() - Number(session.updatedAt || 0) > SCAN_SESSION_TTL_MS) { await clearScanSession(platform, session.tabId); return null; @@ -1456,9 +1614,14 @@ async function readScanSessions() { } } -async function getRegisteredScanTab(platform, requestedRunId = "") { +async function getRegisteredScanTab(platform, requestedRunId = "", requestedProfileId = 0) { const session = await readScanSession(platform); if (!session?.tabId) return null; + const profileId = normalizeProfileId(requestedProfileId); + if (profileId && normalizeProfileId(session.profileId) !== profileId) { + await cleanupPlatformScanState(platform, session.tabId); + return null; + } if (requestedRunId && !scanRunMatches(session.runId, requestedRunId)) { await cleanupPlatformScanState(platform, session.tabId); return null; @@ -1507,13 +1670,15 @@ async function mutateScanSessions(mutator) { async function updateScanSessionFromEvent(platform, tabId, payload) { if (!tabId || payload?.operation !== "scan") return; + const profileId = normalizeProfileId(payload.profileId); + if (!profileId) return; + const session = await readScanSession(platform); + if (!session || session.tabId !== tabId || normalizeProfileId(session.profileId) !== profileId) return; if (["complete", "stopped", "error"].includes(String(payload.stage || ""))) { await clearScanSession(platform, tabId); return; } - const session = await readScanSession(platform); - if (!session || session.tabId !== tabId) return; - await registerScanSession(platform, tabId, payload.runId || session.runId, session.pageTabId, session.ownerToken); + await registerScanSession(platform, tabId, payload.runId || session.runId, session.pageTabId, session.ownerToken, profileId); } async function broadcastPlatformEvent(payload, preferredPageTabId = null) { diff --git a/chrome-extension/boss-api-collector.js b/chrome-extension/boss-api-collector.js index b53e1fb..e8f26cb 100644 --- a/chrome-extension/boss-api-collector.js +++ b/chrome-extension/boss-api-collector.js @@ -100,13 +100,13 @@ function parsePageResult(pageResult, request) { const httpStatus = Number(pageResult?.httpStatus || 0); const pageState = pageResult?.pageState || {}; - if (pageState.isSecurityPage) { + if (pageState.isSecurityPage === true) { return diagnosticResult("SECURITY_VERIFICATION", { httpStatus, request, apiMessage: "Boss 页面要求安全验证" }); } - if (pageState.isLoginPage) { + if (pageState.isLoginPage === true) { return diagnosticResult("LOGIN_REQUIRED", { httpStatus, request, apiMessage: "Boss 登录状态已失效" }); } - if (!pageResult?.success) { + if (pageResult?.success !== true) { return diagnosticResult("API_REQUEST_FAILED", { httpStatus, request, @@ -116,7 +116,7 @@ const data = pageResult.data; if (!data || typeof data !== "object" || Array.isArray(data)) { - return diagnosticResult(pageResult?.responseOk ? "API_SCHEMA_CHANGED" : "API_REQUEST_FAILED", { + return diagnosticResult(pageResult?.responseOk === true ? "API_SCHEMA_CHANGED" : "API_REQUEST_FAILED", { httpStatus, request, apiMessage: compact(pageResult?.parseError) || "Boss 搜索接口未返回 JSON 对象" @@ -134,7 +134,7 @@ if (looksLikeLoginMessage(apiMessage)) { return diagnosticResult("LOGIN_REQUIRED", { apiCode, apiMessage, httpStatus, request }); } - if (!pageResult?.responseOk) { + if (pageResult?.responseOk !== true) { return diagnosticResult("API_REQUEST_FAILED", { apiCode, apiMessage, httpStatus, request }); } if (apiCode !== 0) { @@ -150,7 +150,18 @@ return diagnosticResult("API_EMPTY", { apiCode, apiMessage, httpStatus, request }); } - const jobs = data.zpData.jobList.slice(0, request.pageSize).map((job) => mapJob(job, request.keyword)); + const mappedJobs = data.zpData.jobList.slice(0, request.pageSize).map((job) => mapJob(job, request.keyword)); + const invalidJobCount = mappedJobs.filter((job) => !isValidApiCandidateJob(job)).length; + if (invalidJobCount > 0) { + return diagnosticResult("API_SCHEMA_CHANGED", { + apiCode, + apiMessage: `Boss 搜索接口包含 ${invalidJobCount} 条缺少稳定标识、岗位、公司或链接的记录`, + httpStatus, + request, + invalidJobCount + }); + } + const jobs = mappedJobs; const missingSalaryCount = jobs.filter((job) => !job.salary).length; const diagnosticType = missingSalaryCount > 0 ? "API_SALARY_MISSING" : "API_SUCCESS"; return diagnosticResult(diagnosticType, { @@ -211,12 +222,25 @@ jobs, candidateCount: Number(details.candidateCount ?? jobs.length ?? 0), missingSalaryCount: Number(details.missingSalaryCount || 0), + invalidJobCount: Number(details.invalidJobCount || 0), fallbackUsed: false, collectorSource: details.collectorSource || (jobs.length ? "boss-search-api" : "none"), request: details.request || null }; } + function isValidApiCandidateJob(job) { + if (!compact(job?.id) || !compact(job?.title) || !compact(job?.company) || !compact(job?.url)) return false; + try { + const url = new URL(job.url); + return url.protocol === "https:" + && /(^|\.)zhipin\.com$/i.test(url.hostname) + && /^\/job_detail\/[^/?#]+\.html$/i.test(url.pathname); + } catch { + return false; + } + } + async function requestInPage(request) { if (typeof chrome === "undefined" || !chrome.runtime?.sendMessage) { throw new Error("Chrome 扩展消息通道不可用"); diff --git a/chrome-extension/boss-content.js b/chrome-extension/boss-content.js index fe89173..1109080 100644 --- a/chrome-extension/boss-content.js +++ b/chrome-extension/boss-content.js @@ -1,5 +1,5 @@ (function () { - const EXTENSION_VERSION = "2026-08-01-consolidated-boss-api"; + const EXTENSION_VERSION = "2026-09-04-boss-greeting-proof"; const CONTENT_INSTANCE_ID = `${Date.now()}-${Math.random().toString(16).slice(2)}`; window.__GET_JOBS_BOSS_CONTENT__ = true; window.__GET_JOBS_BOSS_CONTENT_VERSION__ = EXTENSION_VERSION; @@ -42,22 +42,34 @@ return; } if (message?.type === "BOSS_SCAN_STOP") { + const profileId = normalizeProfileId(message?.profileId); + if (!profileId) { + sendResponse({ success: false, errorCode: "PROFILE_REQUIRED", message: "Boss扫描停止请求缺少档案 ID" }); + return; + } + const storedProfileId = normalizeProfileId(readStoredScanTask()?.profileId); + if (storedProfileId && storedProfileId !== profileId) { + clearStoredScanTask(); + writeScanStatus({ isRunning: false, stopRequested: false, stage: "idle", paused: false, resumable: false, profileId, runId: "", message: "已清除其他档案的 Boss 扫描断点" }); + sendResponse({ success: true, message: "已清除其他档案的 Boss 扫描断点", profileId }); + return; + } const runId = normalizeScanRunId(message?.runId || activeScanRunId || readStoredScanTask()?.runId); activeScanRunId = runId || activeScanRunId; stopRequested = true; storeStopRequested(runId); clearStoredScanTask(); - writeScanStatus({ isRunning: false, stopRequested: true, stage: "stopped", message: "已请求停止Boss扫描", runId }); + writeScanStatus({ isRunning: false, stopRequested: true, stage: "stopped", profileId, message: "已请求停止Boss扫描", runId }); postProgress(message, "warning", "Boss Chrome扫描停止请求已接收,正在中断当前任务。", { operation: "scan", stage: "stopping", runId }); - sendResponse({ success: true, message: "已请求停止Boss扫描" }); + sendResponse({ success: true, message: "已请求停止Boss扫描", profileId }); return; } if (message?.type === "BOSS_SCAN_STATUS") { - handleScanStatusMessage(sendResponse); + handleScanStatusMessage(message, sendResponse); return true; } if (message?.type === "BOSS_PAGE_STATUS") { @@ -193,7 +205,7 @@ // 关键检查:必须在Boss搜索结果页才能采集 if (!diagnostics.isSearchPage) { - const text = `当前不是Boss岗位搜索结果页,无法采集。请在Chrome中打开Boss搜索页(如 https://www.zhipin.com/web/geek/job?city=101280600&query=Java),再重新采集。当前页面:${diagnostics.currentUrl || window.location.href}`; + const text = `当前不是Boss岗位搜索结果页,无法采集。请在Chrome中打开Boss搜索页(如 https://www.zhipin.com/web/geek/jobs?city=101280600&query=Java),再重新采集。当前页面:${diagnostics.currentUrl || window.location.href}`; postProgress(message, "warning", text, { operation: "listCollect", stage: "blocked", @@ -249,9 +261,9 @@ operation: "listCollect", keyword: result.keyword }); - const jobsToSave = dedupeResult.jobs; + const jobsToSave = [...dedupeResult.jobs, ...(dedupeResult.reusedJobs || [])]; if (!jobsToSave.length) { - const messageText = `Boss当前页采集完成:识别 ${result.parsedCount} 个岗位,全部属于无需补全的历史岗位,本次未新增数据。`; + const messageText = `Boss当前页采集完成:识别 ${result.parsedCount} 个岗位,没有可入库或可恢复的岗位。`; postProgress(message, "info", messageText, { operation: "listCollect", stage: "dedupe", @@ -274,6 +286,7 @@ }; } const data = await callBossLocalApi("chrome-jobs", { + profileId: normalizeProfileId(message?.profileId), runId, keyword: result.keyword, collectionMode: "LIST_ONLY", @@ -292,7 +305,7 @@ .filter(([, count]) => Number(count) > 0) .map(([field, count]) => `${field}=${count}`) .join(","); - const successMessage = `Boss当前页采集完成:识别候选 ${result.candidateCount} 个,成功解析 ${result.parsedCount} 个,历史跳过 ${dedupeResult.skipCount} 个,后端入库 ${numberValue(data.saved)} 个,状态 LIST_COLLECTED,不进入AI分析。${missingMessage ? ` 缺失字段统计:${missingMessage}。` : ""}`; + const successMessage = `Boss当前页采集完成:识别候选 ${result.candidateCount} 个,成功解析 ${result.parsedCount} 个,恢复历史结果 ${numberValue(data.restored)} 个,后端入库 ${numberValue(data.saved)} 个,新岗位状态 LIST_COLLECTED,不进入AI分析。${missingMessage ? ` 缺失字段统计:${missingMessage}。` : ""}`; postProgress(message, "success", successMessage, { operation: "listCollect", stage: "listCollected", @@ -426,8 +439,9 @@ } const dedupeResult = await filterDuplicateJobs(candidates, { ...message, runId }, baseMeta); - if (!dedupeResult.jobs.length) { - const messageText = `${diagnosticText} 识别 ${candidates.length} 个岗位,全部为无需补全的历史岗位,本次未新增。`; + const jobsToSave = [...dedupeResult.jobs, ...(dedupeResult.reusedJobs || [])]; + if (!jobsToSave.length) { + const messageText = `${diagnosticText} 识别 ${candidates.length} 个岗位,没有可入库或可恢复的岗位。`; postProgress(message, "info", messageText, { ...baseMeta, stage: "dedupe", @@ -455,18 +469,19 @@ } const data = await callBossLocalApi("chrome-jobs", { + profileId: normalizeProfileId(message?.profileId), runId, keyword, collectionMode: "LIST_ONLY", autoDeliver: false, - jobs: dedupeResult.jobs + jobs: jobsToSave }, { pageTabId: message?.pageTabId, timeoutMs: 60000 }); if (!data.success) throw new Error(data.message || "后端未接受 Boss API POC 岗位数据"); - const successMessage = `${diagnosticText} 候选 ${candidates.length} 个,历史跳过 ${dedupeResult.skipCount} 个,后端入库 ${numberValue(data.saved)} 个,状态 LIST_COLLECTED,不进入 AI 分析。`; + const successMessage = `${diagnosticText} 候选 ${candidates.length} 个,恢复历史结果 ${numberValue(data.restored)} 个,后端入库 ${numberValue(data.saved)} 个,新岗位状态 LIST_COLLECTED,不进入 AI 分析。`; postProgress(message, "success", successMessage, { ...baseMeta, stage: "listCollected", @@ -618,7 +633,7 @@ || SCAN_SUPPORT.classifyLocalApiFailure?.(error) || (/Invalid CORS request|cors/i.test(text) ? "CORS_REJECTED" : /超时|timeout|abort/i.test(text) ? "LOCAL_API_TIMEOUT" - : /无法连接|Failed to fetch|NetworkError|本地服务请求失败|6866端口/i.test(text) ? "LOCAL_SERVICE_UNAVAILABLE" + : /无法连接|Failed to fetch|NetworkError|本地服务请求失败|(?:8888|6866)端口/i.test(text) ? "LOCAL_SERVICE_UNAVAILABLE" : "LOCAL_API_ERROR"); const catalog = { CORS_REJECTED: ["Chrome扩展请求被后端CORS规则拒绝", "岗位已采集但本批尚未入库,扫描断点已保留。", "重启更新后的本地服务并重新加载Chrome扩展,然后继续扫描。"], @@ -651,9 +666,21 @@ }; } - async function handleScanStatusMessage(sendResponse) { - const task = await readStoredScanTaskFromAnyStorage(); - const status = readScanStatus(); + async function handleScanStatusMessage(message, sendResponse) { + const profileId = normalizeProfileId(message?.profileId); + if (!profileId) { + sendResponse({ success: false, errorCode: "PROFILE_REQUIRED", message: "Boss扫描状态请求缺少档案 ID", isRunning: false, hasStoredTask: false }); + return; + } + let task = await readStoredScanTaskFromAnyStorage(); + let status = readScanStatus(); + if ((task && normalizeProfileId(task.profileId) !== profileId) + || (normalizeProfileId(status.profileId) && normalizeProfileId(status.profileId) !== profileId)) { + clearStoredScanTask(); + writeScanStatus({ isRunning: false, stopRequested: false, stage: "idle", paused: false, resumable: false, profileId, runId: "", message: "Boss档案已变化,旧扫描断点已清理" }); + task = null; + status = readScanStatus(); + } const paused = Boolean(status.paused || (status.stage === "blocked" && status.resumable)); const hasFreshTask = Boolean(task && isFreshScanTask(task)); const hasResumableTask = Boolean(hasFreshTask || paused); @@ -663,6 +690,10 @@ isRunning: false, stopRequested: false, stage: "idle", + paused: false, + resumable: false, + profileId, + runId: "", message: "Boss旧扫描任务已清理" }); } @@ -675,11 +706,17 @@ resumable: Boolean(nextStatus.resumable || hasResumableTask), runId: nextStatus.runId || task?.runId || "", scanOwnerToken: task?.scanOwnerToken || "", + profileId, hasStoredTask: hasResumableTask }); } async function handleScanStartMessage(message, sendResponse) { + const profileId = normalizeProfileId(message?.profileId); + if (!profileId) { + sendResponse({ success: false, errorCode: "PROFILE_REQUIRED", message: "Boss扫描启动请求缺少档案 ID" }); + return; + } const existingTask = await readStoredScanTaskFromAnyStorage(); const status = readScanStatus(); const incomingTask = normalizeScanTask(message); @@ -689,15 +726,18 @@ && existingTask.keywordCursorKey !== incomingTask.keywordCursorKey ); const sameRun = isSameScanRun(existingTask, incomingTask); - const shouldDiscardExisting = Boolean(existingTask && (!sameRun || configChanged)); + const profileChanged = Boolean(existingTask && normalizeProfileId(existingTask.profileId) !== profileId); + const shouldDiscardExisting = Boolean(existingTask && (profileChanged || !sameRun || configChanged)); if (shouldDiscardExisting) { clearStoredScanTask(); - postProgress(message, "warning", configChanged + postProgress(message, "warning", profileChanged + ? "Boss档案已变化,其他档案或旧版扫描断点已放弃,将从第一个关键词重新开始。" + : configChanged ? "Boss扫描配置已变化,旧断点已放弃,将按新配置重新开始。" : "Boss检测到新的扫描任务,旧断点已清理,将按新关键词重新开始。", { operation: "scan", stage: "checkpointReset", - diagnosticType: configChanged ? "CONFIG_CHANGED" : "NEW_RUN_DISCARDED_CHECKPOINT", + diagnosticType: profileChanged ? "PROFILE_CHANGED" : configChanged ? "CONFIG_CHANGED" : "NEW_RUN_DISCARDED_CHECKPOINT", previousRunId: existingTask?.runId || "", runId: incomingTask.runId || "" }); @@ -722,7 +762,7 @@ updatedAt: Date.now() }); }); - sendResponse({ success: true, message: "Boss Chrome扫描任务已恢复。", resumed: true, runId: activeScanRunId }); + sendResponse({ success: true, message: "Boss Chrome扫描任务已恢复。", resumed: true, runId: activeScanRunId, profileId }); return; } @@ -732,7 +772,7 @@ stopRequestedRunId = ""; clearStopRequested(); startScan({ ...incomingTask, runId }); - sendResponse({ success: true, message: "Boss Chrome扫描任务已启动。", runId }); + sendResponse({ success: true, message: "Boss Chrome扫描任务已启动。", runId, profileId }); } function startScan(message) { @@ -743,7 +783,10 @@ isRunning: true, stopRequested: false, stage: "received", + paused: false, + resumable: false, message: "Boss Chrome扫描任务已接收", + profileId: task.profileId, runId: task.runId, startedAt: task.startedAt, updatedAt: Date.now() @@ -758,7 +801,7 @@ } if (keywords.length) { const startIndex = normalizeKeywordIndex(task.currentIndex, keywords.length); - postProgress(task, "info", `Boss关键词历史:本次从第 ${startIndex + 1}/${keywords.length} 个关键词继续:${keywords[startIndex]}`, { + postProgress(task, "info", `Boss本档案关键词进度:本次从第 ${startIndex + 1}/${keywords.length} 个关键词继续:${keywords[startIndex]}`, { operation: "scan", stage: "keywordCursor", keyword: keywords[startIndex], @@ -908,6 +951,7 @@ const city = first(config.cityCode, "101280600"); let currentIndex = normalizeTaskIndex(task.currentIndex, keywords.length); let totalSaved = Number(task.totalSaved || 0); + let totalRestored = Number(task.totalRestored || 0); if (!keywords.length) { throw new Error("Boss扫描缺少关键词,请先在Boss配置中填写关键词。"); @@ -927,13 +971,14 @@ markKeywordCursorCurrent(task, index, keyword); const url = buildSearchUrl(keyword, city, config); const navigationKey = buildNavigationKey(keyword, city); - const navigationAttempts = task.navigationKey === navigationKey ? Number(task.navigationAttempts || 0) : 0; + const navigationAttempts = bossSearchNavigationAttempts(task, navigationKey); const nextNavigationAttempts = navigationAttempts + 1; const searchTaskState = { ...task, phase: "searching", currentIndex: index, totalSaved, + totalRestored, navigationKey, navigationAttempts: nextNavigationAttempts, navigationStartedAt: Date.now(), @@ -945,7 +990,8 @@ keyword, keywordIndex: index + 1, keywordTotal: keywords.length, - totalSaved + totalSaved, + totalRestored }; writeScanStatus({ isRunning: true, @@ -957,6 +1003,7 @@ keywordIndex: index + 1, keywordTotal: keywords.length, totalSaved, + totalRestored, startedAt: task.startedAt, updatedAt: Date.now() }); @@ -968,15 +1015,19 @@ } const detailResult = await continueBossDetailScan(task, keyword, runId, baseMeta); if (detailResult.pendingNavigation || detailResult.blocked) return detailResult; + if (detailResult.success === false || detailResult.resumable) return detailResult; totalSaved = detailResult.totalSaved; + totalRestored = Number(detailResult.totalRestored ?? task.totalRestored ?? totalRestored ?? 0); if (!stopRequested) advanceKeywordCursor(task, index + 1, keyword); task = { ...task, phase: "nextKeyword", jobs: [], + historicalJobs: [], detailIndex: 0, currentIndex: index + 1, - totalSaved + totalSaved, + totalRestored }; storeScanTask(task); continue; @@ -1007,7 +1058,11 @@ return { success: true, saved: totalSaved, pendingNavigation: true }; } - storeScanTask({ ...searchTaskState, phase: "collecting", navigationAttempts: 0, navigationStartedAt: 0 }); + const collectingTaskState = beginBossSearchCollection({ + ...searchTaskState, + navigationAttempts + }); + storeScanTask(collectingTaskState); postProgress(task, "info", `Boss Chrome开始搜索:${keyword},当前URL:${window.location.href}`, { ...baseMeta, stage: "searching", @@ -1031,6 +1086,23 @@ if (handleBlockingState(task, waitState.diagnostics, baseMeta)) { return { success: true, saved: totalSaved, blocked: true }; } + if (!isCurrentSearchPage(keyword, city, url)) { + if (isBossSearchNavigationExhausted(collectingTaskState)) { + return stopSearchNavigationFailure(collectingTaskState, url); + } + const retryTaskState = retryBossSearchNavigation(collectingTaskState); + postProgress(task, "warning", `Boss搜索页在列表加载期间被重定向,准备重新打开:${keyword}(第 ${retryTaskState.navigationAttempts} 次导航),目标URL:${url},当前URL:${window.location.href}`, { + ...baseMeta, + stage: "searching", + currentUrl: window.location.href, + targetUrl: url, + navigationAttempts: retryTaskState.navigationAttempts, + diagnosticType: "SEARCH_PAGE_REDIRECTED" + }); + storeScanTask(retryTaskState); + openSearchPage(url, retryTaskState); + return { success: true, saved: totalSaved, pendingNavigation: true }; + } postProgress(task, "info", `Boss岗位列表加载检查完成,开始滚动采集。详情链接 ${waitState.diagnostics.detailLinks} 个,搜索结果容器 ${waitState.diagnostics.resultContainers} 个。`, { ...baseMeta, stage: "collecting", @@ -1120,6 +1192,7 @@ const discoveryResult = await collectFreshJobsForKeyword(keyword, task, baseMeta, searchJobLimit, candidates); candidates = discoveryResult.candidates; const freshCandidates = discoveryResult.jobs; + const historicalCandidates = discoveryResult.historicalJobs || []; const detailReadyCandidates = freshCandidates.filter(isDetailQueueJob); const invalidDetailCandidates = Math.max(0, freshCandidates.length - detailReadyCandidates.length); const jobs = detailReadyCandidates.slice(0, searchJobLimit); @@ -1135,9 +1208,25 @@ invalidDetailCandidates, searchJobLimit, discoveryRounds: discoveryResult.rounds, - stoppedByStagnation: discoveryResult.stoppedByStagnation + stoppedByStagnation: discoveryResult.stoppedByStagnation, + stopReason: discoveryResult.stopReason, + stopReasonLabel: collectionStopReasonLabel(discoveryResult.stopReason), + elapsedMs: discoveryResult.elapsedMs }); - if (!jobs.length) { + if (jobs.length < searchJobLimit) { + postProgress(task, "warning", `Boss关键词 ${keyword} 未补足目标:新岗位 ${jobs.length}/${searchJobLimit},停止原因:${collectionStopReasonLabel(discoveryResult.stopReason)}。`, { + ...baseMeta, + stage: "dedupe", + candidateCount: discoveryResult.candidateCount, + conditionFiltered: discoveryResult.filteredCount, + duplicates: discoveryResult.duplicateCount, + fresh: jobs.length, + discoveryRounds: discoveryResult.rounds, + stopReason: discoveryResult.stopReason, + elapsedMs: discoveryResult.elapsedMs + }); + } + if (!jobs.length && !historicalCandidates.length) { postProgress(task, "warning", `Boss关键词 ${keyword} 已继续向下采集,但没有找到新的可分析岗位,跳过本关键词。`, { ...baseMeta, stage: "dedupe", @@ -1153,7 +1242,7 @@ } const diagnostics = buildListDiagnostics(); - postProgress(task, "info", `Boss Chrome采集到 ${discoveryResult.candidateCount} 个候选岗位,学历过滤 ${discoveryResult.filteredCount} 个,历史跳过 ${discoveryResult.skipCount} 个,剩余 ${freshCandidates.length} 个可分析岗位,将进入前 ${jobs.length}/${searchJobLimit} 个详情页做AI比对。详情链接 ${diagnostics.detailLinks} 个。`, { + postProgress(task, "info", `Boss Chrome采集到 ${discoveryResult.candidateCount} 个候选岗位,学历过滤 ${discoveryResult.filteredCount} 个,历史结果 ${historicalCandidates.length} 个,剩余 ${freshCandidates.length} 个可分析岗位,将进入前 ${jobs.length}/${searchJobLimit} 个详情页做AI比对。详情链接 ${diagnostics.detailLinks} 个。`, { ...baseMeta, stage: "details", collected: jobs.length, @@ -1175,6 +1264,7 @@ phase: "detail", detailIndex: 0, jobs, + historicalJobs: historicalCandidates, searchUrl: url }; storeScanTask(detailTask); @@ -1183,15 +1273,6 @@ if (isStopRequested(runId)) stopRequested = true; - if (!stopRequested && shouldAppendAiKeywords(task) && !task.aiKeywordsLoaded) { - const aiResult = await appendAiKeywords(task, keywords); - task = aiResult.task; - keywords = aiResult.keywords; - if (Number(task.currentIndex || 0) < keywords.length) { - return runScanInternal(task); - } - } - if (!stopRequested) { advanceKeywordCursor(task, userKeywordCount(task), ""); } @@ -1202,22 +1283,29 @@ isRunning: false, stopRequested: stopped, stage: stopped ? "stopped" : "complete", - message: stopped ? `Boss Chrome扫描已停止,已提交 ${totalSaved} 个岗位` : `Boss Chrome扫描完成,已提交 ${totalSaved} 个岗位`, + message: stopped + ? `Boss Chrome扫描已停止:新入库 ${totalSaved} 个,恢复历史结果 ${totalRestored} 个` + : `Boss Chrome扫描完成:新入库 ${totalSaved} 个,恢复历史结果 ${totalRestored} 个`, runId, keywordTotal: keywords.length, totalSaved, + totalRestored, saved: totalSaved, startedAt: task.startedAt, updatedAt: Date.now() }); - postProgress(task, stopped ? "warning" : "success", stopped ? `Boss Chrome扫描已停止,已提交 ${totalSaved} 个岗位` : `Boss Chrome扫描完成,已提交 ${totalSaved} 个岗位`, { + postProgress(task, stopped ? "warning" : "success", stopped + ? `Boss Chrome扫描已停止:新入库 ${totalSaved} 个,恢复历史结果 ${totalRestored} 个` + : `Boss Chrome扫描完成:新入库 ${totalSaved} 个,恢复历史结果 ${totalRestored} 个`, { operation: "scan", stage: stopped ? "stopped" : "complete", keywordTotal: keywords.length, totalSaved, + totalRestored, + restored: totalRestored, saved: totalSaved }); - return { success: true, saved: totalSaved }; + return { success: true, saved: totalSaved, restored: totalRestored }; } function buildSearchUrl(keyword, city, config) { @@ -1231,7 +1319,7 @@ addList(params, "industry", config.industry); addList(params, "stage", config.stage); params.set("query", keyword); - return `https://www.zhipin.com/web/geek/job?${params.toString()}`; + return `https://www.zhipin.com/web/geek/jobs?${params.toString()}`; } function collectJobs(keyword, message, baseMeta, options = {}) { @@ -1468,19 +1556,25 @@ async function collectFreshJobsForKeyword(keyword, message, baseMeta, searchJobLimit, initialCandidates = []) { const target = normalizeSearchJobLimit(searchJobLimit); - const maxRounds = bossDiscoveryMaxRounds(target); - const maxCandidates = bossDiscoveryCandidateLimit(target); + const bounds = typeof SCAN_SUPPORT.deepCollectionBounds === "function" + ? SCAN_SUPPORT.deepCollectionBounds(target) + : { target, maxRounds: 30, maxCandidates: 500, maxDurationMs: 180000, maxStagnantRounds: 5 }; + const maxRounds = bounds.maxRounds; + const maxCandidates = bounds.maxCandidates; + const startedAt = Date.now(); const allCandidates = new Map(); const processableJobs = new Map(); const duplicateKeys = new Set(); const enrichKeys = new Set(); const skipKeys = new Set(); + const historicalJobs = new Map(); const conditionFilteredKeys = new Set(); let lastUniqueCount = -1; let lastScrollSignature = ""; let stagnantRounds = 0; let stoppedByStagnation = false; let roundsRan = 0; + let stopReason = ""; addUniqueJobs(allCandidates, initialCandidates, maxCandidates); @@ -1517,9 +1611,10 @@ if (action === "ENRICH") enrichKeys.add(key); if (action === "SKIP") { skipKeys.add(key); + historicalJobs.set(key, { ...job, collectionAction: "REUSE_HISTORY" }); return; } - processableJobs.set(key, job); + processableJobs.set(key, { ...job, collectionAction: "ANALYZE" }); }); const currentScrollSignature = bossScrollSignature(); @@ -1541,23 +1636,56 @@ }); } - if (stagnantRounds >= 2) { - stoppedByStagnation = true; + const elapsedMs = Date.now() - startedAt; + const stopState = { + target, + fresh: processableJobs.size, + candidates: allCandidates.size, + rounds: round + 1, + stagnantRounds, + elapsedMs, + platformExhausted: bossPlatformExhausted(), + stopped: isStopRequested() + }; + stopReason = typeof SCAN_SUPPORT.deepCollectionStopReason === "function" + ? SCAN_SUPPORT.deepCollectionStopReason(stopState) + : (processableJobs.size >= target ? "target_reached" : stagnantRounds >= bounds.maxStagnantRounds ? "stagnation_safety_cap" : ""); + if (stopReason) { + stoppedByStagnation = stopReason === "stagnation_safety_cap"; break; } } + if (!stopReason) { + const finalState = { + target, + fresh: processableJobs.size, + candidates: allCandidates.size, + rounds: roundsRan, + stagnantRounds, + elapsedMs: Date.now() - startedAt, + platformExhausted: bossPlatformExhausted(), + stopped: isStopRequested() + }; + stopReason = typeof SCAN_SUPPORT.deepCollectionStopReason === "function" + ? SCAN_SUPPORT.deepCollectionStopReason(finalState) + : "round_safety_cap"; + } + resetBossScrollPosition(); return { candidates: Array.from(allCandidates.values()), jobs: Array.from(processableJobs.values()).slice(0, target), + historicalJobs: Array.from(historicalJobs.values()).slice(0, target), candidateCount: allCandidates.size, filteredCount: conditionFilteredKeys.size, duplicateCount: duplicateKeys.size, enrichCount: enrichKeys.size, skipCount: skipKeys.size, rounds: roundsRan, - stoppedByStagnation + stoppedByStagnation, + stopReason: stopReason || "round_safety_cap", + elapsedMs: Date.now() - startedAt }; } @@ -1619,12 +1747,41 @@ function bossDiscoveryCandidateLimit(searchJobLimit) { const limit = normalizeSearchJobLimit(searchJobLimit); - return Math.min(300, Math.max(80, limit * 8)); + return typeof SCAN_SUPPORT.deepCollectionBounds === "function" + ? SCAN_SUPPORT.deepCollectionBounds(limit).maxCandidates + : 500; } function bossDiscoveryMaxRounds(searchJobLimit) { const limit = normalizeSearchJobLimit(searchJobLimit); - return Math.min(18, Math.max(6, Math.ceil(limit / 5) + 5)); + return typeof SCAN_SUPPORT.deepCollectionBounds === "function" + ? SCAN_SUPPORT.deepCollectionBounds(limit).maxRounds + : 30; + } + + function bossPlatformExhausted() { + const bodyText = compact(document.body?.innerText || document.body?.textContent || ""); + if (!/(没有更多|暂无更多|已经到底|到底了|全部加载完)/.test(bodyText.slice(-3000))) return false; + const pageHeight = Number(document.documentElement?.scrollHeight || document.body?.scrollHeight || 0); + const windowAtBottom = Number(window.scrollY || 0) + Number(window.innerHeight || 0) >= pageHeight - 40; + const containers = bossScrollableContainers(); + const containersAtBottom = !containers.length || containers.some((target) => + Number(target.scrollTop || 0) + Number(target.clientHeight || 0) >= Number(target.scrollHeight || 0) - 40); + return windowAtBottom || containersAtBottom; + } + + function collectionStopReasonLabel(reason) { + return ({ + target_reached: "已达到目标", + platform_exhausted: "平台结果已到底", + stagnation_safety_cap: "连续5轮没有新增", + timeout_safety_cap: "单关键词采集达到180秒", + candidate_safety_cap: "候选岗位达到500个安全上限", + round_safety_cap: "滚动达到30轮安全上限", + page_safety_cap: "翻页达到安全上限", + blocked: "平台安全验证或页面阻断", + stopped: "用户停止扫描" + })[String(reason || "")] || "安全边界已触发"; } async function scrollForMoreBossCards(round = 0) { @@ -1640,47 +1797,35 @@ async function filterDuplicateJobs(jobs, message, baseMeta) { const list = Array.isArray(jobs) ? jobs : []; - if (!list.length) return { jobs: [], duplicateCount: 0, enrichCount: 0, skipCount: 0, items: [] }; + if (!list.length) return { jobs: [], reusedJobs: [], duplicateCount: 0, enrichCount: 0, skipCount: 0, items: [] }; try { const data = await callBossLocalApi("chrome-jobs-dedupe", { + profileId: normalizeProfileId(message?.profileId), runId: message?.runId, keyword: baseMeta.keyword, jobs: list.map(normalizeJobForDedupe) }, { pageTabId: message?.pageTabId }); - if (!data.success || !Array.isArray(data.items)) throw new Error(data.message || "查重接口返回异常"); + if (data.success !== true || !Array.isArray(data.items)) throw new Error(data.message || "查重接口返回异常"); - const decisions = new Map(data.items.map((item) => [dedupeItemKey(item), String(item.action || (item.duplicate ? "SKIP" : "NEW"))])); - const freshJobs = list.filter((job) => decisions.get(dedupeJobKey(job)) !== "SKIP"); + const partition = SCAN_SUPPORT.partitionDedupeJobs(list, data.items); return { - jobs: freshJobs, + jobs: partition.detailJobs, + reusedJobs: partition.historicalJobs, duplicateCount: Number(data.duplicateCount ?? 0), enrichCount: Number(data.enrichCount ?? data.items.filter((item) => item.action === "ENRICH").length), skipCount: Number(data.skipCount ?? data.items.filter((item) => item.action === "SKIP").length), items: data.items }; } catch (error) { - postProgress(message, "warning", `Boss重复岗位检查失败,将继续扫描本页岗位:${error.message || String(error)}`, { + postProgress(message, "error", `Boss重复岗位检查失败,已停止本轮岗位入队:${error.message || String(error)}`, { ...baseMeta, stage: "dedupe", collected: list.length }); - return { - jobs: list, - duplicateCount: 0, - enrichCount: 0, - skipCount: 0, - items: list.map((job) => ({ - id: job?.id || extractBossId(job?.url), - url: job?.url || "", - title: job?.title || "", - company: job?.company || "", - duplicate: false, - action: "NEW" - })) - }; + throw new Error(`Boss重复岗位检查失败,未执行入队:${error.message || String(error)}`); } } @@ -2024,6 +2169,7 @@ async function continueBossDetailScan(message, keyword, runId, baseMeta) { const jobs = Array.isArray(message.jobs) ? message.jobs : []; + const historicalJobs = Array.isArray(message.historicalJobs) ? message.historicalJobs : []; const detailIndex = Number(message.detailIndex || 0); const totalSaved = Number(message.totalSaved || 0); @@ -2035,17 +2181,32 @@ return { success: true, totalSaved }; } - if (!jobs.length) { - advanceKeywordCursor(message, Number(message.currentIndex || 0) + 1, keyword); - storeScanTask({ ...message, phase: "", currentIndex: Number(message.currentIndex || 0) + 1, totalSaved }); - return { success: true, totalSaved }; - } - if (message.phase === "submitting") { - const submitJobs = jobs.filter(isSubmittableJob).map(normalizeJobForSubmit); + const submitJobs = [...jobs, ...historicalJobs].filter(isSubmittableJob).map(normalizeJobForSubmit); return submitCollectedBossJobs(submitJobs, message, keyword, runId, baseMeta, totalSaved); } + if (!jobs.length) { + const submitJobs = historicalJobs.filter(isSubmittableJob).map(normalizeJobForSubmit); + if (!submitJobs.length) { + advanceKeywordCursor(message, Number(message.currentIndex || 0) + 1, keyword); + storeScanTask({ ...message, phase: "", historicalJobs: [], currentIndex: Number(message.currentIndex || 0) + 1, totalSaved }); + return { success: true, totalSaved }; + } + const submittingTask = { + ...message, + phase: "submitting", + jobs: [], + historicalJobs, + detailIndex: 0, + submitBatchIndex: 0, + submitSummary: null, + totalSaved + }; + storeScanTask(submittingTask); + return submitCollectedBossJobs(submitJobs, submittingTask, keyword, runId, baseMeta, totalSaved); + } + const currentJob = jobs[detailIndex]; if (currentJob && !isDetailQueueJob(currentJob)) { jobs[detailIndex] = markBossDetailNavigationFailed( @@ -2226,7 +2387,7 @@ } const detailSummary = summarizeJobCollection(jobs); - const submitJobs = jobs.filter(isSubmittableJob).map(normalizeJobForSubmit); + const submitJobs = [...jobs, ...historicalJobs].filter(isSubmittableJob).map(normalizeJobForSubmit); if (isStopRequested(runId)) { stopRequested = true; clearStoredScanTask(); @@ -2253,6 +2414,7 @@ ...message, phase: "nextKeyword", jobs: [], + historicalJobs: [], detailIndex: 0, currentIndex: Number(message.currentIndex || 0) + 1, totalSaved @@ -2264,6 +2426,7 @@ ...message, phase: "submitting", jobs, + historicalJobs, detailIndex: jobs.length - 1, detailNavigationKey: "", detailNavigationAttempts: 0, @@ -2368,13 +2531,43 @@ keyword, autoDeliver: isAutoDeliverEnabled(message) }); - if (!data.success) throw new Error(data.message || "Boss岗位提交失败"); + if (data.success !== true) { + const storedTask = readStoredScanTask() || message; + const diagnosticType = String(storedTask?.lastSubmitError?.type || "LOCAL_API_ERROR"); + const failureMessage = data.message || "Boss岗位提交失败"; + writeScanStatus({ + isRunning: false, + stopRequested: false, + stage: "blocked", + paused: true, + resumable: true, + diagnosticType, + message: `${failureMessage} 扫描断点将在24小时内保留。`, + runId: storedTask?.runId || runId, + totalSaved, + totalRestored: numberValue(message.totalRestored), + startedAt: storedTask?.startedAt || message?.startedAt, + updatedAt: Date.now() + }); + return { + ...data, + totalSaved, + totalRestored: numberValue(message.totalRestored), + success: false, + resumable: true + }; + } if (data.cancelled || isStopRequested(runId)) { stopRequested = true; clearStoredScanTask(); - return { success: true, totalSaved: totalSaved + (data.saved || 0) }; + return { + success: true, + totalSaved: totalSaved + numberValue(data.saved), + totalRestored: numberValue(message.totalRestored) + numberValue(data.restored) + }; } const nextTotalSaved = totalSaved + (data.saved || 0); + const nextTotalRestored = numberValue(message.totalRestored) + numberValue(data.restored); postProgress(message, "success", `Boss Chrome已提交后台AI队列:采集 ${data.received ?? submitJobs.length} 个,入库 ${data.saved ?? 0} 个,入队 ${data.queued ?? 0} 个,恢复已有分析 ${data.restored ?? 0} 个,跳过 ${data.skipped ?? 0} 个,信息不足 ${data.insufficient ?? 0} 个。`, { ...baseMeta, stage: "submitted", @@ -2385,7 +2578,8 @@ restored: data.restored ?? 0, insufficient: data.insufficient ?? 0, queueSize: data.queueSize ?? 0, - totalSaved: nextTotalSaved + totalSaved: nextTotalSaved, + totalRestored: nextTotalRestored }); if (isAutoDeliverEnabled(message)) { postProgress(message, "warning", "扫描优先模式已启用:Boss扫描期间不会自动投递,AI通过岗位会进入待确认列表。", { @@ -2397,14 +2591,16 @@ ...message, phase: "nextKeyword", jobs: [], + historicalJobs: [], detailIndex: 0, submitBatchIndex: 0, submitSummary: null, currentIndex: Number(message.currentIndex || 0) + 1, - totalSaved: nextTotalSaved + totalSaved: nextTotalSaved, + totalRestored: nextTotalRestored }); advanceKeywordCursor(message, Number(message.currentIndex || 0) + 1, keyword); - return { success: true, totalSaved: nextTotalSaved }; + return { success: true, totalSaved: nextTotalSaved, totalRestored: nextTotalRestored }; } async function submitBossJobsInBatches(jobs, message, baseMeta, options) { @@ -2457,6 +2653,7 @@ batchAttempt++; try { data = await callBossLocalApi("chrome-jobs", { + profileId: normalizeProfileId(message?.profileId), runId, keyword: options.keyword, jobs: batch, @@ -2467,6 +2664,10 @@ }); break; // 成功,跳出重试循环 } catch (error) { + if (["PROFILE_REQUIRED", "PROFILE_CHANGED"].includes(String(error?.code || ""))) { + clearStoredScanTask(); + throw error; + } if (batchAttempt < maxBatchAttempts) { postProgress(message, "warning", `Boss岗位提交第 ${index + 1}/${batches.length} 批失败,正在重试(${batchAttempt}/${maxBatchAttempts}):${safeErrorMessage(error)}`, { ...baseMeta, @@ -2478,53 +2679,54 @@ await humanPause(800, 1500); continue; } - // 最终失败:记录错误但跳过本批,继续提交其他批次 + // 最终失败:保留当前批次检查点并停止,恢复后从原批次重试。 const reason = safeErrorMessage(error); const diagnostic = buildLocalApiDiagnostic(error, "submitting"); - postProgress(message, "error", `Boss岗位第 ${index + 1}/${batches.length} 批提交失败(已重试),跳过本批继续:${diagnostic.message}`, { + postProgress(message, "error", `Boss岗位第 ${index + 1}/${batches.length} 批提交失败(已重试),已保留断点:${diagnostic.message}`, { ...baseMeta, - stage: "submitBatchSkipped", + stage: "submitBatchFailed", diagnosticType: diagnostic.type, batchIndex: index + 1, batchTotal: batches.length }); - storeScanTask({ - ...message, - phase: "submitting", - jobs: message.jobs, - submitBatchIndex: index + 1, - submitSummary: checkpointSubmitSummary(summary), - lastSubmitError: { type: diagnostic.type, message: reason, failedAt: Date.now() } - }); - // 跳过本批,继续下一个批次 - data = null; - break; + storeScanTask(SCAN_SUPPORT.buildFailedSubmitCheckpoint( + { ...message, jobs: message.jobs }, + index, + checkpointSubmitSummary(summary), + { type: diagnostic.type, message: reason } + )); + return { + ...summary, + success: false, + failedBatchIndex: index, + resumable: true, + message: reason + }; } } - if (!data) { - // 本批已跳过,继续下一批 - continue; - } - - if (!data.success) { + if (data.success !== true) { const diagnostic = buildLocalApiDiagnostic(new Error(data.message || "后台未返回原因"), "submitting"); - postProgress(message, "error", `Boss岗位第 ${index + 1}/${batches.length} 批后台处理失败,跳过本批继续:${diagnostic.message}`, { + postProgress(message, "error", `Boss岗位第 ${index + 1}/${batches.length} 批后台处理失败,已保留断点:${diagnostic.message}`, { ...baseMeta, - stage: "submitBatchSkipped", + stage: "submitBatchFailed", diagnosticType: diagnostic.type, batchIndex: index + 1, batchTotal: batches.length }); - storeScanTask({ - ...message, - phase: "submitting", - jobs: message.jobs, - submitBatchIndex: index + 1, - submitSummary: checkpointSubmitSummary(summary), - lastSubmitError: { type: diagnostic.type, message: data.message || "后台未返回原因", failedAt: Date.now() } - }); - continue; + storeScanTask(SCAN_SUPPORT.buildFailedSubmitCheckpoint( + { ...message, jobs: message.jobs }, + index, + checkpointSubmitSummary(summary), + { type: diagnostic.type, message: data.message || "后台未返回原因" } + )); + return { + ...summary, + success: false, + failedBatchIndex: index, + resumable: true, + message: data.message || "后台未返回原因" + }; } summary.received += numberValue(data.received); @@ -2544,12 +2746,13 @@ lastSubmitError: null }); - postProgress(message, "info", `Boss Chrome第 ${index + 1}/${batches.length} 批已提交:入库 ${numberValue(data.saved)} 个,入队 ${numberValue(data.queued)} 个。`, { + postProgress(message, "info", `Boss Chrome第 ${index + 1}/${batches.length} 批已提交:入库 ${numberValue(data.saved)} 个,恢复历史结果 ${numberValue(data.restored)} 个,入队 ${numberValue(data.queued)} 个。`, { ...baseMeta, stage: "submitting", batchIndex: index + 1, batchTotal: batches.length, saved: summary.saved, + restored: summary.restored, queued: summary.queued, skipped: summary.skipped, insufficient: summary.insufficient @@ -2800,14 +3003,30 @@ await postDeliveryResult(task, false, failure, "PRE_ACTION_ERROR"); return { success: false, outcome: "FAILED", evidence: "PRE_ACTION_ERROR", message: failure.failureReason, failureType: failure.failureType }; } + const configuredGreeting = normalizeGreetingText(task?.greeting || ""); + if (!configuredGreeting) { + const failure = classifyDeliveryFailure("投递任务缺少已确认的沟通话术"); + await postDeliveryResult(task, false, failure, "PRE_ACTION_ERROR", "NOT_SENT", "GREETING_EMPTY"); + return { + success: false, + outcome: "FAILED", + evidence: "PRE_ACTION_ERROR", + greetingOutcome: "NOT_SENT", + greetingEvidence: "GREETING_EMPTY", + message: failure.failureReason, + failureType: failure.failureType + }; + } await sleep(1500); if (detectBossDeliveryStatus(document)) { - const messageText = "Boss岗位页面已显示沟通或投递状态"; - await postDeliveryResult(task, true, messageText, "PLATFORM_STATUS_TEXT"); + const messageText = "Boss岗位页面已存在沟通或投递状态,本次未补发话术,请人工核对"; + await postDeliveryResult(task, null, messageText, "EXISTING_CONVERSATION", "NOT_SENT", "ALREADY_CONTACTED"); return { - success: true, - outcome: "CONFIRMED", - evidence: "PLATFORM_STATUS_TEXT", + success: false, + outcome: "UNKNOWN", + evidence: "EXISTING_CONVERSATION", + greetingOutcome: "NOT_SENT", + greetingEvidence: "ALREADY_CONTACTED", message: messageText }; } @@ -2862,18 +3081,24 @@ return { ...deliveryCheck, message: failure.failureReason, failureType: failure.failureType }; } const greetingResult = await sendConfiguredGreeting(task, message); - const finalMessage = greetingResult?.sent ? `${successMessage},已发送开场白` : successMessage; - const confirmed = deliveryCheck.outcome === "CONFIRMED"; + const confirmed = greetingResult?.sent === true; + const finalMessage = confirmed + ? `${successMessage},已精确确认发送岗位话术` + : `${successMessage},但${greetingResult?.message || "话术发送未确认"}`; await postDeliveryResult( task, confirmed ? true : null, - confirmed ? finalMessage : `${finalMessage},但未检测到明确平台成功状态`, - deliveryCheck.evidence || (confirmed ? "PLATFORM_STATUS_TEXT" : "CHAT_SURFACE_ONLY") + confirmed ? finalMessage : `${finalMessage},已标记待人工确认`, + confirmed ? "GREETING_RENDERED_EXACT" : (deliveryCheck.evidence || "CHAT_SURFACE_ONLY"), + confirmed ? "CONFIRMED" : "UNKNOWN", + greetingResult?.evidence || (confirmed ? "GREETING_RENDERED_EXACT" : "GREETING_UNCONFIRMED") ); const result = { success: confirmed, outcome: confirmed ? "CONFIRMED" : "UNKNOWN", - evidence: deliveryCheck.evidence || (confirmed ? "PLATFORM_STATUS_TEXT" : "CHAT_SURFACE_ONLY"), + evidence: confirmed ? "GREETING_RENDERED_EXACT" : (deliveryCheck.evidence || "CHAT_SURFACE_ONLY"), + greetingOutcome: confirmed ? "CONFIRMED" : "UNKNOWN", + greetingEvidence: greetingResult?.evidence || (confirmed ? "GREETING_RENDERED_EXACT" : "GREETING_UNCONFIRMED"), message: confirmed ? finalMessage : `${finalMessage},结果待人工确认` }; earlyRespond?.({ ...result, early: true }); @@ -2888,30 +3113,65 @@ } async function sendConfiguredGreeting(task, message) { - const greeting = compact(task?.greeting || ""); - if (!greeting) return { attempted: false, sent: false, message: "未配置开场白" }; + const greeting = normalizeGreetingText(task?.greeting || ""); + if (!greeting) return { attempted: false, sent: false, evidence: "GREETING_EMPTY", message: "未配置开场白" }; - const input = await waitForChatInput(4500); - if (!input) return { attempted: false, sent: false, message: "未出现聊天输入框" }; + const input = await waitForChatInput(12000); + if (!input) return { attempted: false, sent: false, evidence: "GREETING_INPUT_MISSING", message: "未出现聊天输入框" }; writeChatInput(input, greeting); await sleep(400); - const sendButton = findSendButton(); + if (readChatInput(input) !== greeting) { + return { attempted: true, sent: false, evidence: "GREETING_INPUT_MISMATCH", message: "聊天输入框内容与确认话术不一致" }; + } + const sendButton = findSendButton(input); if (!sendButton) { postProgress(message, "warning", "Boss Chrome已填入配置开场白,但未找到发送按钮。", { operation: "deliver", stage: "submitting" }); - return { attempted: true, sent: false, message: "未找到发送按钮" }; + return { attempted: true, sent: false, evidence: "GREETING_SEND_BUTTON_MISSING", message: "未找到发送按钮" }; } + const messageCountBefore = countRenderedGreetingMessages(greeting, input); clickElement(sendButton); - await sleep(600); - postProgress(message, "info", "Boss Chrome已发送配置开场白。", { + const sent = await waitForGreetingConfirmation(greeting, input, messageCountBefore, 6000); + postProgress(message, sent ? "info" : "warning", sent + ? "Boss Chrome已精确确认发送岗位话术。" + : "Boss Chrome已点击发送,但未检测到精确话术出现在聊天记录。", { operation: "deliver", stage: "submitting" }); - return { attempted: true, sent: true, message: "已发送配置开场白" }; + return { + attempted: true, + sent, + evidence: sent ? "GREETING_RENDERED_EXACT" : "GREETING_RENDER_UNCONFIRMED", + message: sent ? "已精确确认发送岗位话术" : "点击发送后未检测到精确话术" + }; + } + + async function waitForGreetingConfirmation(greeting, input, beforeCount, timeoutMs) { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + if (countRenderedGreetingMessages(greeting, input) > beforeCount) return true; + await sleep(200); + } + return false; + } + + function countRenderedGreetingMessages(greeting, input) { + const selectors = [ + ".message-item", + ".chat-message", + ".message-content", + "[class*='message-item']", + "[class*='message-content']", + "[class*='chat-record'] [class*='text']" + ]; + const nodes = Array.from(document.querySelectorAll(selectors.join(","))); + return nodes.filter((node) => node !== input + && !node.contains?.(input) + && normalizeGreetingText(node.innerText || node.textContent || "") === greeting).length; } function buildDeliverySuccessMessage(favoriteButton, greetingResult) { @@ -2936,21 +3196,34 @@ "div#chat-input.chat-input[contenteditable='true']", "[contenteditable='true'].chat-input", "[contenteditable='true'][id*='chat']", - "textarea.input-area", - "textarea" + "[class*='chat-input'] [contenteditable='true']", + "[class*='chat'] textarea.input-area", + "[class*='chat'] textarea" ]; for (const selector of selectors) { - const node = Array.from(document.querySelectorAll(selector)).find((el) => el.offsetParent !== null); + const node = Array.from(document.querySelectorAll(selector)).find(isVisibleChatInput); if (node) return node; } return null; } + function isVisibleChatInput(element) { + if (!element || element.offsetParent === null) return false; + const hint = compact([ + element.getAttribute?.("placeholder"), + element.getAttribute?.("aria-label"), + element.getAttribute?.("title") + ].filter(Boolean).join(" ")); + return !/(搜索|职位搜索|公司搜索)/.test(hint); + } + function writeChatInput(input, text) { input.focus?.(); input.click?.(); if (String(input.tagName || "").toLowerCase() === "textarea") { - input.value = text; + const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement?.prototype || {}, "value")?.set; + if (setter) setter.call(input, text); + else input.value = text; input.dispatchEvent(new Event("input", { bubbles: true })); input.dispatchEvent(new Event("change", { bubbles: true })); return; @@ -2960,7 +3233,18 @@ input.dispatchEvent(new InputEvent("input", { bubbles: true, cancelable: true, inputType: "insertText", data: text })); } - function findSendButton() { + function readChatInput(input) { + if (!input) return ""; + return normalizeGreetingText(String(input.tagName || "").toLowerCase() === "textarea" + ? input.value || "" + : input.innerText || input.textContent || ""); + } + + function normalizeGreetingText(value) { + return String(value || "").replace(/\r\n?/g, "\n").trim(); + } + + function findSendButton(input) { const selectors = [ "div.send-message", "button[type='send'].btn-send", @@ -2968,11 +3252,24 @@ "[class*='send-message']", "[class*='btn-send']" ]; - for (const selector of selectors) { - const node = Array.from(document.querySelectorAll(selector)).find((el) => el.offsetParent !== null); - if (node) return node; + const scopes = []; + let scope = input; + for (let depth = 0; scope && depth < 6; depth++) { + if (scope.querySelectorAll) scopes.push(scope); + scope = scope.parentElement; + } + for (const current of scopes) { + for (const selector of selectors) { + const node = Array.from(current.querySelectorAll(selector)) + .find((el) => el.offsetParent !== null); + if (node) return node; + } + const textButton = Array.from(current.querySelectorAll("button, [role='button']")) + .find((el) => el.offsetParent !== null + && normalizeGreetingText(el.textContent || el.innerText || "") === "发送"); + if (textButton) return textButton; } - return findClickable(["发送"]); + return null; } async function deliverBatch(tasks, message) { @@ -2980,6 +3277,9 @@ let failed = 0; let unknown = 0; const results = []; + let halted = false; + let haltedJobId = null; + let unprocessedCount = 0; postProgress(message, "info", `Boss Chrome批量投递开始,共 ${tasks.length} 个待确认岗位。`, { operation: "deliver", stage: "received", @@ -3010,26 +3310,62 @@ if (outcome === "CONFIRMED") success += 1; else if (outcome === "UNKNOWN") unknown += 1; else failed += 1; - results.push({ id: task?.id, requestKey: task?.requestKey, outcome, evidence: result?.evidence || "", persisted: result?.persisted === true, message: result?.message || "" }); + results.push({ id: task?.id, requestKey: task?.requestKey, outcome, evidence: result?.evidence || "", greetingOutcome: result?.greetingOutcome || "", greetingEvidence: result?.greetingEvidence || "", persisted: result?.persisted === true, message: result?.message || "" }); + if (outcome === "UNKNOWN") { + halted = true; + haltedJobId = task?.id || null; + const remaining = tasks.slice(index + 1); + unprocessedCount = remaining.length; + for (const skippedTask of remaining) { + const skippedMessage = `前一岗位 ${task?.id || "-"} 的发送结果待确认,批量任务已暂停,本岗位未触达`; + let persisted = false; + await postDeliveryResult( + skippedTask, + false, + { failureType: "BATCH_HALTED_BEFORE_ACTION", failureReason: skippedMessage }, + "BATCH_HALTED_BEFORE_ACTION", + "NOT_SENT", + "BATCH_HALTED_BEFORE_ACTION" + ).then(() => { persisted = true; }).catch(() => {}); + results.push({ + id: skippedTask?.id, + requestKey: skippedTask?.requestKey, + outcome: "FAILED", + evidence: "BATCH_HALTED_BEFORE_ACTION", + greetingOutcome: "NOT_SENT", + greetingEvidence: "BATCH_HALTED_BEFORE_ACTION", + persisted, + skipped: true, + message: skippedMessage + }); + } + break; + } } - postProgress(message, failed || unknown ? "warning" : "success", `Boss批量投递完成:已确认${success},待确认${unknown},失败${failed}`, { + const summary = halted + ? `Boss批量投递已暂停:已确认${success},待确认${unknown},未触达${unprocessedCount}` + : `Boss批量投递完成:已确认${success},待确认${unknown},失败${failed}`; + postProgress(message, failed || unknown ? "warning" : "success", summary, { operation: "deliver", stage: "complete", keywordTotal: tasks.length, saved: success }); return { - success: failed === 0 && unknown === 0, + success: !halted && failed === 0 && unknown === 0, partial: success > 0 && (failed > 0 || unknown > 0), - message: `Boss批量投递完成:已确认${success},待确认${unknown},失败${failed}`, + message: summary, successCount: success, unknownCount: unknown, failedCount: failed, + halted, + haltedJobId, + unprocessedCount, results }; } - async function postDeliveryResult(task, success, message, evidence) { + async function postDeliveryResult(task, success, message, evidence, greetingOutcome, greetingEvidence) { const failure = success === false ? normalizeFailurePayload(message) : null; const outcome = success === true ? "CONFIRMED" : success === false ? "FAILED" : "UNKNOWN"; await callBossLocalApi("delivery-result", { @@ -3039,7 +3375,9 @@ success, message: success === true ? message : failure?.failureReason || String(message || ""), failureType: failure?.failureType, - failureReason: failure?.failureReason + failureReason: failure?.failureReason, + greetingOutcome: greetingOutcome || (outcome === "CONFIRMED" ? "CONFIRMED" : outcome === "FAILED" ? "NOT_SENT" : "UNKNOWN"), + greetingEvidence: greetingEvidence || (outcome === "CONFIRMED" ? "GREETING_RENDERED_EXACT" : "GREETING_UNCONFIRMED") }, { params: { id: task.id }, pageTabId: task?.pageTabId, @@ -3059,7 +3397,7 @@ }); if (!response?.success) { const error = new Error(response?.message || "Boss本地服务请求失败"); - error.code = response?.errorType || "LOCAL_API_ERROR"; + error.code = response?.data?.errorCode || response?.errorType || "LOCAL_API_ERROR"; error.httpStatus = response?.httpStatus; throw error; } @@ -3091,6 +3429,7 @@ pageTabId: message.pageTabId, payload: { platform: "boss", + profileId: normalizeProfileId(message?.profileId), type, message: text, timestamp: Date.now(), @@ -3109,6 +3448,7 @@ const cursorState = resolveKeywordCursor(message, cursorKeywords, hasExplicitIndex); return { ...message, + profileId: normalizeProfileId(message?.profileId), config: { ...config, keywords, searchJobLimit }, keywords, cursorKeywords, @@ -3116,9 +3456,11 @@ type: "BOSS_SCAN_START", currentIndex: cursorState.currentIndex, totalSaved: Number(message.totalSaved || 0), + totalRestored: Number(message.totalRestored || 0), phase: message.phase || "searching", detailIndex: Number(message.detailIndex || 0), jobs: Array.isArray(message.jobs) ? message.jobs : [], + historicalJobs: Array.isArray(message.historicalJobs) ? message.historicalJobs : [], aiKeywordsLoaded: Boolean(message.aiKeywordsLoaded), autoDeliver: isAutoDeliverEnabled(message), startedAt: message.startedAt || Date.now(), @@ -3295,6 +3637,7 @@ const searchJobLimit = normalizeSearchJobLimit(message?.searchJobLimit ?? config.searchJobLimit); return stableKey({ platform: "boss", + profileId: normalizeProfileId(message?.profileId), keywords: uniqueStrings(keywords || message?.cursorKeywords || []), cityCode: normalizedList(config.cityCode), jobType: compact(config.jobType || ""), @@ -3379,6 +3722,7 @@ } function isFreshScanTask(task) { + if (!normalizeProfileId(task?.profileId)) return false; if (SCAN_SUPPORT.isFreshTask) { return SCAN_SUPPORT.isFreshTask(task, Date.now(), SCAN_TASK_TTL_MS); } @@ -3547,6 +3891,11 @@ return String(value || "").trim(); } + function normalizeProfileId(value) { + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null; + } + function writeScanStatus(nextStatus) { const previous = readScanStatus(); const merged = typeof SCAN_SUPPORT.mergeScanStatus === "function" @@ -3900,6 +4249,40 @@ } } + function bossSearchNavigationAttempts(task, navigationKey) { + return SCAN_SUPPORT.bossSearchNavigationAttempts + ? SCAN_SUPPORT.bossSearchNavigationAttempts(task, navigationKey) + : task?.navigationKey === navigationKey + ? Math.max(0, Math.floor(Number(task?.navigationAttempts) || 0)) + : 0; + } + + function beginBossSearchCollection(task) { + if (SCAN_SUPPORT.beginBossSearchCollection) return SCAN_SUPPORT.beginBossSearchCollection(task); + return { + ...(task || {}), + phase: "collecting", + navigationAttempts: Math.max(0, Math.floor(Number(task?.navigationAttempts) || 0)), + navigationStartedAt: 0 + }; + } + + function retryBossSearchNavigation(task) { + if (SCAN_SUPPORT.retryBossSearchNavigation) return SCAN_SUPPORT.retryBossSearchNavigation(task); + return { + ...(task || {}), + phase: "searching", + navigationAttempts: Math.max(0, Math.floor(Number(task?.navigationAttempts) || 0)) + 1, + navigationStartedAt: Date.now() + }; + } + + function isBossSearchNavigationExhausted(task) { + return SCAN_SUPPORT.isBossSearchNavigationExhausted + ? SCAN_SUPPORT.isBossSearchNavigationExhausted(task, SEARCH_NAVIGATION_MAX_ATTEMPTS) + : Math.max(0, Math.floor(Number(task?.navigationAttempts) || 0)) >= SEARCH_NAVIGATION_MAX_ATTEMPTS; + } + function isSameSearchUrl(left, right) { try { const leftUrl = new URL(left, window.location.origin); diff --git a/chrome-extension/boss-scan-support.js b/chrome-extension/boss-scan-support.js index 225bb5e..b4596d9 100644 --- a/chrome-extension/boss-scan-support.js +++ b/chrome-extension/boss-scan-support.js @@ -1,8 +1,10 @@ (function (root) { - const SUPPORT_VERSION = "2026-07-18-boss-security-resume-fix"; + const SUPPORT_VERSION = "2026-09-03-boss-navigation-loop-fix"; if (root.GetJobsBossScanSupport?.version === SUPPORT_VERSION) return; const DEFAULT_TASK_TTL_MS = 24 * 60 * 60 * 1000; + const DEEP_COLLECTION_MAX_DURATION_MS = 180 * 1000; + const DEEP_COLLECTION_MAX_STAGNANT_ROUNDS = 5; const DEGREE_NAME_BY_CODE = Object.freeze({ "0": "不限", "209": "初中及以下", @@ -18,6 +20,30 @@ return DEGREE_NAME_BY_CODE[String(value ?? "").trim()] || ""; } + function deepCollectionBounds(targetValue) { + const target = Math.max(1, Math.min(200, Math.floor(Number(targetValue) || 20))); + return { + target, + maxRounds: 30, + maxCandidates: 500, + maxDurationMs: DEEP_COLLECTION_MAX_DURATION_MS, + maxStagnantRounds: DEEP_COLLECTION_MAX_STAGNANT_ROUNDS + }; + } + + function deepCollectionStopReason(state = {}) { + const bounds = deepCollectionBounds(state.target); + if (state.stopped) return "stopped"; + if (Number(state.fresh || 0) >= bounds.target) return "target_reached"; + if (state.blocked) return "blocked"; + if (state.platformExhausted) return "platform_exhausted"; + if (Number(state.elapsedMs || 0) >= bounds.maxDurationMs) return "timeout_safety_cap"; + if (Number(state.candidates || 0) >= bounds.maxCandidates) return "candidate_safety_cap"; + if (Number(state.stagnantRounds || 0) >= bounds.maxStagnantRounds) return "stagnation_safety_cap"; + if (Number(state.rounds || 0) >= bounds.maxRounds) return "round_safety_cap"; + return ""; + } + function isBossSecurityInstructionText(value) { const text = String(value || "").replace(/\s+/g, " ").trim(); if (!text) return false; @@ -87,6 +113,33 @@ return true; } + function bossSearchNavigationAttempts(task, navigationKey) { + if (!task || task.navigationKey !== navigationKey) return 0; + return Math.max(0, Math.floor(Number(task.navigationAttempts) || 0)); + } + + function beginBossSearchCollection(task) { + return { + ...(task || {}), + phase: "collecting", + navigationAttempts: Math.max(0, Math.floor(Number(task?.navigationAttempts) || 0)), + navigationStartedAt: 0 + }; + } + + function retryBossSearchNavigation(task, now = Date.now()) { + return { + ...(task || {}), + phase: "searching", + navigationAttempts: Math.max(0, Math.floor(Number(task?.navigationAttempts) || 0)) + 1, + navigationStartedAt: Number(now) + }; + } + + function isBossSearchNavigationExhausted(task, maxAttempts = 5) { + return Math.max(0, Math.floor(Number(task?.navigationAttempts) || 0)) >= Math.max(1, Math.floor(Number(maxAttempts) || 5)); + } + function normalizeRunId(value) { return String(value || "").trim(); } @@ -162,13 +215,65 @@ return Math.min(parsed, total); } + function buildFailedSubmitCheckpoint(task, batchIndex, submitSummary, lastSubmitError, now = Date.now()) { + return { + ...(task || {}), + phase: "submitting", + submitBatchIndex: Math.max(0, Math.floor(Number(batchIndex) || 0)), + submitSummary: { ...(submitSummary || {}) }, + lastSubmitError: { + ...(lastSubmitError || {}), + failedAt: Number(lastSubmitError?.failedAt || now) + } + }; + } + + function partitionDedupeJobs(jobs, items) { + const jobList = Array.isArray(jobs) ? jobs : []; + const decisionItems = Array.isArray(items) ? items : []; + const jobKeys = jobList.map(dedupeDecisionKey); + const itemKeys = decisionItems.map(dedupeDecisionKey); + const allowedActions = new Set(["NEW", "ENRICH", "SKIP"]); + const uniqueJobKeys = new Set(jobKeys); + const uniqueItemKeys = new Set(itemKeys); + const complete = jobKeys.every((key) => key && uniqueItemKeys.has(key)) + && itemKeys.every((key) => key && uniqueJobKeys.has(key)) + && uniqueJobKeys.size === jobKeys.length + && uniqueItemKeys.size === itemKeys.length + && itemKeys.length === jobKeys.length + && decisionItems.every((item) => allowedActions.has(String(item?.action || "").trim().toUpperCase())); + if (!complete) throw new Error("Boss查重接口未完整返回全部岗位决策"); + + const decisionByKey = new Map(decisionItems.map((item) => [ + dedupeDecisionKey(item), + String(item.action).trim().toUpperCase() + ])); + const detailJobs = []; + const historicalJobs = []; + jobList.forEach((job) => { + const action = decisionByKey.get(dedupeDecisionKey(job)); + if (action === "SKIP") { + historicalJobs.push({ ...job, collectionAction: "REUSE_HISTORY" }); + } else { + detailJobs.push({ ...job, collectionAction: "ANALYZE" }); + } + }); + return { detailJobs, historicalJobs }; + } + + function dedupeDecisionKey(item) { + const id = compact(item?.id || extractBossJobId(item?.url)); + if (id) return `id:${id}`; + return `ct:${compact(item?.company).toLowerCase()}::${compact(item?.title).toLowerCase()}`; + } + function classifyLocalApiFailure(error) { const explicit = String(error?.code || ""); if (explicit) return explicit; const text = String(error?.message || error || ""); if (/Invalid CORS request|cors/i.test(text)) return "CORS_REJECTED"; if (/超时|timeout|abort/i.test(text)) return "LOCAL_API_TIMEOUT"; - if (/无法连接|Failed to fetch|NetworkError|本地服务请求失败|6866端口/i.test(text)) { + if (/无法连接|Failed to fetch|NetworkError|本地服务请求失败|(?:8888|6866)端口/i.test(text)) { return "LOCAL_SERVICE_UNAVAILABLE"; } return "LOCAL_API_ERROR"; @@ -181,6 +286,10 @@ root.GetJobsBossScanSupport = Object.freeze({ version: SUPPORT_VERSION, DEFAULT_TASK_TTL_MS, + DEEP_COLLECTION_MAX_DURATION_MS, + DEEP_COLLECTION_MAX_STAGNANT_ROUNDS, + deepCollectionBounds, + deepCollectionStopReason, DEGREE_NAME_BY_CODE, degreeNameForCode, isBossSecurityInstructionText, @@ -190,6 +299,10 @@ isFreshTask, sameScanRun, canResumeScanTask, + bossSearchNavigationAttempts, + beginBossSearchCollection, + retryBossSearchNavigation, + isBossSearchNavigationExhausted, normalizeBossJobUrl, extractBossJobId, isBossJobDetailUrl, @@ -197,6 +310,8 @@ isNonJobNavigationTitle, classifyBossDetailNavigation, normalizeBatchIndex, + buildFailedSubmitCheckpoint, + partitionDedupeJobs, classifyLocalApiFailure }); })(typeof window !== "undefined" ? window : globalThis); diff --git a/chrome-extension/manifest.json b/chrome-extension/manifest.json index 5f91985..c608da1 100644 --- a/chrome-extension/manifest.json +++ b/chrome-extension/manifest.json @@ -1,7 +1,8 @@ { "manifest_version": 3, "name": "投递牛马 Chrome Bridge", - "version": "1.3.0", + "version": "1.4.4", + "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzzdIlNVOv76Y/cSWrjD5Tg2Vlsha8yWHzsn46PBsg724/2dftOUzIIr2n70VRaRgGwEd8FjO/Y768Ori443zF4pQpWvuxXxm05YO25ILQ/+aJLmUycAEdWbkdhcagr4YXnXJdYlSCGSAToSQBjk+owQOdlBLQn5wofPoshrqayoJjRQ5aAUj1SuSlnNv9iimle8GMA1IaA1l5rw6K/chfcgwMTg6HxRAIoludt5JGbIBryi2Lu1hOJRMaDnL7A57ofBnn3qx3H2HIGWGkkTW9EMkls0XMXwx8+mJVIj5HSYl0EeuCvEoTa1W3i1CbOf3kY2yCPKS3Qz3lOvJiwJ4ZQIDAQAB", "description": "Use the signed-in Chrome tabs to scan jobs and confirm deliveries for 投递牛马.", "icons": { "16": "icons/icon16.png", @@ -13,8 +14,6 @@ "host_permissions": [ "http://localhost:6866/*", "http://127.0.0.1:6866/*", - "http://localhost:8888/*", - "http://127.0.0.1:8888/*", "https://www.zhipin.com/*", "https://*.zhipin.com/*", "https://www.zhaopin.com/*", diff --git a/chrome-extension/page-bridge.js b/chrome-extension/page-bridge.js index 6327f12..c478442 100644 --- a/chrome-extension/page-bridge.js +++ b/chrome-extension/page-bridge.js @@ -20,6 +20,7 @@ "ZHILIAN_SCAN_STATUS", "ZHILIAN_SCAN_START", "ZHILIAN_SCAN_STOP", + "ZHILIAN_PAGE_STATUS", "ZHILIAN_DELIVER_ONE", "ZHILIAN_DELIVER_BATCH" ]); diff --git a/chrome-extension/tests/background-tab-routing.test.cjs b/chrome-extension/tests/background-tab-routing.test.cjs index 51d007b..3b6b8e4 100644 --- a/chrome-extension/tests/background-tab-routing.test.cjs +++ b/chrome-extension/tests/background-tab-routing.test.cjs @@ -16,12 +16,26 @@ function readContentVersion(file) { const BOSS_CONTENT_VERSION = readContentVersion("boss-content.js"); const ZHILIAN_CONTENT_VERSION = readContentVersion("zhilian-content.js"); +function jsonResponse(body, { ok = true, status = 200 } = {}) { + return { + ok, + status, + headers: { + get(name) { + return String(name).toLowerCase() === "content-type" ? "application/json; charset=utf-8" : null; + } + }, + async text() { return JSON.stringify(body); } + }; +} + function loadBackground({ tabs, statuses = {}, contentReady = true, bossContentVersion = BOSS_CONTENT_VERSION, zhilianContentVersion = ZHILIAN_CONTENT_VERSION, + bossDeliveryResponses = [], fetchImpl = async () => { throw new Error("fetch should not be called"); } @@ -80,6 +94,11 @@ function loadBackground({ if (message.type === "BOSS_SCAN_STATUS" || message.type === "ZHILIAN_SCAN_STATUS_V2") { return statuses[tabId] || { success: true, isRunning: false, hasStoredTask: false, stage: "idle" }; } + if (message.type === "BOSS_DELIVER_CURRENT_V2") { + const response = bossDeliveryResponses.shift(); + if (response instanceof Error) throw response; + return response || { success: false, outcome: "UNKNOWN", evidence: "NO_CONFIRMATION" }; + } return { success: true }; } }, @@ -169,6 +188,7 @@ test("rejects empty Zhilian keywords before creating or starting a scan", async const response = await context.handlePageMessage({ type: "ZHILIAN_SCAN_START", platform: "zhilian", + profileId: 4, config: { keywords: "[]" } }, { tab: { id: 20, url: "http://localhost:6866/zhilian" } }); @@ -176,6 +196,25 @@ test("rejects empty Zhilian keywords before creating or starting a scan", async assert.equal(response.message, "请至少填写一个搜索关键词"); }); +test("requires a profile for every scan lifecycle request before touching tabs", async () => { + const { context, tabList } = loadBackground({ tabs: [] }); + const sender = { tab: { id: 20, url: "http://localhost:6866/boss" } }; + + for (const [type, platform] of [ + ["BOSS_SCAN_START", "boss"], + ["BOSS_SCAN_STATUS", "boss"], + ["BOSS_SCAN_STOP", "boss"], + ["ZHILIAN_SCAN_START", "zhilian"], + ["ZHILIAN_SCAN_STATUS", "zhilian"], + ["ZHILIAN_SCAN_STOP", "zhilian"] + ]) { + const response = await context.handlePageMessage({ type, platform, config: { keywords: ["Java"] } }, sender); + assert.equal(response.success, false); + assert.equal(response.errorCode, "PROFILE_REQUIRED"); + } + assert.equal(tabList.length, 0); +}); + test("injects all Zhilian dependencies when the content script is missing", async () => { const { context, executedScripts } = loadBackground({ tabs: [{ id: 1, windowId: 1, url: "https://www.zhaopin.com/", status: "complete" }], @@ -253,11 +292,7 @@ test("allows Zhilian job submission through the fixed local API route", async () tabs: [], fetchImpl: async (url, options) => { requests.push({ url, options }); - return { - ok: true, - status: 200, - async text() { return JSON.stringify({ success: true, saved: 1 }); } - }; + return jsonResponse({ success: true, received: 1, saved: 1, queued: 1 }); } }); @@ -265,7 +300,7 @@ test("allows Zhilian job submission through the fixed local API route", async () source: "GET_JOBS_ZHILIAN_CONTENT", type: "ZHILIAN_LOCAL_API", operation: "chrome-jobs", - body: { runId: "run-1", keyword: "Java", jobs: [{ title: "Java工程师" }] } + body: { profileId: 4, runId: "run-1", keyword: "Java", jobs: [{ title: "Java工程师" }] } }, { tab: { id: 8, url: "https://www.zhaopin.com/jobdetail/demo.htm" } }); @@ -283,7 +318,7 @@ test("allows numeric Zhilian delivery result IDs and rejects invalid or unknown tabs: [], fetchImpl: async (url) => { urls.push(url); - return { ok: true, status: 200, async text() { return '{"success":true}'; } }; + return jsonResponse({ success: true, accepted: true, state: "CONFIRMED" }); } }); const sender = { tab: { id: 9, url: "https://www.zhaopin.com/jobdetail/demo.htm" } }; @@ -293,7 +328,7 @@ test("allows numeric Zhilian delivery result IDs and rejects invalid or unknown type: "ZHILIAN_LOCAL_API", operation: "delivery-result", params: { id: 123 }, - body: { success: true } + body: { outcome: "CONFIRMED", evidence: "PLATFORM_STATUS_TEXT" } }, sender); const invalidId = await dispatchRuntimeMessage(runtimeMessageListener, { source: "GET_JOBS_ZHILIAN_CONTENT", @@ -320,11 +355,7 @@ test("allows numeric Zhilian delivery result IDs and rejects invalid or unknown test("treats HTTP 200 business rejection as a failed local API request", async () => { const { context } = loadBackground({ tabs: [], - fetchImpl: async () => ({ - ok: true, - status: 200, - async text() { return JSON.stringify({ success: false, message: "状态已变化" }); } - }) + fetchImpl: async () => jsonResponse({ success: false, message: "状态已变化" }) }); const result = await context.requestLocalApi("/api/boss/jobs/1/delivery-result", { @@ -338,13 +369,31 @@ test("treats HTTP 200 business rejection as a failed local API request", async ( assert.equal(result.message, "状态已变化"); }); +test("preserves backend profile errors and rejects unscoped job submission locally", async () => { + let fetchCalls = 0; + const { context } = loadBackground({ + tabs: [], + fetchImpl: async () => { + fetchCalls += 1; + return jsonResponse({ success: false, errorCode: "PROFILE_CHANGED", message: "档案已切换" }, { ok: false, status: 409 }); + } + }); + + const missing = await context.handleBossLocalApiRequest({ operation: "chrome-jobs", body: { jobs: [] } }); + const changed = await context.handleBossLocalApiRequest({ operation: "chrome-jobs", body: { profileId: 4, jobs: [] } }); + + assert.equal(missing.errorCode, "PROFILE_REQUIRED"); + assert.equal(fetchCalls, 1); + assert.equal(changed.errorType, "PROFILE_CHANGED"); +}); + test("records an empty Boss chat-page response as unknown instead of confirmed", async () => { const requests = []; const { context } = loadBackground({ tabs: [{ id: 7, windowId: 1, url: "https://www.zhipin.com/web/geek/chat", status: "complete" }], fetchImpl: async (url, options) => { requests.push({ url, body: JSON.parse(options.body) }); - return { ok: true, status: 200, async text() { return '{"success":true}'; } }; + return jsonResponse({ success: true, accepted: true, state: "UNKNOWN" }); } }); @@ -376,11 +425,10 @@ test("does not upgrade legacy success booleans to confirmed without explicit evi test("surfaces delivery-result persistence failure instead of reporting a stored outcome", async () => { const { context } = loadBackground({ tabs: [], - fetchImpl: async () => ({ - ok: false, - status: 500, - async text() { return JSON.stringify({ success: false, message: "database unavailable" }); } - }) + fetchImpl: async () => jsonResponse( + { success: false, message: "database unavailable" }, + { ok: false, status: 500 } + ) }); await assert.rejects( @@ -420,8 +468,8 @@ test("keeps Boss and Zhilian scan ownership when both start together", async () const { context, storage } = loadBackground({ tabs: [] }); await Promise.all([ - context.registerScanSession("boss", 1, "boss-run", 10), - context.registerScanSession("zhilian", 2, "zhilian-run", 10) + context.registerScanSession("boss", 1, "boss-run", 10, "", 4), + context.registerScanSession("zhilian", 2, "zhilian-run", 10, "", 4) ]); const sessions = storage.__GET_JOBS_PLATFORM_SCAN_SESSIONS__; @@ -429,6 +477,127 @@ test("keeps Boss and Zhilian scan ownership when both start together", async () assert.equal(sessions.zhilian.tabId, 2); }); +test("halts a Boss batch after the first unknown result and leaves later jobs untouched", async () => { + const requests = []; + const tasks = [1, 2, 3].map((id) => ({ + id, + requestKey: `request-${id}`, + url: `https://www.zhipin.com/job_detail/job-${id}.html`, + greeting: `岗位 ${id} 的精确话术` + })); + const { context, sentMessages } = loadBackground({ + tabs: [{ id: 7, windowId: 1, url: tasks[0].url, status: "complete" }], + bossDeliveryResponses: [{ + success: false, + outcome: "UNKNOWN", + evidence: "CHAT_SURFACE_ONLY", + greetingOutcome: "UNKNOWN", + greetingEvidence: "GREETING_RENDER_UNCONFIRMED", + message: "点击发送后未检测到精确话术" + }], + fetchImpl: async (url, options) => { + const body = JSON.parse(options.body); + requests.push({ url, body }); + return jsonResponse({ success: true, accepted: true, state: body.outcome }); + } + }); + + const result = await context.handleBossDeliver( + { id: 7, windowId: 1, url: tasks[0].url, status: "complete" }, + { hosts: ["zhipin.com"], contentScript: "boss-content.js" }, + { type: "BOSS_DELIVER_BATCH", tasks }, + null + ); + + assert.equal(result.success, false); + assert.equal(result.halted, true); + assert.equal(result.haltedJobId, 1); + assert.equal(result.unknownCount, 1); + assert.equal(result.failedCount, 0); + assert.equal(result.unprocessedCount, 2); + assert.equal(result.results.length, 3); + assert.deepEqual(Array.from(result.results.slice(1), (item) => item.skipped), [true, true]); + assert.equal(sentMessages.filter((entry) => entry.message.type === "BOSS_DELIVER_CURRENT_V2").length, 1); + assert.equal(requests.length, 3); + assert.equal(requests[0].body.outcome, "UNKNOWN"); + assert.deepEqual(requests.slice(1).map((entry) => entry.body.greetingOutcome), ["NOT_SENT", "NOT_SENT"]); + assert.deepEqual(requests.slice(1).map((entry) => entry.body.evidence), ["BATCH_HALTED_BEFORE_ACTION", "BATCH_HALTED_BEFORE_ACTION"]); +}); + +test("preserves Boss existing-conversation and not-sent evidence", async () => { + const requests = []; + const { context } = loadBackground({ + tabs: [], + fetchImpl: async (url, options) => { + requests.push({ url, body: JSON.parse(options.body) }); + return jsonResponse({ success: true, accepted: true, state: "UNKNOWN" }); + } + }); + + const result = await context.recordBossDeliveryResponse( + { id: 51, requestKey: "boss-existing" }, + { + success: false, + outcome: "UNKNOWN", + evidence: "EXISTING_CONVERSATION", + greetingOutcome: "NOT_SENT", + greetingEvidence: "ALREADY_CONTACTED", + message: "已有沟通,本次未补发" + } + ); + + assert.equal(result.outcome, "UNKNOWN"); + assert.equal(result.evidence, "EXISTING_CONVERSATION"); + assert.equal(result.greetingOutcome, "NOT_SENT"); + assert.equal(result.greetingEvidence, "ALREADY_CONTACTED"); + assert.equal(requests[0].body.evidence, "EXISTING_CONVERSATION"); + assert.equal(requests[0].body.greetingOutcome, "NOT_SENT"); + assert.equal(requests[0].body.greetingEvidence, "ALREADY_CONTACTED"); +}); + +test("profile switch and legacy sessions invalidate shared checkpoints", async () => { + const { context, storage } = loadBackground({ + tabs: [ + { id: 1, windowId: 1, url: "https://www.zhipin.com/job_detail/old.html", status: "complete", lastAccessed: 10 }, + { id: 2, windowId: 1, url: "https://www.zhipin.com/web/geek/job", status: "complete", lastAccessed: 20 } + ], + statuses: { + 1: { success: true, isRunning: true, hasStoredTask: true, stage: "details", runId: "old", profileId: 3 } + } + }); + await context.registerScanSession("boss", 1, "old", 10, "", 3); + storage.__GET_JOBS_BOSS_SHARED_SCAN_TASK__ = { runId: "old", profileId: 3 }; + + const selected = await context.findScanPlatformTab("boss", "https://www.zhipin.com/web/geek/job", "new", 4); + + assert.equal(selected.id, 2); + assert.equal(storage.__GET_JOBS_PLATFORM_SCAN_SESSIONS__.boss, undefined); + assert.equal(storage.__GET_JOBS_BOSS_SHARED_SCAN_TASK__, undefined); + + storage.__GET_JOBS_PLATFORM_SCAN_SESSIONS__ = { + boss: { platform: "boss", tabId: 1, runId: "legacy", updatedAt: Date.now() } + }; + storage.__GET_JOBS_BOSS_SHARED_SCAN_TASK__ = { runId: "legacy" }; + assert.equal(await context.readScanSession("boss"), null); + assert.equal(storage.__GET_JOBS_BOSS_SHARED_SCAN_TASK__, undefined); +}); + +test("drops delayed scan events from another profile", async () => { + const { context, sentMessages } = loadBackground({ + tabs: [ + { id: 1, windowId: 1, url: "https://www.zhipin.com/web/geek/job", status: "complete" }, + { id: 10, windowId: 1, url: "http://localhost:6866/boss", status: "complete" } + ] + }); + await context.registerScanSession("boss", 1, "current", 10, "", 4); + + await context.forwardPlatformEvent({ + payload: { platform: "boss", operation: "scan", stage: "details", profileId: 3 } + }, { tab: { id: 1, url: "https://www.zhipin.com/web/geek/job" } }); + + assert.equal(sentMessages.filter((entry) => entry.message.type === "GET_JOBS_EXTENSION_EVENT").length, 0); +}); + test("uses a separate Boss tab for delivery while scanning", async () => { const { context } = loadBackground({ tabs: [ @@ -436,11 +605,11 @@ test("uses a separate Boss tab for delivery while scanning", async () => { { id: 2, windowId: 1, url: "https://www.zhipin.com/", status: "complete", lastAccessed: 20 } ], statuses: { - 1: { success: true, isRunning: true, hasStoredTask: true, stage: "details", runId: "boss-run" }, + 1: { success: true, isRunning: true, hasStoredTask: true, stage: "details", runId: "boss-run", profileId: 4 }, 2: { success: true, isRunning: false, hasStoredTask: false, stage: "idle" } } }); - await context.registerScanSession("boss", 1, "boss-run", 10); + await context.registerScanSession("boss", 1, "boss-run", 10, "", 4); const deliveryTab = await context.findDeliveryPlatformTab("boss", "https://www.zhipin.com/job_detail/demo.html"); @@ -454,9 +623,9 @@ test("status lookup keeps using the registered scan tab after another tab is cli { id: 2, windowId: 1, url: "https://www.zhipin.com/job_detail/other.html", status: "complete", lastAccessed: 999 } ] }); - await context.registerScanSession("boss", 1, "boss-run", 10); + await context.registerScanSession("boss", 1, "boss-run", 10, "", 4); - const scanTab = await context.findRegisteredOrRunningScanTab("boss"); + const scanTab = await context.findRegisteredOrRunningScanTab("boss", 4); const owner = await context.handleScanOwnerStatus("boss", { tab: { id: 2 } }); assert.equal(scanTab.id, 1); @@ -470,17 +639,18 @@ test("new Boss scan run does not keep using a registered stale scan tab", async { id: 2, windowId: 1, url: "https://www.zhipin.com/web/geek/job", status: "complete", lastAccessed: 20 } ], statuses: { - 1: { success: true, isRunning: true, hasStoredTask: true, stage: "details", runId: "boss-old-run" }, + 1: { success: true, isRunning: true, hasStoredTask: true, stage: "details", runId: "boss-old-run", profileId: 4 }, 2: { success: true, isRunning: false, hasStoredTask: false, stage: "idle" } } }); - await context.registerScanSession("boss", 1, "boss-old-run", 10); - storage.__GET_JOBS_BOSS_SHARED_SCAN_TASK__ = { runId: "boss-old-run" }; + await context.registerScanSession("boss", 1, "boss-old-run", 10, "", 4); + storage.__GET_JOBS_BOSS_SHARED_SCAN_TASK__ = { runId: "boss-old-run", profileId: 4 }; const scanTab = await context.findScanPlatformTab( "boss", "https://www.zhipin.com/web/geek/job?city=101280600&query=Java", - "boss-new-run" + "boss-new-run", + 4 ); assert.equal(scanTab.id, 2); @@ -545,11 +715,11 @@ test("Boss stop clears registered scan session and shared checkpoint", async () { id: 1, windowId: 1, url: "https://www.zhipin.com/job_detail/old.html", status: "complete" } ] }); - await context.registerScanSession("boss", 1, "boss-run", 10); - storage.__GET_JOBS_BOSS_SHARED_SCAN_TASK__ = { runId: "boss-run" }; + await context.registerScanSession("boss", 1, "boss-run", 10, "", 4); + storage.__GET_JOBS_BOSS_SHARED_SCAN_TASK__ = { runId: "boss-run", profileId: 4 }; storage.__GET_JOBS_BOSS_SHARED_SCAN_CANCEL__ = { runId: "boss-run", requested: true }; - const response = await context.sendPassiveStop(1, "boss", { type: "BOSS_SCAN_STOP", runId: "boss-run" }, 10); + const response = await context.sendPassiveStop(1, "boss", { type: "BOSS_SCAN_STOP", runId: "boss-run", profileId: 4 }, 10); assert.equal(response.success, true); assert.equal(storage.__GET_JOBS_PLATFORM_SCAN_SESSIONS__.boss, undefined); @@ -581,7 +751,7 @@ test("reports navigation as running while the registered scan content script rel { id: 1, windowId: 1, url: "https://www.zhipin.com/job_detail/demo.html", status: "loading" } ] }); - await context.registerScanSession("boss", 1, "boss-run", 10); + await context.registerScanSession("boss", 1, "boss-run", 10, "", 4); const status = await context.buildRegisteredNavigationStatus("boss", 1); @@ -589,6 +759,7 @@ test("reports navigation as running while the registered scan content script rel assert.equal(status.hasStoredTask, true); assert.equal(status.stage, "navigating"); assert.equal(status.runId, "boss-run"); + assert.equal(status.profileId, 4); }); test("runs only the fixed Boss search API request in the page MAIN world", async () => { diff --git a/chrome-extension/tests/boss-api-collector.test.cjs b/chrome-extension/tests/boss-api-collector.test.cjs index 2fea66f..9516b12 100644 --- a/chrome-extension/tests/boss-api-collector.test.cjs +++ b/chrome-extension/tests/boss-api-collector.test.cjs @@ -9,6 +9,7 @@ function loadCollector() { window.window = window; const context = vm.createContext({ window, + URL, URLSearchParams, console, Set diff --git a/chrome-extension/tests/boss-scan-support.test.cjs b/chrome-extension/tests/boss-scan-support.test.cjs index 51be32b..21597c4 100644 --- a/chrome-extension/tests/boss-scan-support.test.cjs +++ b/chrome-extension/tests/boss-scan-support.test.cjs @@ -12,6 +12,24 @@ function loadSupport() { return window.GetJobsBossScanSupport; } +test("uses the agreed Boss deep collection safety bounds", () => { + const support = loadSupport(); + assert.deepEqual( + JSON.parse(JSON.stringify(support.deepCollectionBounds(40))), + { target: 40, maxRounds: 30, maxCandidates: 500, maxDurationMs: 180000, maxStagnantRounds: 5 } + ); + assert.equal(support.deepCollectionStopReason({ target: 40, fresh: 40 }), "target_reached"); + assert.equal(support.deepCollectionStopReason({ target: 40, fresh: 12, stagnantRounds: 4 }), ""); + assert.equal(support.deepCollectionStopReason({ target: 40, fresh: 12, stagnantRounds: 5 }), "stagnation_safety_cap"); + assert.equal(support.deepCollectionStopReason({ target: 40, fresh: 12, elapsedMs: 180000 }), "timeout_safety_cap"); + assert.equal(support.deepCollectionStopReason({ target: 40, fresh: 12, platformExhausted: true }), "platform_exhausted"); +}); + +test("does not auto append AI keywords after a Boss scan", () => { + const source = fs.readFileSync(path.resolve(__dirname, "..", "boss-content.js"), "utf8"); + assert.doesNotMatch(source, /await appendAiKeywords\(task, keywords\)/); +}); + test("keeps an unfinished Boss checkpoint for 24 hours", () => { const support = loadSupport(); const now = Date.now(); @@ -246,3 +264,128 @@ test("classifies CORS and local service failures for actionable diagnostics", () "LOCAL_SERVICE_UNAVAILABLE" ); }); + +test("preserves Boss search attempts while collecting and increments after route drift", () => { + const support = loadSupport(); + assert.equal(support.version, "2026-09-03-boss-navigation-loop-fix"); + const collecting = support.beginBossSearchCollection({ + type: "BOSS_SCAN_START", + navigationKey: "AIGC产品运营::101280600", + navigationAttempts: 3, + navigationStartedAt: 123 + }); + const retrying = support.retryBossSearchNavigation(collecting, 456); + + assert.equal(collecting.phase, "collecting"); + assert.equal(collecting.navigationAttempts, 3); + assert.equal(collecting.navigationStartedAt, 0); + assert.equal(retrying.phase, "searching"); + assert.equal(retrying.navigationAttempts, 4); + assert.equal(retrying.navigationStartedAt, 456); +}); + +test("stops repeated Boss search redirects at five attempts and resets for the next keyword", () => { + const support = loadSupport(); + const firstNavigationKey = "AIGC产品运营::101280600"; + let task = { + type: "BOSS_SCAN_START", + navigationKey: firstNavigationKey, + navigationAttempts: 0 + }; + + for (let attempt = 1; attempt <= 5; attempt += 1) { + task = support.retryBossSearchNavigation(task, attempt); + assert.equal(task.navigationAttempts, attempt); + assert.equal(support.isBossSearchNavigationExhausted(task, 5), attempt === 5); + } + + assert.equal(support.bossSearchNavigationAttempts(task, firstNavigationKey), 5); + assert.equal(support.bossSearchNavigationAttempts(task, "AI产品运营::101280600"), 0); +}); + +test("keeps singular and plural Boss search paths compatible while generating the canonical plural path", () => { + const content = fs.readFileSync(path.resolve(__dirname, "..", "boss-content.js"), "utf8"); + + assert.match(content, /return `https:\/\/www\.zhipin\.com\/web\/geek\/jobs\?\$\{params\.toString\(\)\}`/); + assert.match(content, /pathname === "\/web\/geek\/job" \|\| pathname === "\/web\/geek\/jobs"/); +}); + +test("rechecks the Boss search URL after waiting for cards before collecting", () => { + const content = fs.readFileSync(path.resolve(__dirname, "..", "boss-content.js"), "utf8"); + const waitIndex = content.indexOf("const waitState = await waitForJobCards();"); + const redirectGuardIndex = content.indexOf("if (!isCurrentSearchPage(keyword, city, url))", waitIndex); + const collectIndex = content.indexOf("const collectResult = collectJobs", waitIndex); + + assert.ok(waitIndex >= 0); + assert.ok(redirectGuardIndex > waitIndex); + assert.ok(collectIndex > redirectGuardIndex); + assert.match(content.slice(redirectGuardIndex, collectIndex), /retryBossSearchNavigation\(collectingTaskState\)/); + assert.match(content.slice(redirectGuardIndex, collectIndex), /stopSearchNavigationFailure\(collectingTaskState, url\)/); +}); + +test("partitions all historical Boss jobs for reuse without detail collection", () => { + const support = loadSupport(); + const jobs = [ + { id: "history-1", company: "甲公司", title: "产品经理", url: "https://www.zhipin.com/job_detail/history-1.html" }, + { id: "history-2", company: "乙公司", title: "运营经理", url: "https://www.zhipin.com/job_detail/history-2.html" }, + ]; + const partition = support.partitionDedupeJobs(jobs, jobs.map((job) => ({ ...job, duplicate: true, action: "SKIP" }))); + + assert.equal(partition.detailJobs.length, 0); + assert.equal(partition.historicalJobs.length, 2); + assert.deepEqual(Array.from(partition.historicalJobs, (job) => job.collectionAction), ["REUSE_HISTORY", "REUSE_HISTORY"]); +}); + +test("keeps new and enrich jobs in details while marking only skips as historical", () => { + const support = loadSupport(); + const jobs = [ + { id: "new-1", company: "甲公司", title: "新岗位", url: "https://www.zhipin.com/job_detail/new-1.html" }, + { id: "enrich-1", company: "乙公司", title: "补全岗位", url: "https://www.zhipin.com/job_detail/enrich-1.html" }, + { id: "history-1", company: "丙公司", title: "历史岗位", url: "https://www.zhipin.com/job_detail/history-1.html" }, + ]; + const partition = support.partitionDedupeJobs(jobs, [ + { ...jobs[0], action: "NEW" }, + { ...jobs[1], action: "ENRICH", duplicate: true }, + { ...jobs[2], action: "SKIP", duplicate: true }, + ]); + + assert.deepEqual(Array.from(partition.detailJobs, (job) => job.id), ["new-1", "enrich-1"]); + assert.deepEqual(Array.from(partition.historicalJobs, (job) => job.id), ["history-1"]); + assert.ok(partition.detailJobs.every((job) => job.collectionAction === "ANALYZE")); +}); + +test("rejects incomplete or invalid Boss dedupe decisions instead of treating them as new", () => { + const support = loadSupport(); + const jobs = [ + { id: "job-1", company: "甲公司", title: "岗位一" }, + { id: "job-2", company: "乙公司", title: "岗位二" }, + ]; + + assert.throws( + () => support.partitionDedupeJobs(jobs, [{ ...jobs[0], action: "NEW" }]), + /未完整返回/ + ); + assert.throws( + () => support.partitionDedupeJobs(jobs, jobs.map((job) => ({ ...job, action: "UNKNOWN" }))), + /未完整返回/ + ); +}); + +test("keeps the failed Boss submit batch index and prior success summary for resume", () => { + const support = loadSupport(); + const checkpoint = support.buildFailedSubmitCheckpoint( + { type: "BOSS_SCAN_START", runId: "boss-submit-retry", jobs: [{ id: 1 }, { id: 2 }] }, + 2, + { received: 40, saved: 36, queued: 30 }, + { type: "LOCAL_SERVICE_UNAVAILABLE", message: "database is locked" }, + 123456, + ); + + assert.equal(checkpoint.phase, "submitting"); + assert.equal(checkpoint.submitBatchIndex, 2); + assert.equal(checkpoint.submitSummary.received, 40); + assert.equal(checkpoint.submitSummary.saved, 36); + assert.equal(checkpoint.submitSummary.queued, 30); + assert.equal(checkpoint.lastSubmitError.failedAt, 123456); + assert.equal(checkpoint.lastSubmitError.message, "database is locked"); +}); diff --git a/chrome-extension/tests/delivery-state-safety.test.cjs b/chrome-extension/tests/delivery-state-safety.test.cjs index dabfdda..de8671c 100644 --- a/chrome-extension/tests/delivery-state-safety.test.cjs +++ b/chrome-extension/tests/delivery-state-safety.test.cjs @@ -44,3 +44,22 @@ test("delivery-result persistence is explicit so the frontend can compensate fai assert.match(boss, /persisted:\s*false/); assert.match(zhilian, /persisted:\s*false/); }); + +test("Boss confirms only an exact rendered greeting and stops the batch on unknown", () => { + const background = source("background.js"); + const boss = source("boss-content.js"); + + assert.match(boss, /readChatInput\(input\)\s*!==\s*greeting/); + assert.match(boss, /function normalizeGreetingText/); + assert.match(boss, /countRenderedGreetingMessages\(greeting, input\)\s*>\s*beforeCount/); + assert.match(boss, /greetingEvidence:\s*greetingResult\?\.evidence/); + assert.match(boss, /GREETING_INPUT_MISSING/); + assert.match(boss, /GREETING_SEND_BUTTON_MISSING/); + assert.match(boss, /GREETING_RENDER_UNCONFIRMED/); + assert.match(background, /GREETING_RENDERED_EXACT/); + assert.match(background, /if \(outcome === "UNKNOWN"\)/); + assert.match(background, /unprocessedCount/); + assert.match(background, /skipped:\s*true/); + assert.doesNotMatch(boss, /return findClickable\(\["发送"\]\)/); + assert.doesNotMatch(background, /for \(let attempt = 0; attempt < 3; attempt\+\+\) \{\s*try \{\s*const response = await chrome\.tabs\.sendMessage\(tabId, \{\s*\.\.\.message,\s*type: "BOSS_DELIVER_CURRENT_V2"/s); +}); diff --git a/chrome-extension/tests/manifest-id.test.cjs b/chrome-extension/tests/manifest-id.test.cjs new file mode 100644 index 0000000..7a5896e --- /dev/null +++ b/chrome-extension/tests/manifest-id.test.cjs @@ -0,0 +1,33 @@ +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +const EXPECTED_EXTENSION_ID = 'igmjpelbjhlglhegjbgmdbgfcdflmigp'; + +function extensionIdFromKey(key) { + const publicKey = Buffer.from(key, 'base64'); + const hash = crypto.createHash('sha256').update(publicKey).digest(); + return Array.from(hash.subarray(0, 16), (byte) => + `${String.fromCharCode(97 + (byte >> 4))}${String.fromCharCode(97 + (byte & 0x0f))}`, + ).join(''); +} + +test('manifest public key derives the backend allowlisted extension id', () => { + const manifestPath = path.join(__dirname, '..', 'manifest.json'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + assert.equal(manifest.version, '1.4.4'); + assert.equal(extensionIdFromKey(manifest.key), EXPECTED_EXTENSION_ID); + + const publicKey = crypto.createPublicKey({ + key: Buffer.from(manifest.key, 'base64'), + format: 'der', + type: 'spki', + }); + assert.equal(publicKey.asymmetricKeyType, 'rsa'); + assert.deepEqual( + manifest.host_permissions.filter((permission) => permission.startsWith('http://localhost:') || permission.startsWith('http://127.0.0.1:')), + ['http://localhost:6866/*', 'http://127.0.0.1:6866/*'], + ); +}); diff --git a/chrome-extension/tests/profile-scoped-scan-contract.test.cjs b/chrome-extension/tests/profile-scoped-scan-contract.test.cjs new file mode 100644 index 0000000..6910172 --- /dev/null +++ b/chrome-extension/tests/profile-scoped-scan-contract.test.cjs @@ -0,0 +1,43 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); + +const extensionDir = path.resolve(__dirname, ".."); + +function source(file) { + return fs.readFileSync(path.join(extensionDir, file), "utf8"); +} + +test("extension release and both content scripts use the profile-scoped contract", () => { + const manifest = JSON.parse(source("manifest.json")); + const background = source("background.js"); + const boss = source("boss-content.js"); + const zhilian = source("zhilian-content.js"); + + assert.equal(manifest.version, "1.4.4"); + assert.match(background, /BACKGROUND_VERSION = "2026-09-04-boss-greeting-proof"/); + assert.match(background, /REQUIRED_BOSS_CONTENT_VERSION = "2026-09-04-boss-greeting-proof"/); + assert.match(boss, /EXTENSION_VERSION = "2026-09-04-boss-greeting-proof"/); + assert.match(zhilian, /EXTENSION_VERSION = "2026-09-03-boss-navigation-loop-fix"/); +}); + +test("both platforms bind cursors, dedupe, submissions and progress to profileId", () => { + for (const file of ["boss-content.js", "zhilian-content.js"]) { + const content = source(file); + assert.match(content, /function buildKeywordCursorKey[\s\S]*?profileId: normalizeProfileId\(message\?\.profileId\)/); + assert.match(content, /chrome-jobs-dedupe[\s\S]*?profileId: normalizeProfileId/); + assert.match(content, /chrome-jobs[\s\S]*?profileId: normalizeProfileId/); + assert.match(content, /function postProgress[\s\S]*?profileId: normalizeProfileId\(message\?\.profileId\)/); + assert.match(content, /if \(!normalizeProfileId\(task\?\.profileId\)\) return false/); + } +}); + +test("profile contract failures are terminal rather than resumable", () => { + const boss = source("boss-content.js"); + const zhilian = source("zhilian-content.js"); + + assert.match(boss, /\["PROFILE_REQUIRED", "PROFILE_CHANGED"\][\s\S]*?clearStoredScanTask\(\)/); + assert.match(zhilian, /\["PROFILE_REQUIRED", "PROFILE_CHANGED"\][\s\S]*?clearStoredScanTask\(\)/); + assert.match(zhilian, /resumable: false/); +}); diff --git a/chrome-extension/tests/zhilian-scan-support.test.cjs b/chrome-extension/tests/zhilian-scan-support.test.cjs index 61c8287..97b1083 100644 --- a/chrome-extension/tests/zhilian-scan-support.test.cjs +++ b/chrome-extension/tests/zhilian-scan-support.test.cjs @@ -17,10 +17,28 @@ test("replaces a stale Zhilian support module after extension reload", () => { const support = loadSupport(staleSupport); assert.notEqual(support, staleSupport); - assert.equal(support.version, "2026-07-29-zhilian-security-resume-fix"); + assert.equal(support.version, "2026-09-03-keyword-deep-fill"); assert.equal(typeof support.isZhilianUrl, "function"); }); +test("uses history-aware deep collection safety bounds for Zhilian", () => { + const support = loadSupport(); + assert.equal(support.DEEP_COLLECTION_MAX_PAGES, 50); + assert.equal(support.DEEP_COLLECTION_MAX_DURATION_MS, 180000); + assert.equal(support.DEEP_COLLECTION_MAX_STAGNANT_PAGES, 5); + assert.equal(support.deepCollectionStopReason({ target: 20, fresh: 20 }), "target_reached"); + assert.equal(support.deepCollectionStopReason({ target: 20, fresh: 8, stagnantPages: 4 }), ""); + assert.equal(support.deepCollectionStopReason({ target: 20, fresh: 8, stagnantPages: 5 }), "stagnation_safety_cap"); + assert.equal(support.deepCollectionStopReason({ target: 20, fresh: 8, platformExhausted: true }), "platform_exhausted"); +}); + +test("routes Zhilian history dedupe before adding page candidates", () => { + const source = fs.readFileSync(path.resolve(__dirname, "..", "zhilian-content.js"), "utf8"); + assert.match(source, /requestZhilianLocalApi\("chrome-jobs-dedupe"/); + assert.match(source, /historyDuplicateCount/); + assert.match(source, /stagnantPages/); +}); + test("does not treat normal Zhilian job descriptions as security verification", () => { const support = loadSupport(); const realFalsePositive = { diff --git a/chrome-extension/zhilian-content.js b/chrome-extension/zhilian-content.js index 432714d..eff5772 100644 --- a/chrome-extension/zhilian-content.js +++ b/chrome-extension/zhilian-content.js @@ -1,5 +1,5 @@ (function () { - const EXTENSION_VERSION = "2026-07-29-zhilian-security-resume-fix"; + const EXTENSION_VERSION = "2026-09-03-boss-navigation-loop-fix"; const CONTENT_INSTANCE_ID = `${Date.now()}-${Math.random().toString(16).slice(2)}`; window.__GET_JOBS_ZHILIAN_CONTENT__ = true; window.__GET_JOBS_ZHILIAN_CONTENT_VERSION__ = EXTENSION_VERSION; @@ -98,7 +98,7 @@ return true; } if (messageType === "ZHILIAN_SCAN_STATUS") { - handleScanStatusMessage(sendResponse); + handleScanStatusMessage(message, sendResponse); return true; } if (messageType === "ZHILIAN_SCAN_START") { @@ -130,6 +130,15 @@ } async function handleScanStopMessage(message) { + const profileId = normalizeProfileId(message?.profileId); + if (!profileId) return { success: false, errorCode: "PROFILE_REQUIRED", message: "智联扫描停止请求缺少档案 ID" }; + const storedTask = await readStoredScanTaskFromAnyStorage(); + const storedProfileId = normalizeProfileId(storedTask?.profileId); + if (storedProfileId && storedProfileId !== profileId) { + clearStoredScanTask(); + writeScanStatus({ isRunning: false, stopRequested: false, stage: "idle", paused: false, resumable: false, profileId, runId: "", message: "已清除其他档案的智联扫描断点" }); + return { success: true, message: "已清除其他档案的智联扫描断点", profileId }; + } stopRequested = true; await storeStopRequested(message?.runId); clearStoredScanTask(); @@ -137,6 +146,7 @@ isRunning: false, stopRequested: true, stage: "stopped", + profileId, message: "已请求停止智联扫描", runId: message?.runId || readScanStatus().runId || "", updatedAt: Date.now() @@ -145,31 +155,37 @@ operation: "scan", stage: "stopping" }); - return { success: true, message: "已请求停止智联扫描" }; + return { success: true, message: "已请求停止智联扫描", profileId }; } async function handleScanStartMessage(message) { + const profileId = normalizeProfileId(message?.profileId); + if (!profileId) return { success: false, errorCode: "PROFILE_REQUIRED", message: "智联扫描启动请求缺少档案 ID" }; if (!scanKeywords(message).length) { return { success: false, message: "请至少填写一个搜索关键词" }; } const existingTask = await readStoredScanTaskFromAnyStorage(); const status = readScanStatus(); const incomingTask = normalizeScanTask(message); + const profileChanged = Boolean(existingTask && normalizeProfileId(existingTask.profileId) !== profileId); const configChanged = Boolean( existingTask?.keywordCursorKey && incomingTask.keywordCursorKey && existingTask.keywordCursorKey !== incomingTask.keywordCursorKey ); - if (configChanged) { + if (profileChanged || configChanged) { clearStoredScanTask(); - postProgress(message, "warning", "智联扫描配置已变化,旧断点已放弃,将按新配置重新开始。", { + postProgress(message, "warning", profileChanged + ? "智联档案已变化,其他档案或旧版扫描断点已放弃,将从第一个关键词重新开始。" + : "智联扫描配置已变化,旧断点已放弃,将按新配置重新开始。", { operation: "scan", stage: "checkpointReset", - diagnosticType: "CONFIG_CHANGED" + diagnosticType: profileChanged ? "PROFILE_CHANGED" : "CONFIG_CHANGED" }); } const canResumeExisting = Boolean( - !configChanged + !profileChanged + && !configChanged && existingTask && !existingTask.completed && (isResumableScanTask(existingTask) || status.resumable || status.stage === "blocked") @@ -188,7 +204,7 @@ updatedAt: Date.now() }); }); - return { success: true, message: "智联 Chrome扫描任务已恢复。", resumed: true, runId: existingTask.runId }; + return { success: true, message: "智联 Chrome扫描任务已恢复。", resumed: true, runId: existingTask.runId, profileId }; } startScan(incomingTask).catch((error) => { @@ -197,10 +213,15 @@ stage: "error" }); }); - return { success: true, message: "智联 Chrome扫描任务已启动。" }; + return { success: true, message: "智联 Chrome扫描任务已启动。", profileId }; } - async function handleScanStatusMessage(sendResponse) { + async function handleScanStatusMessage(message, sendResponse) { + const profileId = normalizeProfileId(message?.profileId); + if (!profileId) { + sendResponse({ success: false, errorCode: "PROFILE_REQUIRED", message: "智联扫描状态请求缺少档案 ID", isRunning: false, hasStoredTask: false }); + return; + } if (await hasStopRequested()) { stopRequested = true; clearStoredScanTask(); @@ -211,8 +232,15 @@ message: "智联扫描已取消" }); } - const task = await readStoredScanTaskFromAnyStorage(); - const status = readScanStatus(); + let task = await readStoredScanTaskFromAnyStorage(); + let status = readScanStatus(); + if ((task && normalizeProfileId(task.profileId) !== profileId) + || (normalizeProfileId(status.profileId) && normalizeProfileId(status.profileId) !== profileId)) { + clearStoredScanTask(); + writeScanStatus({ isRunning: false, stopRequested: false, stage: "idle", paused: false, resumable: false, profileId, runId: "", message: "智联档案已变化,旧扫描断点已清理" }); + task = null; + status = readScanStatus(); + } const paused = Boolean(status.paused || (status.stage === "blocked" && status.resumable)); const hasFreshTask = Boolean(task && isFreshScanTask(task)); const hasResumableTask = Boolean(hasFreshTask || paused); @@ -222,6 +250,10 @@ isRunning: false, stopRequested: false, stage: "idle", + paused: false, + resumable: false, + profileId, + runId: "", message: "智联旧扫描任务已清理" }); } @@ -234,6 +266,7 @@ resumable: Boolean(nextStatus.resumable || hasResumableTask), runId: nextStatus.runId || task?.runId || "", scanOwnerToken: task?.scanOwnerToken || "", + profileId, hasStoredTask: hasResumableTask }); } @@ -245,7 +278,10 @@ isRunning: true, stopRequested: false, stage: "received", + paused: false, + resumable: false, message: "智联 Chrome扫描任务已接收", + profileId: task.profileId, runId: task.runId, startedAt: task.startedAt, updatedAt: Date.now() @@ -260,7 +296,7 @@ } if (keywords.length) { const startIndex = normalizeKeywordIndex(task.currentIndex, keywords.length); - postProgress(task, "info", `智联关键词历史:本次从第 ${startIndex + 1}/${keywords.length} 个关键词继续:${keywords[startIndex]}`, { + postProgress(task, "info", `智联本档案关键词进度:本次从第 ${startIndex + 1}/${keywords.length} 个关键词继续:${keywords[startIndex]}`, { operation: "scan", stage: "keywordCursor", keyword: keywords[startIndex], @@ -701,11 +737,26 @@ const seenJobUrls = new Set(collectedJobs.map((job) => normalizeJobUrlKey(job)).filter(Boolean)); let pageNumber = Math.max(1, Number(task.searchPage || currentSearchPageNumber() || 1)); let pagesScanned = Number(task.pagesScanned || 0); + let stagnantPages = Number(task.stagnantPages || 0); + let historyDuplicateCount = Number(task.historyDuplicateCount || 0); + let candidateCount = Number(task.candidateCount || collectedJobs.length); + const collectionStartedAt = Number(task.collectionStartedAt || Date.now()); + const maxPages = Number(SCAN_SUPPORT.DEEP_COLLECTION_MAX_PAGES || 50); let lastDiagnostics = null; - - while (collectedJobs.length < searchJobLimit && pagesScanned < 50) { + let platformExhausted = false; + let stopReason = ""; + + while (collectedJobs.length < searchJobLimit && pagesScanned < maxPages) { + stopReason = zhilianCollectionStopReason({ + target: searchJobLimit, + fresh: collectedJobs.length, + pages: pagesScanned, + stagnantPages, + elapsedMs: Date.now() - collectionStartedAt + }); + if (stopReason) break; if (await hasStopRequested()) { - return { stopped: true, jobs: collectedJobs.slice(0, searchJobLimit), candidateCount: collectedJobs.length, pagesScanned }; + return { stopped: true, jobs: collectedJobs.slice(0, searchJobLimit), candidateCount, freshCount: collectedJobs.length, historyDuplicateCount, pagesScanned, stopReason: "stopped" }; } if (!isCurrentSearchPage(keyword, config, pageNumber)) { @@ -723,6 +774,10 @@ phase: "collecting", searchPage: pageNumber, pagesScanned, + stagnantPages, + historyDuplicateCount, + candidateCount, + collectionStartedAt, collectedJobs, totalSaved }); @@ -768,8 +823,15 @@ } const collectResult = collectJobs(keyword, task, { ...baseMeta, pageNumber }); + const pendingJobs = collectResult.jobs.filter((job) => { + const key = normalizeJobUrlKey(job); + return key && !seenJobUrls.has(key); + }); + candidateCount += pendingJobs.length; + const dedupeResult = await filterZhilianDuplicateJobs(pendingJobs, task, keyword); + historyDuplicateCount += dedupeResult.duplicateCount; let added = 0; - for (const job of collectResult.jobs) { + for (const job of dedupeResult.jobs) { const key = normalizeJobUrlKey(job); if (!key || seenJobUrls.has(key)) continue; seenJobUrls.add(key); @@ -778,8 +840,20 @@ if (collectedJobs.length >= searchJobLimit) break; } pagesScanned += 1; + stagnantPages = added > 0 ? 0 : stagnantPages + 1; + const nextPageNumber = pageNumber + 1; + const hasNextPage = hasNextSearchPage(nextPageNumber); + platformExhausted = !hasNextPage; + stopReason = zhilianCollectionStopReason({ + target: searchJobLimit, + fresh: collectedJobs.length, + pages: pagesScanned, + stagnantPages, + elapsedMs: Date.now() - collectionStartedAt, + platformExhausted + }); - postProgress(task, collectResult.parsed > 0 ? "info" : "warning", `智联第 ${pageNumber} 页解析完成:候选节点 ${collectResult.nodeCount} 个,首屏数据 ${collectResult.initialStateParsed || 0} 个,成功 ${collectResult.parsed} 个,本页新增 ${added} 个,累计 ${collectedJobs.length}/${searchJobLimit} 个,跳过 ${collectResult.skipped} 个,重复 ${collectResult.duplicated} 个。`, { + postProgress(task, collectResult.parsed > 0 ? "info" : "warning", `智联第 ${pageNumber} 页解析完成:候选节点 ${collectResult.nodeCount} 个,首屏数据 ${collectResult.initialStateParsed || 0} 个,解析 ${collectResult.parsed} 个,历史重复 ${dedupeResult.duplicateCount} 个,本页新增 ${added} 个,累计新增 ${collectedJobs.length}/${searchJobLimit} 个,跳过 ${collectResult.skipped} 个。${stopReason ? ` 停止原因:${collectionStopReasonLabel(stopReason)}。` : ""}`, { ...baseMeta, stage: "collecting", pageNumber, @@ -789,7 +863,13 @@ parsed: collectResult.parsed, added, skipped: collectResult.skipped, + conditionFiltered: 0, duplicated: collectResult.duplicated, + historyDuplicates: historyDuplicateCount, + candidateCount, + fresh: collectedJobs.length, + stagnantPages, + stopReason, initialStateParsed: collectResult.initialStateParsed || 0, errorCount: collectResult.errorCount, pagesScanned @@ -800,27 +880,46 @@ phase: "collecting", searchPage: pageNumber, pagesScanned, + stagnantPages, + historyDuplicateCount, + candidateCount, + collectionStartedAt, collectedJobs, totalSaved }); - if (collectedJobs.length >= searchJobLimit) break; - - const nextPageNumber = pageNumber + 1; - if (!hasNextSearchPage(nextPageNumber)) { - postProgress(task, "info", `智联 Chrome已无下一页,本关键词采集结束:累计 ${collectedJobs.length}/${searchJobLimit} 个岗位进入详情/AI流程。`, { - ...baseMeta, - stage: "collecting", - collected: collectedJobs.length, - searchJobLimit, - pageNumber, - pagesScanned - }); - break; - } + if (stopReason) break; pageNumber = nextPageNumber; } + if (!stopReason) { + stopReason = zhilianCollectionStopReason({ + target: searchJobLimit, + fresh: collectedJobs.length, + pages: pagesScanned, + stagnantPages, + elapsedMs: Date.now() - collectionStartedAt, + platformExhausted + }) || "page_safety_cap"; + } + + if (collectedJobs.length < searchJobLimit) { + postProgress(task, "warning", `智联关键词 ${keyword} 未补足目标:新增岗位 ${collectedJobs.length}/${searchJobLimit},候选 ${candidateCount} 个,历史重复 ${historyDuplicateCount} 个,停止原因:${collectionStopReasonLabel(stopReason)}。`, { + ...baseMeta, + stage: "collecting", + collected: collectedJobs.length, + candidateCount, + conditionFiltered: 0, + historyDuplicates: historyDuplicateCount, + fresh: collectedJobs.length, + searchJobLimit, + pagesScanned, + stagnantPages, + stopReason, + elapsedMs: Date.now() - collectionStartedAt + }); + } + const jobs = collectedJobs.slice(0, searchJobLimit); if (!jobs.length) { const diagnostics = lastDiagnostics || buildListDiagnostics(); @@ -842,10 +941,60 @@ searchJobLimit, ...diagnostics }); - return { empty: true, jobs: [], candidateCount: 0, pagesScanned }; + return { empty: true, jobs: [], candidateCount, freshCount: 0, historyDuplicateCount, pagesScanned, stopReason }; } - return { jobs, candidateCount: collectedJobs.length, pagesScanned, empty: false }; + return { jobs, candidateCount, freshCount: jobs.length, historyDuplicateCount, pagesScanned, stopReason, empty: false }; + } + + async function filterZhilianDuplicateJobs(jobs, task, keyword) { + const list = Array.isArray(jobs) ? jobs : []; + if (!list.length) return { jobs: [], duplicateCount: 0 }; + const data = await requestZhilianLocalApi("chrome-jobs-dedupe", { + body: { profileId: normalizeProfileId(task?.profileId), runId: task?.runId, keyword, jobs: list }, + pageTabId: task?.pageTabId + }); + const items = Array.isArray(data.items) ? data.items : []; + if (items.length !== list.length) { + throw new Error("智联查重接口未完整返回全部岗位决策"); + } + const decisions = new Map(); + for (const item of items) { + const key = normalizeJobUrlKey(item); + if (!key || typeof item?.duplicate !== "boolean" || decisions.has(key)) { + throw new Error("智联查重接口返回了无效或重复的岗位决策"); + } + decisions.set(key, item.duplicate); + } + if (list.some((job) => !decisions.has(normalizeJobUrlKey(job)))) { + throw new Error("智联查重接口没有覆盖全部候选岗位"); + } + const freshJobs = list.filter((job) => decisions.get(normalizeJobUrlKey(job)) === false); + return { jobs: freshJobs, duplicateCount: list.length - freshJobs.length }; + } + + function zhilianCollectionStopReason(state) { + if (typeof SCAN_SUPPORT.deepCollectionStopReason === "function") { + return SCAN_SUPPORT.deepCollectionStopReason(state); + } + if (Number(state?.fresh || 0) >= Number(state?.target || 20)) return "target_reached"; + if (state?.platformExhausted) return "platform_exhausted"; + if (Number(state?.stagnantPages || 0) >= 5) return "stagnation_safety_cap"; + if (Number(state?.elapsedMs || 0) >= 180000) return "timeout_safety_cap"; + if (Number(state?.pages || 0) >= 50) return "page_safety_cap"; + return ""; + } + + function collectionStopReasonLabel(reason) { + return ({ + target_reached: "已达到目标", + platform_exhausted: "平台结果已到底", + stagnation_safety_cap: "连续5页没有新增", + timeout_safety_cap: "单关键词采集达到180秒", + page_safety_cap: "翻页达到50页安全上限", + blocked: "平台安全验证或页面阻断", + stopped: "用户停止扫描" + })[String(reason || "")] || "安全边界已触发"; } function normalizeCollectedJobs(value) { @@ -1215,7 +1364,7 @@ let data; try { data = await requestZhilianLocalApi("chrome-jobs", { - body: { runId, keyword, jobs }, + body: { profileId: normalizeProfileId(message?.profileId), runId, keyword, jobs }, pageTabId: message.pageTabId }); if (!data.success) { @@ -1282,6 +1431,44 @@ async function pauseZhilianSubmission(message, jobs, totalSaved, error, baseMeta) { const reason = error?.message || String(error || "未知错误"); const errorType = error?.errorType || "LOCAL_API_ERROR"; + if (["PROFILE_REQUIRED", "PROFILE_CHANGED"].includes(errorType)) { + const failureMessage = `智联岗位提交已终止:${reason}`; + clearStoredScanTask(); + writeScanStatus({ + isRunning: false, + stopRequested: false, + stage: "error", + paused: false, + profileId: normalizeProfileId(message?.profileId), + message: failureMessage, + runId: message.runId, + resumable: false, + diagnosticType: errorType, + errorType, + httpStatus: error?.httpStatus, + startedAt: message.startedAt, + updatedAt: Date.now() + }); + postProgress(message, "error", failureMessage, { + ...baseMeta, + stage: "error", + paused: false, + resumable: false, + errorType, + httpStatus: error?.httpStatus + }); + return { + success: false, + totalSaved, + totalRead: Number(message.totalRead || 0), + totalReceived: Number(message.totalReceived || 0), + totalInsufficient: Number(message.totalInsufficient || 0), + paused: false, + resumable: false, + message: failureMessage, + errorType + }; + } const failureMessage = `智联岗位提交失败,扫描断点已保留:${reason}`; const pausedAt = Date.now(); await storeScanTask({ @@ -1624,7 +1811,7 @@ } if (!response?.success) { const error = new Error(response?.message || "智联本地服务请求失败"); - error.errorType = response?.errorType || "LOCAL_API_ERROR"; + error.errorType = response?.data?.errorCode || response?.errorType || "LOCAL_API_ERROR"; error.httpStatus = response?.httpStatus; throw error; } @@ -1637,6 +1824,7 @@ pageTabId: message.pageTabId, payload: { platform: "zhilian", + profileId: normalizeProfileId(message?.profileId), type, message: text, timestamp: Date.now(), @@ -1933,6 +2121,7 @@ const cursorState = resolveKeywordCursor(message, keywords, hasExplicitIndex); return { ...message, + profileId: normalizeProfileId(message?.profileId), config: { ...config, keywords, searchJobLimit }, keywords, source: "GET_JOBS_BACKGROUND", @@ -2039,6 +2228,7 @@ const searchParams = normalizedZhilianSearchParams(config); return stableKey({ platform: "zhilian", + profileId: normalizeProfileId(message?.profileId), keywords: uniqueStrings(keywords), cityCode: searchParams.cityCode, salary: searchParams.salary, @@ -2071,11 +2261,17 @@ return Object.prototype.hasOwnProperty.call(value || {}, key); } + function normalizeProfileId(value) { + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null; + } + function isResumableScanTask(task) { return Boolean(isFreshScanTask(task) && isZhilianUrl(window.location.href)); } function isFreshScanTask(task) { + if (!normalizeProfileId(task?.profileId)) return false; if (!task || task.type !== "ZHILIAN_SCAN_START" || !task.runId) return false; if (task.completed || task.phase === "complete" || task.phase === "stopped" || task.phase === "error") return false; diff --git a/chrome-extension/zhilian-scan-support.js b/chrome-extension/zhilian-scan-support.js index 1cebe65..7c91eea 100644 --- a/chrome-extension/zhilian-scan-support.js +++ b/chrome-extension/zhilian-scan-support.js @@ -1,9 +1,12 @@ (function (root) { - const SUPPORT_VERSION = "2026-07-29-zhilian-security-resume-fix"; + const SUPPORT_VERSION = "2026-09-03-keyword-deep-fill"; if (root.GetJobsZhilianScanSupport?.version === SUPPORT_VERSION) return; const DEFAULT_CITY_CODE = "489"; const DEFAULT_SALARY_CODE = "0000,9999999"; + const DEEP_COLLECTION_MAX_PAGES = 50; + const DEEP_COLLECTION_MAX_DURATION_MS = 180 * 1000; + const DEEP_COLLECTION_MAX_STAGNANT_PAGES = 5; const OFFICIAL_SALARY_CODES = new Set([ DEFAULT_SALARY_CODE, "0000,4000", @@ -89,6 +92,18 @@ return OFFICIAL_SALARY_CODES.has(raw) ? raw : DEFAULT_SALARY_CODE; } + function deepCollectionStopReason(state = {}) { + const target = Math.max(1, Math.min(200, Math.floor(Number(state.target) || 20))); + if (state.stopped) return "stopped"; + if (Number(state.fresh || 0) >= target) return "target_reached"; + if (state.blocked) return "blocked"; + if (state.platformExhausted) return "platform_exhausted"; + if (Number(state.elapsedMs || 0) >= DEEP_COLLECTION_MAX_DURATION_MS) return "timeout_safety_cap"; + if (Number(state.stagnantPages || 0) >= DEEP_COLLECTION_MAX_STAGNANT_PAGES) return "stagnation_safety_cap"; + if (Number(state.pages || 0) >= DEEP_COLLECTION_MAX_PAGES) return "page_safety_cap"; + return ""; + } + function isUnlimitedZhilianSalary(value) { return normalizeZhilianSalaryCode(value) === DEFAULT_SALARY_CODE; } @@ -187,6 +202,10 @@ version: SUPPORT_VERSION, DEFAULT_CITY_CODE, DEFAULT_SALARY_CODE, + DEEP_COLLECTION_MAX_PAGES, + DEEP_COLLECTION_MAX_DURATION_MS, + DEEP_COLLECTION_MAX_STAGNANT_PAGES, + deepCollectionStopReason, normalizeKeywordList, normalizeZhilianCityCode, normalizeZhilianSalaryCode, diff --git a/front/app/ai-config/page.test.tsx b/front/app/ai-config/page.test.tsx index de57e8f..cb7d0e3 100644 --- a/front/app/ai-config/page.test.tsx +++ b/front/app/ai-config/page.test.tsx @@ -33,7 +33,7 @@ function readyResponse() { function profileResponse(url: string, id: number, name: string) { const profile = { id, name } const payload = url.includes('/api/boss/config') - ? { success: true, currentProfile: profile, hasProfile: true, config: { enableAi: 1, sayHi: `${name} hi` } } + ? { success: true, currentProfile: profile, hasProfile: true, config: { enableAi: 1, sayHi: `${name} hi`, nativeGreetingDisabledConfirmed: 1 } } : url.includes('/api/ai/resume') ? { success: true, currentProfile: profile, hasProfile: true, data: { resumeText: `${name} resume` } } : url.includes('/api/ai/companies/priority') @@ -112,6 +112,33 @@ describe('AI config profile snapshot', () => { expect(screen.getByRole('button', { name: /保存配置/ })).toBeDisabled() }) + it('加载并保存关闭 BOSS 平台默认话术确认', async () => { + const fetchMock = vi.fn((input: RequestInfo | URL, _init?: RequestInit) => { + const url = String(input) + if (url.includes('/api/ready')) return Promise.resolve(readyResponse()) + return Promise.resolve(profileResponse(url, 1, 'A')) + }) + vi.stubGlobal('fetch', fetchMock) + vi.stubGlobal('alert', vi.fn()) + + render() + fireEvent.click(await screen.findByRole('button', { name: '切换A' })) + await screen.findByText('当前正在编辑:A') + + const confirmation = screen.getByRole('checkbox', { name: /我已关闭 BOSS 平台自带打招呼语/ }) + expect(confirmation).toBeChecked() + fireEvent.click(confirmation) + fireEvent.click(screen.getByRole('button', { name: /保存配置/ })) + + await waitFor(() => { + const saveCall = fetchMock.mock.calls.find(([url, init]) => + String(url).includes('/api/boss/config') && (init as RequestInit | undefined)?.method === 'PUT') + expect(saveCall).toBeTruthy() + const body = JSON.parse(String((saveCall?.[1] as RequestInit).body)) + expect(body).toMatchObject({ nativeGreetingDisabledConfirmed: 0 }) + }) + }) + it('文件先本地识别为可编辑预览,不会直接保存', async () => { const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { const url = String(input) diff --git a/front/app/ai-config/page.tsx b/front/app/ai-config/page.tsx index 86a84c8..12a0846 100644 --- a/front/app/ai-config/page.tsx +++ b/front/app/ai-config/page.tsx @@ -16,7 +16,7 @@ const ANALYSIS_LOGIC_TEXT = `1. 平台配置页先决定怎么找岗位:关键 2. 自动任务按这些条件进入招聘平台搜索岗位,并读取公司、岗位名、薪资、地点、经验、学历、公司信息和岗位描述。 3. AI 会把你的简历内容和岗位信息放在一起分析,返回 score、decision、summary、strengths、risks、greeting。 4. 分数达到当前档案设置的投递分数线后,岗位进入“待确认”列表;分数线可在 Boss 投递分析页的“岗位数据”区域设置。 -5. 只有你在分析页确认后,系统才会执行实际投递,并优先使用 AI 返回的 greeting。` +5. 只有你在分析页确认后,系统才会执行实际投递,并优先使用岗位 JD 定制话术;AI 生成失败时才使用档案默认兜底。` type AiConfig = { introduce: string @@ -49,6 +49,7 @@ type BossConfigResponse = ProfileAwareResponse & { config?: { enableAi?: unknown sayHi?: string + nativeGreetingDisabledConfirmed?: unknown } } @@ -56,6 +57,7 @@ type GeneratedAiConfig = { introduce?: string prompt?: string sayHi?: string + recommendedKeywords?: string[] } type ResumeParsePreview = { @@ -86,6 +88,7 @@ export default function AiConfigPage() { const [resumeFile, setResumeFile] = useState(null) const [resumeDirty, setResumeDirty] = useState(false) const [sayHi, setSayHi] = useState('') + const [nativeGreetingDisabledConfirmed, setNativeGreetingDisabledConfirmed] = useState(0) const [loading, setLoading] = useState(false) const [generating, setGenerating] = useState(false) @@ -173,6 +176,7 @@ export default function AiConfigPage() { setAiConfig({ introduce: '', prompt: '' }) setEnableAi(0) setSayHi('') + setNativeGreetingDisabledConfirmed(0) setResumeText('') setResumeMeta(null) setResumePreview(null) @@ -236,6 +240,9 @@ export default function AiConfigPage() { : { introduce: '', prompt: '' }) setEnableAi(parseEnableAi(bossResult.config?.enableAi)) setSayHi(bossResult.config?.sayHi || '') + setNativeGreetingDisabledConfirmed(parseEnableAi( + bossResult.config?.nativeGreetingDisabledConfirmed, + )) setResumeText(resume?.resumeText || '') setResumeMeta(resume ? { sourceFilename: resume.sourceFilename, @@ -346,7 +353,11 @@ export default function AiConfigPage() { const response = await fetch(`${API_BASE}/api/boss/config`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sayHi: nextSayHi, enableAi }), + body: JSON.stringify({ + sayHi: nextSayHi, + enableAi, + nativeGreetingDisabledConfirmed, + }), }) await readApiResponse(response, 'Boss默认打招呼语保存失败') } @@ -431,8 +442,9 @@ export default function AiConfigPage() { skipResume: true, showAlert: false, }) - setStatusMessage('已提交简历并生成AI配置') - alert('已提交简历,并生成打招呼话术和AI配置!') + const keywordCount = Array.isArray(result.data?.recommendedKeywords) ? result.data.recommendedKeywords.length : 0 + setStatusMessage(`已提交简历并生成AI配置和 ${keywordCount} 个岗位关键词`) + alert(`已提交简历,并生成打招呼话术、AI配置和 ${keywordCount} 个岗位关键词!请到 Boss 或智联页面点击选择。`) } catch (error) { alert(friendlyApiError(error, '提交简历并生成AI配置失败')) } finally { @@ -718,10 +730,29 @@ export default function AiConfigPage() { className="min-h-[120px] resize-y" />

- AI关闭、AI返回为空或生成失败时,Boss投递会使用这段话术 + 仅当岗位 JD 话术生成失败时使用;分析页会明确标为“AI 失败兜底”

+ +