diff --git a/chrome-extension/background.js b/chrome-extension/background.js index dd4e2c9..65727c0 100644 --- a/chrome-extension/background.js +++ b/chrome-extension/background.js @@ -63,7 +63,7 @@ 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 = "1.8.19"; +const REQUIRED_BOSS_CONTENT_VERSION = "1.8.20"; const REQUIRED_ZHILIAN_CONTENT_VERSION = "1.8.16"; const LOCAL_API_BASE_URLS = ["http://127.0.0.1:6866"]; const BOSS_LOCAL_API_MAX_ATTEMPTS = 3; diff --git a/chrome-extension/boss-content.js b/chrome-extension/boss-content.js index 79ffd3f..6a91dab 100644 --- a/chrome-extension/boss-content.js +++ b/chrome-extension/boss-content.js @@ -1,5 +1,5 @@ (function () { - const EXTENSION_VERSION = "1.8.19"; + const EXTENSION_VERSION = "1.8.20"; // Manifest injection and a readiness probe can meet in the same document. // Reuse its runner instead of leaving the first runner alive without a listener. if (window.__GET_JOBS_BOSS_CONTENT_VERSION__ === EXTENSION_VERSION) return; diff --git a/chrome-extension/boss-delivery-support.js b/chrome-extension/boss-delivery-support.js index 6881393..e3e3a1c 100644 --- a/chrome-extension/boss-delivery-support.js +++ b/chrome-extension/boss-delivery-support.js @@ -4,7 +4,7 @@ const url = new URL(value); if (url.protocol !== "https:" || !/(^|\.)zhipin\.com$/.test(url.hostname)) return ""; if (/\/(?:verify|captcha)(?:[/.]|$)/i.test(url.pathname)) return "Boss页面出现安全验证,已暂停投递,请手动处理"; - if (/\/(?:passport|login)(?:[/.]|$)/i.test(url.pathname)) return "Boss登录状态失效,已暂停投递,请手动登录"; + if (/\/(?:passport|login)(?:[/.]|$)|^\/web\/user(?:\/|$)/i.test(url.pathname)) return "Boss登录状态失效,已暂停投递,请手动登录"; } catch {} return ""; } @@ -14,13 +14,14 @@ const common = /permission|Cannot access|站点权限|脚本未就绪|扩展版本|登录|安全验证|验证码/i.test(message); return { success: false, outcome: "FAILED", evidence: "PRE_ACTION_ERROR", greetingOutcome: "NOT_SENT", greetingEvidence: "PRE_ACTION_ERROR", - actionStarted: false, haltBatch: common, message, + actionStarted: false, haltBatch: common || error?.code === "BOSS_PAGE_NOT_READY", message, + ...(error?.code ? { errorCode: error.code } : {}), failureType: /安全验证|验证码/.test(message) ? "PLATFORM_VERIFICATION" : /登录/.test(message) ? "LOGIN_EXPIRED" : /permission|Cannot access|站点权限/i.test(message) ? "EXTENSION_PERMISSION" : "PRE_ACTION_ERROR" }; } - async function prepare({ chrome, tabId, targetUrl, navigate, ensure, sleep }) { + async function prepare({ chrome, tabId, targetUrl, navigate, ensure, sleep, timeoutMs = 20000, now = Date.now }) { if (targetUrl) { try { const target = new URL(targetUrl); @@ -28,43 +29,61 @@ || !/^\/job_detail\/[^/]+\.html$/.test(target.pathname)) throw new Error(); } catch { return preparationFailure(new Error("目标岗位链接无效,尚未执行投递")); } } + // Navigate once. Readiness polling must not restart a slow load or erase a + // login/verification redirect. Preflight has no target and only observes. + try { + const current = await chrome.tabs.get(tabId); + const blocked = authenticationBlock(current.url) || authenticationBlock(current.pendingUrl); + if (blocked) throw new Error(blocked); + if (targetUrl) await navigate(tabId, targetUrl); + } catch (error) { + const current = await chrome.tabs.get(tabId).catch(() => null); + const blocked = authenticationBlock(current?.url) || authenticationBlock(current?.pendingUrl); + return preparationFailure(blocked ? new Error(blocked) : error); + } + const deadline = now() + timeoutMs; let lastError; - for (let attempt = 0; attempt < 3; attempt++) { + do { try { - if (targetUrl) await navigate(tabId, targetUrl); const before = await chrome.tabs.get(tabId); + const blocked = authenticationBlock(before.url) || authenticationBlock(before.pendingUrl); + if (blocked) throw new Error(blocked); + // A new tab may still report about:blank, with only pendingUrl populated. + if (before.status === "loading" || (before.pendingUrl && before.pendingUrl !== before.url)) { + throw new Error("Boss页面仍在加载,尚未执行投递"); + } const url = new URL(before.url || ""); if (url.protocol !== "https:" || !(url.hostname === "zhipin.com" || url.hostname.endsWith(".zhipin.com"))) { - throw new Error("Boss页面尚未就绪,请打开已登录的Boss岗位页面"); + throw new Error("Boss页面尚未就绪,尚未执行投递"); } - const blocked = authenticationBlock(url.href); - if (blocked) throw new Error(blocked); if (targetUrl && url.pathname !== new URL(targetUrl).pathname) throw new Error("目标岗位ID与当前页面不一致,尚未执行投递"); - if (before.status === "loading" || (before.pendingUrl && before.pendingUrl !== before.url)) { - throw new Error("Boss页面仍在跳转,尚未执行投递"); - } - if (!await chrome.permissions.contains({ origins: [`${url.origin}/*`] })) { - throw new Error(`Chrome扩展缺少站点权限:${url.origin},请在扩展设置中允许访问该站点`); + if (!await chrome.permissions.contains({ origins: [url.origin + "/*"] })) { + throw new Error("Chrome扩展缺少站点权限:" + url.origin + ",请在扩展设置中允许访问该站点"); } await ensure(tabId); const status = await chrome.tabs.sendMessage(tabId, { source: "GET_JOBS_BACKGROUND", type: "BOSS_PAGE_STATUS" }); - if (!status?.chromePageReady || !status?.isLoggedIn) throw new Error(status?.message || "Boss页面登录状态未确认"); + if (status?.hasSecurityPrompt || status?.hasLoginPrompt) throw new Error( + (status.hasSecurityPrompt ? "Boss页面出现安全验证" : "Boss登录状态失效") + (status.message ? ":" + status.message : "")); + if (!status?.chromePageReady || !status?.isLoggedIn) throw new Error(status?.message || "Boss页面尚未就绪"); const after = await chrome.tabs.get(tabId); if (after.url !== before.url || after.status === "loading" || (after.pendingUrl && after.pendingUrl !== after.url)) { throw new Error("Boss页面在准备过程中发生跳转,尚未执行投递"); } + if (status.currentUrl && status.currentUrl !== after.url) throw new Error("Boss页面回执来自旧页面,尚未执行投递"); return { success: true, actionStarted: false, tabId, currentUrl: after.url }; } catch (error) { lastError = error; - if (/站点权限|登录|安全验证|验证码/.test(error?.message || "")) break; - // Navigation may time out before yielding a verification redirect. Preserve that - // page instead of navigating to the job again on the next preparation attempt. + if (/站点权限|permission|Cannot access|登录|安全验证|验证码|版本不一致|目标岗位ID/i.test(error?.message || "")) break; const current = await chrome.tabs.get(tabId).catch(() => null); const blocked = authenticationBlock(current?.url) || authenticationBlock(current?.pendingUrl); if (blocked) { lastError = new Error(blocked); break; } - if (attempt < 2) await sleep(500); + if (!current || now() >= deadline) break; + await sleep(Math.min(250, deadline - now())); } - } + } while (now() < deadline); + if (now() >= deadline) lastError = Object.assign(new Error( + "Boss页面等待 " + Math.ceil(timeoutMs / 1000) + " 秒后仍未就绪,尚未执行投递。" + (lastError?.message || "") + ), { code: "BOSS_PAGE_NOT_READY" }); return preparationFailure(lastError); } const api = { prepare, preparationFailure }; diff --git a/chrome-extension/manifest.json b/chrome-extension/manifest.json index 2ac93d9..98653b9 100644 --- a/chrome-extension/manifest.json +++ b/chrome-extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "投递牛马 Chrome Bridge", - "version": "1.8.19", + "version": "1.8.20", "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-delivery-preparation.test.cjs b/chrome-extension/tests/boss-delivery-preparation.test.cjs index f2437bb..d951925 100644 --- a/chrome-extension/tests/boss-delivery-preparation.test.cjs +++ b/chrome-extension/tests/boss-delivery-preparation.test.cjs @@ -4,14 +4,15 @@ const { prepare } = require('../boss-delivery-support.js'); const url = 'https://www.zhipin.com/job_detail/abc.html'; function harness(overrides = {}) { let injections = 0, sends = 0, navigations = 0; - const options = { tabId: 1, targetUrl: url, sleep: async () => {}, + let elapsed = 0; + const options = { tabId: 1, targetUrl: url, now: () => elapsed, sleep: async ms => { elapsed += ms; }, navigate: async () => { navigations++; }, ensure: async () => { injections++; }, chrome: { permissions: { contains: async () => true }, tabs: { get: async () => ({ id: 1, url, status: 'complete' }), sendMessage: async (_, message) => { assert.equal(message.type, 'BOSS_PAGE_STATUS'); sends++; return { chromePageReady: true, isLoggedIn: true }; } } } }; overrides.configure?.(options); - return { run: () => prepare(options), counts: () => ({ injections, sends, navigations }) }; + return { run: () => prepare(options), counts: () => ({ injections, sends, navigations }), elapsed: () => elapsed }; } test('permission denial occurs before any delivery action and halts batch', async () => { const h = harness({ configure: o => { o.chrome.permissions.contains = async () => false; } }); @@ -24,10 +25,14 @@ test('the screenshot injection error is not UNKNOWN', async () => { const h = harness({ configure: o => { o.ensure = async () => { throw new Error('Cannot access contents of the page. Extension manifest must request permission to access the respective host.'); }; } }); const r = await h.run(); assert.equal(r.outcome, 'FAILED'); assert.equal(r.actionStarted, false); assert.equal(r.haltBatch, true); }); -test('loading page is retried twice without injecting or sending', async () => { +test('loading page has a bounded wait without injecting, sending or repeated navigation', async () => { const h = harness({ configure: o => { o.chrome.tabs.get = async () => ({ url, status: 'loading' }); } }); - assert.equal((await h.run()).actionStarted, false); - assert.deepEqual(h.counts(), { injections: 0, sends: 0, navigations: 3 }); + const result = await h.run(); + assert.equal(result.actionStarted, false); + assert.equal(result.errorCode, 'BOSS_PAGE_NOT_READY'); + assert.equal(result.haltBatch, true); + assert.equal(h.elapsed(), 20000); + assert.deepEqual(h.counts(), { injections: 0, sends: 0, navigations: 1 }); }); test('navigation during script readiness does not pass preparation', async () => { let n = 0; @@ -39,21 +44,22 @@ test('ready page only probes status and never sends a delivery command', async ( }); test('verification and login redirects halt immediately without repeated navigation', async () => { - for (const [path, type] of [['/web/passport/zp/verify.html', 'PLATFORM_VERIFICATION'], ['/web/passport/login', 'LOGIN_EXPIRED']]) { + for (const [path, type] of [['/web/passport/zp/verify.html', 'PLATFORM_VERIFICATION'], ['/web/passport/login', 'LOGIN_EXPIRED'], ['/web/user/', 'LOGIN_EXPIRED']]) { const h = harness({ configure: o => { o.chrome.tabs.get = async () => ({ url: 'https://www.zhipin.com' + path, status: 'complete' }); } }); const result = await h.run(); assert.equal(result.failureType, type); assert.equal(result.haltBatch, true); assert.equal(result.actionStarted, false); - assert.deepEqual(h.counts(), { injections: 0, sends: 0, navigations: 1 }); + assert.deepEqual(h.counts(), { injections: 0, sends: 0, navigations: 0 }); } }); test('a navigation timeout on verification preserves the page without retrying navigation', async () => { const h = harness({ configure: o => { + let redirected = false; const navigate = o.navigate; - o.navigate = async () => { await navigate(); throw new Error('岗位导航超时'); }; - o.chrome.tabs.get = async () => ({url:'https://www.zhipin.com/web/passport/zp/verify.html',status:'complete'}); + o.navigate = async () => { await navigate(); redirected = true; throw new Error('岗位导航超时'); }; + o.chrome.tabs.get = async () => ({url:redirected?'https://www.zhipin.com/web/passport/zp/verify.html':url,status:'complete'}); } }); const result = await h.run(); assert.equal(result.haltBatch, true); @@ -61,6 +67,56 @@ test('a navigation timeout on verification preserves the page without retrying n assert.deepEqual(h.counts(), {injections:0,sends:0,navigations:1}); }); +test('cold preflight waits more than one second for the committed page without navigation', async () => { + const h = harness({ configure: o => { + delete o.targetUrl; + o.chrome.tabs.get = async () => o.now() < 3500 + ? {url:'about:blank',pendingUrl:'https://www.zhipin.com/',status:'loading'} + : {url:'https://www.zhipin.com/',status:'complete'}; + } }); + assert.equal((await h.run()).success,true); + assert.equal(h.elapsed(),3500); + assert.deepEqual(h.counts(),{injections:1,sends:1,navigations:0}); +}); + +test('pending login redirect is preserved even when the committed job still looks ready', async () => { + const h = harness({ configure: o => { o.chrome.tabs.get = async () => ({url,pendingUrl:'https://www.zhipin.com/web/user/',status:'loading'}); } }); + assert.equal((await h.run()).failureType,'LOGIN_EXPIRED'); + assert.deepEqual(h.counts(),{injections:0,sends:0,navigations:0}); +}); + +test('a newly created blank tab without pendingUrl waits instead of being classified as logged out', async () => { + const h = harness({ configure: o => { + delete o.targetUrl; + o.chrome.tabs.get = async () => ({url:o.now()<1250?'about:blank':url,status:'complete'}); + } }); + assert.equal((await h.run()).success,true); + assert.equal(h.elapsed(),1250); + assert.deepEqual(h.counts(),{injections:1,sends:1,navigations:0}); +}); + +test('explicit login and security status halt immediately even with a generic page message', async () => { + for (const [flag, type] of [['hasLoginPrompt', 'LOGIN_EXPIRED'], ['hasSecurityPrompt', 'PLATFORM_VERIFICATION']]) { + const h = harness({ configure: o => { + o.chrome.tabs.sendMessage = async () => ({[flag]:true,message:'请稍后再试'}); + } }); + const result = await h.run(); + assert.equal(result.failureType,type); + assert.equal(result.actionStarted,false); + assert.equal(result.haltBatch,true); + assert.equal(h.elapsed(),0); + } +}); + +test('readiness from an old document is rejected until its current URL matches', async () => { + const h = harness({ configure: o => { + o.chrome.tabs.sendMessage = async () => ({chromePageReady:true,isLoggedIn:true,currentUrl:o.now()<1500?url.replace('abc','other'):url}); + } }); + assert.equal((await h.run()).success,true); + assert.equal(h.elapsed(),1500); + assert.equal(h.counts().navigations,1); +}); + test('rejects an unsupported target before navigating and rejects a changed job ID', async () => { const invalid = harness({ configure: o => { o.targetUrl = 'https://example.com/job_detail/abc.html'; } }); assert.equal((await invalid.run()).evidence, 'PRE_ACTION_ERROR'); diff --git a/chrome-extension/tests/boss-hr-assistant.test.cjs b/chrome-extension/tests/boss-hr-assistant.test.cjs index 3cd8c46..01013f8 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.19"); + assert.equal(manifest.version, "1.8.20"); }); 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 3d8ba0c..d691517 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.19'); + assert.equal(manifest.version, '1.8.20'); 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 66c0af3..bd7fc3a 100644 --- a/chrome-extension/tests/profile-scoped-scan-contract.test.cjs +++ b/chrome-extension/tests/profile-scoped-scan-contract.test.cjs @@ -15,10 +15,10 @@ 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.19"); + assert.equal(manifest.version, "1.8.20"); assert.match(background, /BACKGROUND_VERSION = "2026-09-14-runtime-adapters"/); - assert.match(background, /REQUIRED_BOSS_CONTENT_VERSION = "1.8.19"/); - assert.match(boss, /EXTENSION_VERSION = "1.8.19"/); + assert.match(background, /REQUIRED_BOSS_CONTENT_VERSION = "1.8.20"/); + assert.match(boss, /EXTENSION_VERSION = "1.8.20"/); assert.match(zhilian, /EXTENSION_VERSION = "1.8.16"/); assert.match(background, /REQUIRED_ZHILIAN_CONTENT_VERSION = "1.8.16"/); const frontendBridge = fs.readFileSync(path.resolve(extensionDir, "../front/lib/chromeBridge.ts"), "utf8"); diff --git a/docs/delivery-recovery.md b/docs/delivery-recovery.md index e3847f6..9132001 100644 --- a/docs/delivery-recovery.md +++ b/docs/delivery-recovery.md @@ -1,7 +1,9 @@ # BOSS / 智联投递恢复 -扩展版本 1.8.19,后台与页面协议 `2026-09-14-runtime-adapters`。 +扩展版本 1.8.20,后台与页面协议 `2026-09-14-runtime-adapters`。 +- BOSS 预检对新建标签页最多等待 20 秒,等待已提交 URL、页面加载及脚本回执同时就绪,避免冷启动超过 1 秒就误报“页面仍在跳转”。岗位导航仅执行一次,重试只读就绪检查;登录、安全验证和权限不足立即暂停,超时仍不触发投递。 +- 隔离 Chromium 回归覆盖真实 MV3 后台和页面脚本的完整流程:延迟首页加载、预检、岗位导航、跨文档进入聊天、多行话术只发送一次和结果记录。招聘页面及 API 均为离线合成响应,不代表真实账号投递验收。 - BOSS 富文本输入框保留确认话术中的真实换行,不再用 textContent 覆盖 innerText 生成的换行结构;发送前仍逐字核对原话术。 - BOSS 点击操作只触发一次 click,避免先派发 click 再调用 `.click()` 导致收藏切换两次、重复打开沟通入口或重复发送话术。 - 页面进入前进/后退缓存(BFCache)时立即撤销旧页面执行资格;返回页面后重新注入脚本,旧异步任务不得继续点击或填写话术。 diff --git a/src/test/java/com/getjobs/application/service/BrowserRuntimeRegressionTest.java b/src/test/java/com/getjobs/application/service/BrowserRuntimeRegressionTest.java index b7a529d..5c469ce 100644 --- a/src/test/java/com/getjobs/application/service/BrowserRuntimeRegressionTest.java +++ b/src/test/java/com/getjobs/application/service/BrowserRuntimeRegressionTest.java @@ -17,7 +17,7 @@ class BrowserRuntimeRegressionTest { Page page; com.sun.net.httpserver.HttpServer api; - @BeforeEach void start() throws Exception { + @BeforeEach void start(TestInfo testInfo) throws Exception { Path extension = temp.resolve("extension"); Path source = Path.of("chrome-extension").toAbsolutePath(); try (var files = Files.walk(source)) { @@ -34,12 +34,46 @@ class BrowserRuntimeRegressionTest { Path background = extension.resolve("background.js"); Files.writeString(background, "globalThis.fetch = async () => { throw new Error('OFFLINE_REGRESSION_NETWORK_DENIED'); };\n" + Files.readString(background)); + if (testInfo.getTestMethod().orElseThrow().getName().equals("bossColdPreflightNavigatesAndSendsOneMultilineGreetingAcrossDocuments")) { + // Test-only bridge into the real MV3 worker; never copied to production. + Files.writeString(background, """ + \nconst fixtureReceipts=[]; + // Chrome-created tabs can navigate before Playwright attaches its + // route interceptor. Hand the blank tab to the test before navigation. + const fixtureCreateTab=chrome.tabs.create.bind(chrome.tabs); + chrome.tabs.create=options=>{ + if(options.url!=='https://www.zhipin.com/')throw new Error('UNEXPECTED_FIXTURE_TAB'); + return fixtureCreateTab({...options,url:'about:blank'}); + }; + globalThis.fetch=async(url,options={})=>{ + const parsed=new URL(url);let body; + if(parsed.origin!=='http://127.0.0.1:6866')throw new Error('OFFLINE_NETWORK_DENIED'); + if(parsed.pathname==='/api/local-auth/action-token')body={success:true,data:{token:'offline-token'}}; + else if(parsed.pathname.endsWith('/validate-dispatch'))body={success:true}; + else if(parsed.pathname.endsWith('/runtime/claim'))body={success:true,enabled:false}; + else if(parsed.pathname==='/api/boss/jobs/10/delivery-result'){ + const result=JSON.parse(options.body);fixtureReceipts.push(result);body={success:true,accepted:true,state:result.outcome}; + } else throw new Error('UNEXPECTED_OFFLINE_API:'+parsed.pathname); + return new Response(JSON.stringify(body),{status:200,headers:{'Content-Type':'application/json'}}); + }; + chrome.runtime.onMessage.addListener((message,sender,reply)=>{ + if(message.type!=='OFFLINE_REGRESSION_REQUEST')return; + if(message.receipts){reply(fixtureReceipts);return;} + chrome.tabs.query({}).then(tabs=>{ + const owner=tabs.find(t=>t.url==='http://localhost:6866/offline-regression'); + return handlePageMessage(message.payload,{tab:owner}); + }).then(reply,error=>reply({fixtureError:error.message})); + return true; + }); + """, StandardOpenOption.APPEND); + } Files.writeString(extension.resolve("regression-probe.html"), "
测试职责
+ +