diff --git a/.gitignore b/.gitignore index 7db9668..28bd6f1 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ pnpm-debug.log* lerna-debug.log* node_modules +/target/ dist dist-ssr *.local @@ -32,7 +33,7 @@ settings.local.json # Tauri updater signing key (never commit the private key) .tauri/*.key - +output/OfferFlow-jd-fix.exe ./docs/updater-release.md designs/ @@ -44,6 +45,6 @@ designs/ .playwright-mcp # Claude Code 的 agent 工作区,由工具自行管理 .claude/worktrees/ - - -.pi \ No newline at end of file +.omc\project-memory.json +.omc\sessions\92219f8a-08f3-41a6-a593-df6c3467bc40.json +.pi diff --git a/package.json b/package.json index 9916179..bf8f38c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "offer_flow", "private": true, - "version": "0.1.22", + "version": "0.1.27", "description": "Local-first desktop job search automation and analysis tool", "license": "Apache-2.0", "repository": "https://github.com/OpenFuckJob/FuckJob.git", diff --git a/scripts/job51-dom.node-test.mjs b/scripts/job51-dom.node-test.mjs new file mode 100644 index 0000000..923ecea --- /dev/null +++ b/scripts/job51-dom.node-test.mjs @@ -0,0 +1,118 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { JSDOM } from 'jsdom'; +import { collectJobs51, listedJobIds51, captureMarkedJobUrl51, markQrEntry51, qrDataUrl51, applyState51, markApply51, readSelection51, markSelection51, sliderGeometry51, sliderPassed51 } from '../src-tauri/src/rpa/job51/ui.js'; +const doc = html => new JSDOM(html, {url:'https://jobs.51job.com/shenzhen/123.html'}).window.document; + +test('slider stops with the handle inside the rail instead of dragging a full rail width',()=>{ + const d=doc('
'); + const slider=d.querySelector('span'); + slider.getBoundingClientRect=()=>({x:100,y:200,width:40,height:40,right:140}); + d.querySelector('#aliyunCaptcha-sliding-body').getBoundingClientRect=()=>({x:100,y:200,width:360,height:40,right:460}); + d.querySelector('#aliyunCaptcha-sliding-text-box').getBoundingClientRect=()=>({x:140,y:200,width:320,height:40,right:460}); + assert.equal(sliderGeometry51(d).x,120); + assert.equal(sliderGeometry51(d).end,440); + d.querySelector('#aliyunCaptcha-sliding-text-box').remove(); + assert.equal(sliderGeometry51(d).end,440); + slider.style.display='none'; + assert.equal(sliderGeometry51(d),null); +}); + +test('verification requires visible job content, not an empty or login page',()=>{ + assert.equal(sliderPassed51(doc('')),false); + assert.equal(sliderPassed51(doc('
页面加载中
')),false); + assert.equal(sliderPassed51(doc('')),false); + assert.equal(sliderPassed51(doc('
Java开发
')),true); + assert.equal(sliderPassed51(doc('
访问验证
Java开发
')),false); + assert.equal(sliderPassed51(new JSDOM('
扫码登录
',{url:'https://login.51job.com/login.php'}).window.document),false); + assert.equal(sliderPassed51(new JSDOM('
Java开发
',{url:'https://we.51job.com/pc/search'}).window.document),true); + assert.equal(sliderPassed51(new JSDOM('
暂无职位
',{url:'https://we.51job.com/pc/search'}).window.document),true); +}); +test('exports the official QR image nested inside a plain paragraph, ignoring hidden and unloaded images', async()=>{ + const d=doc('

'); + const img=d.querySelector('#qrimg'); + img.getBoundingClientRect=()=>({width:150,height:150}); + Object.defineProperty(img,'complete',{value:true}); + Object.defineProperty(img,'naturalWidth',{get:()=>loaded?150:0}); + let loaded=false; + assert.equal(await qrDataUrl51(d),''); + loaded=true; + assert.equal(await qrDataUrl51(d),'data:image/png;base64,correct'); + img.style.display='none'; + assert.equal(await qrDataUrl51(d),''); +}); +test('the injected browser script exports a fetched QR image as base64 for the workspace', async()=>{ + const {window}=new JSDOM('

',{url:'https://login.51job.com/login.php',runScripts:'outside-only'}); + const img=window.document.querySelector('#qrimg'); + img.getBoundingClientRect=()=>({width:150,height:150}); + Object.defineProperties(img,{complete:{value:true},naturalWidth:{value:150}}); + window.fetch=async url=>{ + assert.equal(url,'https://login.51job.com/image.php?token=test'); + return {blob:async()=>new window.Blob(['qr-image'],{type:'image/png'})}; + }; + const code=readFileSync(new URL('../src-tauri/src/rpa/job51/ui.js',import.meta.url),'utf8').replaceAll('export function ','function ').replaceAll('export async function ','async function '); + assert.equal(await window.eval(`(() => { ${code}\nreturn qrDataUrl51(document); })()`),'data:image/png;base64,cXItaW1hZ2U='); +}); +test('blocks a slider verification page even before its body loads',()=>{ + assert.equal(applyState51(doc('滑动验证页面'),'123').kind,'blocked'); +}); +test('detects verification on the search page despite its normal page title',()=>{ + const d=doc('【后端,深圳招聘,求职】-前程无忧
安全验证:请按住滑块
'); + assert.equal(applyState51(d,'').kind,'blocked'); +}); +test('marks the official QR login switch on the default phone login page',()=>{ + const d=doc(``); + assert.equal(markQrEntry51(d),true); + assert.equal(d.querySelector('[data-fj-51-qr]').getAttribute('data-sensor-id'),'sensor_login_wechatScan'); +}); +test('reads main cards and excludes sidebar recommendations and chat-only cards', () => { + const d=doc(`
Java开发甲公司去聊聊
`); + assert.equal(collectJobs51(d).length,1); + assert.equal(collectJobs51(d)[0].platform_job_id,'123'); + assert.equal(collectJobs51(d)[0].company_name,'甲公司'); +}); +test('pagination sees already-applied and chat-only listings so an exhausted first page does not end scanning',()=>{ + const d=doc(`
已投递
去聊聊
`); + assert.equal(collectJobs51(d).length,0); + assert.deepEqual(listedJobIds51(d),['123','456']); +}); +test('captures the site two-stage popup URL instead of the initial _blank placeholder',()=>{ + const d=doc('Java开发'); + const original=d.defaultView.open; + d.querySelector('span').addEventListener('click',()=>{ + const popup=d.defaultView.open('_blank'); + popup.location.href='https://jobs.51job.com/shenzhen/12345678.html?original=1'; + }); + assert.equal(captureMarkedJobUrl51(d),'https://jobs.51job.com/shenzhen/12345678.html?original=1'); + assert.equal(d.defaultView.open,original); +}); +test('apply marks the job-correlated control, never chat or hidden dialogs',()=>{ + const d=doc('
立即投递
立即沟通
'); + assert.equal(applyState51(d,'123').kind,'ready'); + assert.equal(markApply51(d,'999'),false); + assert.equal(markApply51(d,'123'),true); + assert.equal(d.querySelector('[data-fj-51-action]').id,'123'); +}); +test('success requires positive count and unknown dialogs stop automation',()=>{ + assert.equal(applyState51(doc('
投递成功0个,未投递1个
'),'123').kind,'failed'); + assert.equal(applyState51(doc('
投递成功1个,未投递0个
'),'123').kind,'success'); + assert.equal(applyState51(doc('
请选择需要投递的简历
'),'123').kind,'selection'); + assert.equal(applyState51(doc('
已投递
'),'123').kind,'already'); +}); + +test('reads online resume inventory without confusing the language selector',()=>{ + const d=doc(`

Java简历

中文

`); + assert.deepEqual(readSelection51(d),{kind:'online',names:['Java简历','测试简历'],selected:'Java简历'}); + assert.equal(markSelection51(d,'submit','测试简历'),false); + assert.equal(markSelection51(d,'submit','Java简历'),true); + assert.equal(d.querySelector('[data-fj-51-selection]').textContent,'立即申请'); +}); + +test('attachment selection submits only a selected exact name and ignores hidden old dialogs',()=>{ + const d=doc(`
Java.pdf
测试.pdf
`); + assert.deepEqual(readSelection51(d),{kind:'attachment',names:['Java.pdf','测试.pdf'],selected:'Java.pdf'}); + assert.equal(markSelection51(d,'submit','测试.pdf'),false); + assert.equal(markSelection51(d,'submit','Java.pdf'),true); + assert.equal(d.querySelector('[data-fj-51-selection]').textContent,'发送'); +}); diff --git a/scripts/liepin-send-dom.node-test.mjs b/scripts/liepin-send-dom.node-test.mjs index 546d624..36ab453 100644 --- a/scripts/liepin-send-dom.node-test.mjs +++ b/scripts/liepin-send-dom.node-test.mjs @@ -62,6 +62,52 @@ test("mark only the scoped chat input and clear its stale draft", () => { ); }); +test("wait for a chat drawer composer without treating its login history as a blocker", async () => { + const document = makeDocument(` + + `); + const requestRecords = []; + let loaded = false; + const result = await runLiepinSend(document, { + message: "你好", + requestRecords, + inputTimeoutMs: 300, + sleep: async () => { + if (loaded) return; + loaded = true; + const composer = document.createElement("div"); + composer.className = "im-ui-msg-list-content"; + composer.innerHTML = ''; + composer.querySelector("button").addEventListener("click", () => { + requestRecords.push({ + url: "https://api-c.liepin.com/api/com.liepin.im.c.chat.send-push", + status: 200, + }); + }); + document.querySelector(".chat-drawer").append(composer); + }, + }); + + assert.equal(result.blockedModal, false); + assert.equal(result.success, true); +}); + +test("report a composer timeout when no writable chat input loads", async () => { + const document = makeDocument('
历史会话
'); + const result = await runLiepinSend(document, { + message: "你好", + inputTimeoutMs: 300, + sleep: async () => {}, + }); + + assert.equal(result.success, false); + assert.equal(result.reason, "chat-composer-timeout"); +}); + test("do not mistake chat drawer history for a priority modal", async () => { const document = makeDocument(`