Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ pnpm-debug.log*
lerna-debug.log*

node_modules
/target/
dist
dist-ssr
*.local
Expand All @@ -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/
Expand All @@ -44,6 +45,6 @@ designs/
.playwright-mcp
# Claude Code 的 agent 工作区,由工具自行管理
.claude/worktrees/


.pi
.omc\project-memory.json
.omc\sessions\92219f8a-08f3-41a6-a593-df6c3467bc40.json
.pi
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
118 changes: 118 additions & 0 deletions scripts/job51-dom.node-test.mjs
Original file line number Diff line number Diff line change
@@ -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('<div id="aliyunCaptcha-sliding-body"><div id="aliyunCaptcha-sliding-text-box"></div><span id="aliyunCaptcha-sliding-slider"></span></div>');
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('<div>页面加载中</div>')),false);
assert.equal(sliderPassed51(doc('<div class="job_msg" style="display:none">Java开发</div>')),false);
assert.equal(sliderPassed51(doc('<div class="job_msg">Java开发</div>')),true);
assert.equal(sliderPassed51(doc('<div id="tips_title">访问验证</div><div class="job_msg">Java开发</div>')),false);
assert.equal(sliderPassed51(new JSDOM('<div class="job_msg">扫码登录</div>',{url:'https://login.51job.com/login.php'}).window.document),false);
assert.equal(sliderPassed51(new JSDOM('<div class="joblist-item">Java开发</div>',{url:'https://we.51job.com/pc/search'}).window.document),true);
assert.equal(sliderPassed51(new JSDOM('<div>暂无职位</div>',{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('<img class="qrcode" src="data:image/png;base64,wrong" style="display:none"><div class="login_qr"><div class="qrImg"><p><img id="qrimg" src="data:image/png;base64,correct"></p></div></div>');
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('<div class="qrImg"><p><img id="qrimg" src="/image.php?token=test"></p></div>',{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('<title>滑动验证页面</title>'),'123').kind,'blocked');
});
test('detects verification on the search page despite its normal page title',()=>{
const d=doc('<title>【后端,深圳招聘,求职】-前程无忧</title><div>安全验证:请按住滑块</div>');
assert.equal(applyState51(d,'').kind,'blocked');
});
test('marks the official QR login switch on the default phone login page',()=>{
const d=doc(`<input type="password"><i class="passIcon" data-sensor-id="sensor_login_wechatScan"></i><button>登录 / 注册</button>`);
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(`<div class="joblist-item"><div sensorsdata='{"jobId":"123","jobTitle":"Java开发","jobSalary":"1万","jobArea":"深圳"}'><span class="jname">Java开发</span><span class="cname">甲公司</span><button>投递</button><span>去聊聊</span></div></div><aside><a href="/456.html">推荐</a></aside>`);
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(`<div class="joblist-item"><div sensorsdata='{"jobId":"123"}'><span>已投递</span></div></div><div class="joblist-item"><div sensorsdata='{"jobId":"456"}'><span>去聊聊</span></div></div>`);
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('<span data-fj-51-title="1">Java开发</span>');
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('<div class="apply-btn-new" id="123">立即投递</div><div>立即沟通</div><div style="display:none" class="success-popup-2">投递成功</div>');
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('<div class="el-dialog__body">投递成功0个,未投递1个</div>'),'123').kind,'failed');
assert.equal(applyState51(doc('<div class="el-dialog__body">投递成功1个,未投递0个</div>'),'123').kind,'success');
assert.equal(applyState51(doc('<div class="el-dialog__wrapper">请选择需要投递的简历</div>'),'123').kind,'selection');
assert.equal(applyState51(doc('<div class="apply-btn-new" id="123">已投递</div>'),'123').kind,'already');
});

test('reads online resume inventory without confusing the language selector',()=>{
const d=doc(`<div class="apply-component-resume-dialog"><div class="pc-apply-resume__select--resume"><p title="Java简历">Java简历</p><ul style="display:none"><li title="Java简历">Java简历</li><li title="测试简历">测试简历</li></ul></div><div class="pc-apply-resume__select--lang"><p>中文</p><ul><li>中文</li><li>中英文</li></ul></div><div class="apply-component-resume-dialog-footer"><a class="btn">立即申请</a></div></div>`);
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(`<div class="apply-component-resume-dialog" style="display:none">旧弹窗</div><div class="attachment_resume_dialog"><div class="attachment_item"><div class="radio selected"></div><div class="name">Java.pdf</div></div><div class="attachment_item"><div class="radio"></div><div class="name">测试.pdf</div></div><button>发送</button></div>`);
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,'发送');
});
46 changes: 46 additions & 0 deletions scripts/liepin-send-dom.node-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(`
<div role="dialog" class="chat-drawer">
<header>我的沟通</header>
<div>不支持此消息查看,请登录“猎聘APP”查看消息内容!</div>
<div class="im-ui-contact-list-item">历史会话</div>
</div>
`);
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 = '<textarea></textarea><button class="ant-im-btn">发送</button>';
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('<div class="im-ui-contact-list-item">历史会话</div>');
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(`
<div role="dialog" class="chat-drawer" data-modal="chat">
Expand Down
71 changes: 71 additions & 0 deletions scripts/resume-flow.node-test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
import { readResumeUi, markResumeUi, resumeProof, resumeSelectionReady } from '../src-tauri/src/rpa/resume_ui.js';

const documentFor = html => new JSDOM(html).window.document;
const bossHtml = `<section class="chat-conversation"><header class="chat-user">吴女士</header>
<ul class="message-list"><li class="message-item"><h3>旧简历.pdf</h3><span>点击预览附件简历</span></li></ul></section>
<div class="dialog-wrap"><h3>请选择要发送的简历</h3><button class="dialog-close">×</button><ul>
<li class="list-item"><div class="item-body"><span class="resume-name">测试简历.docx</span><div class="item-desc">更新于2026.10.02 06:28</div></div><div class="preview">预览</div></li>
<li class="list-item"><div class="item-body"><span class="resume-name">AI简历.pdf</span><div class="item-desc">更新于2026.09.16 10:15</div></div><div class="preview">预览</div></li>
</ul><button disabled>发送</button></div>`;

test('BOSS reads actual docx and pdf rows and marks only the requested preview', () => {
const doc = documentFor(bossHtml);
const state = readResumeUi(doc, 'boss');
assert.deepEqual(state.candidates.map(row => row.name), ['测试简历.docx', 'AI简历.pdf']);
assert.equal(markResumeUi(doc, 'boss', 'preview', state.candidates[0]), true);
assert.equal(doc.querySelector('[data-fj-resume-action]').closest('li').querySelector('.resume-name').textContent, '测试简历.docx');
assert.equal(markResumeUi(doc, 'boss', 'submit'), false);
});

test('cleanup closes preview before selection and does not touch the chat', () => {
const doc = documentFor(bossHtml + `<div class="dialog-wrap"><h3>附件预览</h3><button class="dialog-close">×</button><iframe src="/bzl-office/pdf-viewer"></iframe></div>`);
assert.equal(markResumeUi(doc, 'boss', 'close-preview'), true);
const close = doc.querySelector('[data-fj-resume-action]');
assert.equal(close.closest('.dialog-wrap').querySelector('h3').textContent, '附件预览');
close.closest('.dialog-wrap').remove();
assert.equal(markResumeUi(doc, 'boss', 'close'), true);
assert.equal(doc.querySelector('[data-fj-resume-action]').closest('.dialog-wrap').querySelector('h3').textContent, '请选择要发送的简历');
assert.equal(doc.querySelector('.chat-user').textContent, '吴女士');
});

test('historical files, attachment modal and success toast are not new chat proof', () => {
const doc = documentFor(bossHtml);
const before = resumeProof(doc, 'boss');
doc.body.insertAdjacentHTML('beforeend', '<div>发送成功</div>');
assert.deepEqual(resumeProof(doc, 'boss'), before);
doc.querySelector('.message-list').insertAdjacentHTML('beforeend', '<li class="message-item"><h3>测试简历.docx</h3><span>点击预览附件简历</span></li>');
assert.equal(resumeProof(doc, 'boss').cards.length, before.cards.length + 1);
});

test('Liepin identifies a single checked row without confusing its online resume', () => {
const doc = documentFor(`<div class="ant-modal"><h3>选择附件简历</h3><p>招聘方将同时收到您的默认在线简历和附件简历</p>
<div class="resume-row"><input type="radio" checked><span>Java简历</span><span>2026-10-02 06:28 上传</span><a>预览</a></div><button>立即投递</button></div>`);
const state = readResumeUi(doc, 'liepin');
assert.equal(state.kind, 'list');
assert.deepEqual(state.candidates.map(row => row.name), ['Java简历']);
assert.equal(markResumeUi(doc, 'liepin', 'select', state.candidates[0]), true);
assert.equal(markResumeUi(doc, 'liepin', 'submit'), true);
});

test('dialog header is not mistaken for the attachment list and a row must be selected before submitting', () => {
const doc=documentFor(bossHtml.replace('<h3>请选择要发送的简历</h3><button class="dialog-close">×</button>',
'<div class="dialog-header"><h3>请选择要发送的简历</h3><button class="dialog-close">×</button></div>'));
const candidate=readResumeUi(doc,'boss').candidates[0];
assert.equal(candidate.name,'测试简历.docx');
doc.querySelector('button[disabled]').disabled=false;
assert.equal(resumeSelectionReady(doc,'boss',candidate),false);
doc.querySelector('li.list-item').classList.add('active');
assert.equal(resumeSelectionReady(doc,'boss',candidate),true);
doc.querySelector('.resume-name').textContent='替换后的简历.pdf';
assert.equal(resumeSelectionReady(doc,'boss',candidate),false);
});

test('single BOSS confirmation is recognized separately from a multiple attachment picker', () => {
const doc=documentFor('<div class="panel-resume"><p>确定向 Boss 发送简历吗?</p><div class="btns"><span>取消</span><span>确定</span></div></div>');
assert.equal(readResumeUi(doc,'boss').kind,'confirm');
assert.equal(markResumeUi(doc,'boss','close'),true);
assert.equal(doc.querySelector('[data-fj-resume-action]').textContent,'取消');
});
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "offer_flow"
version = "0.1.22"
version = "0.1.27"
description = "Local-first desktop job search automation and analysis tool"
authors = ["JWJW000"]
license = "Apache-2.0"
Expand Down
20 changes: 19 additions & 1 deletion src-tauri/build.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,21 @@
fn main() {
tauri_build::build()
tauri_build::build();
if std::env::var("TARGET").is_ok_and(|target| target.ends_with("windows-msvc")) {
let manifest = std::path::PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap())
.join("examples/windows.manifest");
println!("cargo:rerun-if-changed={}", manifest.display());
// Library unit-test binaries do not receive rustc-link-arg-tests.
println!("cargo:rustc-link-arg=/MANIFEST:EMBED");
println!("cargo:rustc-link-arg=/MANIFESTINPUT:{}",manifest.display());
// The desktop binary already embeds Tauri's manifest in resource.lib.
// Do not let the linker create a second resource with the same ID.
println!("cargo:rustc-link-arg-bin=offer_flow=/MANIFEST:NO");
println!("cargo:rustc-link-arg-examples=/MANIFEST:EMBED");
println!("cargo:rustc-link-arg-tests=/MANIFEST:EMBED");
println!("cargo:rustc-link-arg-tests=/MANIFESTINPUT:{}",manifest.display());
println!(
"cargo:rustc-link-arg-examples=/MANIFESTINPUT:{}",
manifest.display()
);
}
}
Loading
Loading