From 66bca742676d8380e6959c4609cef2a7c499b66d Mon Sep 17 00:00:00 2001 From: xuxin <15279969124@163.com> Date: Thu, 30 Jul 2026 15:29:24 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E6=8E=A7=E5=88=B6=E5=BB=B6?= =?UTF-8?q?=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .kiro/steering/project-guidelines.md | 13 +++++ package.json | 2 +- src/config.ts | 6 +-- src/handlers/handleAutoFillBeisen.ts | 49 +++++++++--------- src/handlers/handleAutoFillCommon.ts | 25 +++++----- src/lib/autofill.ts | 74 +++++++++++++--------------- src/lib/datePicker.ts | 53 ++++++++++---------- src/lib/experienceSection.ts | 6 +-- src/lib/pickerDetector.ts | 6 +-- src/lib/resumeUpload.ts | 4 +- src/utils/delay.ts | 23 +++++++++ 11 files changed, 148 insertions(+), 113 deletions(-) create mode 100644 src/utils/delay.ts diff --git a/.kiro/steering/project-guidelines.md b/.kiro/steering/project-guidelines.md index bc299a6..e9b3364 100644 --- a/.kiro/steering/project-guidelines.md +++ b/.kiro/steering/project-guidelines.md @@ -101,3 +101,16 @@ src/ - 禁止对 isAsyncDropdown 字段走 `tryClickDropdownListItem` 全页面搜(选项数可能为1,全页面搜会被其他同规格元素干扰) - `pickerFill.ts` 中 `groupBySpec` / `findVisibleListGroups` 的同规格标签组阈值为 ≥2(不可改回 ≥3,否则只有2个选项的下拉列表会漏匹配) - `clickBestOptionInDropdown` 是在指定弹出层 DOM 内递归搜所有可见叶子文字节点做模糊匹配的核心方法,**不可简化其递归逻辑** + + +## 延时调用规范 + +- **项目中所有延时调用必须且只能使用 `src/utils/delay.ts` 中导出的 `delay` 函数** +- 禁止在任何文件中自行编写 `setTimeout` / `new Promise(resolve => setTimeout(...))` 等延时逻辑 +- `delay` 函数接收延时等级参数,统一管理延时时长: + - `delay("low")` — 低延时(微等待,如点击后极短暂停) + - `delay("mid")` — 中延时(等待 DOM 更新、弹出层渲染等) + - `delay("high")` — 高延时(等待接口返回、搜索结果、动画完成等) + - `delay("max")` — 特殊超长延时(谨慎使用) +- 具体毫秒数只在 `src/utils/delay.ts` 中统一设置,调用方只使用等级名称 +- 如需调整全局延时策略,只修改 `delay.ts` 中的 `DELAY_MS` 常量即可 diff --git a/package.json b/package.json index 019297e..69904e2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "offerpie-extension", "displayName": "Offer派智能投递助手", - "version": "0.1.0", + "version": "0.1.1", "description": "offer派平台旗下网申投递助手 - 自动化投递简历\n官网:https://offerpai.com.cn", "scripts": { "dev": "plasmo dev", diff --git a/src/config.ts b/src/config.ts index df4c81a..c9b2582 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,7 +3,7 @@ */ /** 当前环境,手动切换 */ -const ENV = 'prod' +const ENV = 'dev' /** 各环境配置 */ const envConfigs: Record setTimeout(resolve, ms)) -} - /** * 通用的值写入方法(适配各种前端框架的受控组件) * 优先用 execCommand 模拟真实输入,失败则 fallback 到原生 setter @@ -60,12 +56,12 @@ export function forceSetValue(el: HTMLInputElement | HTMLTextAreaElement, value: /** 关闭弹出层 */ export async function closePopup(inputEl: HTMLElement) { inputEl.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", code: "Escape", keyCode: 27, bubbles: true })) - await delay(100) + await delay("mid") inputEl.blur() inputEl.dispatchEvent(new Event("blur", { bubbles: true })) - await delay(100) + await delay("mid") document.body.click() - await delay(200) + await delay("mid") } // ============ 字段填写 ============ @@ -103,7 +99,7 @@ export async function fillMatchedField(field: MatchedFormField): Promise { // 搜不到时再补充搜索页面上其他可见弹出层(处理嵌套容器),最终都搜不到才关闭后走步骤1-4。 if (!isDateValue && field.pickerDropdownElement) { const existingPopupEl = field.pickerDropdownElement - await delay(100) // 等待弹出层渲染完成 + await delay("mid") // 等待弹出层渲染完成 if (clickBestOptionInDropdown(existingPopupEl, fillValue)) { - await delay(300); await closePopup(inputElement) + await delay("mid"); await closePopup(inputElement) console.log(`OfferPie: ✅ [选择器] 已选择 "${labelText}" = "${fillValue}" (已有弹出层)`) return true } @@ -181,7 +177,7 @@ async function fillPickerField(field: MatchedFormField): Promise { const htmlEl = el as HTMLElement if (htmlEl.offsetHeight > 0 && htmlEl.offsetWidth > 0 && htmlEl !== existingPopupEl) { if (clickBestOptionInDropdown(htmlEl, fillValue)) { - await delay(300); await closePopup(inputElement) + await delay("mid"); await closePopup(inputElement) console.log(`OfferPie: ✅ [选择器] 已选择 "${labelText}" = "${fillValue}" (嵌套弹出层)`) return true } @@ -189,7 +185,7 @@ async function fillPickerField(field: MatchedFormField): Promise { } // 仍然没找到,关闭弹出层后走步骤1-4 重新展开 await closePopup(inputElement) - await delay(200) + await delay("mid") } // ---- 步骤1:记录点击前的 DOM 快照 ---- @@ -218,7 +214,7 @@ async function fillPickerField(field: MatchedFormField): Promise { clickTargets[i].click() } inputElement.focus() - await delay(300) + await delay("mid") if (isDateValue) return await fillDatePicker(field) @@ -266,7 +262,7 @@ async function fillPickerField(field: MatchedFormField): Promise { if (dropdownEl) { console.log(`OfferPie: [fillPicker] "${labelText}" 找到弹出层: ${dropdownEl.tagName}.${(dropdownEl.className || "").toString().split(" ")[0]}`) if (clickBestOptionInDropdown(dropdownEl, fillValue)) { - await delay(300); await closePopup(inputElement) + await delay("mid"); await closePopup(inputElement) console.log(`OfferPie: ✅ [选择器] 已选择 "${labelText}" = "${fillValue}"`) return true } @@ -274,23 +270,23 @@ async function fillPickerField(field: MatchedFormField): Promise { // ---- 步骤5:fallback - 在弹出层中搜索(有弹出层在弹出层内搜,没有则全页面搜) ---- if (findAndClickOptionInVisiblePopups(fillValue, labelText, dropdownEl)) { - await delay(300); await closePopup(inputElement) + await delay("mid"); await closePopup(inputElement) console.log(`OfferPie: ✅ [选择器] 已选择 "${labelText}" = "${fillValue}"`) return true } // ---- 步骤6:写入值触发搜索,再尝试一次 ---- forceSetValue(inputElement, fillValue) - await delay(500) + await delay("high") if (findAndClickOptionInVisiblePopups(fillValue, labelText, dropdownEl)) { - await delay(300); await closePopup(inputElement) + await delay("mid"); await closePopup(inputElement) console.log(`OfferPie: ✅ [选择器] 已选择 "${labelText}" = "${fillValue}"`) return true } // ---- 步骤7:兜底 Enter 确认 ---- inputElement.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", code: "Enter", keyCode: 13, bubbles: true })) - await delay(300) + await delay("mid") await closePopup(inputElement) console.log(`OfferPie: ⚠️ [选择器] 未找到匹配选项,已兜底填写 "${labelText}" = "${fillValue}"`) return true @@ -305,7 +301,7 @@ export async function fillAllFields(fields: MatchedFormField[]): Promise<{ total if (field.inputType !== "radio" && !field.inputElement) { result.skipped++; continue } const ok = await fillMatchedField(field) if (ok) result.success++; else result.failed++ - await delay(200) + await delay("mid") } console.log(`===== OfferPie: 自动填写完毕 总计 ${result.total} | 成功 ${result.success} | 失败 ${result.failed} | 跳过 ${result.skipped} =====`) return result @@ -353,7 +349,7 @@ export async function fillSearchPickerField(field: MatchedFormField): Promise 10) { @@ -385,7 +381,7 @@ export async function fillSearchPickerField(field: MatchedFormField): Promise 10) { @@ -414,19 +410,19 @@ export async function fillSearchPickerField(field: MatchedFormField): Promise { // 点击按钮 btn.click() - await delay(150) + await delay("mid") const afterYear = readCurrentYear(panel) const afterMonth = readCurrentMonth(panel) @@ -626,21 +627,21 @@ async function detectNavButtons(panel: CalendarPanelInfo): Promise { if (restoreBtn) { restoreBtn.click() - await delay(150) + await delay("mid") } else { // 没有已知反向按钮,尝试用位置对称的按钮恢复 // 如果当前按钮在前半部分,对称按钮在后半部分 const symmetricIdx = buttons.length - 1 - i if (symmetricIdx !== i && symmetricIdx >= 0 && symmetricIdx < buttons.length) { buttons[symmetricIdx].click() - await delay(150) + await delay("mid") // 验证是否恢复 const checkYear = readCurrentYear(panel) const checkMonth = readCurrentMonth(panel) if (checkYear !== beforeYear || checkMonth !== beforeMonth) { // 没恢复成功,再点一次原按钮的对称位置 buttons[symmetricIdx].click() - await delay(150) + await delay("mid") } } } @@ -684,7 +685,7 @@ async function restoreToDate( if (yearBtn) { for (let i = 0; i < Math.abs(yearDiff) && i < 50; i++) { yearBtn.click() - await delay(80) + await delay("low") } } } @@ -700,7 +701,7 @@ async function restoreToDate( if (monthBtn) { for (let i = 0; i < Math.abs(monthDiff) && i < 24; i++) { monthBtn.click() - await delay(80) + await delay("low") } } } @@ -731,7 +732,7 @@ async function navigateToYearMonth( console.log(`OfferPie: [datePicker] 年份需要${yearDiff > 0 ? "增加" : "减少"} ${steps} 年`) for (let i = 0; i < steps && i < 100; i++) { yearBtn.click() - await delay(100) + await delay("mid") // 验证年份确实变了 const newYear = readCurrentYear(panel) if (newYear === targetYear) break @@ -744,7 +745,7 @@ async function navigateToYearMonth( console.log(`OfferPie: [datePicker] 无年份按钮,用月份按钮跨 ${Math.abs(totalMonthDiff)} 个月`) for (let i = 0; i < Math.abs(totalMonthDiff) && i < 200; i++) { monthBtn.click() - await delay(60) + await delay("low") } } } @@ -770,7 +771,7 @@ async function navigateToYearMonth( console.log(`OfferPie: [datePicker] 月份需要${monthDiff > 0 ? "增加" : "减少"} ${steps} 月`) for (let i = 0; i < steps && i < 24; i++) { btn.click() - await delay(100) + await delay("mid") const newMonth = readCurrentMonth(panel) if (newMonth === targetMonth) break } @@ -887,7 +888,7 @@ async function tryFillMonthPanel( console.log(`OfferPie: [datePicker] 月份面板年份导航: ${yearDiff > 0 ? "+" : ""}${yearDiff}年`) for (let i = 0; i < steps && i < 50; i++) { yearBtn.click() - await delay(150) + await delay("mid") const newYear = readCurrentYear(tempPanel) if (newYear === targetYear) break } @@ -901,7 +902,7 @@ async function tryFillMonthPanel( const steps = Math.abs(yearDiff) for (let i = 0; i < steps && i < 50; i++) { btn.click() - await delay(150) + await delay("mid") const newYear = readCurrentYear(tempPanel) if (newYear === targetYear) break } @@ -926,9 +927,9 @@ async function tryFillMonthPanel( if (inner && isVisible(inner)) deepest = inner as HTMLElement deepest.click() - await delay(100) + await delay("mid") if (deepest !== cell) cell.click() - await delay(300) + await delay("mid") await closePopup(inputElement) console.log(`OfferPie: ✅ [月份选择器] 已选择 "${labelText}" = "${fillValue}" (${targetYear}年${targetMonth}月)`) return true @@ -1059,12 +1060,12 @@ async function clickDayCell( // 三层点击:叶子 → 父级 → 原始元素 deepest.click() - await delay(50) + await delay("low") if (deepest.parentElement) deepest.parentElement.click() - await delay(50) + await delay("low") if (cell !== deepest && cell !== deepest.parentElement) (cell as HTMLElement).click() - await delay(300) + await delay("mid") await closePopup(inputElement) console.log(`OfferPie: ✅ [日期] 已选择 "${labelText}" = "${fillValue}" (日期 ${targetDay})`) return true @@ -1158,9 +1159,9 @@ export async function fillDatePicker(field: MatchedFormField): Promise console.warn(`OfferPie: [datePicker] 无法解析日期值: "${fillValue}"`) // 兜底:直接写入值 + Enter forceSetValue(inputElement, fillValue) - await delay(300) + await delay("mid") inputElement.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", code: "Enter", keyCode: 13, bubbles: true })) - await delay(300) + await delay("mid") await closePopup(inputElement as HTMLElement) return true } @@ -1170,7 +1171,7 @@ export async function fillDatePicker(field: MatchedFormField): Promise // ---- 步骤2:写入日期字符串(触发面板联动) ---- forceSetValue(inputElement, fillValue) - await delay(500) + await delay("high") // ---- 步骤2.5:如果只有年月(day=0),检测是否为月份面板 ---- if (targetDay === 0) { @@ -1208,13 +1209,13 @@ export async function fillDatePicker(field: MatchedFormField): Promise if (targetDay === 0) { // 只需要年月,不需要点日期格子。尝试点击月份面板中的月份 // 如果导航成功了,可能面板已经关闭(某些组件选完月份自动关闭) - await delay(200) + await delay("mid") await closePopup(inputElement as HTMLElement) console.log(`OfferPie: ✅ [日期] 已导航到 "${labelText}" = "${fillValue}" (${targetYear}年${targetMonth}月)`) return true } - await delay(200) + await delay("mid") const clicked = await clickDayCell(panel, targetDay, labelText, fillValue, inputElement as HTMLElement) if (clicked) return true @@ -1250,10 +1251,10 @@ async function fallbackClickDay( break } } - deepest.click(); await delay(50) - if (deepest.parentElement) deepest.parentElement.click(); await delay(50) + deepest.click(); await delay("low") + if (deepest.parentElement) deepest.parentElement.click(); await delay("low") if (cell !== deepest && cell !== deepest.parentElement) (cell as HTMLElement).click() - await delay(300) + await delay("mid") await closePopup(inputElement) console.log(`OfferPie: ✅ [日期-兜底] 已选择 "${labelText}" = "${fillValue}" (日期 ${targetDay})`) return true @@ -1263,7 +1264,7 @@ async function fallbackClickDay( // 最终兜底:Enter 确认 console.warn(`OfferPie: ⚠ [日期] "${labelText}" 未找到日历格子,Enter 确认`) inputElement.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", code: "Enter", keyCode: 13, bubbles: true })) - await delay(300) + await delay("mid") await closePopup(inputElement) return true } diff --git a/src/lib/experienceSection.ts b/src/lib/experienceSection.ts index c8714cb..f632216 100644 --- a/src/lib/experienceSection.ts +++ b/src/lib/experienceSection.ts @@ -15,7 +15,7 @@ */ import { EXPERIENCE_SECTION_CONFIGS, JOB_FORM_LABELS } from "./constants" -import { delay } from "./autofill" +import { delay } from "~utils/delay" import { snapshotElementsInRange, diffSnapshots, findTopLevelNewElements } from "./dom" import type { ExperienceSection, ExperienceSectionConfig, ResumeData } from "./types" @@ -766,7 +766,7 @@ export async function clickAddButton( // 逐层点击,每层点击后检测 input 数量是否增加 for (const target of clickTargets) { target.click() - await delay(400) + await delay("high") const afterInputs = findInputsInRange(titleEl, nextTitleEl) if (afterInputs.length > beforeInputCount) { @@ -1105,7 +1105,7 @@ export async function expandExperienceSections( // 点击添加按钮 const clickSuccess = await clickAddButton(addBtn, result.titleElement, nextTitleEl, beforeInputCount) - await delay(300) + await delay("mid") if (clickSuccess) { // 通过 DOM 差异找出新增的顶层容器(即一段经历的完整 DOM) diff --git a/src/lib/pickerDetector.ts b/src/lib/pickerDetector.ts index d302e7e..523240b 100644 --- a/src/lib/pickerDetector.ts +++ b/src/lib/pickerDetector.ts @@ -17,7 +17,7 @@ import type { MatchedFormField, UILibPickerConfig } from "./types" import { UI_LIB_PICKER_CONFIGS } from "./constants" -import { delay } from "./autofill" +import { delay } from "~utils/delay" /** 通过提示文字判断是否为选择器 */ function hasPickerHintText(field: MatchedFormField, lang: "zh" | "en"): boolean { @@ -109,7 +109,7 @@ async function dismissPopup() { document.activeElement.blur() } document.body.click() - await delay(200) + await delay("mid") } /** @@ -142,7 +142,7 @@ async function detectByClick(field: MatchedFormField): Promise { for (const target of clickTargets) { const before = snapshotDOM() target.click() - await delay(200) + await delay("mid") if (hasNewPopup(before)) { console.log(`OfferPie: [${field.key}] 点击 ${target.tagName}.${(target.className || "").toString().split(" ")[0]} 后检测到弹出层`) diff --git a/src/lib/resumeUpload.ts b/src/lib/resumeUpload.ts index 76653eb..d895582 100644 --- a/src/lib/resumeUpload.ts +++ b/src/lib/resumeUpload.ts @@ -3,7 +3,7 @@ * 负责:检测页面上的简历上传按钮,从 OSS 下载简历文件并注入到 file input */ -import { delay } from "./autofill" +import { delay } from "~utils/delay" /** * 检测页面上的简历上传标签,并自动上传简历文件 @@ -73,7 +73,7 @@ export async function detectAndUploadResume(resumeUrl: string): Promise } for (const target of clickTargets) { target.click() - await delay(300) + await delay("mid") for (const input of Array.from(document.querySelectorAll('input[type="file"]'))) { const id = input.id?.toLowerCase() || "" const name = input.getAttribute("name")?.toLowerCase() || "" diff --git a/src/utils/delay.ts b/src/utils/delay.ts new file mode 100644 index 0000000..92d5aef --- /dev/null +++ b/src/utils/delay.ts @@ -0,0 +1,23 @@ +/** + * 统一延时工具 + * 所有延时调用必须通过此模块,禁止在其他文件中直接使用 setTimeout 或自定义 delay + */ + +/** 延时等级 */ +export type DelayLevel = "low" | "mid" | "high" | "max" + +/** 各等级对应的毫秒数(统一在此处调整) */ +const DELAY_MS: Record = { + low: 50, // <100ms 场景:微等待,点击后极短暂停 + mid: 200, // 100~300ms 场景:等待 DOM 更新、弹出层渲染 + high: 500, // >300ms 场景:等待接口返回、搜索结果、动画完成 + max: 2000, // >2000ms 场景:特殊超长延时(谨慎使用) +} + +/** + * 统一延时函数 + * @param level 延时等级:low / mid / high / max + */ +export function delay(level: DelayLevel): Promise { + return new Promise((resolve) => setTimeout(resolve, DELAY_MS[level])) +}