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",