From 5d8b489bac6b620b555150f9e89184743434e94a Mon Sep 17 00:00:00 2001 From: damingishere-coder Date: Mon, 7 Sep 2026 23:51:03 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E9=80=82?= =?UTF-8?q?=E9=85=8D=E6=99=BA=E8=81=94=E6=96=B0=E7=89=88=E5=B2=97=E4=BD=8D?= =?UTF-8?q?=E9=87=87=E9=9B=86=E5=B9=B6=E4=BF=9D=E6=8A=A4=E9=87=8D=E6=89=AB?= =?UTF-8?q?=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 4 + chrome-extension/background.js | 7 +- chrome-extension/manifest.json | 4 +- .../tests/background-tab-routing.test.cjs | 2 + .../tests/boss-hr-assistant.test.cjs | 2 +- chrome-extension/tests/manifest-id.test.cjs | 2 +- .../profile-scoped-scan-contract.test.cjs | 8 +- .../tests/zhilian-scan-support.test.cjs | 31 +++- chrome-extension/zhilian-content.js | 171 +++++++++++++----- chrome-extension/zhilian-modern-collector.js | 71 ++++++++ chrome-extension/zhilian-scan-support.js | 44 ++++- docs/zhilian-modern-collection-repair.md | 36 ++++ front/lib/zhilian-modern-collector.test.ts | 170 +++++++++++++++++ .../application/service/ZhilianService.java | 13 ++ .../ChromeJobAnalysisQueueServiceTest.java | 19 +- .../ZhilianServiceCrossRunUpsertTest.java | 36 ++++ 16 files changed, 548 insertions(+), 72 deletions(-) create mode 100644 chrome-extension/zhilian-modern-collector.js create mode 100644 docs/zhilian-modern-collection-repair.md create mode 100644 front/lib/zhilian-modern-collector.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a4eae1..813b7f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -106,6 +106,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..5b149cc --- /dev/null +++ b/docs/zhilian-modern-collection-repair.md @@ -0,0 +1,36 @@ +# 智联新版搜索页采集修复 + +## 原因与修复 + +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` 目录加载已解压扩展,然后刷新智联页面和本地工作台。不得仅刷新其他工作树的旧版本目录。 + +## 自动验证 + +```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); From e2285234250319da3efe964600f05534e16de806 Mon Sep 17 00:00:00 2001 From: damingishere-coder Date: Mon, 7 Sep 2026 23:57:35 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E9=85=8D=E7=BD=AE=EF=BC=9A=E5=AF=B9?= =?UTF-8?q?=E9=BD=90=E6=99=BA=E8=81=94DOM=E6=B5=8B=E8=AF=95=E7=9A=84CI?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E7=8E=AF=E5=A2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 3 ++- docs/zhilian-modern-collection-repair.md | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 813b7f8..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 diff --git a/docs/zhilian-modern-collection-repair.md b/docs/zhilian-modern-collection-repair.md index 5b149cc..2954951 100644 --- a/docs/zhilian-modern-collection-repair.md +++ b/docs/zhilian-modern-collection-repair.md @@ -18,6 +18,8 @@ Chrome Bridge 版本为 `1.6.7`,保持原扩展 ID 和权限。使用本修复 ## 自动验证 +前端 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 From e49f6e75dc0fea95a3afbba24ae06c9b87be75cd Mon Sep 17 00:00:00 2001 From: damingishere-coder Date: Tue, 8 Sep 2026 19:45:31 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E6=99=BA?= =?UTF-8?q?=E8=81=94=E9=85=8D=E7=BD=AE=E4=B8=8B=E6=8B=89=E8=8F=9C=E5=8D=95?= =?UTF-8?q?=E4=B8=8E=E6=89=AB=E6=8F=8F=E8=87=AA=E5=8A=A8=E5=BC=80=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- chrome-extension/background.js | 35 +++++++++++++++- chrome-extension/manifest.json | 2 +- .../tests/background-tab-routing.test.cjs | 35 ++++++++++++++++ .../tests/boss-hr-assistant.test.cjs | 2 +- chrome-extension/tests/manifest-id.test.cjs | 2 +- .../profile-scoped-scan-contract.test.cjs | 4 +- docs/zhilian-scan-controls-repair.md | 34 +++++++++++++++ front/app/zhilian/page.test.tsx | 9 +++- front/app/zhilian/page.tsx | 29 +++++-------- front/components/ui/select.test.tsx | 38 +++++++++++++++++ front/components/ui/select.tsx | 42 ++++++++++++++----- front/lib/setupChecklist.ts | 7 ++-- front/lib/zhilian-page-status.test.ts | 5 +++ front/lib/zhilian-page-status.ts | 7 +++- 14 files changed, 210 insertions(+), 41 deletions(-) create mode 100644 docs/zhilian-scan-controls-repair.md create mode 100644 front/components/ui/select.test.tsx diff --git a/chrome-extension/background.js b/chrome-extension/background.js index eb06ff1..679151a 100644 --- a/chrome-extension/background.js +++ b/chrome-extension/background.js @@ -39,7 +39,8 @@ 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-modern-collection"; +const BACKGROUND_VERSION = "2026-09-08-scan-controls"; +let zhilianPagePreparation = null; const CONTENT_READY_RETRIES = 12; const CONTENT_READY_INTERVAL_MS = 250; const TAB_LOAD_TIMEOUT_MS = 10000; @@ -1873,6 +1874,13 @@ async function postPlatformProgress(pageTabId, payload) { } async function queryZhilianPageStatus(message, pageTabId) { + if (message.openIfMissing === true) { + if (!zhilianPagePreparation) { + zhilianPagePreparation = prepareZhilianPageStatus(message, pageTabId) + .finally(() => { zhilianPagePreparation = null; }); + } + return await zhilianPagePreparation; + } const tabs = (await chrome.tabs.query({})) .filter(tab => isSupportedUrl(tab.url || tab.pendingUrl || "", PLATFORM_CONFIG.zhilian)) .sort((a, b) => Number(b.lastAccessed || 0) - Number(a.lastAccessed || 0)); @@ -1889,6 +1897,31 @@ async function queryZhilianPageStatus(message, pageTabId) { || statuses[0]; } +async function prepareZhilianPageStatus(message, pageTabId) { + const passiveMessage = { ...message, openIfMissing: false }; + try { + let tabs = (await chrome.tabs.query({})) + .filter(tab => isSupportedUrl(tab.url || tab.pendingUrl || "", PLATFORM_CONFIG.zhilian)); + if (!tabs.length) { + // Use a fixed official URL; page messages cannot choose an arbitrary destination. + tabs = [await chrome.tabs.create({ url: "https://www.zhaopin.com/jobs?jl=489", active: true })]; + } + await Promise.all(tabs.filter(tab => tab.status === "loading").map(tab => + waitForSupportedTab(tab.id, PLATFORM_CONFIG.zhilian).catch(() => null) + )); + const startedAt = Date.now(); + let status; + do { + status = await queryZhilianPageStatus(passiveMessage, pageTabId); + if (status.chromePageReady || status.hasLoginPrompt || status.hasSecurityPrompt || status.pageState === "NO_TAB") return status; + await sleep(CONTENT_READY_INTERVAL_MS); + } while (Date.now() - startedAt < 8000); + return { ...status, message: status.message || "智联页面已打开,但尚未就绪,请等待页面加载完成后重试" }; + } catch (error) { + return { success: false, chromePageReady: false, message: `自动打开智联页面失败:${error?.message || String(error)}` }; + } +} + async function queryPassivePlatformStatus(tabId, platform, message, pageTabId) { if (message?.type === "BOSS_PAGE_STATUS" || platform === "zhilian") { try { diff --git a/chrome-extension/manifest.json b/chrome-extension/manifest.json index 10d8588..477a0bc 100644 --- a/chrome-extension/manifest.json +++ b/chrome-extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "投递牛马 Chrome Bridge", - "version": "1.6.7", + "version": "1.6.8", "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": { diff --git a/chrome-extension/tests/background-tab-routing.test.cjs b/chrome-extension/tests/background-tab-routing.test.cjs index 250a84e..8376632 100644 --- a/chrome-extension/tests/background-tab-routing.test.cjs +++ b/chrome-extension/tests/background-tab-routing.test.cjs @@ -1081,6 +1081,41 @@ test("Zhilian missing and loading pages remain unavailable without opening a tab assert.equal(loading.sentMessages.length, 0); }); +test("explicit Zhilian preflight opens one official page for concurrent requests and reuses it", async () => { + const harness = loadBackground({ tabs: [], statuses: { 1: { success: true, chromePageReady: true } } }); + const message = { type: "ZHILIAN_PAGE_STATUS", platform: "zhilian", openIfMissing: true, startUrl: "https://example.com/" }; + const results = await Promise.all([harness.context.queryZhilianPageStatus(message, 20), harness.context.queryZhilianPageStatus(message, 20)]); + assert.ok(results.every(result => result.chromePageReady)); + assert.equal(harness.tabList.length, 1); + assert.equal(harness.tabList[0].url, "https://www.zhaopin.com/jobs?jl=489"); + await harness.context.queryZhilianPageStatus(message, 20); + assert.equal(harness.tabList.length, 1); + assert.equal(harness.tabUpdates.length, 0); +}); + +test("explicit Zhilian preflight waits for loading pages without navigating or bypassing login", async () => { + const harness = loadBackground({ tabs: [{ id: 1, url: "https://www.zhaopin.com/jobs", status: "loading" }], statuses: { 1: { success: true, chromePageReady: false, hasLoginPrompt: true } } }); + let waited = false; + harness.context.waitForSupportedTab = async () => { waited = true; harness.tabList[0].status = "complete"; }; + const status = await harness.context.queryZhilianPageStatus({ type: "ZHILIAN_PAGE_STATUS", openIfMissing: true }, 20); + assert.equal(waited, true); + assert.equal(status.hasLoginPrompt, true); + assert.equal(status.chromePageReady, false); + assert.equal(harness.tabList.length, 1); + assert.equal(harness.tabUpdates.length, 0); +}); + +test("auto-opened Zhilian page retains security checks and returns creation failures", async () => { + const harness = loadBackground({ tabs: [], statuses: { 1: { success: true, chromePageReady: false, hasSecurityPrompt: true } } }); + const message = { type: "ZHILIAN_PAGE_STATUS", openIfMissing: true }; + assert.equal((await harness.context.queryZhilianPageStatus(message, 20)).hasSecurityPrompt, true); + const failed = loadBackground({ tabs: [] }); + failed.context.chrome.tabs.create = async () => { throw new Error("cannot create tab"); }; + const status = await failed.context.queryZhilianPageStatus(message, 20); + assert.equal(status.success, false); + assert.match(status.message, /cannot create tab/); +}); + test("rejects job navigation originating from a chat tab", async () => { const { context, tabUpdates }=loadBackground({tabs:[{id:7,url:"https://www.zhipin.com/web/geek/chat",status:"complete"}]}); diff --git a/chrome-extension/tests/boss-hr-assistant.test.cjs b/chrome-extension/tests/boss-hr-assistant.test.cjs index 2957f8c..9b4dd9f 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.7"); + assert.equal(manifest.version, "1.6.8"); }); 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 d9d166a..5ad1fea 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.7'); + assert.equal(manifest.version, '1.6.8'); 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 0462393..8c87ae5 100644 --- a/chrome-extension/tests/profile-scoped-scan-contract.test.cjs +++ b/chrome-extension/tests/profile-scoped-scan-contract.test.cjs @@ -15,8 +15,8 @@ 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.7"); - assert.match(background, /BACKGROUND_VERSION = "2026-09-07-modern-collection"/); + assert.equal(manifest.version, "1.6.8"); + assert.match(background, /BACKGROUND_VERSION = "2026-09-08-scan-controls"/); 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-modern-collection"/); diff --git a/docs/zhilian-scan-controls-repair.md b/docs/zhilian-scan-controls-repair.md new file mode 100644 index 0000000..1c4d03b --- /dev/null +++ b/docs/zhilian-scan-controls-repair.md @@ -0,0 +1,34 @@ +# 智联扫描配置与自动开页修复 + +## 行为变化 + +- 共用 Select 原先固定在按钮下方展开。现在根据可见视口上下空间决定方向,限制菜单宽高,并在滚动、窗口与可见视口变化时重新定位。 +- 每关键词后台 AI 分析岗位数改为 5 到 200、步长 5 的下拉菜单。原先保存的非 5 倍数值以“已保存”选项保留,选择新值后随扫描请求提交。 +- 仅“开始扫描”预检发送 `openIfMissing: true`。扩展没有智联标签页时打开固定官方搜索页,等待加载和页面状态;有页面则复用,不重定向已有页面。并发预检复用同一 Promise,避免重复开页。 +- 首页和定时状态检查仍为被动检查。登录提示、安全验证、未知页面状态仍阻止扫描。 +- Chrome Bridge 版本升为 1.6.8。没有数据库、配置迁移或投递逻辑变更。 + +## 验证 + +在本修复工作树根目录执行: + +```powershell +node scripts/validate-chrome-extension.mjs +node --test chrome-extension/tests/*.test.cjs +pnpm --dir front test +pnpm --dir front build +``` + +- 扩展静态校验:20 个引用文件、16 个 JavaScript 文件通过。 +- 扩展测试:121 项通过,包括无页面自动开页、并发去重、已有页面复用、加载等待、登录和安全验证拦截、开页失败,以及被动检查不创建标签页。 +- 前端测试:25 个文件、84 项通过,包括菜单底部翻转、小视口限制、选择 25 后扫描请求携带 25 和主动预检标记。 +- Next.js 生产构建及 TypeScript 检查通过。 +- 独立只读数据预览(18666)使用构建产物,在 1280 × 720 浏览器视口验证底部薪资菜单完全位于视口内,并实际选择岗位数 25。该预览没有连接生产数据库或扩展,不能代替更新后 Chrome 自动开页的实机验收。 + +## 发布与回滚 + +本修复以已部署的 PR #53 内容为基线,PR 延续其集成分支 `codex/fix-zhilian-analysis-login-20260907`,避免把未相关的历史功能带入 main。 + +合并和部署须由用户针对本次修复明确授权。部署时保留现有 RunDock 托管记录及完整配置;更新前端和扩展后,需重新加载 Chrome Bridge 1.6.8 并刷新工作台。在无智联页面的情况下点击开始扫描,验证自动开页后仍通过真实登录检查。 + +回滚可 revert 本次修复提交,并恢复之前前端产物和 Chrome Bridge 1.6.7;此修复不改数据库,无需回滚岗位或投递记录。 diff --git a/front/app/zhilian/page.test.tsx b/front/app/zhilian/page.test.tsx index 4e131b5..4b2273c 100644 --- a/front/app/zhilian/page.test.tsx +++ b/front/app/zhilian/page.test.tsx @@ -2,9 +2,10 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, expect, it, vi } from 'vitest' import Page from './page' import { sendChromeBridgeMessage } from '@/lib/chromeBridge' +import { validateSetupForPlatform } from '@/lib/setupChecklist' vi.mock('@/lib/zhilian-page-status', () => ({ getZhilianPageStatus: async () => ({ connected: true, ready: true, message: 'Chrome 智联可用' }) })) -vi.mock('@/lib/setupChecklist', () => ({ validateSetupForPlatform: async () => ({ ready: true, missing: [] }), formatSetupMissingMessage: () => '' })) +vi.mock('@/lib/setupChecklist', () => ({ validateSetupForPlatform: vi.fn(async () => ({ ready: true, missing: [] })), formatSetupMissingMessage: () => '' })) vi.mock('@/lib/chromeBridge', () => ({ sendChromeBridgeMessage: vi.fn(async () => ({ success: true })), subscribeChromeBridgeEvents: () => () => {} })) afterEach(() => vi.unstubAllGlobals()) @@ -22,8 +23,14 @@ it('配置页不再嵌入分析;启动后提供带档案与批次的独立结 expect(screen.getByRole('link', { name: '智联分析' })).toHaveAttribute('href', '/zhilian/analysis') const start = await screen.findByRole('button', { name: '开始扫描' }) await waitFor(() => expect(start).toBeEnabled()) + fireEvent.click(screen.getByRole('button', { name: '20' })) + const counts = screen.getAllByRole('option').map(option => Number(option.textContent)) + expect(counts).toEqual(Array.from({ length: 40 }, (_, index) => (index + 1) * 5)) + fireEvent.click(screen.getByRole('option', { name: '25' })) fireEvent.click(start) const link = await screen.findByRole('link', { name: '查看本次扫描结果' }) expect(link.getAttribute('href')).toMatch(/\/zhilian\/analysis\?profileId=4&scanRunId=zhilian-/) expect(vi.mocked(sendChromeBridgeMessage).mock.calls.filter(([message]) => message.type === 'ZHILIAN_SCAN_START')).toHaveLength(1) + expect(validateSetupForPlatform).toHaveBeenCalledWith('zhilian', { openPlatformPageIfMissing: true }) + expect(sendChromeBridgeMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'ZHILIAN_SCAN_START', config: expect.objectContaining({ searchJobLimit: 25 }) })) }) diff --git a/front/app/zhilian/page.tsx b/front/app/zhilian/page.tsx index a8cde9b..06c1308 100644 --- a/front/app/zhilian/page.tsx +++ b/front/app/zhilian/page.tsx @@ -7,7 +7,6 @@ import { API_BASE } from '@/lib/api' import { BiSave, BiBriefcase, BiPlay, BiStop, BiLinkExternal, BiCodeAlt } from 'react-icons/bi' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Select } from '@/components/ui/select' import Link from 'next/link' @@ -423,7 +422,7 @@ export default function ZhilianPage() { appendProgressLog({ type: 'error', message: '当前档案 ID 无效,请刷新档案后重试。' }) return } - const setup = await validateSetupForPlatform('zhilian') + const setup = await validateSetupForPlatform('zhilian', { openPlatformPageIfMissing: true }) if (!setup.ready) { const message = formatSetupMissingMessage('智联招聘', setup.missing) appendProgressLog({ type: 'error', message }) @@ -775,24 +774,18 @@ export default function ZhilianPage() {
- { - const rawValue = e.target.value - setSearchJobLimitInput(rawValue) - const parsed = Number(rawValue) - if (Number.isFinite(parsed) && parsed >= 1) { - setConfig((c) => ({ ...c, searchJobLimit: Math.min(Math.floor(parsed), 200) })) - } - }} - onBlur={() => commitSearchJobLimit(searchJobLimitInput)} + onChange={(e) => commitSearchJobLimit(e.target.value)} disabled={!hasProfile} - /> + > + {Number(searchJobLimitInput) % 5 !== 0 && ( + + )} + {Array.from({ length: 40 }, (_, index) => (index + 1) * 5).map((limit) => ( + + ))} +
diff --git a/front/components/ui/select.test.tsx b/front/components/ui/select.test.tsx new file mode 100644 index 0000000..3f15a82 --- /dev/null +++ b/front/components/ui/select.test.tsx @@ -0,0 +1,38 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { Select } from './select' + +afterEach(() => vi.unstubAllGlobals()) + +it('底部菜单向上展开,并随窗口变化重新定位;选择选项后关闭', () => { + vi.stubGlobal('innerHeight', 600) + vi.stubGlobal('innerWidth', 800) + const onChange = vi.fn() + render() + const trigger = screen.getByRole('button') + vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue({ top: 540, bottom: 580, left: 20, width: 500 } as DOMRect) + fireEvent.click(trigger) + const panel = screen.getByRole('listbox').parentElement! + expect(Number.parseFloat(panel.style.top) + Number.parseFloat(panel.style.maxHeight)).toBeLessThanOrEqual(540) + vi.stubGlobal('innerHeight', 900) + fireEvent.resize(window) + expect(panel.style.top).toBe('588px') + fireEvent.click(screen.getByRole('option', { name: '25' })) + expect(onChange).toHaveBeenCalledWith({ target: { value: '25' } }) + expect(screen.queryByRole('listbox')).not.toBeInTheDocument() +}) + +it('小窗口菜单限制高度和宽度,并支持 Escape 关闭', () => { + vi.stubGlobal('innerHeight', 180) + vi.stubGlobal('innerWidth', 300) + render() + const trigger = screen.getByRole('button') + vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue({ top: 50, bottom: 90, left: 260, width: 500 } as DOMRect) + fireEvent.click(trigger) + const panel = screen.getByRole('listbox').parentElement! + expect(panel.style.maxHeight).toBe('74px') + expect(panel.style.width).toBe('284px') + expect(panel.style.left).toBe('8px') + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.queryByRole('listbox')).not.toBeInTheDocument() +}) diff --git a/front/components/ui/select.tsx b/front/components/ui/select.tsx index cd20f36..9b2aa2a 100644 --- a/front/components/ui/select.tsx +++ b/front/components/ui/select.tsx @@ -21,7 +21,7 @@ const Select = React.forwardRef( const wrapperRef = React.useRef(null) const buttonRef = React.useRef(null) const dropdownRef = React.useRef(null) - const [dropdownPosition, setDropdownPosition] = React.useState({ top: 0, left: 0, width: 0 }) + const [dropdownPosition, setDropdownPosition] = React.useState({ top: 0, left: 0, width: 0, maxHeight: 224 }) // 确保组件已挂载(解决 SSR 问题) React.useEffect(() => { @@ -42,25 +42,43 @@ const Select = React.forwardRef( const updatePosition = React.useCallback(() => { if (buttonRef.current) { const rect = buttonRef.current.getBoundingClientRect() + const margin = 8 + const viewport = window.visualViewport + const viewportTop = viewport?.offsetTop ?? 0 + const viewportLeft = viewport?.offsetLeft ?? 0 + const viewportHeight = viewport?.height ?? window.innerHeight + const viewportWidth = viewport?.width ?? window.innerWidth + const below = Math.max(0, viewportTop + viewportHeight - rect.bottom - margin * 2) + const above = Math.max(0, rect.top - viewportTop - margin * 2) + const preferredHeight = Math.min(224, dropdownRef.current?.scrollHeight || 224) + const openAbove = below < preferredHeight && above > below + const maxHeight = Math.min(224, openAbove ? above : below) + const height = Math.min(preferredHeight, maxHeight) + const width = Math.min(rect.width, Math.max(0, viewportWidth - margin * 2)) setDropdownPosition({ - top: rect.bottom + 8, - left: rect.left, - width: rect.width, + top: Math.max(viewportTop + margin, Math.min(openAbove ? rect.top - margin - height : rect.bottom + margin, viewportTop + viewportHeight - margin - height)), + left: Math.max(viewportLeft + margin, Math.min(rect.left, viewportLeft + viewportWidth - width - margin)), + width, + maxHeight, }) } }, []) // 打开时计算位置 - React.useEffect(() => { + React.useLayoutEffect(() => { if (open) { updatePosition() // 监听滚动和窗口大小变化,更新位置 const handleUpdate = () => updatePosition() window.addEventListener('scroll', handleUpdate, true) window.addEventListener('resize', handleUpdate) + window.visualViewport?.addEventListener('resize', handleUpdate) + window.visualViewport?.addEventListener('scroll', handleUpdate) return () => { window.removeEventListener('scroll', handleUpdate, true) window.removeEventListener('resize', handleUpdate) + window.visualViewport?.removeEventListener('resize', handleUpdate) + window.visualViewport?.removeEventListener('scroll', handleUpdate) } } }, [open, updatePosition]) @@ -85,11 +103,8 @@ const Select = React.forwardRef( } if (open) { - // 使用 setTimeout 确保 DOM 已更新 - setTimeout(() => { - document.addEventListener('mousedown', handleClickOutside) - document.addEventListener('keydown', handleEscape) - }, 0) + document.addEventListener('mousedown', handleClickOutside) + document.addEventListener('keydown', handleEscape) } return () => { @@ -106,6 +121,8 @@ const Select = React.forwardRef( id={id as string} type="button" disabled={disabled} + aria-haspopup="listbox" + aria-expanded={open} onClick={() => setOpen((v) => !v)} className={cn( "flex h-10 w-full rounded-lg border border-slate-200 bg-white/90 px-4 py-2 pr-8 text-sm text-slate-800 shadow-[0_1px_2px_rgba(15,23,42,0.03)] transition-all duration-200 hover:border-blue-200 hover:bg-white dark:border-white/10 dark:bg-white/5 dark:text-slate-100", @@ -126,14 +143,17 @@ const Select = React.forwardRef( top: `${dropdownPosition.top}px`, left: `${dropdownPosition.left}px`, width: `${dropdownPosition.width}px`, + maxHeight: `${dropdownPosition.maxHeight}px`, }} > -
    +
      {options.map((o) => { const active = String(value ?? '') === String(o.value) return (
    • { } } -async function checkLogin(platform: "boss" | "zhilian"): Promise { +async function checkLogin(platform: "boss" | "zhilian", openIfMissing = false): Promise { const title = platform === "boss" ? "Boss登录状态" : "智联登录状态" const href = platform === "boss" ? "/boss" : "/zhilian" @@ -170,7 +171,7 @@ async function checkLogin(platform: "boss" | "zhilian"): Promise } } - const status = await getZhilianPageStatus() + const status = await getZhilianPageStatus({ openIfMissing }) return item("zhilianLogin", title, status.ready, status.message, "检查智联页面", href, !status.connected) } @@ -197,7 +198,7 @@ export async function validateSetupForPlatform(platform: "boss" | "zhilian", opt checkResume(), ] if (requirePlatformLogin) { - checkers.push(checkLogin(platform)) + checkers.push(checkLogin(platform, options.openPlatformPageIfMissing === true)) } const items = await Promise.all(checkers) const requiredKeys: SetupCheckKey[] = ["backend", "chromeBridge", "aiConfig", "resume"] diff --git a/front/lib/zhilian-page-status.test.ts b/front/lib/zhilian-page-status.test.ts index 954f75f..fb92b19 100644 --- a/front/lib/zhilian-page-status.test.ts +++ b/front/lib/zhilian-page-status.test.ts @@ -12,6 +12,11 @@ beforeEach(() => { }) describe('智联 Chrome 状态', () => { + it('主动启动允许自动开页,并留出加载时间;登录和安全验证仍阻止扫描', async () => { + vi.mocked(sendChromeBridgeMessage).mockResolvedValue({ success: true, chromePageReady: true, hasLoginPrompt: true }) + expect((await getZhilianPageStatus({ openIfMissing: true })).ready).toBe(false) + expect(sendChromeBridgeMessage).toHaveBeenCalledWith({ type: 'ZHILIAN_PAGE_STATUS', platform: 'zhilian', openIfMissing: true }, 35000) + }) it('Chrome 可用时不再读取后端浏览器登录状态', async () => { const fetcher = vi.fn(async (url: string) => ({ ok: true, json: async () => url.endsWith('/api/ready') ? { ready: true, status: "UP" } : { success: true, data: { introduce: '简介', prompt: '分析', resumeText: '简历' } } })) diff --git a/front/lib/zhilian-page-status.ts b/front/lib/zhilian-page-status.ts index a040859..ca5f76c 100644 --- a/front/lib/zhilian-page-status.ts +++ b/front/lib/zhilian-page-status.ts @@ -6,11 +6,14 @@ export type ZhilianPageStatus = { message: string } -export async function getZhilianPageStatus(): Promise { +export async function getZhilianPageStatus(options: { openIfMissing?: boolean } = {}): Promise { try { const bridge = await getChromeBridgeStatus() if (!bridge.success) return { connected: false, ready: false, message: bridge.message || 'Chrome扩展未连接,请加载或重新加载扩展。' } - const status = await sendChromeBridgeMessage({ type: 'ZHILIAN_PAGE_STATUS', platform: 'zhilian' }, 8000) + const status = await sendChromeBridgeMessage({ + type: 'ZHILIAN_PAGE_STATUS', platform: 'zhilian', + ...(options.openIfMissing ? { openIfMissing: true } : {}), + }, options.openIfMissing ? 35000 : 8000) const ready = status.success === true && status.chromePageReady === true && !status.hasLoginPrompt && !status.hasSecurityPrompt return {