优化选择器操作逻辑,通用模式代码单独封装

This commit is contained in:
2026-06-11 18:38:49 +08:00
parent c00d9dbe00
commit e3a4ae2381
11 changed files with 931 additions and 585 deletions
+13 -429
View File
@@ -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 也脱离了 DOMReact 重新渲染导致),重新定位
// 这对于没有 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" />