优化选择器操作逻辑,通用模式代码单独封装
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user