优化选择器操作逻辑,通用模式代码单独封装
This commit is contained in:
@@ -47,3 +47,57 @@ src/
|
||||
- fillPickerField中的DOM差异对比弹出层检测是核心机制,不要简化或删除
|
||||
- detectPickerField的方式3(主动点击检测DOM变化)是自建组件选择器识别的关键,不要删除
|
||||
- UI_LIB_PICKER_CONFIGS里不要加容易误匹配的配置(如之前Beisen Phoenix被误匹配到新东方自建网站)
|
||||
|
||||
## 填充操作核心规范
|
||||
|
||||
- **所有字段填充必须走 `fillMatchedField` 统一入口**,禁止在外部自行编写填充逻辑
|
||||
- **选择器类型字段(下拉、日期、月份、级联等)绝对不能用 `forceSetValue` 直接写值**,这类 input 通常是受控组件,直接写值无效
|
||||
- **选择器类型检测必须走 `detectPickerField` 统一入口**(pickerDetector.ts),禁止在外部自行判断字段是否为选择器
|
||||
- detectPickerField 内部已封装三种检测方式:UI组件库类名匹配 → 提示文字检测 → 主动点击DOM差异对比
|
||||
- 不要自行通过 readonly、class 等属性简单判断是否为选择器,这样会漏判
|
||||
- 时间字段(开始时间/结束时间/起止时间)如果没有找到 `placeholder="年"/"月"` 的下拉输入框,必须标记 `isPicker=true` 走 `fillMatchedField` → `fillPickerField` → `fillDatePicker` → `tryFillMonthPanel` 已封装的完整链路
|
||||
- **禁止在新增代码中自行编写选择器展开、月份点击、日期导航等操作逻辑**,必须先查看项目中已封装的方法并直接引用:
|
||||
- 选择器类型检测 → `pickerDetector.ts`(detectPickerField)
|
||||
- 日期/月份选择器操作 → `datePicker.ts`(fillDatePicker / tryFillMonthPanel / navigateToYearMonth / clickDayCell)
|
||||
- 下拉选项匹配与点击 → `pickerFill.ts`(clickBestOptionInDropdown / findAndClickOptionInVisiblePopups)
|
||||
- 选择器展开与DOM差异检测 → `autofill.ts` 中的 `fillPickerField`
|
||||
- 单选按钮点击 → `autofill.ts` 中的 `fillRadioField`
|
||||
- 搜索型选择器 → `autofill.ts` 中的 `fillSearchPickerField`
|
||||
- 年月下拉选择器 → `autofill.ts` 中的 `fillYearMonthPicker`
|
||||
- `datePicker.ts` 中的按钮探测逻辑(detectNavButtons:逐个点击观察年月变化)是通用日期选择器适配的核心,不要简化或删除
|
||||
|
||||
## 经历段落定位核心规范
|
||||
|
||||
- `experienceSection.ts` 中的段落容器定位逻辑(DOM 差异对比 + 特征重建)是5大经历区域填写的核心,**不可擅自修改或简化**
|
||||
- 核心原理:
|
||||
1. 点击添加按钮前后,用 `snapshotElementsInRange` / `diffSnapshots` / `findTopLevelNewElements`(dom.ts)对比 DOM 快照差异
|
||||
2. 找到新增的顶层容器(一段经历的完整 DOM),记住它的 tag + className 前缀作为段容器特征
|
||||
3. 同时保存新增段容器的 DOM 引用(`lastNewContainerEl`)
|
||||
4. 所有段添加完后,从 `lastNewContainerEl.parentElement` 出发,在父级 children 中匹配所有同特征兄弟容器
|
||||
5. 按 DOM 顺序重建 `segmentRanges` 数组,确保每段经历的 containerElement 精确对应
|
||||
- **禁止**用 input 差集(`beforeInputs`)来定位段容器——React 重渲染会导致原始段的 input 节点被重建,差集不可靠
|
||||
- **禁止**依赖 `titleElement` 存活状态来做重建——React 重渲染可能使 titleElement 脱离 DOM
|
||||
- 对于初始有1段且不需要添加的经历:走 `detectFirstSegment` 的 fallback 逻辑(无同级兄弟时 containerElement 可能为 null,`matchFormFieldsInRange` 有 fallback 用 startElement/endElement 范围搜索)
|
||||
|
||||
## 下拉选择器弹出层搜索核心规范
|
||||
|
||||
- **弹出层检测不依赖任何固定类名**,全部走 DOM 差异对比(点击前后全页面快照对比新增/新可见元素)
|
||||
- **禁止使用 POPUP_SELECTORS 或任何固定 CSS 选择器来检测弹出层**,必须走全页面可见性差异对比(`offsetHeight > 0 && offsetWidth > 0`)
|
||||
- **pickerDetector.ts 方式3(主动点击检测)的核心约束**:
|
||||
- 检测到弹出层后 **禁止关闭弹出层**(不调用 dismissPopup)
|
||||
- 将弹出层 DOM 引用保存到 `field.pickerDropdownElement`
|
||||
- fillPickerField 步骤0 直接在已有弹出层内搜索,避免二次点击触发 toggle 关闭
|
||||
- 弹出层由用户选择后自动关闭,无需手动干预
|
||||
- **选项搜索必须限定在检测到的弹出层元素内**,禁止全页面搜:
|
||||
- `findAndClickOptionInVisiblePopups(fillValue, labelText, popupEl?)` 没有 `popupEl` 时直接返回 false,不做全页面搜
|
||||
- `fillPickerField` 步骤0 用 `field.pickerDropdownElement`(pickerDetector 保存的引用)在弹出层内搜索
|
||||
- `fillPickerField` 步骤4 在 DOM 差异检测到的 `dropdownEl` 内搜索
|
||||
- `fillSearchPickerField` 轮询检测到的 `newPopupEl` 传给 `clickBestOptionInDropdown` 和 `findAndClickOptionInVisiblePopups`
|
||||
- **fillPickerField 步骤0**:pickerDetector 可能已展开弹出层(toggle 冲突问题),所以在步骤1之前先尝试在当前页面搜索匹配选项,避免二次点击关闭弹出层。**不可删除此步骤**
|
||||
- **isAsyncDropdown 字段的特殊处理**(FormLabelItem.isAsyncDropdown = true):
|
||||
- 接口异步下拉字段(如学校、专业)输入值后需轮询等待接口返回数据(最多3秒)
|
||||
- 弹出层检测阈值为 ≥1 个可见子元素(普通字段为 ≥2)
|
||||
- 检测到弹出层后优先用 `clickBestOptionInDropdown(newPopupEl)` 在弹出层内直接做文字匹配
|
||||
- 禁止对 isAsyncDropdown 字段走 `tryClickDropdownListItem` 全页面搜(选项数可能为1,全页面搜会被其他同规格元素干扰)
|
||||
- `pickerFill.ts` 中 `groupBySpec` / `findVisibleListGroups` 的同规格标签组阈值为 ≥2(不可改回 ≥3,否则只有2个选项的下拉列表会漏匹配)
|
||||
- `clickBestOptionInDropdown` 是在指定弹出层 DOM 内递归搜所有可见叶子文字节点做模糊匹配的核心方法,**不可简化其递归逻辑**
|
||||
|
||||
+13
-429
@@ -7,15 +7,8 @@
|
||||
import { useState, useEffect } from "react"
|
||||
import { getCookieValue } from "~utils/cookie"
|
||||
import { getCustomizeResume } from "~api/aiApi"
|
||||
import { fillMatchedField, delay, isSearchPickerField, fillSearchPickerField, isTimePeriodField, isTimeSingleField, fillTimePeriodField, fillTimeSingleField } from "~lib/autofill"
|
||||
import { extractDomStructure, detectPageLanguage, isJobApplicationForm } from "~lib/dom"
|
||||
import { matchFormFields, matchFormFieldsInRange, matchMainFields } from "~lib/formMatcher"
|
||||
import { detectPickerField } from "~lib/pickerDetector"
|
||||
import { detectAndUploadResume } from "~lib/resumeUpload"
|
||||
import { getMockResumeData2 } from "~lib/constants"
|
||||
import { getResumeFieldValue } from "~lib/resumeDataHelper"
|
||||
import { locateExperienceSections, expandExperienceSections, sortExperienceByTime, relocateSegmentContainer } from "~lib/experienceSection"
|
||||
import type { MatchedFormField, ResumeData, ExperienceSection, JobInfo, UnmatchedFormField } from "~lib/types"
|
||||
import { handleAutoFillCommon } from "~handlers/handleAutoFillCommon"
|
||||
import type { MatchedFormField, ResumeData, JobInfo } from "~lib/types"
|
||||
import "./SidebarPanel.scss"
|
||||
|
||||
/** 侧边栏面板的 Props */
|
||||
@@ -71,430 +64,21 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
|
||||
|
||||
/**
|
||||
* 自动填写按钮点击处理
|
||||
* 流程:提取 DOM → 检测语言 → 判断是否表单页 → 检测简历上传 → 匹配字段 → 识别选择器 → 填充测试数据
|
||||
* 内部根据条件判断走 handlers/ 下具体哪个处理文件:
|
||||
* - handleAutoFillCommon:通用模式(当前默认)
|
||||
* - 后续特殊网站会在此处加条件分支(如根据 domain 或 jobInfo 来源判断)
|
||||
*/
|
||||
const handleAutoFill = async () => {
|
||||
setFilling(true)
|
||||
try {
|
||||
// 1. 提取 DOM 结构
|
||||
const domStructure = extractDomStructure()
|
||||
console.log("===== OfferPie: 完整 DOM 树结构 =====")
|
||||
console.log(domStructure)
|
||||
console.log(`===== OfferPie: 结构总长度 ${domStructure.length} 字符 =====`)
|
||||
|
||||
// 2. 检测页面语言,更新到页面参数
|
||||
const lang = detectPageLanguage(domStructure)
|
||||
setPageLang(lang)
|
||||
console.log(`===== OfferPie: 页面语言检测结果 = ${lang} =====`)
|
||||
|
||||
// 3. 判断是否为职位申请表单页面
|
||||
const isForm = isJobApplicationForm(document.body, lang)
|
||||
setIsFormPage(isForm)
|
||||
console.log(`===== OfferPie: 是否为职位申请表单页面 = ${isForm} =====`)
|
||||
|
||||
if (isForm) {
|
||||
// 4. 获取简历数据(优先使用接口数据,无接口数据时 fallback 到 mock)
|
||||
const currentResumeData = resumeData || getMockResumeData2()
|
||||
if (!resumeData) setResumeData(currentResumeData)
|
||||
console.log(`===== OfferPie: 已加载简历数据,教育${currentResumeData.education.length}段 工作${currentResumeData.work.length}段 实习${currentResumeData.internship.length}段 项目${currentResumeData.project.length}段 竞赛${currentResumeData.competition.length}段 =====`)
|
||||
|
||||
// 4.1 检测并上传简历文件
|
||||
const resumeUrl = "https://offerpie.oss-cn-guangzhou.aliyuncs.com/%E6%B4%AA%E8%B5%AB%E2%80%94%E6%95%B0%E6%8D%AE%E3%80%81AI%E7%AE%97%E6%B3%95%E5%B7%A5%E7%A8%8B%E5%B8%88.pdf"
|
||||
const uploaded = await detectAndUploadResume(resumeUrl)
|
||||
console.log(`===== OfferPie: 简历上传 ${uploaded ? "成功" : "跳过(未找到上传按钮或失败)"} =====`)
|
||||
if (uploaded) await delay(1000) // 等待网站解析简历
|
||||
|
||||
// 4.5 定位经历区块并统计已展开段数
|
||||
const sectionResults = locateExperienceSections(document.body, lang)
|
||||
// 4.6 对比简历数据段数,点击添加按钮补足不够的段数
|
||||
const expandedResults = await expandExperienceSections(sectionResults, currentResumeData, lang)
|
||||
|
||||
// 4.7 检测第一段经历是否已被网站自动填写(上传简历后网站可能自动解析填入)
|
||||
let skipPhaseA = false
|
||||
for (const result of expandedResults) {
|
||||
if (!result.titleElement || result.expandedCount === 0) continue
|
||||
const section = result.section as ExperienceSection
|
||||
const segments = result.segmentRanges
|
||||
if (segments.length === 0) continue
|
||||
|
||||
// 检查第一段经历的核心字段(学校/公司/项目名称)是否已有值
|
||||
const firstSeg = segments[0]
|
||||
if (firstSeg.containerElement) {
|
||||
const inputs = firstSeg.containerElement.querySelectorAll(
|
||||
"input:not([type='hidden']):not([type='submit']):not([type='button']):not([type='checkbox']):not([type='radio']):not([type='file']):not([disabled]), textarea:not([disabled])"
|
||||
)
|
||||
for (const inp of Array.from(inputs)) {
|
||||
const inputEl = inp as HTMLInputElement | HTMLTextAreaElement
|
||||
if (inputEl.value && inputEl.value.trim().length > 0) {
|
||||
skipPhaseA = true
|
||||
console.log(`===== OfferPie: 检测到第一段经历[${section}]已有数据("${inputEl.value.trim().substring(0, 20)}"),跳过阶段A =====`)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (skipPhaseA) break
|
||||
}
|
||||
|
||||
// 5. 经历数据按时间排序 + 经历区域字段匹配与填写(阶段A)
|
||||
console.log("===== OfferPie: 阶段A - 经历区域填写 =====")
|
||||
const usedInputs = new Set<Element>() // 全局已使用的 input 集合
|
||||
const excludeRanges: { start: Element; end: Element | null }[] = [] // 经历区域范围(用于阶段B排除)
|
||||
let success = 0, failed = 0, skipped = 0
|
||||
let lastTextInput: HTMLInputElement | HTMLTextAreaElement | null = null
|
||||
let lastTextInputIsPicker = false
|
||||
|
||||
// 如果网站已自动填写经历,跳过阶段A,只记录排除范围
|
||||
if (skipPhaseA) {
|
||||
console.log("===== OfferPie: 阶段A 已跳过(网站已自动填写经历) =====")
|
||||
for (const result of expandedResults) {
|
||||
if (!result.titleElement) continue
|
||||
const titleEl = result.titleElement
|
||||
const allTitles = expandedResults.filter((r) => r.titleElement)
|
||||
const currentIdx = allTitles.findIndex((r) => r.titleElement === titleEl)
|
||||
const nextResult = currentIdx >= 0 && currentIdx < allTitles.length - 1 ? allTitles[currentIdx + 1] : null
|
||||
excludeRanges.push({ start: titleEl, end: nextResult?.titleElement || null })
|
||||
}
|
||||
} else {
|
||||
|
||||
for (const result of expandedResults) {
|
||||
if (!result.titleElement || result.expandedCount === 0) continue
|
||||
const section = result.section as ExperienceSection
|
||||
const sectionData = currentResumeData[section] as { startDate?: string; endDate?: string }[]
|
||||
if (!sectionData || sectionData.length === 0) continue
|
||||
|
||||
// 5.1 对该经历数据按时间排序(最新的在前面)
|
||||
const sortedIndices = sortExperienceByTime(sectionData)
|
||||
console.log(` [${section}] 排序后索引: [${sortedIndices.join(",")}]`)
|
||||
|
||||
// 5.2 记录经历区域范围(用于阶段B排除)
|
||||
// 范围:从该经历大标题到下一个大标题
|
||||
const titleEl = result.titleElement
|
||||
// 找下一个大标题作为范围结束
|
||||
const allTitles = expandedResults.filter((r) => r.titleElement)
|
||||
const currentIdx = allTitles.findIndex((r) => r.titleElement === titleEl)
|
||||
const nextResult = currentIdx >= 0 && currentIdx < allTitles.length - 1 ? allTitles[currentIdx + 1] : null
|
||||
excludeRanges.push({ start: titleEl, end: nextResult?.titleElement || null })
|
||||
|
||||
// 5.3 逐段匹配并填写
|
||||
const segments = result.segmentRanges
|
||||
const fillCount = Math.min(sortedIndices.length, segments.length)
|
||||
|
||||
for (let segIdx = 0; segIdx < fillCount; segIdx++) {
|
||||
const dataIdx = sortedIndices[segIdx] // 排序后对应的简历数据索引
|
||||
const segment = segments[segIdx]
|
||||
|
||||
// 确定该段经历的 DOM 搜索范围
|
||||
// 起始:该段的 startElement(第一个输入框前的标签)
|
||||
const segStartEl = segment.startElement
|
||||
// 结束:下一段的 startElement,或下一个大标题
|
||||
const nextSegment = segIdx < segments.length - 1 ? segments[segIdx + 1] : null
|
||||
const segEndEl = nextSegment?.startElement || nextResult?.titleElement || null
|
||||
|
||||
// 在该段范围内匹配字段
|
||||
const segFields = matchFormFieldsInRange(lang, section, dataIdx, segStartEl, segEndEl, usedInputs, segment.containerElement)
|
||||
console.log(` [${section}] 第${segIdx + 1}段(数据索引${dataIdx})匹配到 ${segFields.length} 个字段`)
|
||||
|
||||
// 逐个填写
|
||||
for (let fIdx = 0; fIdx < segFields.length; fIdx++) {
|
||||
let f = segFields[fIdx]
|
||||
const value = getResumeFieldValue(currentResumeData, f.section, dataIdx, f.resumeField)
|
||||
if (value) f.fillValue = value
|
||||
|
||||
// 【核心】如果 input 已脱离 DOM(React 重新渲染导致),用 locator 重新定位
|
||||
if (f.inputElement && !f.inputElement.isConnected) {
|
||||
console.log(` [重新定位] "${f.labelText}" input 已脱离DOM (isConnected=false)`)
|
||||
|
||||
// 用 locator 重新获取容器
|
||||
let activeContainer: Element | null = null
|
||||
if (segment.locator) {
|
||||
activeContainer = relocateSegmentContainer(segment.locator)
|
||||
}
|
||||
|
||||
if (activeContainer) {
|
||||
// 在重新定位的容器内,用 placeholder 找到对应的 input
|
||||
// 【注意】排除已有值的 input(说明已被填过,可能是其他段重新渲染后的残留)
|
||||
const freshInputs = activeContainer.querySelectorAll(
|
||||
"input:not([type='hidden']):not([type='submit']):not([type='button']):not([type='checkbox']):not([type='radio']):not([type='file']):not([disabled]), textarea:not([disabled])"
|
||||
)
|
||||
const ph = f.inputElement.getAttribute("placeholder") || ""
|
||||
let found: HTMLInputElement | HTMLTextAreaElement | null = null
|
||||
for (const inp of Array.from(freshInputs)) {
|
||||
const inputEl = inp as HTMLInputElement | HTMLTextAreaElement
|
||||
if (usedInputs.has(inp)) continue
|
||||
// 跳过已有值的 input(已被填过)
|
||||
if (inputEl.value && inputEl.value.length > 0) continue
|
||||
if (inp.getAttribute("placeholder") === ph) {
|
||||
found = inputEl
|
||||
break
|
||||
}
|
||||
}
|
||||
if (found) {
|
||||
f.inputElement = found
|
||||
usedInputs.add(found)
|
||||
console.log(` [重新定位] "${f.labelText}" ✅ 已通过 locator 重新定位 (placeholder="${ph}")`)
|
||||
} else {
|
||||
console.log(` [重新定位] "${f.labelText}" ❌ 容器内未找到 placeholder="${ph}" 的空 input`)
|
||||
}
|
||||
} else {
|
||||
console.log(` [重新定位] "${f.labelText}" ❌ locator 重新定位容器失败`)
|
||||
}
|
||||
}
|
||||
|
||||
// 【补充】如果 labelElement 也脱离了 DOM(React 重新渲染导致),重新定位
|
||||
// 这对于没有 input 的纯 div 选择器(如 Ant Design Select)尤其重要
|
||||
// 因为 fillMatchedField 的 picker 无 input 逻辑需要点击 labelElement
|
||||
if (f.labelElement && !f.labelElement.isConnected && segment.locator) {
|
||||
const activeContainer = relocateSegmentContainer(segment.locator)
|
||||
if (activeContainer) {
|
||||
// 在容器内用标签文字重新找到对应的标签元素
|
||||
const allLabels = activeContainer.querySelectorAll("label, span, div, td, th, p, legend, dt")
|
||||
for (const el of Array.from(allLabels)) {
|
||||
const directText = Array.from(el.childNodes)
|
||||
.filter((n) => n.nodeType === Node.TEXT_NODE)
|
||||
.map((n) => n.textContent?.trim())
|
||||
.filter(Boolean)
|
||||
.join("")
|
||||
if (directText && directText.includes(f.labelText) && directText.length < f.labelText.length + 20) {
|
||||
f.labelElement = el
|
||||
console.log(` [重新定位] "${f.labelText}" labelElement ✅ 已重新定位`)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 时间段字段不需要 fillValue(它直接从简历数据取 startDate 和 endDate)
|
||||
if (isTimePeriodField(f.key)) {
|
||||
const startDateVal = getResumeFieldValue(currentResumeData, f.section, dataIdx, "startDate")
|
||||
const endDateVal = getResumeFieldValue(currentResumeData, f.section, dataIdx, "endDate")
|
||||
let ok = await fillTimePeriodField(f, startDateVal, endDateVal, usedInputs)
|
||||
if (!ok) {
|
||||
// fallback 到集成式时间选择器
|
||||
f.fillValue = startDateVal // 用开始时间作为 fillValue
|
||||
await detectPickerField(f, lang)
|
||||
ok = await fillMatchedField(f)
|
||||
}
|
||||
if (ok) { success++ } else { failed++ }
|
||||
} else if (isTimeSingleField(f.key)) {
|
||||
if (!f.fillValue) { skipped++; continue }
|
||||
let ok = await fillTimeSingleField(f, f.fillValue, usedInputs)
|
||||
if (!ok) {
|
||||
await detectPickerField(f, lang)
|
||||
ok = await fillMatchedField(f)
|
||||
}
|
||||
if (ok) { success++ } else { failed++ }
|
||||
} else if (!f.fillValue) {
|
||||
skipped++; continue
|
||||
} else if (isSearchPickerField(f.key)) {
|
||||
const ok = await fillSearchPickerField(f)
|
||||
if (ok) { success++ } else { failed++ }
|
||||
// 搜索选择器内部已处理关闭弹窗,跳过外部关闭逻辑
|
||||
await delay(200)
|
||||
continue
|
||||
} else {
|
||||
await detectPickerField(f, lang)
|
||||
const ok = await fillMatchedField(f)
|
||||
if (ok) { success++ } else { failed++ }
|
||||
}
|
||||
|
||||
// 关闭残留弹窗(只点击纯文本输入框,不点击选择器类型的 input)
|
||||
if (lastTextInput && !lastTextInputIsPicker) {
|
||||
;(lastTextInput as HTMLElement).click()
|
||||
lastTextInput.focus()
|
||||
await delay(100)
|
||||
lastTextInput.blur()
|
||||
} else {
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "Escape", code: "Escape", keyCode: 27, bubbles: true })
|
||||
)
|
||||
document.activeElement.blur()
|
||||
}
|
||||
document.body.click()
|
||||
}
|
||||
await delay(300)
|
||||
|
||||
if (!f.isPicker && f.inputElement && !isTimePeriodField(f.key) && !isTimeSingleField(f.key)) {
|
||||
lastTextInput = f.inputElement
|
||||
lastTextInputIsPicker = false
|
||||
} else {
|
||||
lastTextInputIsPicker = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`===== OfferPie: 阶段A完成 成功${success} 失败${failed} 跳过${skipped} =====`)
|
||||
|
||||
} // end of else (skipPhaseA)
|
||||
|
||||
// 6. 非经历区域字段匹配与填写(阶段B)
|
||||
console.log("===== OfferPie: 阶段B - 非经历区域填写 =====")
|
||||
const mainFields = matchMainFields(document.body, lang, excludeRanges, usedInputs)
|
||||
console.log(` 匹配到 ${mainFields.length} 个非经历字段`)
|
||||
|
||||
for (const f of mainFields) {
|
||||
const value = getResumeFieldValue(currentResumeData, f.section, f.sectionIndex, f.resumeField)
|
||||
if (value) f.fillValue = value
|
||||
|
||||
// 检测该字段是否已被网站自动填入值(上传简历后网站解析填入的)
|
||||
if (f.inputElement && f.inputElement.value && f.inputElement.value.trim().length > 0) {
|
||||
console.log(` [${f.key}] "${f.labelText}" 已有值="${f.inputElement.value.trim()}",跳过`)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
if (!f.fillValue) { skipped++; continue }
|
||||
|
||||
await detectPickerField(f, lang)
|
||||
|
||||
console.log(
|
||||
` [${f.key}] "${f.labelText}" → type: ${f.inputType}` +
|
||||
` | isPicker: ${f.isPicker}` +
|
||||
` | fillValue: "${f.fillValue}"`
|
||||
)
|
||||
|
||||
const ok = await fillMatchedField(f)
|
||||
if (ok) { success++ } else { failed++ }
|
||||
|
||||
if (lastTextInput) {
|
||||
;(lastTextInput as HTMLElement).click()
|
||||
lastTextInput.focus()
|
||||
await delay(100)
|
||||
lastTextInput.blur()
|
||||
} else {
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "Escape", code: "Escape", keyCode: 27, bubbles: true })
|
||||
)
|
||||
document.activeElement.blur()
|
||||
}
|
||||
document.body.click()
|
||||
}
|
||||
await delay(300)
|
||||
|
||||
if (!f.isPicker && f.inputElement) lastTextInput = f.inputElement
|
||||
}
|
||||
|
||||
setFormFields([...mainFields])
|
||||
console.log(`===== OfferPie: 阶段B完成 总计成功${success} 失败${failed} 跳过${skipped} =====`)
|
||||
|
||||
// 7. 阶段C - 收集剩余空白输入框,调用AI接口填写
|
||||
console.log("===== OfferPie: 阶段C - AI辅助填写剩余空白字段 =====")
|
||||
|
||||
// 需要过滤的标签文字(这些不是有效标签)
|
||||
const EXCLUDE_LABEL_TEXTS = ["请输入", "输入", ":", ":", "请选择", "选择", "*", "必填"]
|
||||
|
||||
// 收集非经历区域内所有空白输入框
|
||||
const allInputsOnPage = document.body.querySelectorAll(
|
||||
"input:not([type='hidden']):not([type='submit']):not([type='button']):not([type='checkbox']):not([type='radio']):not([type='file']):not([disabled]), textarea:not([disabled])"
|
||||
)
|
||||
const unmatchedFields: UnmatchedFormField[] = []
|
||||
|
||||
for (const inp of Array.from(allInputsOnPage)) {
|
||||
const inputEl = inp as HTMLInputElement | HTMLTextAreaElement
|
||||
// 跳过已使用的 input
|
||||
if (usedInputs.has(inp)) continue
|
||||
// 跳过已有值的 input
|
||||
if (inputEl.value && inputEl.value.trim().length > 0) continue
|
||||
// 跳过在经历区域范围内的 input
|
||||
let inExcludeRange = false
|
||||
for (const range of excludeRanges) {
|
||||
const afterStart = range.start === inp || (range.start.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING)
|
||||
const beforeEnd = !range.end || (range.end.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_PRECEDING)
|
||||
if (afterStart && beforeEnd) { inExcludeRange = true; break }
|
||||
}
|
||||
if (inExcludeRange) continue
|
||||
|
||||
// 向上查找最近的标签文字
|
||||
let labelText = ""
|
||||
let labelElement: Element | null = null
|
||||
|
||||
// 策略1:查找 input 所在表单项容器内的标签
|
||||
const formItemSelectors = [
|
||||
".form-item", ".form-group", ".form-field",
|
||||
".el-form-item", ".ant-form-item", ".ant-row",
|
||||
".arco-form-item", ".t-form-item", ".n-form-item",
|
||||
"[class*='form-item']", "[class*='form-group']", "[class*='formItem']",
|
||||
]
|
||||
let container: Element | null = null
|
||||
for (const sel of formItemSelectors) {
|
||||
container = inp.closest(sel)
|
||||
if (container) break
|
||||
}
|
||||
if (container) {
|
||||
const labelEls = container.querySelectorAll("label, span, div, td, th, p, legend, dt")
|
||||
for (const el of Array.from(labelEls)) {
|
||||
// 只取直接文本内容
|
||||
const directText = Array.from(el.childNodes)
|
||||
.filter((n) => n.nodeType === Node.TEXT_NODE)
|
||||
.map((n) => n.textContent?.trim())
|
||||
.filter(Boolean)
|
||||
.join("")
|
||||
if (!directText || directText.length > 30) continue
|
||||
// 过滤无效标签文字
|
||||
if (EXCLUDE_LABEL_TEXTS.some((ex) => directText === ex)) continue
|
||||
// 确保标签在 input 之前
|
||||
if (el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) {
|
||||
labelText = directText
|
||||
labelElement = el
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 策略2:向前查找兄弟/父级中的标签
|
||||
if (!labelText) {
|
||||
let prev: Element | null = inp.previousElementSibling
|
||||
for (let i = 0; i < 3 && prev; i++) {
|
||||
const text = prev.textContent?.trim() || ""
|
||||
if (text && text.length <= 20 && !EXCLUDE_LABEL_TEXTS.some((ex) => text === ex)) {
|
||||
labelText = text
|
||||
labelElement = prev
|
||||
break
|
||||
}
|
||||
prev = prev.previousElementSibling
|
||||
}
|
||||
}
|
||||
|
||||
if (!labelText || !labelElement) continue
|
||||
|
||||
// 确定表单类型
|
||||
let formType: UnmatchedFormField["formType"] = "input"
|
||||
if (inputEl.tagName === "TEXTAREA") {
|
||||
formType = "textarea"
|
||||
} else if (inputEl.hasAttribute("readonly") || inputEl.closest("[class*='select']") || inputEl.closest("[class*='picker']")) {
|
||||
formType = "select"
|
||||
}
|
||||
|
||||
unmatchedFields.push({
|
||||
labelText,
|
||||
labelElement,
|
||||
inputElement: inputEl,
|
||||
radioContainer: null,
|
||||
formType,
|
||||
isPicker: formType === "select",
|
||||
fillValue: "",
|
||||
alreadyFilled: false,
|
||||
})
|
||||
usedInputs.add(inp)
|
||||
}
|
||||
|
||||
console.log(` 收集到 ${unmatchedFields.length} 个待填写的空白字段`)
|
||||
|
||||
// 打印收集到的空白字段详情
|
||||
for (const uf of unmatchedFields) {
|
||||
console.log(` [待填写] "${uf.labelText}" | formType: ${uf.formType} | isPicker: ${uf.isPicker}`)
|
||||
}
|
||||
|
||||
console.log(`===== OfferPie: 阶段C完成 收集到 ${unmatchedFields.length} 个空白字段 =====`)
|
||||
} else {
|
||||
setFormFields([])
|
||||
console.log("===== OfferPie: 当前页面不是职位申请表单,跳过字段匹配 =====")
|
||||
}
|
||||
const fillResult = await handleAutoFillCommon({ resumeData, jobInfo })
|
||||
setPageLang(fillResult.lang)
|
||||
setIsFormPage(fillResult.isFormPage)
|
||||
if (fillResult.resumeData) setResumeData(fillResult.resumeData)
|
||||
setFormFields(fillResult.formFields)
|
||||
} catch (e) {
|
||||
console.error("OfferPie: 获取页面结构失败", e)
|
||||
console.error("OfferPie: 自动填写异常", e)
|
||||
}
|
||||
// 1秒后恢复按钮状态
|
||||
setTimeout(() => setFilling(false), 1000)
|
||||
}
|
||||
|
||||
@@ -502,8 +86,8 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
|
||||
<div className="op-container">
|
||||
{/* 顶部操作栏:关闭按钮始终显示,反馈和设置仅登录后显示 */}
|
||||
<div className="op-header">
|
||||
{isLoggedIn && <span className="op-header-link">反馈12345</span>}
|
||||
{isLoggedIn && <span className="op-header-link">设置</span>}
|
||||
{isLoggedIn && <span className="op-header-link">反馈</span>}
|
||||
{/*{isLoggedIn && <span className="op-header-link">设置</span>}*/}
|
||||
<button className="op-close-btn" onClick={onClose}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
/**
|
||||
* 通用模式自动填写处理逻辑
|
||||
* 从 SidebarPanel.tsx 中抽离,便于后续针对特殊网站扩展不同的处理流程
|
||||
*
|
||||
* 【规范】所有填充操作必须走 fillMatchedField 统一入口,
|
||||
* 选择器检测必须走 detectPickerField 统一入口,
|
||||
* 不要在此文件中自行编写选择器操作逻辑,必须引用 lib 中已封装的方法。
|
||||
*/
|
||||
|
||||
import { fillMatchedField, delay, isSearchPickerField, fillSearchPickerField, isTimePeriodField, isTimeSingleField, fillTimePeriodField, fillTimeSingleField } from "~lib/autofill"
|
||||
import { extractDomStructure, detectPageLanguage, isJobApplicationForm } from "~lib/dom"
|
||||
import { matchFormFieldsInRange, matchMainFields } from "~lib/formMatcher"
|
||||
import { detectPickerField } from "~lib/pickerDetector"
|
||||
import { detectAndUploadResume } from "~lib/resumeUpload"
|
||||
import { getMockResumeData2 } from "~lib/constants"
|
||||
import { getResumeFieldValue } from "~lib/resumeDataHelper"
|
||||
import { locateExperienceSections, expandExperienceSections, sortExperienceByTime, relocateSegmentContainer } from "~lib/experienceSection"
|
||||
import type { MatchedFormField, ResumeData, ExperienceSection, JobInfo, UnmatchedFormField } from "~lib/types"
|
||||
|
||||
/** 通用自动填写的参数 */
|
||||
export interface AutoFillCommonParams {
|
||||
/** 简历数据(接口获取的) */
|
||||
resumeData: ResumeData | null
|
||||
/** 岗位信息 */
|
||||
jobInfo: JobInfo | null
|
||||
}
|
||||
|
||||
/** 通用自动填写的返回结果 */
|
||||
export interface AutoFillCommonResult {
|
||||
/** 填写成功数 */
|
||||
success: number
|
||||
/** 填写失败数 */
|
||||
failed: number
|
||||
/** 跳过数 */
|
||||
skipped: number
|
||||
/** 检测到的页面语言 */
|
||||
lang: "zh" | "en"
|
||||
/** 是否为表单页 */
|
||||
isFormPage: boolean
|
||||
/** 使用的简历数据(可能是接口数据或 mock 数据) */
|
||||
resumeData: ResumeData | null
|
||||
/** 匹配到的非经历区域字段 */
|
||||
formFields: MatchedFormField[]
|
||||
/** 收集到的待填写空白字段 */
|
||||
unmatchedFields: UnmatchedFormField[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用模式自动填写主流程
|
||||
* 流程:提取 DOM → 检测语言 → 判断是否表单页 → 上传简历 → 匹配+填写经历 → 匹配+填写非经历 → 收集空白字段
|
||||
*/
|
||||
export async function handleAutoFillCommon(params: AutoFillCommonParams): Promise<AutoFillCommonResult> {
|
||||
const result: AutoFillCommonResult = {
|
||||
success: 0, failed: 0, skipped: 0,
|
||||
lang: "zh", isFormPage: false,
|
||||
resumeData: params.resumeData,
|
||||
formFields: [],
|
||||
unmatchedFields: [],
|
||||
}
|
||||
|
||||
// 1. 提取 DOM 结构
|
||||
const domStructure = extractDomStructure()
|
||||
console.log("===== OfferPie: 完整 DOM 树结构 =====")
|
||||
console.log(domStructure)
|
||||
console.log(`===== OfferPie: 结构总长度 ${domStructure.length} 字符 =====`)
|
||||
|
||||
// 2. 检测页面语言
|
||||
const lang = detectPageLanguage(domStructure)
|
||||
result.lang = lang
|
||||
console.log(`===== OfferPie: 页面语言检测结果 = ${lang} =====`)
|
||||
|
||||
// 3. 判断是否为职位申请表单页面
|
||||
const isForm = isJobApplicationForm(document.body, lang)
|
||||
result.isFormPage = isForm
|
||||
console.log(`===== OfferPie: 是否为职位申请表单页面 = ${isForm} =====`)
|
||||
|
||||
if (!isForm) {
|
||||
console.log("===== OfferPie: 当前页面不是职位申请表单,跳过字段匹配 =====")
|
||||
return result
|
||||
}
|
||||
|
||||
// 4. 获取简历数据(优先使用接口数据,无接口数据时 fallback 到 mock)
|
||||
const currentResumeData = params.resumeData || getMockResumeData2()
|
||||
result.resumeData = currentResumeData
|
||||
console.log(`===== OfferPie: 已加载简历数据,教育${currentResumeData.education.length}段 工作${currentResumeData.work.length}段 实习${currentResumeData.internship.length}段 项目${currentResumeData.project.length}段 竞赛${currentResumeData.competition.length}段 =====`)
|
||||
|
||||
// 4.1 检测并上传简历文件
|
||||
// const resumeUrl = "https://offerpie.oss-cn-guangzhou.aliyuncs.com/%E5%AE%BE%E5%A4%95%E6%B3%95%E5%B0%BC%E4%BA%9A%E5%A4%A7%E5%AD%A6_%E4%B8%81%E5%B1%B9%E6%B6%B5.pdf"
|
||||
// const uploaded = await detectAndUploadResume(resumeUrl)
|
||||
// console.log(`===== OfferPie: 简历上传 ${uploaded ? "成功" : "跳过(未找到上传按钮或失败)"} =====`)
|
||||
// if (uploaded) await delay(1000) // 等待网站解析简历
|
||||
|
||||
// 4.5 定位经历区块并统计已展开段数
|
||||
const sectionResults = locateExperienceSections(document.body, lang)
|
||||
// 4.6 对比简历数据段数,点击添加按钮补足不够的段数
|
||||
const expandedResults = await expandExperienceSections(sectionResults, currentResumeData, lang)
|
||||
|
||||
// 4.7 检测第一段经历是否已被网站自动填写(上传简历后网站可能自动解析填入)
|
||||
let skipPhaseA = false
|
||||
for (const expResult of expandedResults) {
|
||||
if (!expResult.titleElement || expResult.expandedCount === 0) continue
|
||||
const section = expResult.section as ExperienceSection
|
||||
const segments = expResult.segmentRanges
|
||||
if (segments.length === 0) continue
|
||||
|
||||
// 检查第一段经历的核心字段(学校/公司/项目名称)是否已有值
|
||||
const firstSeg = segments[0]
|
||||
if (firstSeg.containerElement) {
|
||||
const inputs = firstSeg.containerElement.querySelectorAll(
|
||||
"input:not([type='hidden']):not([type='submit']):not([type='button']):not([type='checkbox']):not([type='radio']):not([type='file']):not([disabled]), textarea:not([disabled])"
|
||||
)
|
||||
for (const inp of Array.from(inputs)) {
|
||||
const inputEl = inp as HTMLInputElement | HTMLTextAreaElement
|
||||
if (inputEl.value && inputEl.value.trim().length > 0) {
|
||||
skipPhaseA = true
|
||||
console.log(`===== OfferPie: 检测到第一段经历[${section}]已有数据("${inputEl.value.trim().substring(0, 20)}"),跳过阶段A =====`)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (skipPhaseA) break
|
||||
}
|
||||
|
||||
// 5. 经历数据按时间排序 + 经历区域字段匹配与填写(阶段A)
|
||||
console.log("===== OfferPie: 阶段A - 经历区域填写 =====")
|
||||
const usedInputs = new Set<Element>() // 全局已使用的 input 集合
|
||||
const excludeRanges: { start: Element; end: Element | null }[] = [] // 经历区域范围(用于阶段B排除)
|
||||
let lastTextInput: HTMLInputElement | HTMLTextAreaElement | null = null
|
||||
let lastTextInputIsPicker = false
|
||||
|
||||
// 如果网站已自动填写经历,跳过阶段A,只记录排除范围
|
||||
if (skipPhaseA) {
|
||||
console.log("===== OfferPie: 阶段A 已跳过(网站已自动填写经历) =====")
|
||||
for (const expResult of expandedResults) {
|
||||
if (!expResult.titleElement) continue
|
||||
const titleEl = expResult.titleElement
|
||||
const allTitles = expandedResults.filter((r) => r.titleElement)
|
||||
const currentIdx = allTitles.findIndex((r) => r.titleElement === titleEl)
|
||||
const nextExpResult = currentIdx >= 0 && currentIdx < allTitles.length - 1 ? allTitles[currentIdx + 1] : null
|
||||
excludeRanges.push({ start: titleEl, end: nextExpResult?.titleElement || null })
|
||||
}
|
||||
} else {
|
||||
|
||||
for (const expResult of expandedResults) {
|
||||
if (!expResult.titleElement || expResult.expandedCount === 0) continue
|
||||
const section = expResult.section as ExperienceSection
|
||||
const sectionData = currentResumeData[section] as { startDate?: string; endDate?: string }[]
|
||||
if (!sectionData || sectionData.length === 0) continue
|
||||
|
||||
// 5.1 对该经历数据按时间排序(最新的在前面)
|
||||
const sortedIndices = sortExperienceByTime(sectionData)
|
||||
console.log(` [${section}] 排序后索引: [${sortedIndices.join(",")}]`)
|
||||
|
||||
// 5.2 记录经历区域范围(用于阶段B排除)
|
||||
const titleEl = expResult.titleElement
|
||||
const allTitles = expandedResults.filter((r) => r.titleElement)
|
||||
const currentIdx = allTitles.findIndex((r) => r.titleElement === titleEl)
|
||||
const nextExpResult = currentIdx >= 0 && currentIdx < allTitles.length - 1 ? allTitles[currentIdx + 1] : null
|
||||
excludeRanges.push({ start: titleEl, end: nextExpResult?.titleElement || null })
|
||||
|
||||
// 5.3 逐段匹配并填写
|
||||
const segments = expResult.segmentRanges
|
||||
const fillCount = Math.min(sortedIndices.length, segments.length)
|
||||
|
||||
for (let segIdx = 0; segIdx < fillCount; segIdx++) {
|
||||
const dataIdx = sortedIndices[segIdx]
|
||||
const segment = segments[segIdx]
|
||||
|
||||
const segStartEl = segment.startElement
|
||||
const nextSegment = segIdx < segments.length - 1 ? segments[segIdx + 1] : null
|
||||
const segEndEl = nextSegment?.startElement || nextExpResult?.titleElement || null
|
||||
|
||||
// 在该段范围内匹配字段
|
||||
const segFields = matchFormFieldsInRange(lang, section, dataIdx, segStartEl, segEndEl, usedInputs, segment.containerElement)
|
||||
console.log(` [${section}] 第${segIdx + 1}段(数据索引${dataIdx})匹配到 ${segFields.length} 个字段`)
|
||||
|
||||
// 逐个填写
|
||||
for (let fIdx = 0; fIdx < segFields.length; fIdx++) {
|
||||
let f = segFields[fIdx]
|
||||
const value = getResumeFieldValue(currentResumeData, f.section, dataIdx, f.resumeField)
|
||||
if (value) f.fillValue = value
|
||||
|
||||
// 【核心】如果 input 已脱离 DOM(React 重新渲染导致),用 locator 重新定位
|
||||
if (f.inputElement && !f.inputElement.isConnected) {
|
||||
console.log(` [重新定位] "${f.labelText}" input 已脱离DOM (isConnected=false)`)
|
||||
let activeContainer: Element | null = null
|
||||
if (segment.locator) {
|
||||
activeContainer = relocateSegmentContainer(segment.locator)
|
||||
}
|
||||
if (activeContainer) {
|
||||
const freshInputs = activeContainer.querySelectorAll(
|
||||
"input:not([type='hidden']):not([type='submit']):not([type='button']):not([type='checkbox']):not([type='radio']):not([type='file']):not([disabled]), textarea:not([disabled])"
|
||||
)
|
||||
const ph = f.inputElement.getAttribute("placeholder") || ""
|
||||
let found: HTMLInputElement | HTMLTextAreaElement | null = null
|
||||
for (const inp of Array.from(freshInputs)) {
|
||||
const inputEl = inp as HTMLInputElement | HTMLTextAreaElement
|
||||
if (usedInputs.has(inp)) continue
|
||||
if (inputEl.value && inputEl.value.length > 0) continue
|
||||
if (inp.getAttribute("placeholder") === ph) { found = inputEl; break }
|
||||
}
|
||||
if (found) {
|
||||
f.inputElement = found
|
||||
usedInputs.add(found)
|
||||
console.log(` [重新定位] "${f.labelText}" ✅ 已通过 locator 重新定位 (placeholder="${ph}")`)
|
||||
} else {
|
||||
console.log(` [重新定位] "${f.labelText}" ❌ 容器内未找到 placeholder="${ph}" 的空 input`)
|
||||
}
|
||||
} else {
|
||||
console.log(` [重新定位] "${f.labelText}" ❌ locator 重新定位容器失败`)
|
||||
}
|
||||
}
|
||||
|
||||
// 【补充】如果 labelElement 也脱离了 DOM,重新定位
|
||||
if (f.labelElement && !f.labelElement.isConnected && segment.locator) {
|
||||
const activeContainer = relocateSegmentContainer(segment.locator)
|
||||
if (activeContainer) {
|
||||
const allLabels = activeContainer.querySelectorAll("label, span, div, td, th, p, legend, dt")
|
||||
for (const el of Array.from(allLabels)) {
|
||||
const directText = Array.from(el.childNodes)
|
||||
.filter((n) => n.nodeType === Node.TEXT_NODE)
|
||||
.map((n) => n.textContent?.trim())
|
||||
.filter(Boolean)
|
||||
.join("")
|
||||
if (directText && directText.includes(f.labelText) && directText.length < f.labelText.length + 20) {
|
||||
f.labelElement = el
|
||||
console.log(` [重新定位] "${f.labelText}" labelElement ✅ 已重新定位`)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 根据字段类型选择对应的填充方法(全部走已封装的统一入口)
|
||||
if (isTimePeriodField(f.key)) {
|
||||
const startDateVal = getResumeFieldValue(currentResumeData, f.section, dataIdx, "startDate")
|
||||
const endDateVal = getResumeFieldValue(currentResumeData, f.section, dataIdx, "endDate")
|
||||
let ok = await fillTimePeriodField(f, startDateVal, endDateVal, usedInputs)
|
||||
if (!ok) {
|
||||
f.fillValue = startDateVal
|
||||
await detectPickerField(f, lang)
|
||||
ok = await fillMatchedField(f)
|
||||
}
|
||||
if (ok) { result.success++ } else { result.failed++ }
|
||||
} else if (isTimeSingleField(f.key)) {
|
||||
if (!f.fillValue) { result.skipped++; continue }
|
||||
let ok = await fillTimeSingleField(f, f.fillValue, usedInputs)
|
||||
if (!ok) {
|
||||
await detectPickerField(f, lang)
|
||||
ok = await fillMatchedField(f)
|
||||
}
|
||||
if (ok) { result.success++ } else { result.failed++ }
|
||||
} else if (!f.fillValue) {
|
||||
result.skipped++; continue
|
||||
} else if (isSearchPickerField(f.key)) {
|
||||
const ok = await fillSearchPickerField(f)
|
||||
if (ok) { result.success++ } else { result.failed++ }
|
||||
await delay(200)
|
||||
continue
|
||||
} else {
|
||||
await detectPickerField(f, lang)
|
||||
const ok = await fillMatchedField(f)
|
||||
if (ok) { result.success++ } else { result.failed++ }
|
||||
}
|
||||
|
||||
// 关闭残留弹窗
|
||||
if (lastTextInput && !lastTextInputIsPicker) {
|
||||
;(lastTextInput as HTMLElement).click()
|
||||
lastTextInput.focus()
|
||||
await delay(100)
|
||||
lastTextInput.blur()
|
||||
} else {
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "Escape", code: "Escape", keyCode: 27, bubbles: true })
|
||||
)
|
||||
document.activeElement.blur()
|
||||
}
|
||||
document.body.click()
|
||||
}
|
||||
await delay(300)
|
||||
|
||||
if (!f.isPicker && f.inputElement && !isTimePeriodField(f.key) && !isTimeSingleField(f.key)) {
|
||||
lastTextInput = f.inputElement
|
||||
lastTextInputIsPicker = false
|
||||
} else {
|
||||
lastTextInputIsPicker = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`===== OfferPie: 阶段A完成 成功${result.success} 失败${result.failed} 跳过${result.skipped} =====`)
|
||||
|
||||
} // end of else (skipPhaseA)
|
||||
|
||||
// 6. 非经历区域字段匹配与填写(阶段B)
|
||||
console.log("===== OfferPie: 阶段B - 非经历区域填写 =====")
|
||||
const mainFields = matchMainFields(document.body, lang, excludeRanges, usedInputs)
|
||||
console.log(` 匹配到 ${mainFields.length} 个非经历字段`)
|
||||
|
||||
for (const f of mainFields) {
|
||||
const value = getResumeFieldValue(currentResumeData, f.section, f.sectionIndex, f.resumeField)
|
||||
if (value) f.fillValue = value
|
||||
|
||||
// 检测该字段是否已被网站自动填入值
|
||||
if (f.inputElement && f.inputElement.value && f.inputElement.value.trim().length > 0) {
|
||||
console.log(` [${f.key}] "${f.labelText}" 已有值="${f.inputElement.value.trim()}",跳过`)
|
||||
result.skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
if (!f.fillValue) { result.skipped++; continue }
|
||||
|
||||
await detectPickerField(f, lang)
|
||||
|
||||
console.log(
|
||||
` [${f.key}] "${f.labelText}" → type: ${f.inputType}` +
|
||||
` | isPicker: ${f.isPicker}` +
|
||||
` | fillValue: "${f.fillValue}"`
|
||||
)
|
||||
|
||||
const ok = await fillMatchedField(f)
|
||||
if (ok) { result.success++ } else { result.failed++ }
|
||||
|
||||
if (lastTextInput) {
|
||||
;(lastTextInput as HTMLElement).click()
|
||||
lastTextInput.focus()
|
||||
await delay(100)
|
||||
lastTextInput.blur()
|
||||
} else {
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "Escape", code: "Escape", keyCode: 27, bubbles: true })
|
||||
)
|
||||
document.activeElement.blur()
|
||||
}
|
||||
document.body.click()
|
||||
}
|
||||
await delay(300)
|
||||
|
||||
if (!f.isPicker && f.inputElement) lastTextInput = f.inputElement
|
||||
}
|
||||
|
||||
result.formFields = [...mainFields]
|
||||
console.log(`===== OfferPie: 阶段B完成 总计成功${result.success} 失败${result.failed} 跳过${result.skipped} =====`)
|
||||
|
||||
// 7. 阶段C - 收集剩余空白输入框
|
||||
console.log("===== OfferPie: 阶段C - 收集剩余空白字段 =====")
|
||||
|
||||
// 需要过滤的标签文字(这些不是有效标签)
|
||||
const EXCLUDE_LABEL_TEXTS = ["请输入", "输入", ":", ":", "请选择", "选择", "*", "必填"]
|
||||
|
||||
// 收集非经历区域内所有空白输入框
|
||||
const allInputsOnPage = document.body.querySelectorAll(
|
||||
"input:not([type='hidden']):not([type='submit']):not([type='button']):not([type='checkbox']):not([type='radio']):not([type='file']):not([disabled]), textarea:not([disabled])"
|
||||
)
|
||||
|
||||
for (const inp of Array.from(allInputsOnPage)) {
|
||||
const inputEl = inp as HTMLInputElement | HTMLTextAreaElement
|
||||
if (usedInputs.has(inp)) continue
|
||||
if (inputEl.value && inputEl.value.trim().length > 0) continue
|
||||
// 跳过在经历区域范围内的 input
|
||||
let inExcludeRange = false
|
||||
for (const range of excludeRanges) {
|
||||
const afterStart = range.start === inp || (range.start.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING)
|
||||
const beforeEnd = !range.end || (range.end.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_PRECEDING)
|
||||
if (afterStart && beforeEnd) { inExcludeRange = true; break }
|
||||
}
|
||||
if (inExcludeRange) continue
|
||||
|
||||
// 向上查找最近的标签文字
|
||||
let labelText = ""
|
||||
let labelElement: Element | null = null
|
||||
|
||||
// 策略1:查找 input 所在表单项容器内的标签
|
||||
const formItemSelectors = [
|
||||
".form-item", ".form-group", ".form-field",
|
||||
".el-form-item", ".ant-form-item", ".ant-row",
|
||||
".arco-form-item", ".t-form-item", ".n-form-item",
|
||||
"[class*='form-item']", "[class*='form-group']", "[class*='formItem']",
|
||||
]
|
||||
let container: Element | null = null
|
||||
for (const sel of formItemSelectors) {
|
||||
container = inp.closest(sel)
|
||||
if (container) break
|
||||
}
|
||||
if (container) {
|
||||
const labelEls = container.querySelectorAll("label, span, div, td, th, p, legend, dt")
|
||||
for (const el of Array.from(labelEls)) {
|
||||
const directText = Array.from(el.childNodes)
|
||||
.filter((n) => n.nodeType === Node.TEXT_NODE)
|
||||
.map((n) => n.textContent?.trim())
|
||||
.filter(Boolean)
|
||||
.join("")
|
||||
if (!directText || directText.length > 30) continue
|
||||
if (EXCLUDE_LABEL_TEXTS.some((ex) => directText === ex)) continue
|
||||
if (el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) {
|
||||
labelText = directText
|
||||
labelElement = el
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 策略2:向前查找兄弟/父级中的标签
|
||||
if (!labelText) {
|
||||
let prev: Element | null = inp.previousElementSibling
|
||||
for (let i = 0; i < 3 && prev; i++) {
|
||||
const text = prev.textContent?.trim() || ""
|
||||
if (text && text.length <= 20 && !EXCLUDE_LABEL_TEXTS.some((ex) => text === ex)) {
|
||||
labelText = text
|
||||
labelElement = prev
|
||||
break
|
||||
}
|
||||
prev = prev.previousElementSibling
|
||||
}
|
||||
}
|
||||
|
||||
if (!labelText || !labelElement) continue
|
||||
|
||||
// 确定表单类型
|
||||
let formType: UnmatchedFormField["formType"] = "input"
|
||||
if (inputEl.tagName === "TEXTAREA") {
|
||||
formType = "textarea"
|
||||
} else if (inputEl.hasAttribute("readonly") || inputEl.closest("[class*='select']") || inputEl.closest("[class*='picker']")) {
|
||||
formType = "select"
|
||||
}
|
||||
|
||||
result.unmatchedFields.push({
|
||||
labelText,
|
||||
labelElement,
|
||||
inputElement: inputEl,
|
||||
radioContainer: null,
|
||||
formType,
|
||||
isPicker: formType === "select",
|
||||
fillValue: "",
|
||||
alreadyFilled: false,
|
||||
})
|
||||
usedInputs.add(inp)
|
||||
}
|
||||
|
||||
console.log(` 收集到 ${result.unmatchedFields.length} 个待填写的空白字段`)
|
||||
for (const uf of result.unmatchedFields) {
|
||||
console.log(` [待填写] "${uf.labelText}" | formType: ${uf.formType} | isPicker: ${uf.isPicker}`)
|
||||
}
|
||||
console.log(`===== OfferPie: 阶段C完成 收集到 ${result.unmatchedFields.length} 个空白字段 =====`)
|
||||
|
||||
return result
|
||||
}
|
||||
+174
-74
@@ -6,6 +6,7 @@
|
||||
import type { MatchedFormField } from "./types"
|
||||
import { findAndClickOptionInVisiblePopups, clickBestOptionInDropdown } from "./pickerFill"
|
||||
import { fillDatePicker } from "./datePicker"
|
||||
import { JOB_FORM_LABELS } from "./constants"
|
||||
|
||||
// ============ 工具函数 ============
|
||||
|
||||
@@ -69,7 +70,18 @@ export async function closePopup(inputEl: HTMLElement) {
|
||||
|
||||
// ============ 字段填写 ============
|
||||
|
||||
/** 填写单个匹配到的表单字段 */
|
||||
/**
|
||||
* 【核心统一入口】填写单个匹配到的表单字段
|
||||
*
|
||||
* 【重要规范】所有填充操作必须走此入口,不要在外部自行编写填充逻辑!
|
||||
* 此方法内部已封装了所有类型的填充分支:
|
||||
* - radio → fillRadioField(单选按钮组点击)
|
||||
* - isPicker → fillPickerField(选择器:下拉/日期/月份/级联等,内含 DOM 差异对比逻辑)
|
||||
* - 普通 input/textarea → forceSetValue(值写入)
|
||||
*
|
||||
* 【禁止】不要绕过此入口直接调用 forceSetValue 来处理选择器类型字段!
|
||||
* 选择器类型的 input 通常是受控组件,直接写值无效,必须走选择器操作链路。
|
||||
*/
|
||||
export async function fillMatchedField(field: MatchedFormField): Promise<boolean> {
|
||||
const { labelText, inputElement, isPicker, fillValue, inputType, radioContainer } = field
|
||||
if (!fillValue) { console.warn(`OfferPie: ⏭ "${labelText}" 无填写值,跳过`); return false }
|
||||
@@ -126,6 +138,12 @@ function fillRadioField(container: Element, labelText: string, fillValue: string
|
||||
|
||||
/**
|
||||
* 【重要】填写选择器类型字段
|
||||
*
|
||||
* 【规范】所有选择器操作(下拉、日期、月份、级联等)的详细逻辑都已封装在本方法和子模块中:
|
||||
* - 日期/月份选择器 → datePicker.ts(fillDatePicker / tryFillMonthPanel)
|
||||
* - 下拉选项匹配 → pickerFill.ts(clickBestOptionInDropdown / findAndClickOptionInVisiblePopups)
|
||||
* 不要在调用方自行编写选择器操作代码,必须通过 fillMatchedField → 此方法来调用已封装的逻辑。
|
||||
*
|
||||
* 核心流程:
|
||||
* 1. 记录点击前的 DOM 快照(body 子元素 + 所有可见弹出层)
|
||||
* 2. 点击 input 及其单子标签父级链来展开选择器
|
||||
@@ -141,22 +159,48 @@ async function fillPickerField(field: MatchedFormField): Promise<boolean> {
|
||||
if (!inputElement) return false
|
||||
const isDateValue = /^\d{4}[.\-\/]\d{1,2}([.\-\/]\d{1,2})?$/.test(fillValue)
|
||||
|
||||
// ---- 步骤1:记录点击前的 DOM 快照 ----
|
||||
const POPUP_SELECTORS = [
|
||||
'[class*="dropdown"]', '[class*="popup"]', '[class*="popper"]',
|
||||
'[class*="picker-panel"]', '[class*="overlay"]', '[class*="popover"]',
|
||||
'[class*="select-dropdown"]', '[class*="cascader"]',
|
||||
'[class*="menu"][class*="content"]',
|
||||
'[role="listbox"]', '[role="menu"]',
|
||||
]
|
||||
const beforeBodyChildren = new Set(Array.from(document.body.children))
|
||||
const beforeVisiblePopups = new Set<Element>()
|
||||
for (const sel of POPUP_SELECTORS) {
|
||||
document.querySelectorAll(sel).forEach((el) => {
|
||||
if ((el as HTMLElement).offsetHeight > 0 && (el as HTMLElement).offsetWidth > 0) beforeVisiblePopups.add(el)
|
||||
})
|
||||
// ---- 步骤0:检查是否已有弹出层(pickerDetector 方式3 已展开并保存了引用) ----
|
||||
// 【注意】步骤0 不可删除——pickerDetector 检测时已点击展开了弹出层,
|
||||
// 如果直接进步骤2 会二次点击导致 toggle 关闭弹出层。
|
||||
// pickerDetector 不关闭弹出层,弹出层引用保存在 field.pickerDropdownElement 中。
|
||||
// 此步骤直接在已有弹出层内用 clickBestOptionInDropdown 搜索选项。
|
||||
// 搜不到时再补充搜索页面上其他可见弹出层(处理嵌套容器),最终都搜不到才关闭后走步骤1-4。
|
||||
if (!isDateValue && field.pickerDropdownElement) {
|
||||
const existingPopupEl = field.pickerDropdownElement
|
||||
await delay(100) // 等待弹出层渲染完成
|
||||
if (clickBestOptionInDropdown(existingPopupEl, fillValue)) {
|
||||
await delay(300); await closePopup(inputElement)
|
||||
console.log(`OfferPie: ✅ [选择器] 已选择 "${labelText}" = "${fillValue}" (已有弹出层)`)
|
||||
return true
|
||||
}
|
||||
// 弹出层内没找到,可能需要在更大范围搜(弹出层可能有嵌套容器)
|
||||
// 尝试从 body 找当前可见的弹出层
|
||||
const allPopupSels = '[class*="dropdown"], [class*="popup"], [class*="popper"], [class*="select-dropdown"], [role="listbox"], [role="menu"]'
|
||||
const visiblePopups = document.querySelectorAll(allPopupSels)
|
||||
for (const el of Array.from(visiblePopups)) {
|
||||
const htmlEl = el as HTMLElement
|
||||
if (htmlEl.offsetHeight > 0 && htmlEl.offsetWidth > 0 && htmlEl !== existingPopupEl) {
|
||||
if (clickBestOptionInDropdown(htmlEl, fillValue)) {
|
||||
await delay(300); await closePopup(inputElement)
|
||||
console.log(`OfferPie: ✅ [选择器] 已选择 "${labelText}" = "${fillValue}" (嵌套弹出层)`)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
// 仍然没找到,关闭弹出层后走步骤1-4 重新展开
|
||||
await closePopup(inputElement)
|
||||
await delay(200)
|
||||
}
|
||||
|
||||
// ---- 步骤1:记录点击前的 DOM 快照 ----
|
||||
const beforeBodyChildren = new Set(Array.from(document.body.children))
|
||||
// 全页面记录点击前所有可见的大块元素
|
||||
const beforeVisibleEls = new Set<Element>()
|
||||
document.querySelectorAll("*").forEach((el) => {
|
||||
const htmlEl = el as HTMLElement
|
||||
if (htmlEl.offsetHeight > 30 && htmlEl.offsetWidth > 30) beforeVisibleEls.add(el)
|
||||
})
|
||||
|
||||
// ---- 步骤2:点击展开选择器(input + 单子标签父级链) ----
|
||||
const clickTargets: HTMLElement[] = [inputElement as HTMLElement]
|
||||
let current: HTMLElement | null = inputElement as HTMLElement
|
||||
@@ -188,17 +232,33 @@ async function fillPickerField(field: MatchedFormField): Promise<boolean> {
|
||||
break
|
||||
}
|
||||
}
|
||||
// 策略B:新变为可见的弹出层
|
||||
|
||||
// 策略B:全页面扫描,找点击后新变为可见的、含有 ≥3 个同规格子元素的容器
|
||||
if (!dropdownEl) {
|
||||
for (const sel of POPUP_SELECTORS) {
|
||||
for (const el of Array.from(document.querySelectorAll(sel))) {
|
||||
const htmlEl = el as HTMLElement
|
||||
if (htmlEl.offsetHeight > 10 && htmlEl.offsetWidth > 0 && !beforeVisiblePopups.has(el)) {
|
||||
dropdownEl = htmlEl
|
||||
break
|
||||
}
|
||||
const candidates: HTMLElement[] = []
|
||||
document.querySelectorAll("*").forEach((el) => {
|
||||
const htmlEl = el as HTMLElement
|
||||
if (htmlEl.offsetHeight > 30 && htmlEl.offsetWidth > 30 && !beforeVisibleEls.has(el)) {
|
||||
candidates.push(htmlEl)
|
||||
}
|
||||
if (dropdownEl) break
|
||||
})
|
||||
// 从候选中找含有列表特征的容器(≥3 个同规格可见子元素)
|
||||
for (const candidate of candidates) {
|
||||
const visibleKids = Array.from(candidate.children).filter(c => (c as HTMLElement).offsetHeight > 0 && (c as HTMLElement).offsetWidth > 0)
|
||||
if (visibleKids.length >= 3) {
|
||||
const firstTag = visibleKids[0].tagName
|
||||
const firstClass = (visibleKids[0].className && typeof visibleKids[0].className === "string") ? visibleKids[0].className.trim().split(/\s+/)[0] || "" : ""
|
||||
const sameSpec = visibleKids.filter(c => {
|
||||
const cls = (c.className && typeof c.className === "string") ? c.className.trim().split(/\s+/)[0] || "" : ""
|
||||
return c.tagName === firstTag && cls === firstClass
|
||||
})
|
||||
if (sameSpec.length >= 3) { dropdownEl = candidate; break }
|
||||
}
|
||||
}
|
||||
// 如果没找到有列表特征的,取最大的新增可见元素
|
||||
if (!dropdownEl && candidates.length > 0) {
|
||||
candidates.sort((a, b) => (b.offsetHeight * b.offsetWidth) - (a.offsetHeight * a.offsetWidth))
|
||||
dropdownEl = candidates[0]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,34 +272,17 @@ async function fillPickerField(field: MatchedFormField): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 步骤5:fallback - 全局可见弹出层搜索 ----
|
||||
if (findAndClickOptionInVisiblePopups(fillValue, labelText)) {
|
||||
// ---- 步骤5:fallback - 在弹出层中搜索(有弹出层在弹出层内搜,没有则全页面搜) ----
|
||||
if (findAndClickOptionInVisiblePopups(fillValue, labelText, dropdownEl)) {
|
||||
await delay(300); await closePopup(inputElement)
|
||||
console.log(`OfferPie: ✅ [选择器] 已选择 "${labelText}" = "${fillValue}"`)
|
||||
return true
|
||||
}
|
||||
|
||||
// ---- 步骤5b:fallback - input 父级附近搜索弹出层 ----
|
||||
let parentEl: Element | null = inputElement.parentElement
|
||||
for (let i = 0; i < 8 && parentEl; i++) {
|
||||
const dropdowns = parentEl.querySelectorAll('[class*="dropdown"], [class*="select-dropdown"], [class*="option"], [role="listbox"]')
|
||||
for (const dd of Array.from(dropdowns)) {
|
||||
const htmlDd = dd as HTMLElement
|
||||
if (htmlDd.offsetHeight > 10 && htmlDd.offsetWidth > 0) {
|
||||
if (clickBestOptionInDropdown(htmlDd, fillValue)) {
|
||||
await delay(300); await closePopup(inputElement)
|
||||
console.log(`OfferPie: ✅ [选择器] 已选择 "${labelText}" = "${fillValue}"`)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
parentEl = parentEl.parentElement
|
||||
}
|
||||
|
||||
// ---- 步骤6:写入值触发搜索,再尝试一次 ----
|
||||
forceSetValue(inputElement, fillValue)
|
||||
await delay(500)
|
||||
if (findAndClickOptionInVisiblePopups(fillValue, labelText)) {
|
||||
if (findAndClickOptionInVisiblePopups(fillValue, labelText, dropdownEl)) {
|
||||
await delay(300); await closePopup(inputElement)
|
||||
console.log(`OfferPie: ✅ [选择器] 已选择 "${labelText}" = "${fillValue}"`)
|
||||
return true
|
||||
@@ -290,52 +333,77 @@ export function isSearchPickerField(key: string): boolean {
|
||||
* 4. 如果没有弹出层 → 说明是普通输入框,值已经写入,直接返回成功
|
||||
*/
|
||||
export async function fillSearchPickerField(field: MatchedFormField): Promise<boolean> {
|
||||
const { labelText, inputElement, fillValue } = field
|
||||
const { labelText, inputElement, fillValue, key } = field
|
||||
if (!inputElement || !fillValue) return false
|
||||
|
||||
console.log(`OfferPie: [搜索选择器] "${labelText}" 开始填写 "${fillValue}"`)
|
||||
|
||||
// 判断是否为接口异步下拉字段(需要等待接口返回数据)
|
||||
const labelConfig = JOB_FORM_LABELS.find(l => l.key === key)
|
||||
const isAsyncDropdown = labelConfig?.isAsyncDropdown === true
|
||||
|
||||
// 1. 记录当前可见弹出层快照
|
||||
const POPUP_SELECTORS = [
|
||||
'[class*="dropdown"]', '[class*="popup"]', '[class*="popper"]',
|
||||
'[class*="overlay"]', '[class*="popover"]', '[class*="select-dropdown"]',
|
||||
'[role="listbox"]', '[role="menu"]',
|
||||
]
|
||||
const beforeBodyChildren = new Set(Array.from(document.body.children))
|
||||
const beforeVisiblePopups = new Set<Element>()
|
||||
for (const sel of POPUP_SELECTORS) {
|
||||
document.querySelectorAll(sel).forEach((el) => {
|
||||
if ((el as HTMLElement).offsetHeight > 0 && (el as HTMLElement).offsetWidth > 0) beforeVisiblePopups.add(el)
|
||||
})
|
||||
}
|
||||
const beforeVisibleEls = new Set<Element>()
|
||||
document.querySelectorAll("*").forEach((el) => {
|
||||
const htmlEl = el as HTMLElement
|
||||
if (htmlEl.offsetHeight > 30 && htmlEl.offsetWidth > 30) beforeVisibleEls.add(el)
|
||||
})
|
||||
|
||||
// 2. 点击 input 获取焦点并输入值
|
||||
inputElement.focus()
|
||||
;(inputElement as HTMLElement).click()
|
||||
await delay(200)
|
||||
forceSetValue(inputElement, fillValue)
|
||||
await delay(800)
|
||||
|
||||
// 3. 检测是否有新的弹出层出现
|
||||
// 3. 等待弹出层出现(异步下拉字段轮询等待,普通字段固定等待)
|
||||
let hasNewPopup = false
|
||||
// 检查 body 下新增的子元素
|
||||
for (const child of Array.from(document.body.children)) {
|
||||
if (!beforeBodyChildren.has(child) && (child as HTMLElement).offsetHeight > 10) {
|
||||
hasNewPopup = true
|
||||
break
|
||||
}
|
||||
}
|
||||
// 检查新变为可见的弹出层
|
||||
if (!hasNewPopup) {
|
||||
for (const sel of POPUP_SELECTORS) {
|
||||
for (const el of Array.from(document.querySelectorAll(sel))) {
|
||||
const htmlEl = el as HTMLElement
|
||||
if (htmlEl.offsetHeight > 10 && htmlEl.offsetWidth > 0 && !beforeVisiblePopups.has(el)) {
|
||||
let newPopupEl: HTMLElement | null = null // 记录新出现的弹出层元素
|
||||
if (isAsyncDropdown) {
|
||||
// 接口异步下拉:轮询等待弹出层出现,最多等3秒
|
||||
console.log(`OfferPie: [搜索选择器] "${labelText}" 异步下拉字段,等待接口返回...`)
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await delay(200)
|
||||
// 检查是否有新弹出层
|
||||
for (const child of Array.from(document.body.children)) {
|
||||
if (!beforeBodyChildren.has(child) && (child as HTMLElement).offsetHeight > 10) {
|
||||
hasNewPopup = true
|
||||
newPopupEl = child as HTMLElement
|
||||
break
|
||||
}
|
||||
}
|
||||
if (hasNewPopup) break
|
||||
// 全页面扫描新出现的可见元素(异步下拉可能只有1个选项)
|
||||
document.querySelectorAll("*").forEach((el) => {
|
||||
if (hasNewPopup) return
|
||||
const htmlEl = el as HTMLElement
|
||||
if (htmlEl.offsetHeight > 30 && htmlEl.offsetWidth > 30 && !beforeVisibleEls.has(el)) {
|
||||
const visibleKids = Array.from(htmlEl.children).filter(c => (c as HTMLElement).offsetHeight > 0 && (c as HTMLElement).offsetWidth > 0)
|
||||
if (visibleKids.length >= 1) { hasNewPopup = true; newPopupEl = htmlEl }
|
||||
}
|
||||
})
|
||||
if (hasNewPopup) break
|
||||
}
|
||||
} else {
|
||||
await delay(401)
|
||||
// 检查 body 下新增的子元素
|
||||
for (const child of Array.from(document.body.children)) {
|
||||
if (!beforeBodyChildren.has(child) && (child as HTMLElement).offsetHeight > 10) {
|
||||
hasNewPopup = true
|
||||
newPopupEl = child as HTMLElement
|
||||
break
|
||||
}
|
||||
}
|
||||
// 全页面扫描新变为可见的大块元素
|
||||
if (!hasNewPopup) {
|
||||
document.querySelectorAll("*").forEach((el) => {
|
||||
if (hasNewPopup) return
|
||||
const htmlEl = el as HTMLElement
|
||||
if (htmlEl.offsetHeight > 30 && htmlEl.offsetWidth > 30 && !beforeVisibleEls.has(el)) {
|
||||
const visibleKids = Array.from(htmlEl.children).filter(c => (c as HTMLElement).offsetHeight > 0 && (c as HTMLElement).offsetWidth > 0)
|
||||
if (visibleKids.length >= 2) { hasNewPopup = true; newPopupEl = htmlEl }
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,9 +422,20 @@ export async function fillSearchPickerField(field: MatchedFormField): Promise<bo
|
||||
// 5. 有弹出层 → 走搜索选择器逻辑,等待搜索结果
|
||||
await delay(500)
|
||||
|
||||
// 【注意】对异步下拉字段,必须优先在检测到的弹出层元素内直接做文字匹配
|
||||
// 不能走 tryClickDropdownListItem 全页面搜——异步下拉可能只有1-2个选项,全页面搜会被其他同规格元素干扰
|
||||
if (isAsyncDropdown && newPopupEl) {
|
||||
if (clickBestOptionInDropdown(newPopupEl, fillValue)) {
|
||||
await delay(300)
|
||||
await closePopup(inputElement)
|
||||
console.log(`OfferPie: ✅ [搜索选择器] 已选择 "${labelText}" = "${fillValue}"`)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 在弹出层中查找匹配选项
|
||||
const { findAndClickOptionInVisiblePopups } = await import("./pickerFill")
|
||||
if (findAndClickOptionInVisiblePopups(fillValue, labelText)) {
|
||||
if (findAndClickOptionInVisiblePopups(fillValue, labelText, newPopupEl)) {
|
||||
await delay(300)
|
||||
await closePopup(inputElement)
|
||||
console.log(`OfferPie: ✅ [搜索选择器] 已选择 "${labelText}" = "${fillValue}"`)
|
||||
@@ -365,7 +444,7 @@ export async function fillSearchPickerField(field: MatchedFormField): Promise<bo
|
||||
|
||||
// 6. 再等一会重试(有些网站接口慢)
|
||||
await delay(600)
|
||||
if (findAndClickOptionInVisiblePopups(fillValue, labelText)) {
|
||||
if (findAndClickOptionInVisiblePopups(fillValue, labelText, newPopupEl)) {
|
||||
await delay(300)
|
||||
await closePopup(inputElement)
|
||||
console.log(`OfferPie: ✅ [搜索选择器] 已选择 "${labelText}" = "${fillValue}"(重试)`)
|
||||
@@ -486,6 +565,9 @@ async function fillYearMonthPicker(inputEl: HTMLInputElement | HTMLTextAreaEleme
|
||||
/**
|
||||
* 填写合并式起止时间字段("就读时间"/"起止时间"后面跟4个年月年月输入框)
|
||||
*
|
||||
* 【规范】集成式时间选择器操作已封装在 datePicker.ts 中(fillDatePicker / tryFillMonthPanel),
|
||||
* 不要在此方法内自行编写日历面板操作代码,fallback 时走 fillMatchedField 统一入口。
|
||||
*
|
||||
* 检测逻辑:
|
||||
* 1. 从标签元素往后找所有 placeholder 为"年"或"月"的 input
|
||||
* 2. 如果找到4个(年月年月),按顺序填入开始年、开始月、结束年、结束月
|
||||
@@ -643,6 +725,9 @@ export async function fillTimePeriodField(
|
||||
/**
|
||||
* 填写单独时间字段("开始时间"或"结束时间"后面跟年月输入框或集成式选择器)
|
||||
*
|
||||
* 【规范】集成式时间选择器操作已封装在 datePicker.ts 中(fillDatePicker / tryFillMonthPanel),
|
||||
* 不要在此方法内自行编写日历面板操作代码,fallback 时走 fillMatchedField 统一入口。
|
||||
*
|
||||
* @param field - 匹配到的字段
|
||||
* @param dateStr - 时间字符串(如 "2025.09")
|
||||
* @param usedInputs - 已使用的 input 集合
|
||||
@@ -688,6 +773,21 @@ export async function fillTimeSingleField(
|
||||
return true
|
||||
}
|
||||
|
||||
// 情况B2:没有年月输入框,走集成式时间选择器逻辑(由外部处理)
|
||||
// 情况B2:没有年月输入框,走集成式时间选择器逻辑
|
||||
// 【注意】时间字段一定不能走普通 forceSetValue 填写,必须走选择器逻辑!
|
||||
// 这类字段的 input 通常是受控组件(readonly 或 React 控制),直接写值无效。
|
||||
// 【规范】不要在这里自己写展开面板、点击月份等操作代码!
|
||||
// 必须走 fillMatchedField → fillPickerField → fillDatePicker → tryFillMonthPanel 已封装的完整链路。
|
||||
if (field.inputElement) {
|
||||
console.log(`OfferPie: [时间选择器] "${field.labelText}" 无年月下拉框,走集成式选择器逻辑(fillMatchedField统一入口)`)
|
||||
field.isPicker = true
|
||||
field.fillValue = dateStr
|
||||
usedInputs.add(field.inputElement)
|
||||
|
||||
// 走 fillMatchedField 统一入口(内部: fillPickerField 点击展开 → isDateValue → fillDatePicker → tryFillMonthPanel)
|
||||
const ok = await fillMatchedField(field)
|
||||
return ok
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -39,9 +39,9 @@ export const JOB_FORM_LABELS: FormLabelItem[] = [
|
||||
{ key: "emergencyPhone", zh: ["紧急联系方式", "紧急联系电话", "紧急联系人电话"], en: ["Emergency Phone", "Emergency Contact Number"], section: "main", resumeField: "" },
|
||||
{ key: "portfolioUrl", zh: ["作品集", "作品链接", "个人作品", "作品集链接"], en: ["Portfolio", "Portfolio URL", "Portfolio Link", "Work Samples"], section: "main", resumeField: "portfolioUrl" },
|
||||
// ---- 教育经历(数组) ----
|
||||
{ key: "school", zh: ["学校", "学校名称", "毕业院校", "院校", "毕业学校"], en: ["School", "University", "College", "Institution", "School Name"], section: "education", resumeField: "school" },
|
||||
{ key: "school", zh: ["学校", "学校名称", "毕业院校", "院校", "毕业学校"], en: ["School", "University", "College", "Institution", "School Name"], section: "education", resumeField: "school", isAsyncDropdown: true },
|
||||
{ key: "schoolLocation", zh: ["学校所在地", "学校所在城市"], en: ["School Location", "Campus Location"], section: "education", resumeField: "" },
|
||||
{ key: "major", zh: ["专业", "专业名称", "所学专业", "主修课程"], en: ["Major", "Field of Study", "Specialization", "Program"], section: "education", resumeField: "major" },
|
||||
{ key: "major", zh: ["专业", "专业名称", "所学专业", "主修课程"], en: ["Major", "Field of Study", "Specialization", "Program"], section: "education", resumeField: "major", isAsyncDropdown: true },
|
||||
{ key: "secondMajor", zh: ["第二专业", "辅修专业", "辅修"], en: ["Second Major", "Minor", "Double Major"], section: "education", resumeField: "" },
|
||||
{ key: "degree", zh: ["学历", "学位", "最高学历", "最高学位"], en: ["Degree", "Education Level", "Qualification", "Education"], section: "education", resumeField: "degree" },
|
||||
{ key: "studyType", zh: ["学习形式", "就读方式", "全日制/非全日制", "学习方式"], en: ["Study Type", "Study Mode", "Full-time/Part-time"], section: "education", resumeField: "studyType" },
|
||||
|
||||
@@ -93,3 +93,53 @@ export function buildSelector(el: Element): string {
|
||||
}
|
||||
return parts.join(" > ")
|
||||
}
|
||||
|
||||
// ============ DOM 快照与差异对比 ============
|
||||
|
||||
/**
|
||||
* 对指定范围内(startEl 到 endEl 之间)的所有元素做快照
|
||||
* 返回范围内所有元素的 Set,用于后续差异对比
|
||||
*
|
||||
* @param startEl - 范围起始元素(不含)
|
||||
* @param endEl - 范围结束元素(不含),null 表示到 body 末尾
|
||||
* @returns 范围内所有 Element 的 Set
|
||||
*/
|
||||
export function snapshotElementsInRange(startEl: Element, endEl: Element | null): Set<Element> {
|
||||
const snapshot = new Set<Element>()
|
||||
const allElements = document.body.querySelectorAll("*")
|
||||
for (const el of Array.from(allElements)) {
|
||||
if (!(startEl.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING)) continue
|
||||
if (endEl && !(endEl.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_PRECEDING)) continue
|
||||
snapshot.add(el)
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
/**
|
||||
* 对比两次快照,找出新增的元素(在 after 中存在但 before 中不存在的)
|
||||
* 返回新增元素列表(按 DOM 顺序)
|
||||
*/
|
||||
export function diffSnapshots(before: Set<Element>, after: Set<Element>): Element[] {
|
||||
const newElements: Element[] = []
|
||||
for (const el of after) {
|
||||
if (!before.has(el)) newElements.push(el)
|
||||
}
|
||||
return newElements
|
||||
}
|
||||
|
||||
/**
|
||||
* 从新增的元素列表中,找到最顶层的新增容器(不被其他新增元素包含的)
|
||||
* 通常点击添加按钮后只会新增一个顶层容器(一段经历的完整 DOM)
|
||||
*/
|
||||
export function findTopLevelNewElements(newElements: Element[]): Element[] {
|
||||
const newSet = new Set(newElements)
|
||||
return newElements.filter((el) => {
|
||||
// 如果它的某个祖先也在新增列表中,说明它不是顶层
|
||||
let parent = el.parentElement
|
||||
while (parent) {
|
||||
if (newSet.has(parent)) return false
|
||||
parent = parent.parentElement
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
import { EXPERIENCE_SECTION_CONFIGS, JOB_FORM_LABELS } from "./constants"
|
||||
import { delay } from "./autofill"
|
||||
import { snapshotElementsInRange, diffSnapshots, findTopLevelNewElements } from "./dom"
|
||||
import type { ExperienceSection, ExperienceSectionConfig, ResumeData } from "./types"
|
||||
|
||||
// ====================================================================
|
||||
@@ -222,11 +223,14 @@ function isSameSignature(a: TitleSignature, b: TitleSignature): boolean {
|
||||
* 遍历页面所有短文本叶子元素,提取其 signature 并与参考值比较
|
||||
* 【注意】跳过导航/锚点/菜单区域的元素
|
||||
*/
|
||||
function findAllTitlesWithSameSignature(rootEl: Element, refSignature: TitleSignature): PageTitle[] {
|
||||
function findAllTitlesWithSameSignature(rootEl: Element, refSignature: TitleSignature, refElement?: Element): PageTitle[] {
|
||||
const results: PageTitle[] = []
|
||||
const walker = document.createTreeWalker(rootEl, NodeFilter.SHOW_ELEMENT)
|
||||
let node: Node | null = walker.nextNode()
|
||||
|
||||
// 参考元素自身的 className(用于过滤同级但不同类名的干扰元素,如副标题/注释)
|
||||
const refSelfClassName = refElement && typeof refElement.className === "string" ? refElement.className.trim() : ""
|
||||
|
||||
// 判断元素是否在导航区域(复用逻辑)
|
||||
const isInNavArea = (el: Element): boolean => {
|
||||
let current: Element | null = el
|
||||
@@ -262,6 +266,15 @@ function findAllTitlesWithSameSignature(rootEl: Element, refSignature: TitleSign
|
||||
if (!isInNavArea(el)) {
|
||||
const sig = extractTitleSignature(el)
|
||||
if (isSameSignature(sig, refSignature)) {
|
||||
// 【注意】如果有参考元素的 className,额外验证候选元素自身 className 必须一致
|
||||
// 过滤掉同级但不同类名的干扰元素(如大标题下方的副标题/注释文字)
|
||||
if (refSelfClassName) {
|
||||
const elClassName = typeof el.className === "string" ? el.className.trim() : ""
|
||||
if (elClassName !== refSelfClassName) {
|
||||
node = walker.nextNode()
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (!results.some((r) => r.element === el)) {
|
||||
results.push({ element: el, text: directText, signature: sig })
|
||||
}
|
||||
@@ -744,9 +757,9 @@ export function locateExperienceSections(
|
||||
}))
|
||||
}
|
||||
|
||||
// 用第一个找到的大标题的 TitleSignature 找出页面所有同结构大标题
|
||||
// 用第一个找到的大标题的 TitleSignature + 自身 className 找出页面所有同结构大标题
|
||||
const refSignature = extractTitleSignature(located[0].titleElement)
|
||||
let allPageTitles = findAllTitlesWithSameSignature(rootEl, refSignature)
|
||||
let allPageTitles = findAllTitlesWithSameSignature(rootEl, refSignature, located[0].titleElement)
|
||||
|
||||
// 检查找到的大标题是否包含已定位的经历大标题
|
||||
const locatedElements = new Set(located.map((l) => l.titleElement))
|
||||
@@ -819,9 +832,10 @@ export function locateExperienceSections(
|
||||
*/
|
||||
function getPageTitles(
|
||||
locateResults: ExperienceSectionLocateResult[],
|
||||
refSignature: TitleSignature
|
||||
refSignature: TitleSignature,
|
||||
refElement?: Element
|
||||
): PageTitle[] {
|
||||
let allPageTitles = findAllTitlesWithSameSignature(document.body, refSignature)
|
||||
let allPageTitles = findAllTitlesWithSameSignature(document.body, refSignature, refElement)
|
||||
|
||||
const locatedElements = new Set(locateResults.filter((r) => r.titleElement).map((r) => r.titleElement!))
|
||||
const containsLocated = allPageTitles.some((pt) => locatedElements.has(pt.element))
|
||||
@@ -879,17 +893,28 @@ export async function expandExperienceSections(
|
||||
const config = EXPERIENCE_SECTION_CONFIGS.find((c) => c.section === result.section)
|
||||
if (!config) continue
|
||||
|
||||
/**
|
||||
* 【核心逻辑 - 不可擅自修改】
|
||||
* 记录通过 DOM 差异检测到的"一段经历容器"的特征(tagName + className)
|
||||
* 原理:点击添加按钮前后对比 DOM 快照,找出新增的顶层容器,记住它的结构特征。
|
||||
* 所有段添加完后,用这个特征从新增段的 parentElement 出发,找父级下所有匹配的兄弟容器,
|
||||
* 按 DOM 顺序重建 segmentRanges,确保每段经历的容器定位精确。
|
||||
* 这种方式不依赖 input 差集(避免 React 重渲染问题),不依赖 titleElement 存活状态。
|
||||
*/
|
||||
let segmentContainerSignature: { tag: string; className: string } | null = null
|
||||
/** 记录新增段容器的 DOM 引用(用于重建时直接找父级,不依赖 titleElement) */
|
||||
let lastNewContainerEl: Element | null = null
|
||||
|
||||
// 逐次点击添加按钮
|
||||
for (let addIdx = 0; addIdx < needAdd; addIdx++) {
|
||||
// 每次点击前重新获取范围边界(DOM 可能已变化)
|
||||
const allPageTitles = getPageTitles(locateResults, refSignature)
|
||||
const allPageTitles = getPageTitles(locateResults, refSignature, firstLocated.titleElement)
|
||||
const idx = allPageTitles.findIndex((pt) => pt.element === result.titleElement)
|
||||
const nextTitleEl = idx >= 0 && idx < allPageTitles.length - 1 ? allPageTitles[idx + 1].element : null
|
||||
|
||||
// 记录当前 input 快照
|
||||
const currentInputs = findInputsInRange(result.titleElement, nextTitleEl)
|
||||
const beforeInputSet = new Set<Element>(currentInputs)
|
||||
const beforeInputCount = currentInputs.length
|
||||
// 点击前对范围内 DOM 做快照(用于差异对比找新增的段容器)
|
||||
const beforeSnapshot = snapshotElementsInRange(result.titleElement, nextTitleEl)
|
||||
const beforeInputCount = findInputsInRange(result.titleElement, nextTitleEl).length
|
||||
|
||||
// 查找添加按钮
|
||||
const addBtn = findAddButton(result.titleElement, nextTitleEl, config, lang)
|
||||
@@ -905,21 +930,40 @@ export async function expandExperienceSections(
|
||||
await delay(300)
|
||||
|
||||
if (clickSuccess) {
|
||||
// 通过 DOM 差异检测新增的段落
|
||||
const updatedTitles = getPageTitles(locateResults, refSignature)
|
||||
// 通过 DOM 差异找出新增的顶层容器(即一段经历的完整 DOM)
|
||||
const updatedTitles = getPageTitles(locateResults, refSignature, firstLocated.titleElement)
|
||||
const newIdx = updatedTitles.findIndex((pt) => pt.element === result.titleElement)
|
||||
const newNextTitleEl = newIdx >= 0 && newIdx < updatedTitles.length - 1 ? updatedTitles[newIdx + 1].element : null
|
||||
|
||||
const newSegment = detectNewSegmentFromDiff(beforeInputSet, result.titleElement, newNextTitleEl, result.segmentRanges, result.titleText)
|
||||
if (newSegment) {
|
||||
result.segmentRanges.push(newSegment)
|
||||
result.expandedCount = result.segmentRanges.length
|
||||
const containerInfo = newSegment.containerElement
|
||||
? `<${newSegment.containerElement.tagName.toLowerCase()} class="${(typeof newSegment.containerElement.className === 'string' ? newSegment.containerElement.className : '').substring(0, 40)}"> nth-child(${newSegment.nthChildIndex})`
|
||||
: "null"
|
||||
console.log(` ✅ 第${addIdx + 1}次:添加成功,当前${result.expandedCount}段 | 容器: ${containerInfo}`)
|
||||
const afterSnapshot = snapshotElementsInRange(result.titleElement, newNextTitleEl)
|
||||
const newElements = diffSnapshots(beforeSnapshot, afterSnapshot)
|
||||
const topLevelNew = findTopLevelNewElements(newElements)
|
||||
|
||||
// 从新增的顶层元素中找到包含 input 的段容器
|
||||
let newContainerEl: Element | null = null
|
||||
for (const el of topLevelNew) {
|
||||
if (el.querySelectorAll(INPUT_SEL).length >= 2) {
|
||||
newContainerEl = el
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (newContainerEl) {
|
||||
// 记住段容器的特征(第一次找到时记录,后续复用)
|
||||
if (!segmentContainerSignature) {
|
||||
segmentContainerSignature = {
|
||||
tag: newContainerEl.tagName.toLowerCase(),
|
||||
className: typeof newContainerEl.className === "string" ? newContainerEl.className.split(" ")[0] : "",
|
||||
}
|
||||
console.log(` [特征] 段容器特征: <${segmentContainerSignature.tag} class="${segmentContainerSignature.className}">`)
|
||||
}
|
||||
// 记住新增段的 DOM 引用(重建时用它找父级)
|
||||
lastNewContainerEl = newContainerEl
|
||||
|
||||
result.expandedCount++
|
||||
console.log(` ✅ 第${addIdx + 1}次:添加成功,当前${result.expandedCount}段`)
|
||||
} else {
|
||||
console.log(` ⚠️ 第${addIdx + 1}次:点击成功但未检测到新段落`)
|
||||
console.log(` ⚠️ 第${addIdx + 1}次:点击成功但未检测到新段容器`)
|
||||
result.expandedCount++
|
||||
}
|
||||
|
||||
@@ -933,6 +977,40 @@ export async function expandExperienceSections(
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 【核心逻辑 - 不可擅自修改】所有段添加完毕后,用段容器特征在最终 DOM 中重建 segmentRanges
|
||||
// 直接从已记住的新增段 DOM 引用找父级,不依赖 titleElement(可能因 React 重渲染脱离 DOM)
|
||||
// 在父级的 children 中按 tag + className 前缀匹配所有同类兄弟,按 DOM 顺序构建段落列表
|
||||
if (segmentContainerSignature && lastNewContainerEl) {
|
||||
const parentEl = lastNewContainerEl.parentElement
|
||||
if (parentEl) {
|
||||
// 在父级的 children 中找所有匹配特征的兄弟容器(按 DOM 顺序)
|
||||
const matchingChildren = Array.from(parentEl.children).filter((child) => {
|
||||
const tag = child.tagName.toLowerCase()
|
||||
const cls = typeof child.className === "string" ? child.className.split(" ")[0] : ""
|
||||
return tag === segmentContainerSignature!.tag && cls === segmentContainerSignature!.className
|
||||
})
|
||||
|
||||
if (matchingChildren.length >= 2) {
|
||||
result.segmentRanges = matchingChildren.map((el, i) => {
|
||||
const inputs = el.querySelectorAll(INPUT_SEL)
|
||||
const firstInput = inputs.length > 0 ? inputs[0] : el
|
||||
const startLabel = findLabelBeforeInput(firstInput, el)
|
||||
const childIdx = Array.from(parentEl.children).indexOf(el)
|
||||
return {
|
||||
startElement: startLabel || firstInput,
|
||||
endElement: inputs.length > 0 ? inputs[inputs.length - 1] : el,
|
||||
isNewlyAdded: i > 0,
|
||||
containerElement: el,
|
||||
nthChildIndex: childIdx + 1,
|
||||
locator: buildLocator(el, parentEl, childIdx + 1, result.titleText),
|
||||
}
|
||||
})
|
||||
result.expandedCount = result.segmentRanges.length
|
||||
console.log(` [重建] 用段容器特征重建 segmentRanges: ${result.segmentRanges.length} 段`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 打印最终结果
|
||||
|
||||
+67
-32
@@ -4,22 +4,21 @@
|
||||
* 检测方式:
|
||||
* 1. UI组件库类名匹配(快速路径)
|
||||
* 2. 提示文字检测("请选择"等)
|
||||
* 3. 主动点击检测:点击 input 及其单子标签父级链,对比点击前后 DOM 变化判断是否有弹出层
|
||||
* 3. 主动点击检测:点击 input 及其单子标签父级链,对比点击前后全页面 DOM 可见性变化判断是否有弹出层
|
||||
*
|
||||
* 【注意】方式3 的核心机制:
|
||||
* - 点击前记录全页面所有可见元素快照(offsetHeight > 0 && offsetWidth > 0)
|
||||
* - 点击后对比哪些元素是新变为可见的,以此判断弹出层出现
|
||||
* - 检测到弹出层后【不关闭】,保存弹出层 DOM 引用到 field.pickerDropdownElement
|
||||
* - fillPickerField 步骤0 直接在已有弹出层内搜索选项,避免二次点击导致 toggle 关闭
|
||||
* - 禁止使用固定的 CSS 类名选择器(如 POPUP_SELECTORS)来检测弹出层,必须走纯 DOM 差异对比
|
||||
* - 这和 fillPickerField 步骤1/3 的 DOM 差异检测是同一套方法,保持一致
|
||||
*/
|
||||
|
||||
import type { MatchedFormField, UILibPickerConfig } from "./types"
|
||||
import { UI_LIB_PICKER_CONFIGS } from "./constants"
|
||||
import { delay } from "./autofill"
|
||||
|
||||
/** 弹出层相关的 CSS 选择器 */
|
||||
const POPUP_SELECTORS = [
|
||||
'[class*="dropdown"]', '[class*="popup"]', '[class*="popper"]',
|
||||
'[class*="picker-panel"]', '[class*="overlay"]', '[class*="popover"]',
|
||||
'[class*="select-dropdown"]', '[class*="cascader"]',
|
||||
'[class*="menu"][class*="content"]',
|
||||
'[role="listbox"]', '[role="menu"]',
|
||||
]
|
||||
|
||||
/** 通过提示文字判断是否为选择器 */
|
||||
function hasPickerHintText(field: MatchedFormField, lang: "zh" | "en"): boolean {
|
||||
const hints = lang === "zh"
|
||||
@@ -70,32 +69,33 @@ function matchUILibTrigger(field: MatchedFormField): UILibPickerConfig | null {
|
||||
return null
|
||||
}
|
||||
|
||||
/** 记录当前页面所有可见弹出层和 body 直接子元素 */
|
||||
function snapshotDOM(): { bodyChildren: Set<Element>; visiblePopups: Set<Element> } {
|
||||
/**
|
||||
* 记录当前 body 直接子元素 + 全页面可见元素快照(用于点击前后 DOM 差异对比)
|
||||
* 【注意】不使用任何固定 CSS 选择器,记录全页面所有可见元素,保证通用性
|
||||
* offsetHeight/offsetWidth > 0 即视为可见,不设更高阈值(避免漏判小尺寸弹出层)
|
||||
*/
|
||||
function snapshotDOM(): { bodyChildren: Set<Element>; visibleEls: Set<Element> } {
|
||||
const bodyChildren = new Set(Array.from(document.body.children))
|
||||
const visiblePopups = new Set<Element>()
|
||||
for (const sel of POPUP_SELECTORS) {
|
||||
document.querySelectorAll(sel).forEach((el) => {
|
||||
const htmlEl = el as HTMLElement
|
||||
if (htmlEl.offsetHeight > 0 && htmlEl.offsetWidth > 0) visiblePopups.add(el)
|
||||
})
|
||||
}
|
||||
return { bodyChildren, visiblePopups }
|
||||
// 记录所有可见的元素(offsetHeight > 0 且 offsetWidth > 0)
|
||||
const visibleEls = new Set<Element>()
|
||||
document.querySelectorAll("*").forEach((el) => {
|
||||
const htmlEl = el as HTMLElement
|
||||
if (htmlEl.offsetHeight > 0 && htmlEl.offsetWidth > 0) visibleEls.add(el)
|
||||
})
|
||||
return { bodyChildren, visibleEls }
|
||||
}
|
||||
|
||||
/** 对比 DOM 快照,检测是否有新增弹出层 */
|
||||
/** 对比 DOM 快照,检测是否有新出现的可见元素(弹出层) */
|
||||
function hasNewPopup(before: ReturnType<typeof snapshotDOM>): boolean {
|
||||
// 检查 body 下新增的直接子元素
|
||||
// 检查 body 下新增的直接子元素(offsetHeight > 0 才算有效弹出层)
|
||||
for (const child of Array.from(document.body.children)) {
|
||||
if (!before.bodyChildren.has(child) && (child as HTMLElement).offsetHeight > 10) return true
|
||||
if (!before.bodyChildren.has(child) && (child as HTMLElement).offsetHeight > 0) return true
|
||||
}
|
||||
// 检查新变为可见的弹出层
|
||||
for (const sel of POPUP_SELECTORS) {
|
||||
const els = document.querySelectorAll(sel)
|
||||
for (const el of Array.from(els)) {
|
||||
const htmlEl = el as HTMLElement
|
||||
if (htmlEl.offsetHeight > 10 && htmlEl.offsetWidth > 0 && !before.visiblePopups.has(el)) return true
|
||||
}
|
||||
// 检查新变为可见的元素(点击前不可见,点击后可见)
|
||||
const allEls = document.querySelectorAll("*")
|
||||
for (const el of Array.from(allEls)) {
|
||||
const htmlEl = el as HTMLElement
|
||||
if (htmlEl.offsetHeight > 0 && htmlEl.offsetWidth > 0 && !before.visibleEls.has(el)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -114,7 +114,13 @@ async function dismissPopup() {
|
||||
|
||||
/**
|
||||
* 主动点击检测:点击 input 及其单子标签父级链,对比 DOM 变化判断是否有弹出层
|
||||
* 如果检测到弹出层,关闭后返回 true
|
||||
* 如果检测到弹出层,【不关闭弹出层】,将弹出层 DOM 引用保存到 field.pickerDropdownElement,返回 true
|
||||
* 点击选择后弹出层会自行关闭,无需手动 dismiss
|
||||
*
|
||||
* 【注意】此处不可调用 dismissPopup 关闭弹出层!原因:
|
||||
* - fillPickerField 步骤0 需要直接在已有弹出层内搜索选项
|
||||
* - 如果这里关闭了弹出层,步骤2 再点击会触发 toggle 导致弹出层反而不展开
|
||||
* - Phoenix 等组件的弹出层用 Escape/blur/body.click 关闭不可靠,会引发状态不一致
|
||||
*/
|
||||
async function detectByClick(field: MatchedFormField): Promise<boolean> {
|
||||
if (!field.inputElement) return false
|
||||
@@ -140,7 +146,12 @@ async function detectByClick(field: MatchedFormField): Promise<boolean> {
|
||||
|
||||
if (hasNewPopup(before)) {
|
||||
console.log(`OfferPie: [${field.key}] 点击 ${target.tagName}.${(target.className || "").toString().split(" ")[0]} 后检测到弹出层`)
|
||||
await dismissPopup()
|
||||
// 【注意】不关闭弹出层,保存弹出层 DOM 引用供 fillPickerField 步骤0 使用
|
||||
const popupEl = findNewPopupElement(before)
|
||||
if (popupEl) {
|
||||
field.pickerDropdownElement = popupEl
|
||||
console.log(`OfferPie: [${field.key}] 保存弹出层引用: ${popupEl.tagName}.${(popupEl.className || "").toString().split(" ")[0]} offsetHeight=${popupEl.offsetHeight}`)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -150,6 +161,30 @@ async function detectByClick(field: MatchedFormField): Promise<boolean> {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 DOM 快照对比中找到新出现的弹出层元素
|
||||
* 逻辑和 fillPickerField 步骤3 一致:找点击后新变为可见的最大元素
|
||||
*/
|
||||
function findNewPopupElement(before: ReturnType<typeof snapshotDOM>): HTMLElement | null {
|
||||
// 优先:body 下新增的、有实际高度的直接子元素
|
||||
for (const child of Array.from(document.body.children)) {
|
||||
if (!before.bodyChildren.has(child) && (child as HTMLElement).offsetHeight > 0) {
|
||||
return child as HTMLElement
|
||||
}
|
||||
}
|
||||
// 其次:全页面找新变为可见的元素,取面积最大的那个
|
||||
let best: HTMLElement | null = null
|
||||
let bestArea = 0
|
||||
document.querySelectorAll("*").forEach((el) => {
|
||||
const htmlEl = el as HTMLElement
|
||||
if (htmlEl.offsetHeight > 0 && htmlEl.offsetWidth > 0 && !before.visibleEls.has(el)) {
|
||||
const area = htmlEl.offsetHeight * htmlEl.offsetWidth
|
||||
if (area > bestArea) { best = htmlEl; bestArea = area }
|
||||
}
|
||||
})
|
||||
return best
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测单个字段是否为数据选择器类型
|
||||
* 优先级:1.UI组件库类名 → 2.提示文字 → 3.主动点击检测DOM变化
|
||||
|
||||
+20
-27
@@ -78,31 +78,19 @@ export function clickBestOptionInDropdown(dropdownEl: HTMLElement, fillValue: st
|
||||
}
|
||||
|
||||
/**
|
||||
* 在当前页面所有可见的弹出层中查找并点击匹配选项
|
||||
* 【注意】在弹出层中查找并点击匹配选项
|
||||
* - 有 popupEl 时只在该弹出层内搜索(clickBestOptionInDropdown),不能全页面搜
|
||||
* - 没有 popupEl 时直接返回 false,禁止走全页面搜(避免误匹配页面其他同规格文字标签)
|
||||
* - 禁止在此函数内加入特定网站类名的选择器,必须走通用逻辑
|
||||
*/
|
||||
export function findAndClickOptionInVisiblePopups(fillValue: string, labelText: string): boolean {
|
||||
const popupSelectors = [
|
||||
'[class*="dropdown"]', '[class*="popup"]', '[class*="popper"]',
|
||||
'[class*="picker-panel"]', '[class*="overlay"]', '[class*="popover"]',
|
||||
'[class*="select-dropdown"]', '[class*="cascader"]',
|
||||
'[class*="menu"][class*="content"]',
|
||||
'[class*="phoenix-select-dropdown"]', '[class*="phoenix-calendar"]',
|
||||
'[role="listbox"]', '[role="menu"]',
|
||||
]
|
||||
const visiblePopups: HTMLElement[] = []
|
||||
for (const sel of popupSelectors) {
|
||||
document.querySelectorAll(sel).forEach((el) => {
|
||||
const htmlEl = el as HTMLElement
|
||||
if (htmlEl.offsetHeight > 10 && htmlEl.offsetWidth > 0) {
|
||||
const isDuplicate = visiblePopups.some((existing) => existing.contains(htmlEl) || htmlEl.contains(existing))
|
||||
if (!isDuplicate) visiblePopups.push(htmlEl)
|
||||
}
|
||||
})
|
||||
}
|
||||
console.log(`OfferPie: [findOption] "${labelText}" 找到 ${visiblePopups.length} 个可见弹出层`)
|
||||
for (const popup of visiblePopups) {
|
||||
if (clickBestOptionInDropdown(popup, fillValue)) return true
|
||||
export function findAndClickOptionInVisiblePopups(fillValue: string, labelText: string, popupEl?: HTMLElement | null): boolean {
|
||||
// 只在明确的弹出层元素内搜索,不做全页面搜
|
||||
if (popupEl) {
|
||||
console.log(`OfferPie: [findOption] "${labelText}" 在弹出层内搜索`)
|
||||
return clickBestOptionInDropdown(popupEl, fillValue)
|
||||
}
|
||||
// 没有弹出层引用,无法搜索
|
||||
console.log(`OfferPie: [findOption] "${labelText}" 无弹出层引用,跳过`)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -124,7 +112,7 @@ function findVisibleListGroups(): Element[][] {
|
||||
const htmlContainer = container as HTMLElement
|
||||
if (htmlContainer.offsetHeight === 0 || htmlContainer.offsetWidth === 0) continue
|
||||
const group = findSameSpecChildren(container)
|
||||
if (group && group.length >= 3) { visited.add(container); results.push(group) }
|
||||
if (group && group.length >= 2) { visited.add(container); results.push(group) }
|
||||
}
|
||||
}
|
||||
results.sort((a, b) => b.length - a.length)
|
||||
@@ -145,8 +133,9 @@ function findSameSpecChildren(container: Element): Element[] | null {
|
||||
return null
|
||||
}
|
||||
|
||||
// 【注意】阈值为 ≥2,不可改回 ≥3——部分下拉列表只有2个选项(如性别),改大会漏匹配
|
||||
function groupBySpec(elements: Element[]): Element[] | null {
|
||||
if (elements.length < 3) return null
|
||||
if (elements.length < 2) return null
|
||||
const groups = new Map<string, Element[]>()
|
||||
for (const el of elements) {
|
||||
if ((el as HTMLElement).offsetHeight === 0 || (el as HTMLElement).offsetWidth === 0) continue
|
||||
@@ -158,7 +147,7 @@ function groupBySpec(elements: Element[]): Element[] | null {
|
||||
}
|
||||
let best: Element[] | null = null
|
||||
for (const [, group] of groups) {
|
||||
if (group.length >= 3 && (!best || group.length > best.length)) best = group
|
||||
if (group.length >= 2 && (!best || group.length > best.length)) best = group
|
||||
}
|
||||
return best
|
||||
}
|
||||
@@ -184,7 +173,11 @@ function clickItemAndParent(deepest: HTMLElement, item: Element) {
|
||||
/** 通用下拉列表检测与点击 */
|
||||
export function tryClickDropdownListItem(fillValue: string): boolean {
|
||||
const listGroups = findVisibleListGroups()
|
||||
for (const group of listGroups) {
|
||||
console.log(`OfferPie: [tryClickDropdownListItem] 找到 ${listGroups.length} 个同规格标签组, fillValue="${fillValue}"`)
|
||||
for (let gi = 0; gi < listGroups.length; gi++) {
|
||||
const group = listGroups[gi]
|
||||
const sampleText = group.slice(0, 3).map(el => (el.textContent?.trim() || "").substring(0, 20)).join(" | ")
|
||||
console.log(`OfferPie: 标签组[${gi}] ${group.length}项: "${sampleText}"...`)
|
||||
let bestMatch: { item: Element; text: string; deepest: HTMLElement; score: number } | null = null
|
||||
for (const item of group) {
|
||||
const deepest = findDeepestTextNode(item)
|
||||
|
||||
@@ -197,6 +197,8 @@ export interface FormLabelItem {
|
||||
section: ResumeSection
|
||||
/** 对应简历数据分区内的字段名 */
|
||||
resumeField: string
|
||||
/** 是否为接口异步下拉模式(输入后需等待接口返回数据再展示弹出层,如学校名称、专业名称) */
|
||||
isAsyncDropdown?: boolean
|
||||
}
|
||||
|
||||
// ============ UI 组件库配置类型 ============
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user