diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a4eae1..4b81510 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,7 +90,8 @@ jobs: - name: Setup Node uses: actions/setup-node@v6 with: - node-version: "20" + # The locked jsdom/undici test dependencies require Node >= 22.19. + node-version: "24" cache: pnpm cache-dependency-path: front/pnpm-lock.yaml @@ -106,6 +107,10 @@ jobs: working-directory: front run: pnpm typecheck + - name: Test Zhilian modern DOM collection + working-directory: front + run: pnpm exec vitest run lib/zhilian-modern-collector.test.ts + - name: Build frontend working-directory: front run: pnpm build diff --git a/chrome-extension/background.js b/chrome-extension/background.js index 590b12b..eb06ff1 100644 --- a/chrome-extension/background.js +++ b/chrome-extension/background.js @@ -22,6 +22,7 @@ const PLATFORM_CONFIG = { contentScript: "zhilian-content.js", contentScripts: [ "zhilian-scan-support.js", + "zhilian-modern-collector.js", "zhilian-content.js" ] } @@ -38,13 +39,13 @@ 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-09-07-zhilian-page-status"; +const BACKGROUND_VERSION = "2026-09-07-modern-collection"; 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-09-06-hr-profile-guard"; -const REQUIRED_ZHILIAN_CONTENT_VERSION = "2026-09-07-zhilian-page-status"; +const REQUIRED_ZHILIAN_CONTENT_VERSION = "2026-09-07-modern-collection"; const LOCAL_API_BASE_URLS = ["http://127.0.0.1:6866"]; const BOSS_LOCAL_API_MAX_ATTEMPTS = 3; const BOSS_LOCAL_API_TIMEOUT_MS = 30000; @@ -2483,7 +2484,7 @@ function isZhilianSearchUrl(url) { const parsed = new URL(url); return parsed.protocol === "https:" && isZhilianHost(parsed.hostname) - && /^\/sou(?:\/|$)/i.test(parsed.pathname); + && /^(?:\/sou(?:\/|$)|\/jobs\/?$)/i.test(parsed.pathname); } catch { return false; } diff --git a/chrome-extension/manifest.json b/chrome-extension/manifest.json index 4fd1b52..10d8588 100644 --- a/chrome-extension/manifest.json +++ b/chrome-extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "投递牛马 Chrome Bridge", - "version": "1.6.6", + "version": "1.6.7", "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzzdIlNVOv76Y/cSWrjD5Tg2Vlsha8yWHzsn46PBsg724/2dftOUzIIr2n70VRaRgGwEd8FjO/Y768Ori443zF4pQpWvuxXxm05YO25ILQ/+aJLmUycAEdWbkdhcagr4YXnXJdYlSCGSAToSQBjk+owQOdlBLQn5wofPoshrqayoJjRQ5aAUj1SuSlnNv9iimle8GMA1IaA1l5rw6K/chfcgwMTg6HxRAIoludt5JGbIBryi2Lu1hOJRMaDnL7A57ofBnn3qx3H2HIGWGkkTW9EMkls0XMXwx8+mJVIj5HSYl0EeuCvEoTa1W3i1CbOf3kY2yCPKS3Qz3lOvJiwJ4ZQIDAQAB", "description": "Use signed-in Chrome tabs to scan jobs, confirm deliveries, and review BOSS HR reply drafts for 投递牛马.", "icons": { @@ -45,7 +45,7 @@ }, { "matches": ["https://www.zhaopin.com/*", "https://*.zhaopin.com/*"], - "js": ["zhilian-scan-support.js", "zhilian-content.js"], + "js": ["zhilian-scan-support.js", "zhilian-modern-collector.js", "zhilian-content.js"], "run_at": "document_idle" }, { diff --git a/chrome-extension/tests/background-tab-routing.test.cjs b/chrome-extension/tests/background-tab-routing.test.cjs index 7312446..250a84e 100644 --- a/chrome-extension/tests/background-tab-routing.test.cjs +++ b/chrome-extension/tests/background-tab-routing.test.cjs @@ -247,6 +247,7 @@ test("injects all Zhilian dependencies when the content script is missing", asyn assert.equal(executedScripts.length, 1); assert.deepEqual(Array.from(executedScripts[0].files), [ "zhilian-scan-support.js", + "zhilian-modern-collector.js", "zhilian-content.js" ]); }); @@ -262,6 +263,7 @@ test("reinjects all Zhilian dependencies when the content script is stale", asyn assert.equal(executedScripts.length, 1); assert.deepEqual(Array.from(executedScripts[0].files), [ "zhilian-scan-support.js", + "zhilian-modern-collector.js", "zhilian-content.js" ]); assert.equal(await context.isContentScriptReady(1, "zhilian-content.js"), true); diff --git a/chrome-extension/tests/boss-hr-assistant.test.cjs b/chrome-extension/tests/boss-hr-assistant.test.cjs index b036fec..2957f8c 100644 --- a/chrome-extension/tests/boss-hr-assistant.test.cjs +++ b/chrome-extension/tests/boss-hr-assistant.test.cjs @@ -15,7 +15,7 @@ test("manifest loads the direct HR bridge and one-minute alarm capability", () = const bossScripts = manifest.content_scripts.find((entry) => entry.matches.some((value) => value.includes("zhipin.com"))).js; assert.deepEqual(bossScripts.slice(-3), ["boss-hr-support.js", "boss-hr-bridge.js", "boss-hr-assistant.js"]); assert.ok(manifest.permissions.includes("alarms")); - assert.equal(manifest.version, "1.6.6"); + assert.equal(manifest.version, "1.6.7"); }); test("assistant exposes policy-gated dedicated watch and preserves explicit manual send", () => { diff --git a/chrome-extension/tests/manifest-id.test.cjs b/chrome-extension/tests/manifest-id.test.cjs index a30333f..d9d166a 100644 --- a/chrome-extension/tests/manifest-id.test.cjs +++ b/chrome-extension/tests/manifest-id.test.cjs @@ -17,7 +17,7 @@ function extensionIdFromKey(key) { 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.6.6'); + assert.equal(manifest.version, '1.6.7'); assert.equal(extensionIdFromKey(manifest.key), EXPECTED_EXTENSION_ID); const publicKey = crypto.createPublicKey({ diff --git a/chrome-extension/tests/profile-scoped-scan-contract.test.cjs b/chrome-extension/tests/profile-scoped-scan-contract.test.cjs index dca07c4..0462393 100644 --- a/chrome-extension/tests/profile-scoped-scan-contract.test.cjs +++ b/chrome-extension/tests/profile-scoped-scan-contract.test.cjs @@ -15,12 +15,12 @@ test("extension release and both content scripts use the profile-scoped contract const boss = source("boss-content.js"); const zhilian = source("zhilian-content.js"); - assert.equal(manifest.version, "1.6.6"); - assert.match(background, /BACKGROUND_VERSION = "2026-09-07-zhilian-page-status"/); + assert.equal(manifest.version, "1.6.7"); + assert.match(background, /BACKGROUND_VERSION = "2026-09-07-modern-collection"/); assert.match(background, /REQUIRED_BOSS_CONTENT_VERSION = "2026-09-06-hr-profile-guard"/); assert.match(boss, /EXTENSION_VERSION = "2026-09-06-hr-profile-guard"/); - assert.match(zhilian, /EXTENSION_VERSION = "2026-09-07-zhilian-page-status"/); - assert.match(background, /REQUIRED_ZHILIAN_CONTENT_VERSION = "2026-09-07-zhilian-page-status"/); + assert.match(zhilian, /EXTENSION_VERSION = "2026-09-07-modern-collection"/); + assert.match(background, /REQUIRED_ZHILIAN_CONTENT_VERSION = "2026-09-07-modern-collection"/); }); test("both platforms bind cursors, dedupe, submissions and progress to profileId", () => { diff --git a/chrome-extension/tests/zhilian-scan-support.test.cjs b/chrome-extension/tests/zhilian-scan-support.test.cjs index aba91a9..323693e 100644 --- a/chrome-extension/tests/zhilian-scan-support.test.cjs +++ b/chrome-extension/tests/zhilian-scan-support.test.cjs @@ -17,7 +17,7 @@ test("replaces a stale Zhilian support module after extension reload", () => { const support = loadSupport(staleSupport); assert.notEqual(support, staleSupport); - assert.equal(support.version, "2026-09-07-page-status"); + assert.equal(support.version, "2026-09-07-modern-collection"); assert.equal(typeof support.isZhilianUrl, "function"); }); @@ -174,7 +174,7 @@ test("builds a Zhilian search URL with official city and salary params", () => { assert.equal( support.buildSearchUrl("Java", { cityCode: "765", salary: "10001,15000" }), - "https://www.zhaopin.com/sou/jl765/?kw=Java&sl=10001%2C15000" + "https://www.zhaopin.com/jobs?jl=765&kw=Java&sl=10001%2C15000" ); }); @@ -183,15 +183,15 @@ test("omits sl when salary is unlimited", () => { assert.equal( support.buildSearchUrl("Java", { cityCode: "765", salary: "0" }), - "https://www.zhaopin.com/sou/jl765/?kw=Java" + "https://www.zhaopin.com/jobs?jl=765&kw=Java" ); assert.equal( support.buildSearchUrl("Java", { cityCode: "765", salary: "\u4e0d\u9650" }), - "https://www.zhaopin.com/sou/jl765/?kw=Java" + "https://www.zhaopin.com/jobs?jl=765&kw=Java" ); assert.equal( support.buildSearchUrl("Java", { cityCode: "765", salary: "0000,9999999" }), - "https://www.zhaopin.com/sou/jl765/?kw=Java" + "https://www.zhaopin.com/jobs?jl=765&kw=Java" ); }); @@ -217,3 +217,24 @@ test("page readiness distinguishes login, security and loading from a usable pag } assert.equal(support.pageStatus({ hasSecurityPrompt: true, hasLoginPrompt: true }).pageState, "SECURITY_REQUIRED"); }); + +test("accepts redirected jobs searches only with matching keyword, city, salary and page", () => { + const support = loadSupport(); + const config = { cityCode: "489" }; + for (const url of ["https://www.zhaopin.com/jobs?jl=489&kw=AI产品运营", "https://www.zhaopin.com/jobs/?jl=489&kw=AI产品运营", "https://www.zhaopin.com/sou/jl489/?kw=AI产品运营"]) { + assert.equal(support.matchesSearchUrl(url, "AI产品运营", config), true); + } + for (const url of ["https://www.zhaopin.com/jobs?jl=765&kw=AI产品运营", "https://www.zhaopin.com/jobs?jl=489&kw=Java", "https://www.zhaopin.com/jobs?jl=489&kw=AI产品运营&sl=10001,15000", "https://www.zhaopin.com/jobs/?pageMode=recommend", "https://evilzhaopin.com/jobs?jl=489&kw=AI产品运营"]) { + assert.equal(support.matchesSearchUrl(url, "AI产品运营", config), false); + } + assert.equal(support.matchesSearchUrl("https://www.zhaopin.com/sou/jl489/?kw=Java&p=2", "Java", config, 2), true); + assert.equal(support.matchesSearchUrl("https://www.zhaopin.com/sou/jl489/?kw=Java&p=2", "Java", config, 1), false); +}); + +test("normalizes official HTTP detail links without relaxing the origin or protocol boundary", () => { + const support = loadSupport(); + assert.equal(support.normalizeJobUrl("http://www.zhaopin.com/jobdetail/CC100J200.htm"), "https://www.zhaopin.com/jobdetail/CC100J200.htm"); + for (const value of ["http://evilzhaopin.com/jobdetail/CC100J200.htm", "https://www.zhaopin.com.evil.test/jobdetail/CC100J200.htm", "javascript:alert(1)", "http://www.zhaopin.com/companydetail/CC100.htm", "https://user:password@www.zhaopin.com/jobdetail/CC100.htm"]) { + assert.equal(support.normalizeJobUrl(value), ""); + } +}); diff --git a/chrome-extension/zhilian-content.js b/chrome-extension/zhilian-content.js index 0891e61..15d1d9e 100644 --- a/chrome-extension/zhilian-content.js +++ b/chrome-extension/zhilian-content.js @@ -1,5 +1,5 @@ (function () { - const EXTENSION_VERSION = "2026-09-07-zhilian-page-status"; + const EXTENSION_VERSION = "2026-09-07-modern-collection"; 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; @@ -583,7 +583,9 @@ } const jobs = collectionResult.jobs; - postProgress(task, "info", `智联 Chrome已按配置采集 ${collectionResult.candidateCount} 个候选岗位,将进入 ${jobs.length}/${searchJobLimit} 个详情页做AI比对`, { + postProgress(task, "info", collectionResult.detailsComplete + ? `智联已读取 ${jobs.length}/${searchJobLimit} 个完整岗位,准备提交后台AI队列` + : `智联 Chrome已按配置采集 ${collectionResult.candidateCount} 个候选岗位,将进入 ${jobs.length}/${searchJobLimit} 个详情页做AI比对`, { ...baseMeta, stage: "details", collected: jobs.length, @@ -593,21 +595,21 @@ const detailTask = { ...baseTask, phase: "detail", - detailIndex: 0, + detailIndex: collectionResult.detailsComplete ? jobs.length : 0, jobs, collectedJobs: [], searchPage: 1, pagesScanned: 0 }; await storeScanTask(detailTask); - postProgress(task, "info", `智联 Chrome正在查看详情 1/${jobs.length}:${jobs[0].title}`, { + if (!collectionResult.detailsComplete) postProgress(task, "info", `智联 Chrome正在查看详情 1/${jobs.length}:${jobs[0].title}`, { ...baseMeta, stage: "details", collected: jobs.length, detailIndex: 1, detailTotal: jobs.length }); - const firstNavigation = await navigateToDetail(task, jobs[0].url); + const firstNavigation = collectionResult.detailsComplete ? { status: "ready" } : await navigateToDetail(task, jobs[0].url); if (firstNavigation.status === "pending") { return { success: true, saved: totalSaved, pendingNavigation: true }; } @@ -737,7 +739,116 @@ }; } + async function collectModernZhilianJobs(task, baseTask, keyword, config, searchJobLimit, baseMeta, totalSaved) { + const collector = window.GetJobsZhilianModernCollector; + if (!collector) throw new Error("智联新版采集脚本未加载,请重新加载扩展并刷新智联页面"); + const resuming = task.modernKeyword === keyword; + const jobs = resuming ? normalizeCollectedJobs(task.collectedJobs).filter(job => job.detailVerified === true) : []; + const seenIds = new Set([...(resuming ? task.modernSeenIds || [] : []), ...jobs.map(job => job.id)]); + const visitedCards = new WeakSet(); + let historyDuplicateCount = resuming ? Number(task.historyDuplicateCount || 0) : 0; + let detailFailures = resuming ? Number(task.modernDetailFailures || 0) : 0; + let rounds = 0; + let stagnant = 0; + const startedAt = Date.now(); + let stopReason = ""; + const checkpoint = async () => storeScanTask({ + ...baseTask, phase: "collecting", searchLayout: "split", modernKeyword: keyword, searchPage: 1, + navigationAttempts: 0, navigationStartedAt: 0, + collectedJobs: jobs, modernSeenIds: [...seenIds], modernDetailFailures: detailFailures, + historyDuplicateCount, totalSaved + }); + const pausedForBlock = async () => handleBlockingState({ + ...baseTask, phase: "collecting", searchPage: 1, collectedJobs: jobs, + modernKeyword: keyword, modernSeenIds: [...seenIds], modernDetailFailures: detailFailures, historyDuplicateCount, totalSaved + }, buildPageBlockDiagnostics(), baseMeta); + + await waitForJobCards(); + if (await hasStopRequested()) return { stopped: true, jobs }; + const initialBlock = await pausedForBlock(); + if (initialBlock) return { paused: true, jobs, message: initialBlock.message }; + // These seeds carry IDs but only cover the initial response. Never use their + // count or a synthetic page number as evidence of further loaded results. + const seeds = collectZhilianInitialStateJobs(keyword); + const seedDedupe = await filterZhilianDuplicateJobs(seeds, task, keyword); + const freshSeedIds = new Set(seedDedupe.jobs.map(job => job.id)); + for (const seed of seeds) { + if (!freshSeedIds.has(seed.id) && !seenIds.has(seed.id)) { + seenIds.add(seed.id); + historyDuplicateCount++; + } + } + + while (!(stopReason = zhilianCollectionStopReason({ target: searchJobLimit, fresh: jobs.length, + pages: rounds, stagnantPages: stagnant, elapsedMs: Date.now() - startedAt }))) { + if (await hasStopRequested()) return { stopped: true, jobs }; + if (!isCurrentSearchPage(keyword, config, 1)) throw new Error("智联搜索条件已改变,已保留采集断点"); + const blocked = await pausedForBlock(); + if (blocked) return { paused: true, jobs, message: blocked.message }; + const cards = Array.from(document.querySelectorAll(".job-list-panel .job-card")); + const countBefore = cards.length; + for (const card of cards) { + if (jobs.length >= searchJobLimit || Date.now() - startedAt >= 180000) break; + if (await hasStopRequested()) return { stopped: true, jobs }; + if (visitedCards.has(card)) continue; + visitedCards.add(card); + const summary = collector.readCard(card); + const matches = seeds.filter(seed => seed.title === summary.title && seed.company === summary.company && seed.salary === summary.salary); + const expectedId = matches.length === 1 ? matches[0].id : ""; + if (expectedId && seenIds.has(expectedId)) continue; + let job = null; + for (let attempt = 0; attempt < 2 && !job; attempt++) { + job = await collector.selectAndRead(document, card, { expectedId, sleep, shouldStop: hasStopRequested }); + if (await hasStopRequested()) return { stopped: true, jobs }; + const block = await pausedForBlock(); + if (block) return { paused: true, jobs, message: block.message }; + } + if (!job) { + detailFailures++; + postProgress(task, "warning", `智联岗位详情未通过身份或正文校验,跳过:${summary.title}`, { ...baseMeta, stage: "collecting", detailFailures }); + await checkpoint(); + continue; + } + if (seenIds.has(job.id)) continue; + job.keyword = keyword; + const dedupe = await filterZhilianDuplicateJobs([job], task, keyword); + seenIds.add(job.id); + historyDuplicateCount += dedupe.duplicateCount; + jobs.push(...dedupe.jobs); + await checkpoint(); + postProgress(task, "info", `智联新版列表:已读取完整详情 ${jobs.length}/${searchJobLimit} 个,历史重复 ${historyDuplicateCount} 个,详情失败 ${detailFailures} 个。`, { + ...baseMeta, stage: "collecting", collected: jobs.length, candidateCount: seenIds.size, + historyDuplicates: historyDuplicateCount, detailFailures, jobId: job.id, jobTitle: job.title + }); + } + if (jobs.length >= searchJobLimit) break; + const lastCard = document.querySelector(".job-list-panel")?.lastElementChild; + lastCard?.scrollIntoView({ block: "end" }); + window.scrollBy(0, Math.max(600, window.innerHeight || 0)); + let grew = false; + for (let poll = 0; poll < 10 && !await hasStopRequested(); poll++) { + await sleep(500); + if (document.querySelectorAll(".job-list-panel .job-card").length > countBefore) { grew = true; break; } + } + stagnant = grew ? 0 : stagnant + 1; + rounds++; + } + await checkpoint(); + stopReason = jobs.length >= searchJobLimit ? "target_reached" : stopReason; + postProgress(task, jobs.length >= searchJobLimit ? "info" : "warning", + `智联完整岗位详情 ${jobs.length}/${searchJobLimit} 个,历史重复 ${historyDuplicateCount} 个,详情失败 ${detailFailures} 个。${collectionStopReasonLabel(stopReason)}`, { + ...baseMeta, stage: "collecting", collected: jobs.length, historyDuplicates: historyDuplicateCount, detailFailures, stopReason + }); + if (!jobs.length && !seeds.length && !document.querySelector(".job-card")) { + throw new Error("智联未识别到有效岗位数据,请检查空结果、登录或页面结构;未提交AI分析"); + } + return { jobs, detailsComplete: true, empty: !jobs.length, candidateCount: seenIds.size, pagesScanned: rounds, stopReason }; + } + async function collectJobsAcrossSearchPages(task, baseTask, keyword, config, searchJobLimit, baseMeta, totalSaved) { + if (/^\/jobs\/?$/i.test(window.location.pathname)) { + return await collectModernZhilianJobs(task, baseTask, keyword, config, searchJobLimit, baseMeta, totalSaved); + } let collectedJobs = normalizeCollectedJobs(task.collectedJobs); const seenJobUrls = new Set(collectedJobs.map((job) => normalizeJobUrlKey(job)).filter(Boolean)); let pageNumber = Math.max(1, Number(task.searchPage || currentSearchPageNumber() || 1)); @@ -1536,13 +1647,11 @@ } function enrichZhilianJobFromCurrentDetail(job, message, detailIndex, detailTotal) { - const listDescription = job.description || ""; try { const detailText = zhilianDetailDescription(); - const fullText = compact(document.body?.innerText || ""); const tags = zhilianDetailTags(); const detailUrl = isZhilianJobDetailUrl(window.location.href) ? window.location.href : job.url; - const description = detailText || stripCompanyOnlyText(fullText) || listDescription; + const description = detailText || ""; return { ...job, title: zhilianDetailTitle() || job.title, @@ -1556,10 +1665,10 @@ url: detailUrl }; } catch (error) { - postProgress(message, "warning", `智联 Chrome详情读取失败,改用列表文本:${job.title}`); + postProgress(message, "warning", `智联 Chrome详情读取失败,标记信息不足:${job.title}`); return { ...job, - description: stripCompanyOnlyText(listDescription), + description: "", detailIndex, detailTotal }; @@ -2448,30 +2557,8 @@ } function isCurrentSearchPage(keyword, config, pageNumber = 1) { - try { - const current = new URL(window.location.href); - if (current.protocol !== "https:" || !/(^|\.)zhaopin\.com$/i.test(current.hostname)) return false; - if (!current.pathname.startsWith("/sou")) return false; - - const expectedPage = Math.max(1, Math.floor(Number(pageNumber) || 1)); - const currentPage = currentSearchPageNumber(); - if (currentPage !== expectedPage) return false; - - const target = new URL(buildSearchUrl(keyword, config, pageNumber)); - const currentKeyword = current.searchParams.get("kw") || current.searchParams.get("keyword") || current.searchParams.get("query") || ""; - const targetKeyword = target.searchParams.get("kw") || keyword; - if (compact(currentKeyword) === compact(targetKeyword) || decodeURIComponentSafe(currentKeyword) === keyword) return true; - - const keywordInPath = current.pathname.match(/\/kw([^/]+)/); - if (!keywordInPath) return false; - const encodedKeyword = encodeURIComponent(keyword); - const rawKeyword = keywordInPath[1] || ""; - if (rawKeyword === encodedKeyword || decodeURIComponentSafe(rawKeyword) === keyword) return true; - const pageText = compact([document.title, document.body?.innerText || ""].filter(Boolean).join(" ")); - return Boolean(rawKeyword && pageText.includes(keyword)); - } catch { - return false; - } + return typeof SCAN_SUPPORT.matchesSearchUrl === "function" + && SCAN_SUPPORT.matchesSearchUrl(window.location.href, keyword, config, pageNumber); } async function waitForJobCards() { @@ -2802,12 +2889,11 @@ function zhilianDetailDescription() { const selectors = [ + ".job-description__content", "[class*='job-sec-text']", "[class*='job-sec']", "[class*='job-description']", "[class*='jobDescription']", - "[class*='job-detail']", - "[class*='jobDetail']", "[class*='describ']", "[class*='responsibility']", "[class*='position-detail']", @@ -2822,6 +2908,7 @@ function zhilianDetailTitle() { return cleanJobTitle(textOf(document, [ + ".job-detail-summary__title-text", "[class*='job-title']", "[class*='jobTitle']", "[class*='jobname']", @@ -2918,14 +3005,8 @@ } function normalizeZhilianJobUrl(rawUrl) { - try { - const parsed = new URL(rawUrl || "", window.location.origin); - if (parsed.protocol !== "https:" || !/(^|\.)zhaopin\.com$/i.test(parsed.hostname)) return ""; - parsed.hash = ""; - return parsed.href; - } catch { - return String(rawUrl || ""); - } + return typeof SCAN_SUPPORT.normalizeJobUrl === "function" + ? SCAN_SUPPORT.normalizeJobUrl(rawUrl, window.location.origin) : ""; } function resolveZhilianJobUrl(linkOrUrl) { @@ -2971,7 +3052,7 @@ } function isZhilianSearchPath(pathname) { - return /^\/sou(\/|$)/.test(String(pathname || "").toLowerCase()); + return /^(?:\/sou(?:\/|$)|\/jobs\/?$)/.test(String(pathname || "").toLowerCase()); } function isCurrentZhilianJobDetailPage(expectedUrl) { diff --git a/chrome-extension/zhilian-modern-collector.js b/chrome-extension/zhilian-modern-collector.js new file mode 100644 index 0000000..7a5a27e --- /dev/null +++ b/chrome-extension/zhilian-modern-collector.js @@ -0,0 +1,71 @@ +(function (root) { + const VERSION = "2026-09-07-modern-collection"; + const text = (node) => String(node?.innerText || node?.textContent || "").replace(/\s+/g, " ").trim(); + const field = (node, selector) => text(node?.querySelector(selector)); + const idFromUrl = (url) => String(url || "").match(/\/jobdetail\/([^/?#.]+)\.htm/i)?.[1] || ""; + + function readCard(card) { + const titleNode = card.querySelector(".job-card__title-clamp [aria-label]"); + const tags = field(card, ".job-card__skill-tags"); + return { + title: titleNode?.getAttribute("aria-label") || field(card, ".job-card__title-clamp"), + company: field(card, ".job-card__company-name"), + salary: field(card, ".job-card__salary"), + location: field(card, ".job-card__location"), + experience: tags.match(/经验不限|不限经验|在校\/应届|应届|[0-9]+-[0-9]+年|[0-9]+年以内|[0-9]+年以上/)?.[0] || "", + degree: tags.match(/学历不限|本科|大专|硕士|博士|高中|中专/)?.[0] || "" + }; + } + + function readDetail(document, card, expectedId = "") { + const panel = document.querySelector(".job-split-layout__right"); + if (!panel || !card?.classList.contains("job-card--active")) return null; + const item = readCard(card); + const title = field(panel, ".job-detail-summary__title-text"); + const url = root.GetJobsZhilianScanSupport.normalizeJobUrl(panel.querySelector('a[href*="/jobdetail/"]')?.getAttribute("href")); + const id = idFromUrl(url); + const description = field(panel, ".job-description__content"); + if (!id || (expectedId && id !== expectedId) || title !== item.title || description.length < 30 || !item.company) return null; + const tags = Array.from(panel.querySelectorAll(".job-detail-summary__tag")).map(text); + return { + ...item, id, url, title, description, + salary: field(panel, ".job-detail-summary__salary") || item.salary, + location: tags[0] || item.location, + experience: tags.find(t => /经验|应届|\d.*年/.test(t) && !/发布/.test(t)) || item.experience, + degree: tags.find(t => /学历|本科|大专|硕士|博士|高中|中专/.test(t)) || item.degree, + source: "zhilian-split-panel", + detailVerified: true + }; + } + + async function selectAndRead(document, card, { expectedId = "", sleep, shouldStop }) { + if (!expectedId) { + const summary = readCard(card); + const indistinguishable = Array.from(document.querySelectorAll(".job-list-panel .job-card")) + .filter(candidate => JSON.stringify(readCard(candidate)) === JSON.stringify(summary)); + // There is no DOM identity to distinguish identical cards. Do not guess + // which backend job a cached panel belongs to; report and skip instead. + if (indistinguishable.length > 1) return null; + } + const previousUrl = document.querySelector('.job-split-layout__right a[href*="/jobdetail/"]')?.getAttribute("href") || ""; + const wasActive = card.classList.contains("job-card--active"); + // Select only the title area; company, chat and application controls are never clicked. + card.querySelector(".job-card__title-clamp")?.click(); + let lastSignature = ""; + for (let attempt = 0; attempt < 30; attempt++) { + if (await shouldStop()) return null; + await sleep(500); + const job = readDetail(document, card, expectedId); + if (!job || (!wasActive && idFromUrl(previousUrl) === job.id)) { + lastSignature = ""; + continue; + } + const signature = `${job.id}\n${job.description}\n${job.salary}\n${job.location}`; + if (signature === lastSignature) return job; + lastSignature = signature; + } + return null; + } + + root.GetJobsZhilianModernCollector = Object.freeze({ version: VERSION, readCard, readDetail, selectAndRead }); +})(typeof window === "undefined" ? globalThis : window); diff --git a/chrome-extension/zhilian-scan-support.js b/chrome-extension/zhilian-scan-support.js index 092ff2f..945c872 100644 --- a/chrome-extension/zhilian-scan-support.js +++ b/chrome-extension/zhilian-scan-support.js @@ -1,5 +1,5 @@ (function (root) { - const SUPPORT_VERSION = "2026-09-07-page-status"; + const SUPPORT_VERSION = "2026-09-07-modern-collection"; if (root.GetJobsZhilianScanSupport?.version === SUPPORT_VERSION) return; const DEFAULT_CITY_CODE = "489"; @@ -129,7 +129,7 @@ function isZhilianSearchUrl(value) { if (!isZhilianUrl(value)) return false; try { - return /^\/sou(?:\/|$)/i.test(new URL(String(value)).pathname); + return /^(?:\/sou(?:\/|$)|\/jobs\/?$)/i.test(new URL(String(value)).pathname); } catch { return false; } @@ -192,10 +192,44 @@ const search = normalizedSearchParamsForCursor(config); const page = Math.max(1, Math.floor(Number(pageNumber) || 1)); const params = new URLSearchParams(); + params.set("jl", search.cityCode); params.set("kw", String(keyword || "")); if (!isUnlimitedZhilianSalary(search.salary)) params.set("sl", search.salary); - if (page > 1) params.set("p", String(page)); - return `https://www.zhaopin.com/sou/jl${search.cityCode}/?${params.toString()}`; + // Explicit page numbers are only used by the legacy paged layout. + if (page > 1) { + params.delete("jl"); + params.set("p", String(page)); + return `https://www.zhaopin.com/sou/jl${search.cityCode}/?${params.toString()}`; + } + return `https://www.zhaopin.com/jobs?${params.toString()}`; + } + + function matchesSearchUrl(value, keyword, config = {}, pageNumber = 1) { + if (!isZhilianSearchUrl(value)) return false; + const current = new URL(value); + const search = normalizedSearchParamsForCursor(config); + const city = current.searchParams.get("jl") || current.pathname.match(/\/jl(\d+)/i)?.[1]; + let word = current.searchParams.get("kw") || current.searchParams.get("keyword") || current.searchParams.get("query"); + if (!word) { + try { word = decodeURIComponent(current.pathname.match(/\/kw([^/]+)/)?.[1] || ""); } catch { return false; } + } + const salary = current.searchParams.get("sl") || DEFAULT_SALARY_CODE; + const page = Number(current.searchParams.get("p") || current.searchParams.get("page") || current.searchParams.get("pageIndex") || current.pathname.match(/\/p(\d+)/)?.[1] || 1); + return compact(word).toLowerCase() === compact(keyword).toLowerCase() + && city === search.cityCode && salary === search.salary + && page === Math.max(1, Math.floor(Number(pageNumber) || 1)); + } + + function normalizeJobUrl(value, origin = "https://www.zhaopin.com") { + try { + const url = new URL(String(value || ""), origin); + if (!/^(?:www\.)?zhaopin\.com$|^jobs\.zhaopin\.com$/i.test(url.hostname) + || url.username || url.password || url.port) return ""; + if (url.protocol === "http:" && /^\/(?:jobdetail|job_detail|positiondetail|job)\//i.test(url.pathname)) url.protocol = "https:"; + if (url.protocol !== "https:") return ""; + url.hash = ""; + return url.href; + } catch { return ""; } } function pageStatus({ hasLoginPrompt = false, hasSecurityPrompt = false, loading = false } = {}) { @@ -206,6 +240,8 @@ } root.GetJobsZhilianScanSupport = Object.freeze({ + matchesSearchUrl, + normalizeJobUrl, pageStatus, version: SUPPORT_VERSION, DEFAULT_CITY_CODE, diff --git a/docs/zhilian-modern-collection-repair.md b/docs/zhilian-modern-collection-repair.md new file mode 100644 index 0000000..2954951 --- /dev/null +++ b/docs/zhilian-modern-collection-repair.md @@ -0,0 +1,38 @@ +# 智联新版搜索页采集修复 + +## 原因与修复 + +2026-09-07 实际 Chrome 页面将 `/sou/jl489/?kw=...` 转为 `/jobs?jl=489&kw=...`,旧路径判断导致反复导航五次后失败。页面初始数据有 20 条岗位,但详情 URL 使用 HTTP,被旧校验过滤。滚动后 DOM 卡片增至 40 条,而内嵌 JSON 仍只有最初 20 条。 + +- 搜索检查同时识别 `/sou` 和 `/jobs`,核对关键词、城市、薪资及页码。新版列表使用正常滚动加载,不伪造分页 URL。 +- 仅将智联本站合法的 HTTP 岗位详情 URL 规范化到 HTTPS,仍拒绝伪装域名、凭据 URL 和不受支持的协议。 +- 逐张点击岗位标题,等待右侧岗位 ID、标题与稳定正文;对已知岗位校验预期 ID。无法区分的相同摘要卡片没有独立身份时跳过,不能猜测归属。 +- 从限定区域读取正文和基本字段,保留列表的招聘公司,避免代招客户公司或推荐职位污染数据。已确认的详情直接提交,不重复打开详情页。 +- 采集进度按关键词和稳定岗位 ID 保存;后台去重后继续寻找新岗位。身份、正文校验失败不提交为完整岗位;登录/验证/接口失败保留对应暂停状态。 +- 重扫更新保留已有非空核心字段和更完整的描述;保留原档案范围、AI 状态和投递状态。 +- 修正批处理测试的竞态:先持久化十个任务,再启动工作线程验证两个五条批次,避免把正常的流式小批次当作失败。 + +## 扩展更新 + +Chrome Bridge 版本为 `1.6.7`,保持原扩展 ID 和权限。使用本修复工作树的 `chrome-extension` 目录加载已解压扩展,然后刷新智联页面和本地工作台。不得仅刷新其他工作树的旧版本目录。 + +## 自动验证 + +前端 DOM 测试使用 Node 24,与 CI 一致;锁定的 jsdom/undici 依赖要求 Node >= 22.19,Node 20 无法启动测试环境。 + +```powershell +node scripts/validate-chrome-extension.mjs +node --test chrome-extension/tests/*.test.cjs +pnpm --dir front exec vitest run lib/zhilian-modern-collector.test.ts +pnpm --dir front exec tsc --noEmit +pnpm --dir front build +.\gradlew.bat test --no-daemon --console=plain +``` + +DOM 用例使用按真实页面结构制作的脱敏样例,包含同名岗位、详情延迟切换、无链接卡片、首屏后追加、历史重复、断点恢复及完整详情只提交一次。CI 执行该 DOM 测试。 + +## 真实验收与回退 + +真实验收配置为“AI产品运营、全国、20 个岗位”,沿用当前档案和 AI Provider;核对同一扫描批次的有效岗位、入库、队列终态及分析页面,不执行投递、聊天或自动重试未知 AI 结果。自动测试通过不等于真实验收通过。 + +上线前备份共享数据库、RunDock 进程记录和旧扩展。服务切换必须获准后由 RunDock 执行;确认监听进程归属和 `/api/ready`。如需回退,恢复旧受管进程的启动目录/参数,并加载备份扩展;代码用普通 revert 回退,不覆盖用户数据或回灌旧数据库。 diff --git a/front/lib/zhilian-modern-collector.test.ts b/front/lib/zhilian-modern-collector.test.ts new file mode 100644 index 0000000..5a7cc44 --- /dev/null +++ b/front/lib/zhilian-modern-collector.test.ts @@ -0,0 +1,170 @@ +import { readFileSync } from 'node:fs' +import { runInNewContext } from 'node:vm' +import { resolve } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +type Job = { id: string; title: string; company: string; description: string; url: string; detailVerified: boolean } +type Collector = { + readCard(card: Element): Partial + readDetail(document: Document, card: Element, expectedId?: string): Job | null + selectAndRead(document: Document, card: Element, hooks: { expectedId?: string; sleep: () => Promise; shouldStop: () => Promise }): Promise +} +const scope: Record = {} +for (const file of ['zhilian-scan-support.js', 'zhilian-modern-collector.js']) { + runInNewContext(readFileSync(resolve(process.cwd(), '../chrome-extension', file), 'utf8'), { window: scope, URL, URLSearchParams }) +} +const collector = scope.GetJobsZhilianModernCollector as Collector +const description = '岗位职责:负责产品需求分析、运营推广和数据跟踪。任职要求:熟悉人工智能产品并具备项目交付经验。' + +// Sanitized fixture follows the live /jobs split layout: cards deliberately +// have no job link; the currently selected detail exposes its canonical URL. +function addCard(title = 'AI产品运营', company = '招聘服务公司') { + const card = document.createElement('div') + card.className = 'job-card' + card.innerHTML = `
${title}
8000-12000元
本科 3-5年
${company}
北京 海淀
` + document.querySelector('.job-list-panel')!.append(card) + return card +} +function showDetail(card: Element, id = 'CC100J200', content = description) { + document.querySelectorAll('.job-card').forEach(c => c.classList.remove('job-card--active')) + card.classList.add('job-card--active') + document.querySelector('.job-split-layout__right')!.innerHTML = `

${collector.readCard(card).title}8000-12000元

北京·海淀区3-5年本科客户公司:某客户
${content}
不应混入的公司介绍
查看更多信息` +} + +describe('Zhilian modern split list', () => { + beforeEach(() => { document.body.innerHTML = '
' }) + + it('extracts core fields and exact JD from a linkless card, keeping the recruiter company', () => { + const card = addCard() + showDetail(card) + expect(card.querySelector('a[href]')).toBeNull() + expect(collector.readDetail(document, card, 'CC100J200')).toMatchObject({ + id: 'CC100J200', title: 'AI产品运营', company: '招聘服务公司', salary: '8000-12000元', + location: '北京·海淀区', experience: '3-5年', degree: '本科', description, detailVerified: true + }) + }) + + it('rejects stale detail IDs, inactive cards and incomplete descriptions', () => { + const a = addCard(), b = addCard('其他岗位') + showDetail(a) + expect(collector.readDetail(document, a, 'CC100J999')).toBeNull() + expect(collector.readDetail(document, b)).toBeNull() + showDetail(a, 'CC100J200', '加载中') + expect(collector.readDetail(document, a)).toBeNull() + }) + + it('waits for changed identity and stable body when two cards share the same title', async () => { + const a = addCard(), b = addCard() + showDetail(a) + let ticks = 0 + b.querySelector('.job-card__title-clamp')!.addEventListener('click', () => { + a.classList.remove('job-card--active'); b.classList.add('job-card--active') + }) + const job = await collector.selectAndRead(document, b, { + expectedId: 'CC100J201', shouldStop: async () => false, + sleep: async () => { if (++ticks === 3) showDetail(b, 'CC100J201') } + }) + expect(job?.id).toBe('CC100J201') + expect(ticks).toBeGreaterThanOrEqual(4) + }) + + it('times out rather than assigning an unchanged right panel to another card', async () => { + const a = addCard(), b = addCard() + showDetail(a) + b.querySelector('.job-card__title-clamp')!.addEventListener('click', () => { + a.classList.remove('job-card--active'); b.classList.add('job-card--active') + }) + expect(await collector.selectAndRead(document, b, { expectedId: 'CC100J201', sleep: async () => {}, shouldStop: async () => false })).toBeNull() + }) + + it('rejects indistinguishable cards when there is no independently known job identity', async () => { + const a = addCard(), b = addCard(); showDetail(b) + expect(await collector.selectAndRead(document, a, { sleep: async () => {}, shouldStop: async () => false })).toBeNull() + expect(await collector.selectAndRead(document, b, { sleep: async () => {}, shouldStop: async () => false })).toBeNull() + }) + + it('reads appended cards beyond the immutable first twenty without requiring their links', async () => { + for (let i = 0; i < 20; i++) addCard(`初始岗位${i}`) + const appended = addCard('滚动新增岗位') + appended.querySelector('.job-card__title-clamp')!.addEventListener('click', () => showDetail(appended, 'CC100J221')) + expect((await collector.selectAndRead(document, appended, { sleep: async () => {}, shouldStop: async () => false }))?.id).toBe('CC100J221') + }) + + it('honors cancellation without accepting the current detail', async () => { + const card = addCard(); showDetail(card) + expect(await collector.selectAndRead(document, card, { sleep: async () => {}, shouldStop: async () => true })).toBeNull() + }) + + it('continues after twenty historical duplicates and checkpoints a fresh appended job', async () => { + const seeds = Array.from({ length: 20 }, (_, i) => { + addCard(`初始岗位${i}`) + return { id: `CC100J${i}`, title: `初始岗位${i}`, company: '招聘服务公司', salary: '8000-12000元' } + }) + Element.prototype.scrollIntoView = vi.fn() + const saved: Array<{ collectedJobs: Job[]; modernSeenIds: string[] }> = [] + const support = scope.GetJobsZhilianScanSupport as { deepCollectionStopReason: (state: unknown) => string } + const code = readFileSync(resolve(process.cwd(), '../chrome-extension/zhilian-content.js'), 'utf8') + const functionSource = code.slice(code.indexOf(' async function collectModernZhilianJobs('), code.indexOf(' async function collectJobsAcrossSearchPages(')) + const context = { + window: { + GetJobsZhilianModernCollector: collector, innerHeight: 800, + scrollBy: () => { + const card = addCard('滚动新增岗位') + card.querySelector('.job-card__title-clamp')!.addEventListener('click', () => showDetail(card, 'CC100J221')) + } + }, document, Date, Set, WeakSet, + normalizeCollectedJobs: (jobs: unknown) => jobs || [], + storeScanTask: async (task: { collectedJobs: Job[]; modernSeenIds: string[] }) => { saved.push(structuredClone(task)) }, + handleBlockingState: async () => null, buildPageBlockDiagnostics: () => ({}), + waitForJobCards: async () => {}, hasStopRequested: async () => false, + collectZhilianInitialStateJobs: () => seeds, + filterZhilianDuplicateJobs: async (jobs: Job[]) => ({ jobs: jobs.filter(j => j.id === 'CC100J221'), duplicateCount: jobs.filter(j => j.id !== 'CC100J221').length }), + zhilianCollectionStopReason: support.deepCollectionStopReason, + isCurrentSearchPage: () => true, sleep: async () => {}, postProgress: () => {}, collectionStopReasonLabel: (s: string) => s + } + const collect = runInNewContext(`${functionSource}\ncollectModernZhilianJobs`, context) + const result = await collect({}, {}, 'AI产品运营', {}, 1, {}, 0) + expect(result.detailsComplete).toBe(true) + expect(result.jobs.map((j: Job) => j.id)).toEqual(['CC100J221']) + expect(saved.at(-1)?.modernSeenIds).toHaveLength(21) + expect(saved.at(-1)?.collectedJobs[0].description).toBe(description) + + // Resume uses stable IDs and already verified details; it does not click + // another card or call AI while reconstructing the collection result. + const resumed = await collect(saved.at(-1), {}, 'AI产品运营', {}, 1, {}, 0) + expect(resumed.jobs).toHaveLength(1) + expect(resumed.jobs[0].id).toBe('CC100J221') + + // If there are only historical duplicates, report an empty collection to + // the runner instead of falling through to jobs[0].title. + document.querySelector('.job-list-panel')!.lastElementChild!.remove() + context.window.scrollBy = () => {} + const duplicatesOnly = await collect({}, {}, 'AI产品运营', {}, 1, {}, 0) + expect(duplicatesOnly.empty).toBe(true) + expect(duplicatesOnly.jobs).toHaveLength(0) + }) + + it('submits verified panel jobs once without navigating them to standalone details', async () => { + const jobs = Array.from({ length: 20 }, (_, i) => ({ id: `CC100J${i}`, title: `岗位${i}`, url: `https://www.zhaopin.com/jobdetail/CC100J${i}.htm`, description, detailVerified: true })) + const source = readFileSync(resolve(process.cwd(), '../chrome-extension/zhilian-content.js'), 'utf8') + const functionSource = source.slice(source.indexOf(' async function runScanInternal('), source.indexOf(' function collectJobs(')) + const navigation = vi.fn() + const submit = vi.fn(async (task: { detailIndex: number }) => { + expect(task.detailIndex).toBe(20) + return { totalSaved: 20, totalRead: 20, totalReceived: 20, totalInsufficient: 0 } + }) + const context = { + stopRequested: false, Date, window: { location: { href: 'https://www.zhaopin.com/jobs?jl=489&kw=AI产品运营' } }, + normalizeScanTask: (m: unknown) => m, scanKeywords: () => ['AI产品运营'], normalizeTaskIndex: () => 0, + hasStopRequested: async () => false, markKeywordCursorCurrent: () => {}, buildSearchUrl: () => '', buildSearchNavigationKey: () => '', + writeScanStatus: () => {}, isCurrentSearchPage: () => true, storeScanTask: async () => {}, postProgress: () => {}, + waitForPage: async () => {}, sleep: async () => {}, normalizeSearchJobLimit: () => 20, + collectJobsAcrossSearchPages: async () => ({ jobs, candidateCount: 20, detailsComplete: true }), + navigateToDetail: navigation, continueZhilianDetailScan: submit, advanceKeywordCursor: () => {}, clearStoredScanTask: () => {} + } + const run = runInNewContext(`${functionSource}\nrunScanInternal`, context) + expect((await run({ config: {}, runId: 'test-run', currentIndex: 0 })).saved).toBe(20) + expect(navigation).not.toHaveBeenCalled() + expect(submit).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/java/com/getjobs/application/service/ZhilianService.java b/src/main/java/com/getjobs/application/service/ZhilianService.java index d834a28..45d4b2a 100644 --- a/src/main/java/com/getjobs/application/service/ZhilianService.java +++ b/src/main/java/com/getjobs/application/service/ZhilianService.java @@ -329,6 +329,19 @@ public synchronized ZhilianJobDataEntity upsertChromeJob(ZhilianJobDataEntity en } entity.setId(existing.getId()); + entity.setJobTitle(firstNonBlank(entity.getJobTitle(), existing.getJobTitle())); + entity.setJobLink(firstNonBlank(entity.getJobLink(), existing.getJobLink())); + entity.setCompanyName(firstNonBlank(entity.getCompanyName(), existing.getCompanyName())); + entity.setSalary(firstNonBlank(entity.getSalary(), existing.getSalary())); + entity.setLocation(firstNonBlank(entity.getLocation(), existing.getLocation())); + entity.setExperience(firstNonBlank(entity.getExperience(), existing.getExperience())); + entity.setDegree(firstNonBlank(entity.getDegree(), existing.getDegree())); + String incomingDescription = firstNonBlank(entity.getJobDescription(), ""); + String existingDescription = firstNonBlank(existing.getJobDescription(), ""); + if (incomingDescription == null || (existingDescription != null + && existingDescription.length() > incomingDescription.length())) { + entity.setJobDescription(existing.getJobDescription()); + } entity.setProfileId(profileId); entity.setCreateTime(existing.getCreateTime()); entity.setUpdateTime(now); diff --git a/src/test/java/com/getjobs/application/service/ChromeJobAnalysisQueueServiceTest.java b/src/test/java/com/getjobs/application/service/ChromeJobAnalysisQueueServiceTest.java index e4ef388..21ad2d5 100644 --- a/src/test/java/com/getjobs/application/service/ChromeJobAnalysisQueueServiceTest.java +++ b/src/test/java/com/getjobs/application/service/ChromeJobAnalysisQueueServiceTest.java @@ -275,7 +275,7 @@ void tenCompatibleTasksRunAsTwoBatchesWithAtMostTwoConcurrentCalls() throws Exce maxActive.accumulateAndGet(current, Math::max); entered.countDown(); try { - assertThat(release.await(2, TimeUnit.SECONDS)).isTrue(); + assertThat(release.await(10, TimeUnit.SECONDS)).isTrue(); } finally { active.decrementAndGet(); } @@ -283,15 +283,20 @@ void tenCompatibleTasksRunAsTwoBatchesWithAtMostTwoConcurrentCalls() throws Exce jobs.forEach(job -> results.put(job.taskId(), successResult())); return results; }).when(analysisService).analyzeJobs(any()); - queue = new ChromeJobAnalysisQueueService(analysisService, store); + // This test asserts dispatch of two full batches. Persist all ten tasks + // before starting workers; streaming enqueue may legitimately claim 3+2+5. for (int index = 0; index < 10; index++) { - assertThat(queue.enqueue(job(request("boss", "job-batch-" + index, "run-batch"))).isQueued()) + assertThat(store.submit(request("boss", "job-batch-" + index, "run-batch")).created()) .isTrue(); } - - assertThat(entered.await(3, TimeUnit.SECONDS)).isTrue(); - assertThat(maxActive.get()).isLessThanOrEqualTo(2); - release.countDown(); + queue = new ChromeJobAnalysisQueueService(analysisService, store); + queue.initialize(); + try { + assertThat(entered.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(maxActive.get()).isLessThanOrEqualTo(2); + } finally { + release.countDown(); + } for (int index = 0; index < 10; index++) { awaitStatus(submittedTaskId("job-batch-" + index), "SUCCEEDED"); } diff --git a/src/test/java/com/getjobs/application/service/ZhilianServiceCrossRunUpsertTest.java b/src/test/java/com/getjobs/application/service/ZhilianServiceCrossRunUpsertTest.java index 904d601..3be693e 100644 --- a/src/test/java/com/getjobs/application/service/ZhilianServiceCrossRunUpsertTest.java +++ b/src/test/java/com/getjobs/application/service/ZhilianServiceCrossRunUpsertTest.java @@ -16,6 +16,42 @@ import static org.mockito.Mockito.when; class ZhilianServiceCrossRunUpsertTest { + @Test + void incompleteRescanPreservesPreviouslyCollectedCoreFieldsAndDescription() { + ProfileService profiles = mock(ProfileService.class); + ZhilianJobDataMapper mapper = mock(ZhilianJobDataMapper.class); + ZhilianService service = new ZhilianService(null, null, mapper, null, profiles); + ZhilianJobDataEntity existing = new ZhilianJobDataEntity(); + existing.setId(11L); + existing.setJobId("CC100J200"); + existing.setJobTitle("产品运营"); + existing.setCompanyName("招聘公司"); + existing.setJobLink("https://www.zhaopin.com/jobdetail/CC100J200.htm"); + existing.setSalary("8000-12000元"); + existing.setLocation("北京"); + existing.setExperience("3-5年"); + existing.setDegree("本科"); + existing.setJobDescription("岗位职责:负责人工智能产品运营、需求收集与分析、客户培训和效果跟踪。任职要求:本科,三年以上经验。"); + when(mapper.selectOne(any(Wrapper.class))).thenReturn(existing); + when(mapper.selectById(11L)).thenReturn(existing); + ZhilianJobDataEntity incoming = new ZhilianJobDataEntity(); + incoming.setJobId("CC100J200"); + incoming.setSalary(" "); + incoming.setJobDescription("列表摘要"); + service.upsertChromeJob(incoming, "rescan", 7L); + ArgumentCaptor captor = ArgumentCaptor.forClass(ZhilianJobDataEntity.class); + verify(mapper).updateById(captor.capture()); + ZhilianJobDataEntity updated = captor.getValue(); + assertThat(updated.getJobTitle()).isEqualTo(existing.getJobTitle()); + assertThat(updated.getJobLink()).isEqualTo(existing.getJobLink()); + assertThat(updated.getCompanyName()).isEqualTo(existing.getCompanyName()); + assertThat(updated.getSalary()).isEqualTo(existing.getSalary()); + assertThat(updated.getLocation()).isEqualTo(existing.getLocation()); + assertThat(updated.getExperience()).isEqualTo(existing.getExperience()); + assertThat(updated.getDegree()).isEqualTo(existing.getDegree()); + assertThat(updated.getJobDescription()).isEqualTo(existing.getJobDescription()); + } + @Test void sameJobAcrossScanRunsUpdatesExistingRowAndPreservesWorkflowState() { ProfileService profileService = mock(ProfileService.class);