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/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 @@
+
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/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
')
+ 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(``)
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/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')!
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("已投递");