From 5d1afbcdc877ff80224a58a982a666244a5d30ed Mon Sep 17 00:00:00 2001 From: damingishere-coder Date: Tue, 15 Sep 2026 09:30:17 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E6=96=B0=E5=A2=9E=EF=BC=9A=E4=BF=9D?= =?UTF-8?q?=E7=95=99=20BOSS=20=E7=9C=9F=E5=AE=9E=E8=84=B1=E6=95=8F?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E7=BC=BA=E5=A4=B1=E7=9A=84=E5=9B=9E=E5=BD=92?= =?UTF-8?q?=E6=A0=B7=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- chrome-extension/tests/fixtures/README.md | 4 ++- .../detail/redacted-header-only-20260915.html | 1 + chrome-extension/tests/fixtures/catalog.json | 30 +++++++++++++++++++ front/lib/recruitment-fixtures.test.ts | 12 ++++++-- 4 files changed, 43 insertions(+), 4 deletions(-) create mode 100644 chrome-extension/tests/fixtures/boss/detail/redacted-header-only-20260915.html diff --git a/chrome-extension/tests/fixtures/README.md b/chrome-extension/tests/fixtures/README.md index a7ff2bb..8adcf39 100644 --- a/chrome-extension/tests/fixtures/README.md +++ b/chrome-extension/tests/fixtures/README.md @@ -1,6 +1,8 @@ # 招聘 DOM Fixture -`catalog.json` 是样本索引,每条包含平台、页面类型、来源、HTML 路径及期望结果。当前样本全部是根据已有测试和采集器编写的 **synthetic** 样本,未访问招聘账号,`capturedAt` 为 null。不要将其改名为真实网站验收记录。 +`catalog.json` 是样本索引,每条包含平台、页面类型、来源、HTML 路径及期望结果。保留 23 份根据已有测试和采集器编写的 **synthetic** 样本(`capturedAt` 为 null),另有用户主动导出的 **structural-redacted** 样本;二者不能互相冒充,也不等于真实投递验收。 + +- `boss/detail/redacted-header-only-20260915`:用户使用 1.8.12 导出的真实结构,正文和身份已替换。只含标题区域与地址,缺 JD、公司和岗位身份,是导出不完整的故障证据。禁止用标题/地址的文本回退结果证明完整 JD 已采集。原始截图未提交。 - BOSS 列表覆盖去重、外部链接、导航伪链接、空列表及替换后的卡片;详情覆盖岗位与公司信息。 - 智联复用现代采集器,覆盖无岗位链接的列表卡片、独立详情身份、过期详情和空列表。 diff --git a/chrome-extension/tests/fixtures/boss/detail/redacted-header-only-20260915.html b/chrome-extension/tests/fixtures/boss/detail/redacted-header-only-20260915.html new file mode 100644 index 0000000..30b1501 --- /dev/null +++ b/chrome-extension/tests/fixtures/boss/detail/redacted-header-only-20260915.html @@ -0,0 +1 @@ +
示例产品运营00115-25K
脱敏文本立即沟通
示例办公地址

示例办公地址

示例办公地址

diff --git a/chrome-extension/tests/fixtures/catalog.json b/chrome-extension/tests/fixtures/catalog.json index 7c61cdf..8a4e3df 100644 --- a/chrome-extension/tests/fixtures/catalog.json +++ b/chrome-extension/tests/fixtures/catalog.json @@ -401,5 +401,35 @@ "marker": "已向对方发送简历和打招呼语", "detectorCoverage": "existing-delivery-confirmation-dom" } + }, + { + "id": "boss/detail/redacted-header-only-20260915", + "platform": "boss", + "pageType": "JOB_DETAIL", + "file": "boss/detail/redacted-header-only-20260915.html", + "provenance": { + "kind": "structural-redacted", + "capturedAt": "2026-09-15T01:19:39.4290000Z", + "reference": "user-provided boss-job_detail-fixture.json", + "redactionVersion": "structural-fixture/1", + "limitations": [ + "1.8.12 仅导出标题和地址,缺少 JD、公司及岗位身份;是故障样本,不代表完整页面或采集通过。", + "截图只用于人工核对,不纳入仓库;原文已由用户导出器替换。" + ] + }, + "expected": { + "detail": { + "title": "示例产品运营001", + "company": "", + "salary": "15-25K", + "companyAddress": "示例办公地址示例办公地址示例办公地址" + }, + "absentSelectors": [ + ".job-description", + ".job-sec-text", + ".job-detail-section", + ".company-name" + ] + } } ] diff --git a/front/lib/recruitment-fixtures.test.ts b/front/lib/recruitment-fixtures.test.ts index b20ddf0..ed16e0a 100644 --- a/front/lib/recruitment-fixtures.test.ts +++ b/front/lib/recruitment-fixtures.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest' const extension = resolve(process.cwd(), '../chrome-extension') const fixtureRoot = resolve(extension, 'tests/fixtures') -type Fixture = { id: string; platform: string; file: string; provenance: { kind: string; reference: string; capturedAt: string | null }; expected: { jobs?: Record[]; candidateCount?: number; detail?: Record; rejectedExpectedId?: string; cardCount?: number; marker?: string } } +type Fixture = { id: string; platform: string; file: string; provenance: { kind: string; reference: string; capturedAt: string | null; redactionVersion?: string; limitations?: string[] }; expected: { jobs?: Record[]; candidateCount?: number; detail?: Record; rejectedExpectedId?: string; cardCount?: number; marker?: string; absentSelectors?: string[] } } const fixtures: Fixture[] = JSON.parse(readFileSync(resolve(fixtureRoot, 'catalog.json'), 'utf8')) function load(fixture: Fixture) { const doc = document.implementation.createHTMLDocument('offline fixture') @@ -23,8 +23,13 @@ describe('versioned recruitment fixture baseline', () => { it('has unique identities and honest provenance', () => { expect(new Set(fixtures.map(f => f.id)).size).toBe(fixtures.length) for (const f of fixtures) { - expect(f.provenance.kind).toBe('synthetic') - expect(f.provenance.capturedAt).toBeNull() + expect(['synthetic', 'structural-redacted']).toContain(f.provenance.kind) + if (f.provenance.kind === 'synthetic') expect(f.provenance.capturedAt).toBeNull() + else { + expect(Number.isFinite(Date.parse(f.provenance.capturedAt!))).toBe(true) + expect(f.provenance.redactionVersion).toBeTruthy() + expect(f.provenance.limitations?.length).toBeGreaterThan(0) + } expect(f.provenance.reference).toBeTruthy() expect(f.file).toBe(`${f.id}.html`) } @@ -45,6 +50,7 @@ describe('versioned recruitment fixture baseline', () => { if (expected.rejectedExpectedId) expect(scope.GetJobsZhilianModernCollector.readDetail(doc, doc.querySelector('.job-card'), expected.rejectedExpectedId)).toBeNull() if (expected.cardCount !== undefined) expect(doc.querySelectorAll('.job-card')).toHaveLength(expected.cardCount) if (expected.marker) expect(doc.body.textContent).toContain(expected.marker) + for (const selector of expected.absentSelectors || []) expect(doc.querySelector(selector)).toBeNull() }) it('BOSS explicit parsers do not read the ambient page or mutate the supplied DOM', () => { const fixture = fixtures.find(f => f.id === 'boss/detail/full')! From 7d678b56d8652faa2db8e3815618e6ccd2117b3e Mon Sep 17 00:00:00 2001 From: damingishere-coder Date: Tue, 15 Sep 2026 09:30:18 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E6=89=A9?= =?UTF-8?q?=E5=B1=95=20BOSS=20=E6=A0=B7=E6=9C=AC=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E8=8C=83=E5=9B=B4=E5=B9=B6=E6=8F=90=E7=A4=BA=E7=BC=BA=E5=A4=B1?= =?UTF-8?q?=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- chrome-extension/fixture-export.js | 74 +++++++++++++++---- chrome-extension/fixture-popup.js | 16 ++-- chrome-extension/manifest.json | 2 +- .../tests/boss-hr-assistant.test.cjs | 2 +- chrome-extension/tests/manifest-id.test.cjs | 2 +- .../profile-scoped-scan-contract.test.cjs | 2 +- docs/dom-fixture-regression.md | 10 ++- docs/job-agent-release-acceptance.md | 2 + front/lib/fixture-export.test.ts | 38 +++++++++- .../service/BrowserRuntimeRegressionTest.java | 22 ++++++ 10 files changed, 142 insertions(+), 28 deletions(-) diff --git a/chrome-extension/fixture-export.js b/chrome-extension/fixture-export.js index c450f15..ca47c5d 100644 --- a/chrome-extension/fixture-export.js +++ b/chrome-extension/fixture-export.js @@ -1,9 +1,29 @@ (function (root) { const VERSION = "structural-fixture/1"; + const REDACTION_VERSION = "structural-fixture/2"; + const errors = Object.freeze({ + UNSUPPORTED_PAGE: "当前页面不是受支持的 BOSS / 智联 HTTPS 招聘页。", + CHAT_PAGE: "聊天页面不支持导出,请切换到岗位搜索列表或详情。", + INVALID_TYPE: "样本类型不支持,请重新选择。", + NO_STRUCTURE: "未找到该类型的白名单结构。请确认岗位已加载;页面结构可能尚未适配,不会改为保存整页。", + TOO_LARGE: "页面结构过大,已停止导出。请减少加载的岗位后重试,不会输出截断样本。", + NO_ACTIVE_TAB: "未找到当前标签页,请保持招聘页面在前台。", + PAGE_CHANGED: "页面已变化或未返回有效样本,请在页面稳定后重新生成。", + CAPTURE_FAILED: "无法读取页面,请刷新招聘标签页并重新加载扩展后再试。" + }); + const warningMessages = Object.freeze({ + MISSING_JOB_CARDS: "缺少可识别的岗位卡片结构", + MISSING_TITLE: "缺少岗位标题", + MISSING_COMPANY: "缺少公司名称", + MISSING_DESCRIPTION: "缺少职位描述(JD)", + MISSING_JOB_IDENTITY: "缺少岗位 ID 或详情链接,不能验证岗位关联" + }); + function fail(code) { const error = new Error(errors[code]); error.code = code; throw error; } const roots = Object.freeze({ boss: { - SEARCH: ".job-list-box, .search-job-result, .pagination", - JOB_DETAIL: ".job-banner, .job-detail-header, .job-description, .job-detail-section, .job-sec, .company-info, .company-name, .job-address", + // Bounded patterns already supported by boss-selectors.js. Never use body/main or generic content roots. + SEARCH: ".job-list-box, .search-job-result, .pagination, .job-card-wrapper, .job-card-body, li.job-card-box, [class*='job-card'], [class*='search-list'], [class*='result-list'], [class*='job-list']", + JOB_DETAIL: ".job-banner, .job-detail-header, .job-description, .job-detail-section, .job-sec, .job-sec-text, .job-detail, .detail-content, [class*='job-detail'], [class*='job-sec'], [class*='description'], .company-info, .company-name, .job-address", BLOCKER: ".dialog, .modal, .dialog-wrap, .login-dialog, .verify-dialog, .verify-box" }, zhilian: { @@ -14,7 +34,7 @@ }); const classes = new Set(("job-list-box search-job-result pagination next prev disabled empty loading " + "job-card-box job-card-wrapper job-card-body job-card job-name job-title company-name salary job-salary job-area tag-list " + - "job-banner job-detail-header job-description job-detail-section job-sec job-sec-text text company-info job-address " + + "job-banner job-detail-header job-description job-detail-section job-sec job-sec-text job-detail detail-content text company-info job-address " + "boss-name boss-title boss-active-time dialog modal dialog-wrap login-dialog verify-dialog verify-box " + "job-list-panel job-split-layout__right job-card--active job-card__title-clamp job-card__salary job-card__skill-tags " + "job-card__company-name job-card__location job-detail-summary__title-text job-detail-summary__salary " + @@ -34,24 +54,25 @@ ]; function platformFor(href) { const url = new URL(href); - if (url.protocol !== "https:") throw new Error("仅支持招聘网站 HTTPS 页面"); + if (url.protocol !== "https:") fail("UNSUPPORTED_PAGE"); if (/(^|\.)zhipin\.com$/i.test(url.hostname)) return "boss"; if (/(^|\.)zhaopin\.com$/i.test(url.hostname)) return "zhilian"; - throw new Error("当前页面不支持 Fixture 导出"); + fail("UNSUPPORTED_PAGE"); } // No outerHTML of the source, storage, network, page click or form reads. // Free text and attributes are substituted, not regex-scrubbed and retained. function capture(document, href, pageType) { const platform = platformFor(href); - if (/\/(chat|im|message)(\/|$)/i.test(new URL(href).pathname)) throw new Error("不导出聊天页面"); + if (/\/(chat|im|message)(\/|$)/i.test(new URL(href).pathname)) fail("CHAT_PAGE"); const selector = roots[platform][pageType]; - if (!selector) throw new Error("不支持的样本类型"); + if (!selector) fail("INVALID_TYPE"); const candidates = Array.from(document.querySelectorAll(selector)); - if (candidates.length > 200) throw new Error("匹配结构过多,已停止导出"); + if (candidates.length > 1000) fail("TOO_LARGE"); const selected = candidates - .filter(node => !node.closest(excluded)) + .filter(node => tags.has(node.localName) && !node.closest(excluded)) .filter((node, _, all) => !all.some(parent => parent !== node && parent.contains(node))); - if (!selected.length) throw new Error("未找到白名单结构;不会退回导出整个页面"); + if (selected.length > 200) fail("TOO_LARGE"); + if (!selected.length) fail("NO_STRUCTURE"); const out = document.implementation.createHTMLDocument("脱敏结构样本"); const identities = new Map(), titles = new Map(), companies = new Map(); let visited = 0; @@ -78,7 +99,7 @@ return "脱敏文本"; } function copy(node, depth = 0) { - if (++visited > 5000 || depth > 60) throw new Error("结构过大,已停止导出;不会输出不完整样本"); + if (++visited > 5000 || depth > 60) fail("TOO_LARGE"); if (node.nodeType === 3) return out.createTextNode(substitute(node.textContent, node.parentElement)); if (node.nodeType !== 1 || node.matches(excluded)) return null; if (!tags.has(node.localName)) return null; @@ -114,13 +135,40 @@ const result = copy(node); if (result) out.body.appendChild(result); } + if (!out.body.children.length) fail("NO_STRUCTURE"); + // Coverage describes retained structural markers, never claims the real text or platform flow was verified. + const count = selector => out.body.querySelectorAll(selector).length; + const coverage = { + jobCards: count(".job-card-box,.job-card-wrapper,.job-card-body,.job-card"), + titles: count(".job-name,.job-title,.job-card__title-clamp,.job-detail-summary__title-text"), + companies: count(".company-name,.job-card__company-name"), + descriptions: count(".job-description,.job-sec-text,.job-detail-section .text,.job-description__content"), + jobIdentities: count("[data-jobid],[data-job-id],[data-jid],[data-position-id],a[href*='/job_detail/'],a[href*='/jobdetail/']") + }; + const warnings = []; + if (pageType === "SEARCH" && !coverage.jobCards) warnings.push("MISSING_JOB_CARDS"); + if (pageType !== "BLOCKER") { + if (!coverage.titles) warnings.push("MISSING_TITLE"); + if (!coverage.companies) warnings.push("MISSING_COMPANY"); + if (!coverage.jobIdentities) warnings.push("MISSING_JOB_IDENTITY"); + } + if (pageType === "JOB_DETAIL" && !coverage.descriptions) warnings.push("MISSING_DESCRIPTION"); return { format: VERSION, platform, pageType, - provenance: { kind: "structural-redacted", capturedAt: new Date().toISOString(), source: "user-triggered-extension-export", content: "synthetic-replacements", redactionVersion: VERSION }, + provenance: { kind: "structural-redacted", capturedAt: new Date().toISOString(), source: "user-triggered-extension-export", content: "synthetic-replacements", redactionVersion: REDACTION_VERSION }, html: out.body.innerHTML, rootCount: selected.length, nodeCount: visited, + coverage, warnings, reviewRequired: true, limitations: ["自由文本与身份已替换,不能证明真实正文解析正确", "只保留白名单类名和有限样式,不是完整视觉快照", "岗位别名仅在本次导出内部保持关联", "不采集聊天,未知结构需要另写合成样本"] }; } - root.GetJobsFixtureExporter = Object.freeze({ capture, platformFor, version: VERSION }); + function captureSafely(document, href, pageType) { + try { return { ok: true, bundle: capture(document, href, pageType) }; } + catch (error) { return { ok: false, errorCode: Object.hasOwn(errors, error?.code) ? error.code : "CAPTURE_FAILED" }; } + } + root.GetJobsFixtureExporter = Object.freeze({ + capture, captureSafely, platformFor, version: REDACTION_VERSION, + errorMessage: code => Object.hasOwn(errors, code) ? errors[code] : errors.CAPTURE_FAILED, + warningMessage: code => Object.hasOwn(warningMessages, code) ? warningMessages[code] : "部分结构未识别" + }); })(typeof window !== "undefined" ? window : globalThis); diff --git a/chrome-extension/fixture-popup.js b/chrome-extension/fixture-popup.js index 7e8b7ff..b510127 100644 --- a/chrome-extension/fixture-popup.js +++ b/chrome-extension/fixture-popup.js @@ -15,18 +15,22 @@ status.textContent = "正在生成脱敏结构预览…"; try { const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); - if (!tab?.id || !tab.url) throw new Error("未找到当前页面"); + if (!tab?.id || !tab.url) throw { code: "NO_ACTIVE_TAB" }; const platform = window.GetJobsFixtureExporter.platformFor(tab.url); await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ["fixture-export.js"] }); if (generation !== current) return; - const result = await chrome.scripting.executeScript({ target: { tabId: tab.id }, args: [type], func: type => window.GetJobsFixtureExporter.capture(document, window.location.href, type) }); + const result = await chrome.scripting.executeScript({ target: { tabId: tab.id }, args: [type], func: type => window.GetJobsFixtureExporter.captureSafely(document, window.location.href, type) }); if (generation !== current) return; - if (result.length !== 1 || !result[0].result || result[0].result.platform !== platform) throw new Error("页面已变化或未产生样本,请重新检查"); - bundle = result[0].result; + if (result.length !== 1 || !result[0].result) throw { code: "PAGE_CHANGED" }; + const captureResult = result[0].result; + if (!captureResult.ok) throw { code: captureResult.errorCode }; + if (captureResult.bundle?.platform !== platform || captureResult.bundle.pageType !== type) throw { code: "PAGE_CHANGED" }; + bundle = captureResult.bundle; preview.value = JSON.stringify(bundle, null, 2); reviewed.disabled = false; status.textContent = `已生成 ${bundle.rootCount} 个脱敏结构。请检查预览后再下载;不代表真实网站测试通过。`; - } catch { - if (generation === current) status.textContent = "无法导出:请确认当前为支持的招聘页面、样本类型正确且扩展已重载。没有生成文件。"; + if (bundle.warnings?.length) status.textContent += ` 当前是部分结构样本:${bundle.warnings.map(code => window.GetJobsFixtureExporter.warningMessage(code)).join(";")}。可保留用于诊断,但不能作为完整采集验收。`; + } catch (error) { + if (generation === current) status.textContent = `无法导出:${window.GetJobsFixtureExporter.errorMessage(error?.code)} 没有生成文件。`; } finally { capture.disabled = false; } }); download.addEventListener("click", () => { diff --git a/chrome-extension/manifest.json b/chrome-extension/manifest.json index 04f2ecc..0b06461 100644 --- a/chrome-extension/manifest.json +++ b/chrome-extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "投递牛马 Chrome Bridge", - "version": "1.8.12", + "version": "1.8.13", "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/boss-hr-assistant.test.cjs b/chrome-extension/tests/boss-hr-assistant.test.cjs index 76b5f76..30010d2 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.includes("https://www.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.8.12"); + assert.equal(manifest.version, "1.8.13"); }); 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 73a1f39..ca2b808 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.8.12'); + assert.equal(manifest.version, '1.8.13'); 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 f825625..e3965bd 100644 --- a/chrome-extension/tests/profile-scoped-scan-contract.test.cjs +++ b/chrome-extension/tests/profile-scoped-scan-contract.test.cjs @@ -15,7 +15,7 @@ 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.8.12"); + assert.equal(manifest.version, "1.8.13"); assert.match(background, /BACKGROUND_VERSION = "2026-09-14-runtime-adapters"/); assert.match(background, /REQUIRED_BOSS_CONTENT_VERSION = "2026-09-14-boss-adapter"/); assert.match(boss, /EXTENSION_VERSION = "2026-09-14-boss-adapter"/); diff --git a/docs/dom-fixture-regression.md b/docs/dom-fixture-regression.md index 9c16e3e..ba74957 100644 --- a/docs/dom-fixture-regression.md +++ b/docs/dom-fixture-regression.md @@ -1,10 +1,10 @@ # P0.2:DOM Fixture 与纯解析入口 -本轮保护现有采集/投递行为。没有修改 BOSS content、智联执行器、后台派发、AI、后端或数据库;没有新增权限、自动采集或上传通道。扩展版本为 1.8.9。 +本轮保护现有采集/投递行为。没有修改 BOSS content、智联执行器、后台派发、AI、后端或数据库;没有新增权限、自动采集或上传通道。导出器兼容修复随扩展 1.8.13 发布。 ## 样本与运行 -`chrome-extension/tests/fixtures/catalog.json` 管理平台、页面类型、来源、日期、HTML 路径及期望结果。目前 23 份 HTML 全部是 **合成样本**,来自已有测试形状与代码约定;没有真实账号数据,没有真实网站验收结论。 +`chrome-extension/tests/fixtures/catalog.json` 管理平台、页面类型、来源、日期、HTML 路径及期望结果。保留 23 份 **合成样本**;新增一份 2026-09-15 用户主动导出的 **脱敏结构故障样本**,仅有标题和地址,不能当成完整页面或投递验收通过。没有保存账号数据或原始截图。 - BOSS:列表字段、去重、非法/导航 URL、空列表、卡片替换后的结构;详情的标题、公司、薪资、地点、经验、学历、JD、公司介绍。 - 智联:现代分栏详情、稳定岗位身份、旧详情拒绝、空列表;复用现有 `readCard/readDetail`,不重构智联。 @@ -40,12 +40,16 @@ pnpm typecheck ## 用户主动导出 -1. 在 Chrome 扩展管理页重新加载 1.8.9。打开自己正在使用的 BOSS / 智联页面,无需启动扫描。 +1. 在 Chrome 扩展管理页重新加载 1.8.13,并刷新招聘标签页。打开自己正在使用的 BOSS / 智联页面,无需启动扫描。 2. 点击工具栏上的投递牛马扩展图标,选择搜索列表、岗位详情或阻断弹窗。 3. 点击“生成脱敏预览”。只有此动作才读取当前标签页的白名单 DOM;没有白名单结构就失败,不回退整页抓取。 4. 在只读文本框检查 JSON。确认无个人信息、聊天或凭据后勾选确认框,再点击下载。 5. 下载在本地完成,没有上传或数据库写入。关闭弹窗/清除预览即丢弃内存样本;重新生成、切换类型和清除都使旧确认失效。 +1.8.13 的 BOSS 搜索/详情根范围补入现有 `boss-selectors.js` 已支持的卡片、列表和详情结构模式,仍不读取整页或保留任意类名。失败返回固定错误码及对应说明,不回显可能包含账号或地址参数的原始异常。样本额外附带 `coverage` 和 `warnings`,缺少 JD、公司、卡片或岗位身份时弹窗明确标注“部分结构样本”;可预览下载用于诊断,不能作为完整验收。`coverage` 仅统计保留下来的结构标记,不证明真实字段值或交互正确。 + +本次输入未包含搜索列表 DOM,截图无法确定其精确类名。已验证的是新增结构模式的离线回归;当天真实列表/完整 JD 的兼容性仍需用户重导验证。不要把这次兼容扩展标记为真实 Smoke 已通过。 + 导出器先构造新的脱敏 DOM,再序列化。原始整页 HTML 不先落盘,也不发送给后台。脚本、图片、表单、可编辑区、聊天和身份子树被排除;任意属性、类名、URL 参数/片段被删除。只保留固定标签、类名、少量 display/visibility/position/opacity 值、已知状态词;自由文本和身份替换为虚构内容。同名标题保持同名,岗位 ID 在一次导出内一致映射。跨导出别名不能当成稳定业务 ID。 这是 **结构脱敏样本**:能复现已知 Selector 与有限布局形状,不能证明真实 JD 或未知状态词解析正确;未允许的类名与文本会损失。遇到新结构应人工编写对应合成变体和期望结果,不能放宽为原文全量导出。超过节点/深度/根数量限制则整次失败,不输出部分成功。 diff --git a/docs/job-agent-release-acceptance.md b/docs/job-agent-release-acceptance.md index b188d99..776a6b1 100644 --- a/docs/job-agent-release-acceptance.md +++ b/docs/job-agent-release-acceptance.md @@ -31,6 +31,8 @@ 5. 核对页面成功证据、同一 requestKey 的 Attempt / 时间线。已有 UNKNOWN 只读对账,不重投。 6. 将日期、commit、扩展版本、平台、证据和 PASS / FAIL 写入验收记录。不要记录 Cookie、令牌、聊天正文或账号资料。 +2026-09-15 用户确认加载 1.8.12,并提供 BOSS 详情脱敏 JSON 与导出失败截图:详情只含标题和地址,搜索导出失败。本次记为“导出验收未通过”,已将脱敏详情保留为故障 Fixture;1.8.13 补充结构范围、失败原因和不完整提示后,等待同页面重新导出。没有据此执行真实投递或启用 Runtime。 + 真实 Smoke 未完成前不启用新 Runtime、不删除 Legacy、不宣称 Phase A 全部验收。实际 Next.js 确认链已由离线浏览器测试覆盖;招聘平台预检和副作用仍是合成场景,不能代替真人网站验收。 ## 回滚与后续使用 diff --git a/front/lib/fixture-export.test.ts b/front/lib/fixture-export.test.ts index c16405b..b87e4de 100644 --- a/front/lib/fixture-export.test.ts +++ b/front/lib/fixture-export.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it, vi } from 'vitest' const root = resolve(process.cwd(), '../chrome-extension') const source = (name: string) => readFileSync(resolve(root, name), 'utf8') -type Bundle = { html: string; platform: string; pageType: string; rootCount: number; provenance: { kind: string }; reviewRequired: boolean } +type Bundle = { html: string; platform: string; pageType: string; rootCount: number; provenance: { kind: string }; reviewRequired: boolean; warnings: string[] } type Exporter = { capture(doc: Document, href: string, type: string): Bundle } function exporter() { const scope: { GetJobsFixtureExporter?: Exporter } = {} @@ -18,6 +18,23 @@ function doc(html: string) { return d } describe('user-triggered structural redaction', () => { + it('exports BOSS collector-supported split containers without retaining unknown class names or private text', () => { + const input = doc('
PRIVATE_TITLEPRIVATE_COMPANY30-50K
PRIVATE_TITLE
PRIVATE_JD
PRIVATE_HR
') + const list = exporter().capture(input, 'https://www.zhipin.com/web/geek/jobs', 'SEARCH') + expect(doc(list.html).querySelector('.job-card-wrapper')).not.toBeNull() + expect(list.warnings).not.toContain('MISSING_JOB_CARDS') + const detail = exporter().capture(input, 'https://www.zhipin.com/web/geek/jobs', 'JOB_DETAIL') + expect(doc(detail.html).querySelector('.job-sec-text')?.textContent).toContain('岗位职责:负责示例产品') + expect(detail.warnings).not.toContain('MISSING_DESCRIPTION') + expect(JSON.stringify([list, detail])).not.toMatch(/PRIVATE|new-job-list/) + }) + it('marks the user-exported header-only detail as incomplete rather than treating header text as JD', () => { + const input = doc(readFileSync(resolve(root, 'tests/fixtures/boss/detail/redacted-header-only-20260915.html'), 'utf8')) + const bundle = exporter().capture(input, 'https://www.zhipin.com/web/geek/jobs', 'JOB_DETAIL') + expect(bundle.warnings).toContain('MISSING_DESCRIPTION') + expect(bundle.warnings).toContain('MISSING_COMPANY') + expect(bundle.html).not.toContain('岗位职责:') + }) it.each(['boss', 'zhilian'])('discards arbitrary private text and attributes on %s', platform => { const d = doc(`
PRIVATE_FREE_TEXTPRIVATE_CONTACTPRIVATE_NAME 13800138000 private@example.invalid
PRIVATE_EDITOR
PRIVATE_CHAT
PRIVATE_MESSAGE
`) const before = d.body.innerHTML @@ -91,7 +108,7 @@ function popup(url = 'https://www.zhipin.com/web/geek/jobs') { }) as typeof d.createElement) const bundle = { html: '
脱敏文本
', platform: 'boss', pageType: 'SEARCH', rootCount: 1 } const query = vi.fn(async () => [{ id: 1, url }]) - const executeScript = vi.fn(async () => [{ result: bundle }]) + const executeScript = vi.fn(async () => [{ result: { ok: true, bundle } }]) const scope = { document: d, chrome: { tabs: { query }, scripting: { executeScript } }, URL: Object.assign(class extends URL {}, { createObjectURL: blob, revokeObjectURL: vi.fn() }), Blob, setTimeout: (fn: () => void) => fn(), window: {} } runInNewContext(source('fixture-export.js'), scope) runInNewContext(source('fixture-popup.js'), scope) @@ -99,6 +116,23 @@ function popup(url = 'https://www.zhipin.com/web/geek/jobs') { return { d, query, executeScript, blob, click, button } } describe('fixture popup confirmation boundary', () => { + it('shows a safe, specific root-missing diagnostic without leaking page error text', async () => { + const h = popup() + h.executeScript.mockResolvedValueOnce([] as any).mockResolvedValueOnce([{ result: { ok: false, errorCode: 'NO_STRUCTURE', message: 'PRIVATE_ERROR_URL' } }] as any) + h.button('capture').click() + await vi.waitFor(() => expect(h.button('capture').disabled).toBe(false)) + expect(h.d.getElementById('status')?.textContent).toContain('未找到') + expect(h.d.getElementById('status')?.textContent).not.toContain('PRIVATE') + expect(h.button('download').disabled).toBe(true) + }) + it('keeps partial samples review-gated and displays missing-JD warning', async () => { + const h = popup() + h.executeScript.mockResolvedValueOnce([] as any).mockResolvedValueOnce([{ result: { ok: true, bundle: { platform: 'boss', pageType: 'SEARCH', html: '
', rootCount: 1, warnings: ['MISSING_DESCRIPTION'] } } }] as any) + h.button('capture').click() + await vi.waitFor(() => expect(h.button('reviewed').disabled).toBe(false)) + expect(h.d.getElementById('status')?.textContent).toContain('缺少职位描述') + expect(h.button('download').disabled).toBe(true) + }) it('does nothing on open; capture previews and explicit review gates download', async () => { const h = popup() expect(h.query).not.toHaveBeenCalled() diff --git a/src/test/java/com/getjobs/application/service/BrowserRuntimeRegressionTest.java b/src/test/java/com/getjobs/application/service/BrowserRuntimeRegressionTest.java index e37b74e..c40d6e6 100644 --- a/src/test/java/com/getjobs/application/service/BrowserRuntimeRegressionTest.java +++ b/src/test/java/com/getjobs/application/service/BrowserRuntimeRegressionTest.java @@ -82,6 +82,28 @@ Object probe(String body) { + "const result = await chrome.scripting.executeScript({target:{tabId:tab.id},func:async()=>{" + body + "}}); return result[0].result; }", page.url()); } + @Test void fixturePopupReportsMissingRootsAndIncompleteDetailAcrossRealInjection() throws Exception { + fixture("boss", "detail/redacted-header-only-20260915.html", ""); + String original = page.locator("body").innerHTML(); + worker.navigate(worker.url().replace("regression-probe.html", "fixture-popup.html")); + // Model the toolbar popup's active recruiting tab; all injection and exporter code stays real. + worker.evaluate("url=>{const query=chrome.tabs.query.bind(chrome.tabs);chrome.tabs.query=async()=>" + + "(await query({})).filter(tab=>tab.url===url);}", page.url()); + worker.locator("#capture").click(); + worker.getByText("未找到该类型的白名单结构", new Page.GetByTextOptions().setExact(false)).waitFor(); + assertThat(worker.locator("#download").isDisabled()).isTrue(); + worker.locator("#page-type").selectOption("JOB_DETAIL"); + worker.locator("#capture").click(); + worker.getByText("缺少职位描述(JD)", new Page.GetByTextOptions().setExact(false)).waitFor(); + var bundle = new com.fasterxml.jackson.databind.ObjectMapper().readTree(worker.locator("#preview").inputValue()); + assertThat(bundle.path("coverage").path("descriptions").asInt()).isZero(); + assertThat(bundle.path("provenance").path("redactionVersion").asText()).isEqualTo("structural-fixture/2"); + assertThat(worker.locator("#download").isDisabled()).isTrue(); + worker.locator("#reviewed").check(); + assertThat(worker.locator("#download").isEnabled()).isTrue(); + assertThat(page.locator("body").innerHTML()).isEqualTo(original); + } + @Test void realLayoutRejectsHiddenSuccessAndQuotaOverridesVisibleSuccess() throws Exception { fixture("zhilian", "states/success.html", ""); assertThat(probe("return GetJobsZhilianPageEvidence.detectStatus(document)")).isEqualTo("已投递");