From 3afd36fd931c15911b41212aa43f9dca4d7ffacf Mon Sep 17 00:00:00 2001 From: wardforce <3093340276@qq.com> Date: Tue, 8 Sep 2026 10:17:34 +0800 Subject: [PATCH] Implement job automation improvements --- package.json | 2 +- scripts/liepin-send-dom.node-test.mjs | 443 +++++++++++++++++ src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/browser.rs | 75 ++- .../rpa/liepin/handler/position_say_hello.rs | 454 ++++++++++------- src-tauri/src/rpa/liepin/send_text.js | 456 ++++++++++++++++++ src-tauri/tauri.conf.json | 2 +- 8 files changed, 1248 insertions(+), 188 deletions(-) create mode 100644 scripts/liepin-send-dom.node-test.mjs create mode 100644 src-tauri/src/rpa/liepin/send_text.js diff --git a/package.json b/package.json index b09627f..bd53ccd 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "offer_flow", "private": true, - "version": "0.1.21", + "version": "0.1.24", "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/liepin-send-dom.node-test.mjs b/scripts/liepin-send-dom.node-test.mjs new file mode 100644 index 0000000..dc4e9ae --- /dev/null +++ b/scripts/liepin-send-dom.node-test.mjs @@ -0,0 +1,443 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { JSDOM } from "jsdom"; + +import { + markLiepinChatInput, + runLiepinSend, +} from "../src-tauri/src/rpa/liepin/send_text.js"; + +function makeDocument(html) { + return new JSDOM(html, { pretendToBeVisual: true }).window.document; +} + +function makeSleepTracker() { + const calls = []; + const sleep = async (ms) => { + calls.push(ms); + }; + return { calls, sleep }; +} + +test("ignore hidden chat ancestors and fill without synthesizing Enter", async () => { + const document = makeDocument(` + +
+
+ `); + let clicks = 0; + let enterEvents = 0; + const requestRecords = []; + document.addEventListener("keyup", event => { if (event.key === "Enter") enterEvents++; }); + document.querySelector("section[hidden] button").onclick = () => assert.fail("hidden chat clicked"); + document.querySelector("#active button").onclick = () => { + clicks++; + requestRecords.push({ url: "https://api-c.liepin.com/api/com.liepin.im.c.chat.send-push", status: 200 }); + }; + const result = await runLiepinSend(document, { + message: "你好", requestRecords, sleep: async () => {}, + }); + assert.equal(result.success, true); + assert.equal(clicks, 1); + assert.equal(enterEvents, 0); + assert.equal(document.querySelector("section[hidden] textarea").value, "旧会话"); +}); + +test("mark only the scoped chat input and clear its stale draft", () => { + const document = makeDocument(` + +
+ + +
+ `); + + assert.equal(markLiepinChatInput(document), true); + assert.equal(document.querySelector("input").value, "page search"); + assert.equal(document.querySelector("textarea").value, ""); + assert.equal( + document.querySelector("textarea").getAttribute("data-fj-liepin-chat-input"), + "1", + ); +}); + +test("do not mistake chat drawer history for a priority modal", async () => { + const document = makeDocument(` + + `); + const requestRecords = []; + const drawer = document.querySelector("[data-modal='chat']"); + let drawerClosed = false; + drawer.querySelector("button").addEventListener("click", () => { + drawerClosed = true; + drawer.remove(); + }); + document.querySelector(".ant-im-btn").addEventListener("click", () => { + requestRecords.push({ + url: "https://api-c.liepin.com/api/com.liepin.im.c.chat.send-push", + status: 200, + }); + }); + + const result = await runLiepinSend(document, { + message: "你好", + requestRecords, + sleep: async () => {}, + }); + + assert.equal(drawerClosed, false); + assert.equal(result.success, true); +}); + +test("close only visible priority communication modal before sending", async () => { + const document = makeDocument(` +
+
优先沟通
+
体验
+ +
+
+
登录
+ +
+
+ + +
+ `); + const { sleep } = makeSleepTracker(); + const requestRecords = []; + const input = document.querySelector("textarea"); + const button = document.querySelector(".ant-im-btn"); + document.querySelector(".close-priority").addEventListener("click", () => { + document.querySelector("[data-modal='priority']").hidden = true; + }); + button.addEventListener("click", () => { + input.value = ""; + requestRecords.push({ + url: "https://api-c.liepin.com/api/com.liepin.im.c.chat.send-push", + status: 200, + }); + }); + const result = await runLiepinSend(document, { + message: "你好", + requestRecords, + sleep, + }); + + assert.equal(document.querySelector("[data-modal='priority']").hidden, true); + assert.equal(document.querySelector("[data-modal='login']").hidden, false); + assert.equal(result.success, true); +}); + +test("ignore distractor inputs and buttons outside the chat container", async () => { + const document = makeDocument(` + + +
+ + +
+ `); + const { sleep } = makeSleepTracker(); + const requestRecords = []; + const input = document.querySelector("textarea"); + const button = document.querySelector(".ant-im-btn"); + button.addEventListener("click", () => { + input.value = ""; + requestRecords.push({ + url: "https://api-c.liepin.com/api/com.liepin.im.c.chat.send-push", + status: 200, + }); + }); + const result = await runLiepinSend(document, { + message: "你好", + requestRecords, + sleep, + }); + + assert.equal(result.success, true); + assert.equal(document.querySelector("input").value, "distractor"); + assert.equal(document.querySelector(".send-now").textContent, "send"); +}); + +test("enable disabled send button after input event", async () => { + const document = makeDocument(` +
+ + +
+ `); + const { sleep } = makeSleepTracker(); + const requestRecords = []; + const input = document.querySelector("textarea"); + const button = document.querySelector(".ant-im-btn"); + input.addEventListener("input", () => { + button.disabled = false; + button.classList.remove("ant-im-btn-disabled"); + }); + button.addEventListener("click", () => { + input.value = ""; + requestRecords.push({ + url: "https://api-c.liepin.com/api/com.liepin.im.c.chat.send-push", + status: 200, + }); + }); + + const result = await runLiepinSend(document, { + message: "你好", + requestRecords, + sleep, + }); + + assert.equal(result.success, true); + assert.equal( + document.querySelector(".ant-im-btn").disabled, + false, + ); +}); + +test("retry at most once when no send-push arrives and message stays present", async () => { + const document = makeDocument(` +
+ + +
+ `); + const { sleep } = makeSleepTracker(); + const requestRecords = []; + + const result = await runLiepinSend(document, { + message: "你好", + requestRecords, + sleep, + }); + + assert.equal(result.attempts, 2); + assert.equal(result.retried, true); +}); + +test("do not retry when send-push or a new bubble is observed", async () => { + const document = makeDocument(` +
+ + +
你好
+
+ `); + const { sleep } = makeSleepTracker(); + const requestRecords = [ + { url: "https://api-c.liepin.com/api/com.liepin.im.c.chat.send-push", status: 200 }, + ]; + + const result = await runLiepinSend(document, { + message: "你好", + requestRecords, + sleep, + }); + + assert.equal(result.retried, false); + assert.equal(result.attempts, 1); +}); + +test("leave an unknown verification modal open and return a clear error", async () => { + const document = makeDocument(` +
+
安全验证
+ +
+
+ + +
+ `); + const result = await runLiepinSend(document, { message: "你好" }); + + assert.equal(result.success, false); + assert.match(result.reason, /^blocked-modal:/); + assert.equal(document.querySelector("[data-modal='verification']").hidden, false); +}); + +test("do not treat a pre-existing identical bubble as a new send", async () => { + const document = makeDocument(` +
+ + +
你好
+
+ `); + const { sleep } = makeSleepTracker(); + const result = await runLiepinSend(document, { + message: "你好", + requestRecords: [], + sleep, + }); + + assert.equal(result.success, false); + assert.equal(result.attempts, 2); + assert.equal(result.retried, true); +}); + +test("replace a stale draft instead of appending it to the new message", async () => { + const document = makeDocument(` +
+ + +
+ `); + const requestRecords = []; + let submitted = ""; + document.querySelector(".ant-im-btn").addEventListener("click", () => { + submitted = document.querySelector("textarea").value; + requestRecords.push({ + url: "https://api-c.liepin.com/api/com.liepin.im.c.chat.send-push", + status: 200, + }); + }); + + const result = await runLiepinSend(document, { + message: "你好", + requestRecords, + }); + + assert.equal(result.success, true); + assert.equal(submitted, "你好"); +}); + +test("keep waiting for send proof after the input clears", async () => { + const document = makeDocument(` +
+ + +
+ `); + const requestRecords = []; + document.querySelector(".ant-im-btn").addEventListener("click", () => { + document.querySelector("textarea").value = ""; + }); + let slept = false; + const sleep = async () => { + if (!slept) { + slept = true; + requestRecords.push({ + url: "https://api-c.liepin.com/api/com.liepin.im.c.chat.send-push", + status: 200, + }); + } + }; + + const result = await runLiepinSend(document, { + message: "你好", + requestRecords, + sleep, + }); + + assert.equal(result.success, true); + assert.equal(result.requestSucceeded, true); +}); + +test("use real timer polling when no test sleep override is provided", async () => { + const document = makeDocument(` +
+ + +
+ `); + const requestRecords = []; + document.querySelector(".ant-im-btn").addEventListener("click", () => { + document.defaultView.setTimeout(() => { + requestRecords.push({ + url: "https://api-c.liepin.com/api/com.liepin.im.c.chat.send-push", + status: 200, + }); + }, 10); + }); + + const result = await runLiepinSend(document, { + message: "你好", + requestRecords, + proofTimeoutMs: 500, + }); + + assert.equal(result.success, true); +}); + +test("ignore ordinary containers whose class only contains dialog text", async () => { + const document = makeDocument(` +
请登录后查看历史说明
+
+ + +
+ `); + const requestRecords = []; + document.querySelector(".ant-im-btn").addEventListener("click", () => { + requestRecords.push({ + url: "https://api-c.liepin.com/api/com.liepin.im.c.chat.send-push", + status: 200, + }); + }); + + const result = await runLiepinSend(document, { + message: "你好", + requestRecords, + }); + + assert.equal(result.success, true); + assert.equal(document.querySelector(".dialog-preview").hidden, false); +}); + +test("wait for the priority modal mask to disappear before clicking send", async () => { + const document = makeDocument(` +
+
+
+
优先沟通
+
立即体验
+ +
+
+
+ + +
+ `); + const requestRecords = []; + const modal = document.querySelector("[data-modal='priority']"); + const mask = document.querySelector(".ant-modal-mask"); + let closeClicked = false; + document.querySelector(".ant-modal-close").addEventListener("click", () => { + closeClicked = true; + }); + const sleep = async () => { + if (closeClicked) { + modal.hidden = true; + mask.hidden = true; + } + }; + let maskWasGoneAtSend = false; + document.querySelector(".ant-im-btn").addEventListener("click", () => { + maskWasGoneAtSend = mask.hidden; + requestRecords.push({ + url: "https://api-c.liepin.com/api/com.liepin.im.c.chat.send-push", + status: 200, + }); + }); + + const result = await runLiepinSend(document, { + message: "你好", + requestRecords, + sleep, + }); + + assert.equal(result.success, true); + assert.equal(maskWasGoneAtSend, true); +}); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5974cd7..d36148a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "offer_flow" -version = "0.1.21" +version = "0.1.24" dependencies = [ "anyhow", "base64 0.22.1", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 66994d1..3f999f0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "offer_flow" -version = "0.1.21" +version = "0.1.24" description = "Local-first desktop job search automation and analysis tool" authors = ["JWJW000"] license = "Apache-2.0" diff --git a/src-tauri/src/browser.rs b/src-tauri/src/browser.rs index 20b3c42..e643f90 100644 --- a/src-tauri/src/browser.rs +++ b/src-tauri/src/browser.rs @@ -12,7 +12,7 @@ use std::{ atomic::{AtomicUsize, Ordering}, Mutex, RwLock, }, - time::Duration, + time::{Duration, Instant}, }; use anyhow::{anyhow, Result}; @@ -20,6 +20,7 @@ use once_cell::sync::Lazy; use rust_drission::{cdp::CdpClient, stealth_inject, ChromiumPage, Page}; use serde::{Deserialize, Serialize}; use tauri::Manager; +use uuid::Uuid; use crate::config::{self, BrowserConfig}; @@ -892,22 +893,54 @@ pub fn close_browser_session() -> Result<()> { Ok(()) } -fn create_background_target(client: &CdpClient) -> Result { +fn create_background_target(client: &CdpClient, url: &str) -> Result { client - .send("Target.createTarget", Some(background_target_params()))? + .send("Target.createTarget", Some(background_target_params(url)))? .get("targetId") .and_then(serde_json::Value::as_str) .map(str::to_string) .ok_or_else(|| anyhow!("后台标签页创建成功,但 CDP 未返回 targetId")) } -fn background_target_params() -> serde_json::Value { +fn background_target_url(token: &str) -> String { + format!("about:blank#offer-flow-{token}") +} + +fn background_target_params(url: &str) -> serde_json::Value { serde_json::json!({ - "url": "about:blank", + "url": url, "background": true }) } +fn attach_background_target(browser: &ChromiumPage, url: &str, target_id: &str) -> Result { + let deadline = Instant::now() + Duration::from_secs(3); + loop { + match browser + .browser() + .get_tab("offer-flow-background", None, Some(url), Some("page")) + { + Ok(Some(tab)) => { + // Verify the attached session, not just the URL filter in rust_drission. + let info = tab.run_cdp("Target.getTargetInfo", None)?; + if info.pointer("/targetInfo/targetId").and_then(serde_json::Value::as_str) + != Some(target_id) + { + return Err(anyhow!("后台标签页会话与创建的 targetId 不一致")); + } + return Ok(tab); + } + Ok(None) if Instant::now() < deadline => {} + Ok(None) => return Err(anyhow!("无法附加到后台标签页 {url}")), + Err(error) if Instant::now() < deadline => { + let _ = error; + } + Err(error) => return Err(error.into()), + } + std::thread::sleep(Duration::from_millis(50)); + } +} + fn close_target(client: &CdpClient, target_id: &str) { let _ = client.send( "Target.closeTarget", @@ -926,19 +959,13 @@ pub fn new_stealth_tab(browser: &ChromiumPage) -> Result { .map_err(|e| anyhow!("获取浏览器调试端口读锁失败: {}", e))? .ok_or_else(|| anyhow!("浏览器调试端口未知,无法创建后台标签页"))?; let client = connect_browser_cdp(port)?; - let target_id = create_background_target(&client)?; - let tab = match browser - .browser() - .get_tab(&target_id, None, None, Some("page")) - { - Ok(Some(tab)) => tab, - Ok(None) => { - close_target(&client, &target_id); - return Err(anyhow!("无法附加到刚创建的后台标签页 {target_id}")); - } + let target_url = background_target_url(&Uuid::new_v4().simple().to_string()); + let target_id = create_background_target(&client, &target_url)?; + let tab = match attach_background_target(browser, &target_url, &target_id) { + Ok(tab) => tab, Err(error) => { close_target(&client, &target_id); - return Err(error.into()); + return Err(error); } }; if let Err(error) = stealth_inject(&tab) { @@ -1110,11 +1137,12 @@ mod tests { #[test] fn business_tabs_are_requested_without_foreground_activation() { - let params = background_target_params(); + let url = background_target_url("probe"); + let params = background_target_params(&url); assert_eq!( params.get("url").and_then(serde_json::Value::as_str), - Some("about:blank") + Some("about:blank#offer-flow-probe") ); assert_eq!( params @@ -1124,6 +1152,17 @@ mod tests { ); } + #[test] + fn background_tab_lookup_is_filtered_by_its_unique_url() { + let source = include_str!("browser.rs"); + let start = source.find("fn attach_background_target").unwrap(); + let end = source[start..].find("fn close_target").unwrap() + start; + let body = &source[start..end]; + + assert!(body.contains("Some(url)")); + assert!(!body.contains("get_tab(&target_id, None, None")); + } + #[test] fn managed_browser_starts_minimized_without_throttling_background_work() { let config = BrowserConfig { diff --git a/src-tauri/src/rpa/liepin/handler/position_say_hello.rs b/src-tauri/src/rpa/liepin/handler/position_say_hello.rs index 9352d13..9da3612 100644 --- a/src-tauri/src/rpa/liepin/handler/position_say_hello.rs +++ b/src-tauri/src/rpa/liepin/handler/position_say_hello.rs @@ -11,6 +11,7 @@ use crate::{ common::{upload_image_to_file_input, RpaJob}, conversation::SendVerdict, greet::build_greet_resources, + human_input, human_pace::GreetPacer, liepin::LIEPIN_SITE_URL, run_flow::is_job_task_stop_requested, @@ -22,6 +23,7 @@ use crate::{ use chrono::Local; use rust_drission::{utils::sleep_random_ms, ChromiumPage, Page}; use serde::Deserialize; +use serde_json::Value; use urlencoding::encode; pub async fn position_say_hello( @@ -166,11 +168,11 @@ pub async fn position_say_hello_on_page( // 「筛选通过即分析」不关心后面招呼发没发出去,所以在进入打招呼之前就登记 auto_analysis::schedule( - &build_job_detail(&job, &config), + &build_job_detail(&job, &config, false), AnalysisTrigger::FilterPassed, &config, ); - let greeted = match greet_job(connection, job.clone(), config.clone()).await { + let greeted = match greet_job(connection, page, job.clone(), config.clone()).await { Ok(false) => { stats.skipped_hold += 1; consecutive_greet_failures = 0; @@ -647,6 +649,7 @@ fn parse_company_from_card_text(card_text: &str, link_text: &str) -> Option Result { @@ -673,33 +676,46 @@ async fn greet_job( )?; } - click_first( - &page, - &[ - ".btn-apply", - ".apply-btn", - "button[class*='apply']", - "a[class*='apply']", - "button[class*='chat']", - "a[class*='chat']", - ], - )?; - sleep_random_ms(800, 1200); - - match build_greet_resources(&config, &job).await? { + if is_external_apply_only(&page)? { + logger::info(format!( + "猎聘跳过 {},该岗位仅支持外部网申,未生成或发送站内消息", + job.title + ))?; + return Ok(false); + } + + let resume_sent = match build_greet_resources(&config, &job).await? { // 整轮取消:既不发文本也不发图片,也不记为已沟通 SendVerdict::Hold(reason) => { logger::info(format!("猎聘跳过 {},未发送任何内容:{reason}", job.title))?; return Ok(false); } - SendVerdict::Send(resources) => send_resources(&page, resources)?, - } - let saved = save_job_detail(&job, &config); + SendVerdict::Send(resources) => { + let entry = click_first( + &page, + LIEPIN_CONTACT_ENTRY_SELECTORS, + )?; + if entry_sends_resume(entry) { + logger::info("猎聘已点击投简历入口,继续发送招呼消息")?; + } + sleep_random_ms(800, 1200); + send_resources(&page, resources)?; + let resume_sent = confirm_resume_delivery(&page)?; + if !resume_sent { + logger::warning("猎聘消息已发送,但尚未确认简历投递,保留简历未投递状态")?; + } + resume_sent + } + }; + let saved = save_job_detail(&job, &config, resume_sent); auto_analysis::schedule(&saved, AnalysisTrigger::GreetSent, &config); logger::info(format!("猎聘 {} 初次沟通成功", job.title))?; Ok(true) } .await; + if let Err(error) = main_page.run_cdp("Page.bringToFront", None) { + let _ = logger::warning(format!("猎聘恢复主搜索页失败,继续清理详情页:{error}")); + } let close_result = page.close(); match (result, close_result) { @@ -1047,158 +1063,123 @@ fn build_wait_image_delivery_script(since: f64) -> String { const INPUT_READY_TIMEOUT_MS: u32 = 15000; const SEND_BUTTON_READY_TIMEOUT_MS: u32 = 15000; const INPUT_CLEARED_TIMEOUT_MS: u32 = 8000; +const CHAT_INPUT_MARKER_SELECTOR: &str = "[data-fj-liepin-chat-input='1']"; +const LIEPIN_CONTACT_ENTRY_SELECTORS: &[&str] = &[ + "a[data-selector='apply-job']", + "a[data-selector='chat-chat']", + "a.btn-chat", + ".btn-apply", + ".apply-btn", + "button[class*='apply']", + "a[class*='apply']", + "button[class*='chat']", + "a[class*='chat']", +]; +const LIEPIN_EXTERNAL_APPLY_SELECTOR: &str = "a[data-selector='apply-ats']"; + +fn is_external_apply_only(page: &Page) -> Result { + for selector in LIEPIN_CONTACT_ENTRY_SELECTORS { + if page.ele(selector)?.is_some() { + return Ok(false); + } + } + Ok(page.ele(LIEPIN_EXTERNAL_APPLY_SELECTOR)?.is_some()) +} fn send_text_resource(page: &Page, text: &str) -> Result<(), anyhow::Error> { - let value = page.run_js_await(&build_send_text_script(text))?; - let result = value.get("value").cloned().unwrap_or(value); + install_request_recorder(page)?; + unwrap_runtime_value(page.run_js_await(&build_mark_chat_input_script())?)?; + if let Some(input) = page.ele(CHAT_INPUT_MARKER_SELECTOR)? { + let _ = human_input::type_text(page, &input, text); + } + if is_job_task_stop_requested() { + return Err(anyhow::anyhow!("任务已停止,本条猎聘消息未发送")); + } + let result = unwrap_runtime_value(page.run_js_await(&build_send_text_script(text))?)?; let success = result .get("success") .and_then(|value| value.as_bool()) .unwrap_or(false); - let message = result - .get("message") + let reason = result + .get("reason") .and_then(|value| value.as_str()) - .unwrap_or_default(); + .unwrap_or("unknown"); if !success { - return Err(anyhow::anyhow!("消息发送失败:{}", message)); + return Err(anyhow::anyhow!("消息发送失败:{}", reason)); } - // 成功不单独记,末尾的“初次沟通成功”已经覆盖 + logger::info(format!( + "猎聘文本发送确认:尝试 {} 次,弹窗关闭 {},请求确认 {},气泡确认 {}", + result + .get("attempts") + .and_then(|value| value.as_u64()) + .unwrap_or_default(), + result + .get("popupClosed") + .and_then(|value| value.as_bool()) + .unwrap_or(false), + result + .get("requestSucceeded") + .and_then(|value| value.as_bool()) + .unwrap_or(false), + result + .get("bubbleSeen") + .and_then(|value| value.as_bool()) + .unwrap_or(false), + ))?; Ok(()) } -fn build_send_text_script(text: &str) -> String { - let text_json = serde_json::to_string(text).unwrap_or_else(|_| "\"\"".to_string()); - format!( - r#" - (async () => {{ - const message = {text_json}; - const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - const visible = (el) => {{ - if (!el) return false; - const rect = el.getBoundingClientRect(); - const style = window.getComputedStyle(el); - return rect.width > 0 && rect.height > 0 - && style.visibility !== "hidden" - && style.display !== "none"; - }}; - const setNativeValue = (el, value) => {{ - const proto = el instanceof HTMLTextAreaElement - ? HTMLTextAreaElement.prototype - : HTMLInputElement.prototype; - const descriptor = Object.getOwnPropertyDescriptor(proto, "value"); - if (descriptor && descriptor.set) {{ - descriptor.set.call(el, value); - }} else {{ - el.value = value; - }} - }}; - const dispatch = (el) => {{ - el.dispatchEvent(new InputEvent("input", {{ bubbles: true, inputType: "insertText", data: message }})); - el.dispatchEvent(new Event("change", {{ bubbles: true }})); - el.dispatchEvent(new KeyboardEvent("keyup", {{ bubbles: true, key: "Enter" }})); - }}; - const inputValue = (el) => (el.isContentEditable ? el.textContent : el.value) || ""; - const isDisabled = (el) => {{ - const className = el.getAttribute("class") || ""; - const ariaDisabled = el.getAttribute("aria-disabled"); - return Boolean(el.disabled) - || ariaDisabled === "true" - || className.includes("disabled") - || className.includes("ant-im-btn-disabled"); - }}; - - // 等状态而不是等时间:条件成立立刻继续,页面慢就自动多等, - // 超时只是异常上限,不参与正常判定 - const waitFor = async (getter, timeoutMs, label) => {{ - const deadline = performance.now() + timeoutMs; - for (;;) {{ - const found = getter(); - if (found) return found; - if (performance.now() >= deadline) {{ - return null; - }} - await sleep(150); - }} - }}; - const findInput = () => Array - .from(document.querySelectorAll("textarea, input[type='text'], div[contenteditable='true'], [contenteditable='true']")) - .filter(visible) - .find((el) => !el.disabled && !el.readOnly); - const findSendButton = () => {{ - const antImButtons = Array.from(document.querySelectorAll(".ant-im-btn")); - const preferred = antImButtons[1]; - if (preferred && visible(preferred) && !isDisabled(preferred)) {{ - return preferred; - }} - return Array - .from(document.querySelectorAll("button.im-ui-basic-send-btn, button.ant-im-btn-primary, button, a, div[role='button'], span[role='button'], .btn-send, .send-btn, [class*='send'], [class*='Send']")) - .filter(visible) - .find((el) => {{ - const text = (el.innerText || el.textContent || "").trim(); - const className = el.getAttribute("class") || ""; - return (text === "发送" - || text.includes("发送") - || className.includes("im-ui-basic-send-btn") - || className.includes("ant-im-btn-primary") - || /(^|\s)(btn-send|send-btn)(\s|$)/.test(className) - || /send|Send/.test(className)) - && !isDisabled(el); - }}); - }}; - - // 聊天窗可能还在加载,等它出现而不是查一次就判死 - const input = await waitFor(findInput, {INPUT_READY_TIMEOUT_MS}); - if (!input) {{ - return {{ - success: false, - message: "聊天输入框在 {INPUT_READY_TIMEOUT_MS} 毫秒内未出现" - }}; - }} - - input.focus(); - if (input.isContentEditable) {{ - input.textContent = message; - }} else {{ - setNativeValue(input, message); - }} - dispatch(input); - - // 填入内容后按钮才会由 disabled 变可用,同样等状态 - const button = await waitFor(findSendButton, {SEND_BUTTON_READY_TIMEOUT_MS}); - if (!button) {{ - return {{ - success: false, - message: "发送按钮在 {SEND_BUTTON_READY_TIMEOUT_MS} 毫秒内未变为可用", - inputText: inputValue(input) - }}; - }} +fn unwrap_runtime_value(value: Value) -> Result { + if value.get("subtype").and_then(Value::as_str) == Some("error") { + let class_name = value + .get("className") + .and_then(Value::as_str) + .unwrap_or("JavaScriptError"); + let description = value + .get("description") + .and_then(Value::as_str) + .unwrap_or("页面脚本未返回详情"); + return Err(anyhow::anyhow!("猎聘页面脚本异常:{class_name}: {description}")); + } + Ok(value.get("value").cloned().unwrap_or(value)) +} - button.scrollIntoView({{ block: "center", inline: "center" }}); - button.click(); +fn liepin_send_script_source() -> String { + include_str!("../send_text.js") + .replace("export { markLiepinChatInput, runLiepinSend };", "") +} - // 发出去的标志是输入框被清空,等这个状态,别按固定时长猜 - const cleared = await waitFor( - () => !inputValue(input).includes(message) || null, - {INPUT_CLEARED_TIMEOUT_MS} - ); +fn build_mark_chat_input_script() -> String { + let mut script = String::from("(() => {\n"); + script.push_str(&liepin_send_script_source()); + script.push_str("\nreturn markLiepinChatInput(document);\n})()"); + script +} - return {{ - success: Boolean(cleared), - message: cleared ? "输入框已清空" : "已点击发送按钮,但输入框内容未清空", - inputText: inputValue(input), - buttonText: (button.innerText || button.textContent || "").trim(), - buttonClass: button.getAttribute("class") || "" - }}; +fn build_send_text_script(text: &str) -> String { + let text_json = serde_json::to_string(text).unwrap_or_else(|_| "\"\"".to_string()); + let mut script = String::from("(async () => {\n"); + script.push_str(&liepin_send_script_source()); + script.push_str(&format!( + r#" + return await runLiepinSend(document, {{ + message: {text_json}, + inputTimeoutMs: {INPUT_READY_TIMEOUT_MS}, + buttonTimeoutMs: {SEND_BUTTON_READY_TIMEOUT_MS}, + proofTimeoutMs: {INPUT_CLEARED_TIMEOUT_MS}, + requestSince: performance.now() + }}); }})() "# - ) + )); + script } /// 按当前任务上下文拼出岗位记录。 /// 打招呼后落库和「筛选通过即分析」共用它——后者触发时岗位还没入库,只能拿这份内存数据去分析。 -fn build_job_detail(job: &RpaJob, config: &AppRuntimeConfig) -> JobDetail { +fn build_job_detail(job: &RpaJob, config: &AppRuntimeConfig, resume_sent: bool) -> JobDetail { let now = Local::now().format("%Y-%m-%d %H:%M:%S").to_string(); let active = config.active_job_profile.as_ref(); JobDetail { @@ -1214,15 +1195,15 @@ fn build_job_detail(job: &RpaJob, config: &AppRuntimeConfig) -> JobDetail { salary: job.salary.clone(), location: job.location.clone(), is_reply: false, - is_send_resume: false, + is_send_resume: resume_sent, created_at: now.clone(), - resume_sent_at: None, + resume_sent_at: resume_sent.then(|| now.clone()), updated_at: now, } } -fn save_job_detail(job: &RpaJob, config: &AppRuntimeConfig) -> JobDetail { - let job_detail = build_job_detail(job, config); +fn save_job_detail(job: &RpaJob, config: &AppRuntimeConfig, resume_sent: bool) -> JobDetail { + let job_detail = build_job_detail(job, config, resume_sent); if let Err(e) = job_detail_dao::create(job_detail.clone()) { let _ = logger::warning(format!("保存猎聘岗位数据失败: {}", e)); @@ -1264,14 +1245,42 @@ pub(crate) fn text_from_first(page: &Page, selectors: &[&str]) -> Result Result<(), anyhow::Error> { +fn entry_sends_resume(selector: &str) -> bool { + selector.contains("apply") && !selector.contains("apply-ats") +} + +fn resume_delivery_label_confirmed(label: &str) -> bool { + matches!(label.trim(), "已投递" | "已投递简历" | "已申请") +} + +fn confirm_resume_delivery(page: &Page) -> Result { + // Clicking the application entry can merely open a dialog. Only the + // job's own application status is evidence of delivery. + let deadline = Instant::now() + Duration::from_secs(3); + loop { + if let Some(entry) = page.ele("a[data-selector='apply-job']")? { + if resume_delivery_label_confirmed(&entry.text_content()?) { + return Ok(true); + } + } + if Instant::now() >= deadline { + return Ok(false); + } + std::thread::sleep(Duration::from_millis(200)); + } +} + +pub(crate) fn click_first<'a>( + page: &Page, + selectors: &'a [&'a str], +) -> Result<&'a str, anyhow::Error> { let deadline = Instant::now() + CLICKABLE_READY_TIMEOUT; loop { for selector in selectors { // 页面正在导航时 ele 会短暂报错,这属于“还没就绪”而不是失败 if let Ok(Some(ele)) = page.ele(selector) { ele.click()?; - return Ok(()); + return Ok(selector); } } @@ -1435,13 +1444,14 @@ mod tests { let script = build_send_text_script("你好,想进一步沟通"); assert!(script.contains("InputEvent(\"input\"")); - assert!(script.contains("text.includes(\"发送\")")); + assert!(script.contains("textOf(node).includes(\"发送\")")); assert!(script.contains("button.im-ui-basic-send-btn")); assert!(script.contains("button.ant-im-btn-primary")); - assert!(script.contains("ariaDisabled === \"true\"")); - assert!(script.contains("document.querySelectorAll(\".ant-im-btn\")")); - assert!(script.contains("antImButtons[1]")); - assert!(script.contains("button.click()")); + assert!(script.contains("im-ui-msg-list-content")); + assert!(!script.contains("antImButtons[1]")); + assert!(!script.contains("[class*='send']")); + assert!(script.contains("activeButton.click()")); + assert!(script.contains("send-push")); } #[test] @@ -1450,21 +1460,133 @@ mod tests { let script = build_send_text_script("你好"); assert!(!script.contains("await sleep(500)")); - assert!(script.contains("waitFor(findInput")); - assert!(script.contains("waitFor(findSendButton")); - assert!(script.contains("聊天输入框在 15000 毫秒内未出现")); - assert!(script.contains("发送按钮在 15000 毫秒内未变为可用")); + assert!(script.contains("inputTimeoutMs: 15000")); + assert!(script.contains("buttonTimeoutMs: 15000")); + assert!(script.contains("proofTimeoutMs: 8000")); + assert!(script.contains("observeSendState")); } #[test] fn send_text_script_confirms_delivery_by_waiting_for_the_input_to_clear() { let script = build_send_text_script("你好"); - assert!(script.contains("inputValue(input).includes(message)")); - assert!(script.contains("已点击发送按钮,但输入框内容未清空")); - assert!(script.contains("输入框已清空")); + assert!(script.contains("inputCleared")); + assert!(script.contains("requestSucceeded")); + assert!(script.contains("input-cleared-without-proof")); + } + + #[test] + fn send_text_script_does_not_choose_buttons_by_page_order() { + let script = build_send_text_script("你好"); + + assert!(!script.contains("antImButtons[1]")); + assert!(!script.contains("[class*='send']")); + assert!(script.contains("im-ui-msg-list-content")); } + #[test] + fn greeting_is_generated_before_opening_the_chat_window() { + let source = include_str!("position_say_hello.rs"); + let decision = source.find("match build_greet_resources").unwrap(); + let click = source[decision..].find("click_first").unwrap() + decision; + + assert!(decision < click); + } + + #[test] + fn send_text_resource_checks_stop_after_human_input() { + let source = include_str!("position_say_hello.rs"); + let start = source.find("fn send_text_resource").unwrap(); + let end = source[start..].find("fn build_send_text_script").unwrap() + start; + let body = &source[start..end]; + let typed = body.find("human_input::type_text").unwrap(); + let stopped = body[typed..] + .find("is_job_task_stop_requested") + .unwrap() + + typed; + let send = body[stopped..].find("build_send_text_script").unwrap() + stopped; + + assert!(typed < stopped && stopped < send); + } + + #[test] + fn main_search_page_is_restored_before_detail_tab_cleanup() { + let source = include_str!("position_say_hello.rs"); + let start = source.find("async fn greet_job").unwrap(); + let end = source[start..].find("fn greet_failure_message").unwrap() + start; + let body = &source[start..end]; + let restore = body.find("Page.bringToFront").unwrap(); + let close = body.find("let close_result = page.close()").unwrap(); + + assert!(restore < close); + } + + #[test] + fn generated_send_scripts_are_scoped_independently() { + let mark = build_mark_chat_input_script(); + let send = build_send_text_script("你好"); + + assert!(mark.trim_start().starts_with("(() => {")); + assert!(mark.trim_end().ends_with("})()")); + assert!(send.trim_start().starts_with("(async () => {")); + assert!(send.trim_end().ends_with("})()")); + } + + #[test] + fn runtime_errors_keep_the_cdp_description() { + let error = unwrap_runtime_value(serde_json::json!({ + "type": "object", + "subtype": "error", + "className": "SyntaxError", + "description": "SyntaxError: Identifier already declared" + })) + .unwrap_err(); + + assert!(error.to_string().contains("SyntaxError")); + assert!(error.to_string().contains("already declared")); + } + + #[test] + fn contact_entry_selectors_prefer_chat_and_exclude_external_apply() { + assert_eq!(LIEPIN_CONTACT_ENTRY_SELECTORS[0], "a[data-selector='apply-job']"); + assert_eq!(LIEPIN_CONTACT_ENTRY_SELECTORS[1], "a[data-selector='chat-chat']"); + assert_eq!(LIEPIN_EXTERNAL_APPLY_SELECTOR, "a[data-selector='apply-ats']"); + } + + #[test] + fn only_apply_entries_are_resume_attempts() { + assert!(entry_sends_resume("a[data-selector='apply-job']")); + assert!(entry_sends_resume(".btn-apply")); + assert!(!entry_sends_resume("a[data-selector='chat-chat']")); + assert!(!entry_sends_resume("a.btn-chat")); + } + + #[test] + fn resume_delivery_requires_an_explicit_completed_status() { + for label in ["投简历", "继续聊", "投递中", "投递失败", "", "未投递"] { + assert!(!resume_delivery_label_confirmed(label), "{label}"); + } + for label in ["已投递", "已投递简历", " 已申请 "] { + assert!(resume_delivery_label_confirmed(label), "{label}"); + } + } + + #[test] + fn applied_job_records_resume_delivery_time() { + let candidate = LiepinJobCandidate { + link_text: "AI应用工程师 【 深圳 】 15-25k 应届 本科".to_string(), + card_text: "AI应用工程师 【 深圳 】 15-25k 应届 本科 示例公司".to_string(), + href: "https://www.liepin.com/lptjob/12345678".to_string(), + }; + let job = candidate_to_rpa_job(candidate).unwrap(); + + let detail = build_job_detail(&job, &default_app_config(), true); + + assert!(detail.is_send_resume); + assert!(detail.resume_sent_at.is_some()); + } + + #[test] fn job_card_selectors_exclude_hot_job_category_items() { let selectors = job_card_selectors(); diff --git a/src-tauri/src/rpa/liepin/send_text.js b/src-tauri/src/rpa/liepin/send_text.js new file mode 100644 index 0000000..9df3f4d --- /dev/null +++ b/src-tauri/src/rpa/liepin/send_text.js @@ -0,0 +1,456 @@ +const CHAT_CONTAINER_SELECTOR = ".im-ui-msg-list-content"; +const INPUT_SELECTOR = "textarea, input[type='text'], [contenteditable='true']"; +const SEND_BUTTON_SELECTOR = ".ant-im-btn, button.im-ui-basic-send-btn, button.ant-im-btn-primary"; +const MODAL_SELECTOR = "[role='dialog'], .ant-modal"; +const MODAL_MASK_SELECTOR = ".ant-modal-mask, .ant-modal-wrap"; +const CHAT_INPUT_MARKER = "data-fj-liepin-chat-input"; +const MODAL_CLOSE_TIMEOUT_MS = 3000; +const SEND_REQUEST_RE = /chat\.send-push|chat\.send|send-push/i; +const PRIORITY_HINT_RE = /优先沟通/; +const PRIORITY_FEATURE_RE = /(体验|购买|开通|支付|送\d+次|赠\d+次|扫码|¥|¥)/; +const BLOCKING_MODAL_RE = /(登录|验证码|验证|手机号|密码|安全验证|扫码|滑块)/; + +function textOf(node) { + return String(node?.innerText || node?.textContent || "").replace(/\s+/g, " ").trim(); +} + +function isVisible(node) { + if (!node?.isConnected) return false; + for (let current = node; current; current = current.parentElement) { + if (current.hidden || current.getAttribute?.("aria-hidden") === "true") return false; + const style = current.ownerDocument.defaultView.getComputedStyle(current); + if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") return false; + } + return true; +} + +function findVisible(list) { + return list.find(isVisible) || null; +} + +function inputValue(node) { + if (!node) return ""; + if (node.isContentEditable) { + return String(node.textContent || "").replace(/\s+/g, " ").trim(); + } + return String(node.value || ""); +} + +function setInputValue(node, message) { + if (!node) return false; + const view = node.ownerDocument.defaultView; + if (node.isContentEditable) { + node.textContent = message; + } else { + const proto = node instanceof view.HTMLTextAreaElement + ? view.HTMLTextAreaElement.prototype + : view.HTMLInputElement.prototype; + const descriptor = Object.getOwnPropertyDescriptor(proto, "value"); + if (descriptor?.set) { + descriptor.set.call(node, message); + } else { + node.value = message; + } + } + + const beforeInputEvent = typeof view.InputEvent === "function" + ? new view.InputEvent("beforeinput", { bubbles: true, cancelable: true, inputType: "insertText", data: message }) + : new view.Event("beforeinput", { bubbles: true, cancelable: true }); + const inputEvent = typeof view.InputEvent === "function" + ? new view.InputEvent("input", { bubbles: true, cancelable: true, inputType: "insertText", data: message }) + : new view.Event("input", { bubbles: true, cancelable: true }); + node.dispatchEvent(beforeInputEvent); + node.dispatchEvent(inputEvent); + node.dispatchEvent(new view.Event("change", { bubbles: true })); + return true; +} + +async function closePriorityModal(modal, document, sleep) { + const candidates = Array.from( + modal.querySelectorAll(".ant-modal-close, button, [role='button'], a") + ).filter((node) => isVisible(node)); + + const closeButton = candidates.find((node) => { + const text = textOf(node); + const aria = String(node.getAttribute?.("aria-label") || ""); + return node.classList.contains("ant-modal-close") + || /^(x|×)$/i.test(text) + || /关闭|关闭弹窗|暂不|知道了|取消/i.test(text) + || /关闭|dismiss|close/i.test(aria); + }); + + if (!closeButton) return false; + const modalRoot = modal.closest(".ant-modal-root"); + closeButton.click(); + return Boolean(await waitFor(() => { + const modalGone = !modal.isConnected || !isVisible(modal); + const masksGone = Array.from((modalRoot || modal).querySelectorAll(MODAL_MASK_SELECTOR)) + .every((mask) => !isVisible(mask)); + return modalGone && masksGone; + }, MODAL_CLOSE_TIMEOUT_MS, sleep)); +} + +function classifyModal(modal) { + if (modal.querySelector(CHAT_CONTAINER_SELECTOR)) return "other"; + const text = textOf(modal); + const hasPriorityTitle = Array.from(modal.querySelectorAll("h1, h2, h3, [class*='title'], [class*='header'], div, span")) + .some((node) => textOf(node) === "优先沟通"); + if (hasPriorityTitle && PRIORITY_HINT_RE.test(text) && PRIORITY_FEATURE_RE.test(text)) { + return "priority"; + } + if (BLOCKING_MODAL_RE.test(text)) { + return "blocking"; + } + return "other"; +} + +async function handleKnownModal(document, sleep) { + const visibleModals = Array.from(document.querySelectorAll(MODAL_SELECTOR)).filter(isVisible); + const blocking = visibleModals.find((modal) => classifyModal(modal) === "blocking"); + if (blocking) { + return { + blocked: true, + reason: `blocked-modal:${textOf(blocking)}`, + closed: false, + }; + } + + let closed = false; + for (const modal of visibleModals) { + if (classifyModal(modal) !== "priority") continue; + const currentClosed = await closePriorityModal(modal, document, sleep); + if (!currentClosed) { + return { + blocked: true, + reason: "priority-modal-close-timeout", + closed, + }; + } + closed = true; + } + + return { + blocked: false, + reason: "", + closed, + }; +} + +function findChatContainer(document) { + const marker = findVisible(Array.from(document.querySelectorAll(CHAT_CONTAINER_SELECTOR))); + if (!marker) return null; + let candidate = marker; + for (let container = marker; container && container !== document.body; container = container.parentElement) { + if (findChatInput(container)) { + candidate = container; + if (findAnySendButton(container)) return container; + } + } + return candidate; +} + +function findAnySendButton(container) { + return container + ? Array.from(container.querySelectorAll(SEND_BUTTON_SELECTOR)) + .find((node) => isVisible(node) + && (textOf(node) === "发送" || textOf(node).includes("发送"))) || null + : null; +} + +function findChatInput(container) { + if (!container) return null; + const inputs = Array.from(container.querySelectorAll(INPUT_SELECTOR)).filter(isVisible); + return inputs.find((node) => !node.disabled && !node.readOnly) || null; +} + +function markLiepinChatInput(document) { + document.querySelectorAll(`[${CHAT_INPUT_MARKER}]`) + .forEach((node) => node.removeAttribute(CHAT_INPUT_MARKER)); + const input = findChatInput(findChatContainer(document)); + if (!input) return false; + input.setAttribute(CHAT_INPUT_MARKER, "1"); + if (inputValue(input)) setInputValue(input, ""); + return true; +} + +function isDisabled(node) { + if (!node) return true; + const className = String(node.getAttribute?.("class") || ""); + return Boolean(node.disabled) + || node.getAttribute?.("aria-disabled") === "true" + || className.includes("disabled") + || className.includes("ant-im-btn-disabled"); +} + +function findSendButton(container) { + if (!container) return null; + const buttons = Array.from(container.querySelectorAll(SEND_BUTTON_SELECTOR)).filter(isVisible); + return buttons.find((node) => !isDisabled(node) + && (textOf(node) === "发送" || textOf(node).includes("发送"))) || null; +} + +function matchingBubbleCount(container, message) { + if (!container) return 0; + return Array.from(container.querySelectorAll(".im-ui-message-item-send, .bubble, [data-message='sent']")).filter((node) => { + if (!isVisible(node)) return false; + return textOf(node).includes(message); + }).length; +} + +function hasMatchingBubble(container, message, initialBubbleCount) { + return matchingBubbleCount(container, message) > initialBubbleCount; +} + +function isAfter(record, since) { + return !Number.isFinite(Number(record?.at)) || Number(record.at) > since; +} + +function hasSendRequest(records, since) { + return records.some((record) => isAfter(record, since) + && SEND_REQUEST_RE.test(String(record?.url || ""))); +} + +function hasSuccessfulSendRequest(records, since) { + return records.some((record) => { + const url = String(record?.url || ""); + const status = Number(record?.status || 0); + return isAfter(record, since) && SEND_REQUEST_RE.test(url) + && status >= 200 && status < 300; + }); +} + +async function waitFor(getter, timeoutMs, sleep) { + const rounds = Math.max(1, Math.ceil(timeoutMs / 150)); + for (let round = 0; round < rounds; round += 1) { + const value = getter(); + if (value) return value; + await sleep(150); + } + return null; +} + +async function observeSendState({ + requestRecords, + container, + input, + message, + sleep, + requestSince, + proofTimeoutMs, + initialBubbleCount, +}) { + let requestSeen = false; + let requestSucceeded = false; + let bubbleSeen = false; + let inputCleared = false; + + const rounds = Math.max(1, Math.ceil(proofTimeoutMs / 150)); + for (let round = 0; round < rounds; round += 1) { + requestSeen = requestSeen || hasSendRequest(requestRecords, requestSince); + requestSucceeded = requestSucceeded || hasSuccessfulSendRequest(requestRecords, requestSince); + bubbleSeen = bubbleSeen || hasMatchingBubble(container, message, initialBubbleCount); + inputCleared = inputCleared || !inputValue(input).includes(message); + if (requestSucceeded || bubbleSeen) break; + await sleep(150); + } + + return { + requestSeen, + requestSucceeded, + bubbleSeen, + inputCleared, + messageStillPresent: inputValue(input).includes(message), + }; +} + +async function clickSendOnce({ + container, + input, + message, + requestRecords, + requestSince, + sleep, + buttonTimeoutMs, + proofTimeoutMs, + initialBubbleCount, +}) { + if (inputValue(input) !== message) { + setInputValue(input, message); + } + + const button = await waitFor(() => findSendButton(container), buttonTimeoutMs, sleep); + if (!button) { + return { + clicked: false, + requestSeen: false, + requestSucceeded: false, + bubbleSeen: false, + inputCleared: false, + messageStillPresent: inputValue(input).includes(message), + reason: "send-button-missing", + }; + } + + const activeButton = findSendButton(container) || button; + if (!activeButton || isDisabled(activeButton)) { + return { + clicked: false, + requestSeen: false, + requestSucceeded: false, + bubbleSeen: false, + inputCleared: false, + messageStillPresent: inputValue(input).includes(message), + reason: "send-button-disabled", + }; + } + + activeButton.click(); + const observed = await observeSendState({ + requestRecords, + requestSince, + container, + input, + message, + sleep, + proofTimeoutMs, + initialBubbleCount, + }); + return { + clicked: true, + ...observed, + reason: observed.requestSucceeded + ? "send-request" + : observed.bubbleSeen + ? "bubble" + : observed.inputCleared + ? "input-cleared-without-proof" + : "no-signal", + }; +} + +async function runLiepinSend(document, { + message, + requestRecords = null, + sleep = null, + requestSince = 0, + inputTimeoutMs = 15000, + buttonTimeoutMs = 15000, + proofTimeoutMs = 8000, +} = {}) { + const text = String(message || ""); + const wait = sleep || ((ms) => new Promise((resolve) => { + document.defaultView.setTimeout(resolve, ms); + })); + const result = { + success: false, + attempts: 0, + retried: false, + popupClosed: false, + blockedModal: false, + requestSeen: false, + requestSucceeded: false, + bubbleSeen: false, + inputCleared: false, + reason: "", + }; + + if (!text.trim()) { + return { ...result, reason: "empty-message" }; + } + + const records = requestRecords || document.defaultView?.__fjRequestRecords || []; + + const modalState = await handleKnownModal(document, wait); + if (modalState.blocked) { + return { ...result, blockedModal: true, reason: modalState.reason }; + } + result.popupClosed = modalState.closed; + + let container = findChatContainer(document); + if (!container) { + return { ...result, reason: "chat-container-missing" }; + } + + let input = await waitFor(() => findChatInput(container), inputTimeoutMs, wait); + if (!input) { + return { ...result, reason: "chat-input-missing" }; + } + + let firstAttempt = await clickSendOnce({ + container, + input, + message: text, + requestRecords: records, + requestSince, + sleep: wait, + buttonTimeoutMs, + proofTimeoutMs, + initialBubbleCount: matchingBubbleCount(container, text), + }); + result.attempts = 1; + result.requestSeen = firstAttempt.requestSeen; + result.requestSucceeded = firstAttempt.requestSucceeded; + result.bubbleSeen = firstAttempt.bubbleSeen; + result.inputCleared = firstAttempt.inputCleared; + + if (firstAttempt.requestSucceeded || firstAttempt.bubbleSeen) { + return { + ...result, + success: firstAttempt.requestSucceeded || firstAttempt.bubbleSeen, + reason: firstAttempt.reason, + }; + } + + if (firstAttempt.requestSeen || firstAttempt.bubbleSeen || firstAttempt.inputCleared || !firstAttempt.messageStillPresent) { + return { + ...result, + success: false, + reason: firstAttempt.reason, + }; + } + + const retryModalState = await handleKnownModal(document, wait); + if (retryModalState.blocked) { + return { + ...result, + attempts: 1, + blockedModal: true, + reason: retryModalState.reason, + }; + } + + result.popupClosed = result.popupClosed || retryModalState.closed; + + container = findChatContainer(document) || container; + input = findChatInput(container) || input; + + const secondAttempt = await clickSendOnce({ + container, + input, + message: text, + requestRecords: records, + requestSince, + sleep: wait, + buttonTimeoutMs, + proofTimeoutMs, + initialBubbleCount: matchingBubbleCount(container, text), + }); + + return { + ...result, + attempts: 2, + retried: true, + requestSeen: result.requestSeen || secondAttempt.requestSeen, + requestSucceeded: result.requestSucceeded || secondAttempt.requestSucceeded, + bubbleSeen: result.bubbleSeen || secondAttempt.bubbleSeen, + inputCleared: result.inputCleared || secondAttempt.inputCleared, + success: + result.requestSucceeded + || secondAttempt.requestSucceeded + || result.bubbleSeen + || secondAttempt.bubbleSeen, + reason: secondAttempt.reason, + }; +} + +export { markLiepinChatInput, runLiepinSend }; diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 1573166..9d77521 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OfferFlow", - "version": "0.1.21", + "version": "0.1.24", "identifier": "me.pgthinker.fj", "build": { "beforeDevCommand": "pnpm dev",