经历部分填写逻辑优化

This commit is contained in:
xuxin
2026-05-12 21:21:59 +08:00
parent 888d4450f1
commit 84a2a3993a
6 changed files with 1971 additions and 387 deletions
+194 -36
View File
@@ -5,15 +5,15 @@
*/ */
import { useState } from "react" import { useState } from "react"
import { fillMatchedField, delay } from "~lib/autofill" import { fillMatchedField, delay, isSearchPickerField, fillSearchPickerField, isTimePeriodField, isTimeSingleField, fillTimePeriodField, fillTimeSingleField } from "~lib/autofill"
import { extractDomStructure, detectPageLanguage, isJobApplicationForm } from "~lib/dom" import { extractDomStructure, detectPageLanguage, isJobApplicationForm } from "~lib/dom"
import { matchFormFields } from "~lib/formMatcher" import { matchFormFields, matchFormFieldsInRange, matchMainFields } from "~lib/formMatcher"
import { detectPickerField } from "~lib/pickerDetector" import { detectPickerField } from "~lib/pickerDetector"
import { detectAndUploadResume } from "~lib/resumeUpload" import { detectAndUploadResume } from "~lib/resumeUpload"
import { getMockResumeData } from "~lib/constants" import { getMockResumeData } from "~lib/constants"
import { getResumeFieldValue } from "~lib/resumeDataHelper" import { getResumeFieldValue } from "~lib/resumeDataHelper"
import { locateExperienceSections, expandExperienceSections } from "~lib/experienceSection" import { locateExperienceSections, expandExperienceSections, sortExperienceByTime, relocateSegmentContainer } from "~lib/experienceSection"
import type { MatchedFormField, ResumeData } from "~lib/types" import type { MatchedFormField, ResumeData, ExperienceSection } from "~lib/types"
import "./SidebarPanel.scss" import "./SidebarPanel.scss"
/** 侧边栏面板的 Props */ /** 侧边栏面板的 Props */
@@ -74,50 +74,163 @@ export function SidebarPanel({ onClose }: SidebarPanelProps) {
const sectionResults = locateExperienceSections(document.body, lang) const sectionResults = locateExperienceSections(document.body, lang)
// 4.6 对比简历数据段数,点击添加按钮补足不够的段数 // 4.6 对比简历数据段数,点击添加按钮补足不够的段数
const expandedResults = await expandExperienceSections(sectionResults, currentResumeData, lang) const expandedResults = await expandExperienceSections(sectionResults, currentResumeData, lang)
// TODO: 后续实现步骤:
// 5. 对5种经历的标签关系数据做特殊标记(后续完善)
// 6. 所有经历段数添加完毕后,再进入统一的字段匹配和填写流程
// 5. 匹配表单字段 // 5. 经历数据按时间排序 + 经历区域字段匹配与填写(阶段A)
const fields = matchFormFields(document.body, lang) console.log("===== OfferPie: 阶段A - 经历区域填写 =====")
console.log(`===== OfferPie: 匹配到 ${fields.length} 个表单字段 =====`) const usedInputs = new Set<Element>() // 全局已使用的 input 集合
const excludeRanges: { start: Element; end: Element | null }[] = [] // 经历区域范围(用于阶段B排除)
// 6. 逐个:检测选择器 → 填充数据 → 立即填写(一个个来,避免多个选择器同时打开)
let success = 0, failed = 0, skipped = 0 let success = 0, failed = 0, skipped = 0
// 记录上一个非 picker 的输入框,用于每次填完后点击它来关闭残留弹窗
let lastTextInput: HTMLInputElement | HTMLTextAreaElement | null = null let lastTextInput: HTMLInputElement | HTMLTextAreaElement | null = null
let lastTextInputIsPicker = false
for (const f of fields) { for (const result of expandedResults) {
// 从简历数据中获取填写值(根据 section + sectionIndex + resumeField 定位) if (!result.titleElement || result.expandedCount === 0) continue
if (currentResumeData) { const section = result.section as ExperienceSection
const value = getResumeFieldValue(currentResumeData, f.section, f.sectionIndex, f.resumeField) 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 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 (!f.fillValue) { skipped++; continue }
// 检测选择器类型(会点击展开再关闭) if (activeContainer) {
await detectPickerField(f, lang) // 在重新定位的容器内,用 placeholder 找到对应的 input
// 【注意】排除已有值的 input(说明已被填过,可能是其他段重新渲染后的残留)
console.log( const freshInputs = activeContainer.querySelectorAll(
` [${f.key}] "${f.labelText}" → input: ${f.inputSelector || "未找到"}` + "input:not([type='hidden']):not([type='submit']):not([type='button']):not([type='checkbox']):not([type='radio']):not([type='file']):not([disabled]), textarea:not([disabled])"
` | type: ${f.inputType}` +
` | isPicker: ${f.isPicker}` +
` | pickerSelector: ${f.pickerDropdownSelector || "无"}` +
` | fillValue: "${f.fillValue}"`
) )
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) const ok = await fillMatchedField(f)
if (ok) { success++ } else { failed++ } if (ok) { success++ } else { failed++ }
}
// 每个字段填完后,点击上一个非 picker 输入框来关闭残留弹窗 // 关闭残留弹窗(只点击纯文本输入框,不点击选择器类型的 input)
if (lastTextInput) { if (lastTextInput && !lastTextInputIsPicker) {
;(lastTextInput as HTMLElement).click() ;(lastTextInput as HTMLElement).click()
lastTextInput.focus() lastTextInput.focus()
await delay(100) await delay(100)
lastTextInput.blur() lastTextInput.blur()
} else { } else {
// 兜底:只按 Escape 关闭弹窗,不点击任何元素(避免误触链接导致页面跳转)
if (document.activeElement instanceof HTMLElement) { if (document.activeElement instanceof HTMLElement) {
document.activeElement.dispatchEvent( document.activeElement.dispatchEvent(
new KeyboardEvent("keydown", { key: "Escape", code: "Escape", keyCode: 27, bubbles: true }) new KeyboardEvent("keydown", { key: "Escape", code: "Escape", keyCode: 27, bubbles: true })
@@ -128,14 +241,59 @@ export function SidebarPanel({ onClose }: SidebarPanelProps) {
} }
await delay(300) await delay(300)
// 更新上一个非 picker 输入框的记录 if (!f.isPicker && f.inputElement && !isTimePeriodField(f.key) && !isTimeSingleField(f.key)) {
if (!f.isPicker && f.inputElement) {
lastTextInput = f.inputElement lastTextInput = f.inputElement
lastTextInputIsPicker = false
} else {
lastTextInputIsPicker = true
} }
} }
}
}
console.log(`===== OfferPie: 阶段A完成 成功${success} 失败${failed} 跳过${skipped} =====`)
// 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.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(fields) setFormFields([...mainFields])
console.log(`===== OfferPie: 填写结果 成功${success} 失败${failed} 跳过${skipped} =====`) console.log(`===== OfferPie: 阶段B完成 总计成功${success} 失败${failed} 跳过${skipped} =====`)
} else { } else {
setFormFields([]) setFormFields([])
console.log("===== OfferPie: 当前页面不是职位申请表单,跳过字段匹配 =====") console.log("===== OfferPie: 当前页面不是职位申请表单,跳过字段匹配 =====")
@@ -151,7 +309,7 @@ export function SidebarPanel({ onClose }: SidebarPanelProps) {
<div className="op-container"> <div className="op-container">
{/* 顶部操作栏:反馈、设置、关闭按钮 */} {/* 顶部操作栏:反馈、设置、关闭按钮 */}
<div className="op-header"> <div className="op-header">
<span className="op-header-link">5</span> <span className="op-header-link">123456789</span>
<span className="op-header-link"></span> <span className="op-header-link"></span>
{/* 关闭按钮:点击后隐藏侧边栏 */} {/* 关闭按钮:点击后隐藏侧边栏 */}
<button className="op-close-btn" onClick={onClose}> <button className="op-close-btn" onClick={onClose}>
+457 -3
View File
@@ -17,15 +17,33 @@ export function delay(ms: number) {
/** /**
* 通用的值写入方法(适配各种前端框架的受控组件) * 通用的值写入方法(适配各种前端框架的受控组件)
* 优先用 execCommand 模拟真实输入,失败则 fallback 到原生 setter * 优先用 execCommand 模拟真实输入,失败则 fallback 到原生 setter
* 【改进】确保元素在视口内且真正获得焦点后再写入
*/ */
export function forceSetValue(el: HTMLInputElement | HTMLTextAreaElement, value: string) { export function forceSetValue(el: HTMLInputElement | HTMLTextAreaElement, value: string) {
if (el instanceof HTMLInputElement && el.type === "file") { if (el instanceof HTMLInputElement && el.type === "file") {
console.warn("OfferPie: 跳过 file 类型 input") console.warn("OfferPie: 跳过 file 类型 input")
return return
} }
// 确保元素在视口内(scrollIntoView
if (typeof el.scrollIntoView === "function") {
el.scrollIntoView({ block: "center", behavior: "instant" })
}
el.focus() el.focus()
// 确保 focus 生效
el.dispatchEvent(new FocusEvent("focus", { bubbles: true }))
el.dispatchEvent(new FocusEvent("focusin", { bubbles: true }))
if (el.value) el.select() if (el.value) el.select()
// 调试:记录写入前状态
const isConnected = el.isConnected
const isActiveEl = document.activeElement === el
console.log(`OfferPie: [forceSetValue] 写入前: isConnected=${isConnected} | isActiveElement=${isActiveEl} | placeholder="${el.getAttribute("placeholder")}" | 当前value="${el.value}"`)
const inserted = document.execCommand("insertText", false, value) const inserted = document.execCommand("insertText", false, value)
// 调试:记录 execCommand 结果
console.log(`OfferPie: [forceSetValue] execCommand结果: inserted=${inserted} | 写入后value="${el.value}" | 目标value="${value}"`)
if (!inserted || el.value !== value) { if (!inserted || el.value !== value) {
const isTextarea = el instanceof HTMLTextAreaElement const isTextarea = el instanceof HTMLTextAreaElement
const setter = Object.getOwnPropertyDescriptor( const setter = Object.getOwnPropertyDescriptor(
@@ -34,8 +52,8 @@ export function forceSetValue(el: HTMLInputElement | HTMLTextAreaElement, value:
setter?.call(el, value) setter?.call(el, value)
el.dispatchEvent(new InputEvent("input", { bubbles: true, cancelable: true, inputType: "insertText", data: value })) el.dispatchEvent(new InputEvent("input", { bubbles: true, cancelable: true, inputType: "insertText", data: value }))
el.dispatchEvent(new Event("change", { bubbles: true })) el.dispatchEvent(new Event("change", { bubbles: true }))
console.log(`OfferPie: [forceSetValue] fallback setter后value="${el.value}"`)
} }
el.dispatchEvent(new Event("blur", { bubbles: true }))
} }
/** 关闭弹出层 */ /** 关闭弹出层 */
@@ -57,7 +75,19 @@ export async function fillMatchedField(field: MatchedFormField): Promise<boolean
if (!fillValue) { console.warn(`OfferPie: ⏭ "${labelText}" 无填写值,跳过`); return false } if (!fillValue) { console.warn(`OfferPie: ⏭ "${labelText}" 无填写值,跳过`); return false }
try { try {
if (inputType === "radio" && radioContainer) return fillRadioField(radioContainer, labelText, fillValue) if (inputType === "radio" && radioContainer) return fillRadioField(radioContainer, labelText, fillValue)
if (!inputElement) { console.warn(`OfferPie: ❌ "${labelText}" 未找到 input 元素,跳过`); return false } if (!inputElement) {
// TODO: 【待完善】对于没有 input 的纯 div 选择器(如 Ant Design Select),
// 需要找到正确的点击方式触发下拉弹出层。
// 当前问题:Ant Design Select 的 .click() 和 mousedown 事件都无法触发展开,
// 可能需要模拟更完整的用户交互序列或找到组件内部的触发入口。
// 暂时跳过,后续完善。
if (isPicker && field.labelElement) {
console.log(`OfferPie: ⏭ [picker无input] "${labelText}" 暂不支持纯 div 选择器,跳过 (TODO)`)
return false
}
console.warn(`OfferPie: ❌ "${labelText}" 未找到 input 元素,跳过`)
return false
}
if (inputElement instanceof HTMLInputElement && inputElement.type === "file") { console.warn(`OfferPie: ⏭ "${labelText}" 是文件上传框,跳过`); return false } if (inputElement instanceof HTMLInputElement && inputElement.type === "file") { console.warn(`OfferPie: ⏭ "${labelText}" 是文件上传框,跳过`); return false }
if (isPicker) return await fillPickerField(field) if (isPicker) return await fillPickerField(field)
inputElement.focus() inputElement.focus()
@@ -109,7 +139,7 @@ function fillRadioField(container: Element, labelText: string, fillValue: string
async function fillPickerField(field: MatchedFormField): Promise<boolean> { async function fillPickerField(field: MatchedFormField): Promise<boolean> {
const { labelText, inputElement, fillValue } = field const { labelText, inputElement, fillValue } = field
if (!inputElement) return false if (!inputElement) return false
const isDateValue = /^\d{4}-\d{2}-\d{2}$/.test(fillValue) const isDateValue = /^\d{4}[.\-\/]\d{1,2}([.\-\/]\d{1,2})?$/.test(fillValue)
// ---- 步骤1:记录点击前的 DOM 快照 ---- // ---- 步骤1:记录点击前的 DOM 快照 ----
const POPUP_SELECTORS = [ const POPUP_SELECTORS = [
@@ -237,3 +267,427 @@ export async function fillAllFields(fields: MatchedFormField[]): Promise<{ total
console.log(`===== OfferPie: 自动填写完毕 总计 ${result.total} | 成功 ${result.success} | 失败 ${result.failed} | 跳过 ${result.skipped} =====`) console.log(`===== OfferPie: 自动填写完毕 总计 ${result.total} | 成功 ${result.success} | 失败 ${result.failed} | 跳过 ${result.skipped} =====`)
return result return result
} }
// ============ 学校搜索型选择器特殊处理 ============
/** 需要走搜索型选择器逻辑的字段 key 列表 */
const SEARCH_PICKER_KEYS = ["school", "major"]
/**
* 判断字段是否需要走搜索型选择器逻辑
*/
export function isSearchPickerField(key: string): boolean {
return SEARCH_PICKER_KEYS.includes(key)
}
/**
* 填写搜索型选择器字段(如学校名称)
*
* 【改进】先检测是否真的有搜索下拉行为:
* 1. 先用 forceSetValue 输入值
* 2. 等待短暂时间后检测是否有弹出层出现
* 3. 如果有弹出层 → 走搜索选择器逻辑(在弹出层中找匹配选项)
* 4. 如果没有弹出层 → 说明是普通输入框,值已经写入,直接返回成功
*/
export async function fillSearchPickerField(field: MatchedFormField): Promise<boolean> {
const { labelText, inputElement, fillValue } = field
if (!inputElement || !fillValue) return false
console.log(`OfferPie: [搜索选择器] "${labelText}" 开始填写 "${fillValue}"`)
// 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)
})
}
// 2. 点击 input 获取焦点并输入值
inputElement.focus()
;(inputElement as HTMLElement).click()
await delay(200)
forceSetValue(inputElement, fillValue)
await delay(800)
// 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)) {
hasNewPopup = true
break
}
}
if (hasNewPopup) break
}
}
// 4. 如果没有弹出层 → 普通输入框,值已写入,直接成功
if (!hasNewPopup) {
// 调试:验证值是否真的写入了
console.log(`OfferPie: [搜索选择器→普通输入] "${labelText}" 写入后验证: value="${inputElement.value}" | 期望="${fillValue}"`)
console.log(`OfferPie: ✅ [搜索选择器→普通输入] "${labelText}" 无弹出层,已直接填写 "${fillValue}"`)
inputElement.dispatchEvent(new Event("blur", { bubbles: true }))
// 调试:blur 后再验证
await delay(50)
console.log(`OfferPie: [搜索选择器→普通输入] "${labelText}" blur后验证: value="${inputElement.value}"`)
return true
}
// 5. 有弹出层 → 走搜索选择器逻辑,等待搜索结果
await delay(500)
// 在弹出层中查找匹配选项
const { findAndClickOptionInVisiblePopups } = await import("./pickerFill")
if (findAndClickOptionInVisiblePopups(fillValue, labelText)) {
await delay(300)
await closePopup(inputElement)
console.log(`OfferPie: ✅ [搜索选择器] 已选择 "${labelText}" = "${fillValue}"`)
return true
}
// 6. 再等一会重试(有些网站接口慢)
await delay(600)
if (findAndClickOptionInVisiblePopups(fillValue, labelText)) {
await delay(300)
await closePopup(inputElement)
console.log(`OfferPie: ✅ [搜索选择器] 已选择 "${labelText}" = "${fillValue}"(重试)`)
return true
}
// 7. 兜底:关闭弹出层,保留输入的值
await closePopup(inputElement)
console.log(`OfferPie: ⚠️ [搜索选择器] "${labelText}" 未找到匹配选项,已兜底保留输入值 "${fillValue}"`)
return true
}
// ============ 经历时间选择器特殊处理 ============
/** 需要走时间选择器特殊逻辑的字段 key 列表(合并式起止时间) */
const TIME_PERIOD_KEYS = ["eduPeriod", "internPeriod", "workPeriod", "projectPeriod"]
/** 需要走时间选择器特殊逻辑的字段 key 列表(单独开始/结束时间) */
const TIME_SINGLE_KEYS = ["eduStartDate", "eduEndDate", "internStartDate", "internEndDate", "workStartDate", "workEndDate", "projectStartDate", "projectEndDate"]
/**
* 判断字段是否为时间段字段(合并式起止时间)
*/
export function isTimePeriodField(key: string): boolean {
return TIME_PERIOD_KEYS.includes(key)
}
/**
* 判断字段是否为单独时间字段(开始时间或结束时间)
*/
export function isTimeSingleField(key: string): boolean {
return TIME_SINGLE_KEYS.includes(key)
}
/**
* 从日期字符串中提取年和月
* 支持格式:2025.09、2025-09、2025-09-01、2025/09
*/
function extractYearMonth(dateStr: string): { year: string; month: string } {
const match = dateStr.match(/(\d{4})[.\-/](\d{1,2})/)
if (match) {
return { year: match[1], month: String(parseInt(match[2])) }
}
// 只有年份
const yearMatch = dateStr.match(/(\d{4})/)
if (yearMatch) {
return { year: yearMatch[1], month: "" }
}
return { year: "", month: "" }
}
/**
* 填写单个年或月的下拉选择器
* 逻辑:点击 input 展开下拉 → 等待弹出层出现 → 在弹出层中精确匹配年/月选项 → 点击选中
*/
async function fillYearMonthPicker(inputEl: HTMLInputElement | HTMLTextAreaElement, value: string): Promise<boolean> {
if (!value) return false
const { clickBestOptionInDropdown } = await import("./pickerFill")
// 记录点击前的 DOM 快照(用于检测新出现的弹出层)
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)
})
}
// 点击 input 展开下拉
inputEl.focus()
;(inputEl as HTMLElement).click()
await delay(300)
// 检测新出现的弹出层
let dropdownEl: HTMLElement | null = null
// 策略A:body 下新增的直接子元素
for (const child of Array.from(document.body.children)) {
if (!beforeBodyChildren.has(child) && (child as HTMLElement).offsetHeight > 10) {
dropdownEl = child as HTMLElement
break
}
}
// 策略B:新变为可见的弹出层
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
}
}
if (dropdownEl) break
}
}
if (dropdownEl) {
// 在弹出层中精确匹配选项并点击
if (clickBestOptionInDropdown(dropdownEl, value)) {
await delay(200)
return true
}
}
// 兜底:直接写入值
forceSetValue(inputEl, value)
await delay(100)
return true
}
/**
* 填写合并式起止时间字段("就读时间"/"起止时间"后面跟4个年月年月输入框)
*
* 检测逻辑:
* 1. 从标签元素往后找所有 placeholder 为"年"或"月"的 input
* 2. 如果找到4个(年月年月),按顺序填入开始年、开始月、结束年、结束月
* 3. 如果找到2个或更少,走集成式时间选择器逻辑
*
* @param field - 匹配到的字段(labelElement 是"就读时间"/"起止时间"标签)
* @param startDate - 开始时间字符串(如 "2025.09"
* @param endDate - 结束时间字符串(如 "2026.12"
* @param usedInputs - 已使用的 input 集合
*/
export async function fillTimePeriodField(
field: MatchedFormField,
startDate: string,
endDate: string,
usedInputs: Set<Element>
): Promise<boolean> {
const { labelElement } = field
if (!labelElement) return false
// 从标签元素往后找 placeholder 为"年"或"月"的 input
const allInputs = document.body.querySelectorAll("input")
const yearMonthInputs: HTMLInputElement[] = []
for (const input of Array.from(allInputs)) {
// 必须在标签元素之后
if (!(labelElement.compareDocumentPosition(input) & Node.DOCUMENT_POSITION_FOLLOWING)) continue
const ph = input.getAttribute("placeholder")?.trim() || ""
if (ph === "年" || ph === "月") {
yearMonthInputs.push(input)
if (yearMonthInputs.length >= 4) break // 最多找4个
} else if (yearMonthInputs.length > 0) {
// 遇到非年月的 input 就停止(说明已经超出这个时间区域了)
break
}
}
console.log(`OfferPie: [时间选择器] "${field.labelText}" 找到 ${yearMonthInputs.length} 个年月输入框`)
if (yearMonthInputs.length >= 4) {
// 情况A1:4个年月年月下拉选择器
const start = extractYearMonth(startDate)
const end = extractYearMonth(endDate)
// 依次填入:开始年、开始月、结束年、结束月
if (start.year) {
await fillYearMonthPicker(yearMonthInputs[0], start.year)
usedInputs.add(yearMonthInputs[0])
}
if (start.month) {
await fillYearMonthPicker(yearMonthInputs[1], start.month)
usedInputs.add(yearMonthInputs[1])
}
if (end.year) {
await fillYearMonthPicker(yearMonthInputs[2], end.year)
usedInputs.add(yearMonthInputs[2])
}
if (end.month) {
await fillYearMonthPicker(yearMonthInputs[3], end.month)
usedInputs.add(yearMonthInputs[3])
}
await delay(200)
// 点击空白关闭弹窗
document.body.click()
await delay(100)
console.log(`OfferPie: ✅ [时间选择器] "${field.labelText}" 已填写 ${start.year}.${start.month} - ${end.year}.${end.month}`)
return true
} else if (yearMonthInputs.length >= 2) {
// 可能是2个年月(只有开始时间的年月),或者其他情况
const start = extractYearMonth(startDate)
if (start.year) {
await fillYearMonthPicker(yearMonthInputs[0], start.year)
usedInputs.add(yearMonthInputs[0])
}
if (start.month) {
await fillYearMonthPicker(yearMonthInputs[1], start.month)
usedInputs.add(yearMonthInputs[1])
}
document.body.click()
await delay(100)
console.log(`OfferPie: ✅ [时间选择器] "${field.labelText}" 已填写 ${start.year}.${start.month}`)
return true
}
// 情况A2:没有年月输入框,检测是否有两个并排的集成式时间选择器 input
// (开始时间 input + 结束时间 input,中间可能有"至"/"到"/"-"等分隔符)
// 从已匹配到的 field.inputElement 开始,往后找下一个最近的 input 作为结束时间
const startInput = field.inputElement
if (startInput && startDate) {
console.log(`OfferPie: [时间选择器] "${field.labelText}" 检测到集成式起止时间,尝试填写开始+结束时间`)
// 填写开始时间(用 field.inputElement
const { fillDatePicker } = await import("./datePicker")
const startField: MatchedFormField = {
...field,
inputElement: startInput,
fillValue: startDate,
isPicker: true,
}
startInput.focus()
;(startInput as HTMLElement).click()
await delay(300)
const startOk = await fillDatePicker(startField)
if (startOk) {
usedInputs.add(startInput)
console.log(`OfferPie: [时间选择器] "${field.labelText}" 开始时间填写成功: ${startDate}`)
}
await delay(500)
// 从开始时间 input 往后找最近的下一个 input 作为结束时间
if (endDate) {
const INPUT_SEL_PERIOD = "input:not([type='hidden']):not([type='submit']):not([type='button']):not([type='checkbox']):not([type='radio']):not([type='file']):not([disabled])"
const allPageInputs = document.body.querySelectorAll(INPUT_SEL_PERIOD)
let endInput: HTMLInputElement | null = null
for (const inp of Array.from(allPageInputs)) {
// 必须在开始时间 input 之后
if (!(startInput.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING)) continue
if (usedInputs.has(inp)) continue
endInput = inp as HTMLInputElement
break // 取最近的第一个
}
if (endInput) {
const endField: MatchedFormField = {
...field,
inputElement: endInput,
fillValue: endDate,
isPicker: true,
}
endInput.focus()
;(endInput as HTMLElement).click()
await delay(300)
const endOk = await fillDatePicker(endField)
if (endOk) {
usedInputs.add(endInput)
console.log(`OfferPie: [时间选择器] "${field.labelText}" 结束时间填写成功: ${endDate}`)
}
} else {
console.log(`OfferPie: [时间选择器] "${field.labelText}" 未找到结束时间 input`)
}
}
console.log(`OfferPie: ✅ [时间选择器] "${field.labelText}" 已填写 ${startDate} - ${endDate}`)
return true
}
// 情况A3:没有 inputElement,走集成式时间选择器逻辑(由外部 detectPickerField + fillMatchedField 处理)
return false
}
/**
* 填写单独时间字段("开始时间"或"结束时间"后面跟年月输入框或集成式选择器)
*
* @param field - 匹配到的字段
* @param dateStr - 时间字符串(如 "2025.09"
* @param usedInputs - 已使用的 input 集合
*/
export async function fillTimeSingleField(
field: MatchedFormField,
dateStr: string,
usedInputs: Set<Element>
): Promise<boolean> {
const { labelElement } = field
if (!labelElement || !dateStr) return false
// 从标签元素往后找 placeholder 为"年"或"月"的 input
const allInputs = document.body.querySelectorAll("input")
const yearMonthInputs: HTMLInputElement[] = []
for (const input of Array.from(allInputs)) {
if (!(labelElement.compareDocumentPosition(input) & Node.DOCUMENT_POSITION_FOLLOWING)) continue
const ph = input.getAttribute("placeholder")?.trim() || ""
if (ph === "年" || ph === "月") {
yearMonthInputs.push(input)
if (yearMonthInputs.length >= 2) break
} else if (yearMonthInputs.length > 0) {
break
}
}
if (yearMonthInputs.length >= 2) {
// 情况B1:年月分开的下拉选择器
const { year, month } = extractYearMonth(dateStr)
if (year) {
await fillYearMonthPicker(yearMonthInputs[0], year)
usedInputs.add(yearMonthInputs[0])
}
if (month) {
await fillYearMonthPicker(yearMonthInputs[1], month)
usedInputs.add(yearMonthInputs[1])
}
document.body.click()
await delay(100)
console.log(`OfferPie: ✅ [时间选择器] "${field.labelText}" 已填写 ${year}.${month}`)
return true
}
// 情况B2:没有年月输入框,走集成式时间选择器逻辑(由外部处理)
return false
}
+33 -29
View File
@@ -46,6 +46,7 @@ export const JOB_FORM_LABELS: FormLabelItem[] = [
{ key: "degree", zh: ["学历", "学位", "最高学历", "最高学位"], en: ["Degree", "Education Level", "Qualification", "Education"], section: "education", resumeField: "degree" }, { 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" }, { key: "studyType", zh: ["学习形式", "就读方式", "全日制/非全日制", "学习方式"], en: ["Study Type", "Study Mode", "Full-time/Part-time"], section: "education", resumeField: "studyType" },
{ key: "graduationDate", zh: ["毕业时间", "毕业日期", "毕业年份"], en: ["Graduation Date", "Graduation Year", "Expected Graduation"], section: "education", resumeField: "endDate" }, { key: "graduationDate", zh: ["毕业时间", "毕业日期", "毕业年份"], en: ["Graduation Date", "Graduation Year", "Expected Graduation"], section: "education", resumeField: "endDate" },
{ key: "eduPeriod", zh: ["就读时间", "就读期间", "起止时间"], en: ["Study Period", "Duration", "Period"], section: "education", resumeField: "startDate,endDate" },
{ key: "eduStartDate", zh: ["入学时间", "开始时间"], en: ["Start Date", "Enrollment Date", "From"], section: "education", resumeField: "startDate" }, { key: "eduStartDate", zh: ["入学时间", "开始时间"], en: ["Start Date", "Enrollment Date", "From"], section: "education", resumeField: "startDate" },
{ key: "eduEndDate", zh: ["结束时间", "毕业时间"], en: ["End Date", "To", "Until"], section: "education", resumeField: "endDate" }, { key: "eduEndDate", zh: ["结束时间", "毕业时间"], en: ["End Date", "To", "Until"], section: "education", resumeField: "endDate" },
{ key: "eduDescription", zh: ["在校经历", "教育描述", "在校描述"], en: ["Education Description", "Academic Description"], section: "education", resumeField: "description" }, { key: "eduDescription", zh: ["在校经历", "教育描述", "在校描述"], en: ["Education Description", "Academic Description"], section: "education", resumeField: "description" },
@@ -64,21 +65,24 @@ export const JOB_FORM_LABELS: FormLabelItem[] = [
{ key: "workCompanyShort", zh: ["公司简称"], en: ["Company Abbreviation", "Short Name"], section: "work", resumeField: "" }, { key: "workCompanyShort", zh: ["公司简称"], en: ["Company Abbreviation", "Short Name"], section: "work", resumeField: "" },
{ key: "workCompanyScale", zh: ["公司规模"], en: ["Company Size", "Company Scale"], section: "work", resumeField: "" }, { key: "workCompanyScale", zh: ["公司规模"], en: ["Company Size", "Company Scale"], section: "work", resumeField: "" },
{ key: "workPosition", zh: ["职位", "职位名称", "岗位", "担任职务", "职位名称"], en: ["Job Title", "Position", "Title", "Role"], section: "work", resumeField: "position" }, { key: "workPosition", zh: ["职位", "职位名称", "岗位", "担任职务", "职位名称"], en: ["Job Title", "Position", "Title", "Role"], section: "work", resumeField: "position" },
{ key: "workPeriod", zh: ["起止时间", "在职时间", "就职时间"], en: ["Period", "Duration", "Employment Period"], section: "work", resumeField: "startDate,endDate" },
{ key: "workStartDate", zh: ["开始时间", "入职时间"], en: ["Start Date", "From"], section: "work", resumeField: "startDate" }, { key: "workStartDate", zh: ["开始时间", "入职时间"], en: ["Start Date", "From"], section: "work", resumeField: "startDate" },
{ key: "workEndDate", zh: ["结束时间", "离职时间"], en: ["End Date", "To"], section: "work", resumeField: "endDate" }, { key: "workEndDate", zh: ["结束时间", "离职时间"], en: ["End Date", "To"], section: "work", resumeField: "endDate" },
{ key: "workDescription", zh: ["工作描述", "工作内容", "职责描述", "岗位职责", "职位描述"], en: ["Job Description", "Responsibilities", "Description", "Duties"], section: "work", resumeField: "description" }, { key: "workDescription", zh: ["工作描述", "工作内容", "职责描述", "岗位职责", "职位描述"], en: ["Job Description", "Responsibilities", "Description", "Duties"], section: "work", resumeField: "description" },
// ---- 实习经历(数组) ---- // ---- 实习经历(数组) ----
{ key: "internCompany", zh: ["实习公司", "实习单位"], en: ["Intern Company", "Internship Company"], section: "internship", resumeField: "companyName" }, { key: "internCompany", zh: ["实习公司", "实习单位"], en: ["Intern Company", "Internship Company"], section: "internship", resumeField: "companyName" },
{ key: "internPosition", zh: ["实习职位", "实习岗位"], en: ["Intern Position", "Internship Role"], section: "internship", resumeField: "position" }, { key: "internPosition", zh: ["实习职位", "实习岗位"], en: ["Intern Position", "Internship Role"], section: "internship", resumeField: "position" },
{ key: "internStartDate", zh: ["实习开始时间"], en: ["Internship Start Date"], section: "internship", resumeField: "startDate" }, { key: "internPeriod", zh: ["起止时间", "实习时间", "就职时间"], en: ["Period", "Duration", "Employment Period"], section: "internship", resumeField: "startDate,endDate" },
{ key: "internEndDate", zh: ["实习结束时间"], en: ["Internship End Date"], section: "internship", resumeField: "endDate" }, { key: "internStartDate", zh: ["实习开始时间", "开始时间"], en: ["Internship Start Date", "Start Date", "From"], section: "internship", resumeField: "startDate" },
{ key: "internDescription", zh: ["实习描述", "实习内容"], en: ["Internship Description", "Intern Duties"], section: "internship", resumeField: "description" }, { key: "internEndDate", zh: ["实习结束时间", "结束时间"], en: ["Internship End Date", "End Date", "To"], section: "internship", resumeField: "endDate" },
{ key: "internDescription", zh: ["实习描述", "实习内容","工作描述", "工作职责", "工作内容", "职责描述", "岗位职责", "职位描述"], en: ["Internship Description", "Intern Duties"], section: "internship", resumeField: "description" },
// ---- 项目经历(数组) ---- // ---- 项目经历(数组) ----
{ key: "projectName", zh: ["项目名称", "项目名", "项目", "项目名称"], en: ["Project Name", "Project", "Project Title"], section: "project", resumeField: "projectName" }, { key: "projectName", zh: ["项目名称", "项目名", "项目", "项目名称"], en: ["Project Name", "Project", "Project Title"], section: "project", resumeField: "projectName" },
{ key: "projectCompany", zh: ["所属公司", "项目所属公司", "项目公司"], en: ["Project Company", "Company"], section: "project", resumeField: "companyName" }, { key: "projectCompany", zh: ["所属公司", "项目所属公司", "项目公司"], en: ["Project Company", "Company"], section: "project", resumeField: "companyName" },
{ key: "projectRole", zh: ["担任角色", "项目角色", "角色"], en: ["Role", "Project Role", "Your Role"], section: "project", resumeField: "role" }, { key: "projectRole", zh: ["担任角色", "项目角色", "角色"], en: ["Role", "Project Role", "Your Role"], section: "project", resumeField: "role" },
{ key: "projectStartDate", zh: ["项目开始时间"], en: ["Project Start Date"], section: "project", resumeField: "startDate" }, { key: "projectPeriod", zh: ["起止时间", "项目时间"], en: ["Period", "Duration", "Project Period"], section: "project", resumeField: "startDate,endDate" },
{ key: "projectEndDate", zh: ["项目结束时间"], en: ["Project End Date"], section: "project", resumeField: "endDate" }, { key: "projectStartDate", zh: ["项目开始时间", "开始时间"], en: ["Project Start Date", "Start Date", "From"], section: "project", resumeField: "startDate" },
{ key: "projectEndDate", zh: ["项目结束时间", "结束时间"], en: ["Project End Date", "End Date", "To"], section: "project", resumeField: "endDate" },
{ key: "projectDescription", zh: ["项目描述", "项目内容", "项目职责"], en: ["Project Description", "Project Details", "Project Responsibilities"], section: "project", resumeField: "description" }, { key: "projectDescription", zh: ["项目描述", "项目内容", "项目职责"], en: ["Project Description", "Project Details", "Project Responsibilities"], section: "project", resumeField: "description" },
// ---- 竞赛经历(数组) ---- // ---- 竞赛经历(数组) ----
{ key: "competitionName", zh: ["竞赛名称", "比赛名称", "竞赛"], en: ["Competition Name", "Contest Name", "Competition"], section: "competition", resumeField: "competitionName" }, { key: "competitionName", zh: ["竞赛名称", "比赛名称", "竞赛"], en: ["Competition Name", "Contest Name", "Competition"], section: "competition", resumeField: "competitionName" },
@@ -316,8 +320,8 @@ export function getMockResumeData(): ResumeData {
major: "人工智能", major: "人工智能",
degree: "本科", degree: "本科",
studyType: "统招", studyType: "统招",
startDate: "2021.09", startDate: "2017.09",
endDate: "2025.07", endDate: "2021.07",
description: [ description: [
{ type: "text", content: "大一:高等数学、Python程序设计、面向对象程序设计、计算机体系结构、线性代数、离散数学等,培养严谨的逻辑思维能力" }, { type: "text", content: "大一:高等数学、Python程序设计、面向对象程序设计、计算机体系结构、线性代数、离散数学等,培养严谨的逻辑思维能力" },
{ type: "text", content: "大二:Java、软件工程导论、概率论与数理统计、数据结构与算法、Web开发、数据库原理、操作系统等,具备数据管理与分析基础" }, { type: "text", content: "大二:Java、软件工程导论、概率论与数理统计、数据结构与算法、Web开发、数据库原理、操作系统等,具备数据管理与分析基础" },
@@ -330,48 +334,48 @@ export function getMockResumeData(): ResumeData {
internship: [ internship: [
{ {
id: "EoNkjAF1", id: "EoNkjAF1",
companyName: "深圳乐动机器人科技有限公司", companyName: "深圳乐动机器人科技有限公司4",
position: "机器人感知算法工程师", position: "机器人感知算法工程师",
startDate: "2024.05", startDate: "2024.05",
endDate: "2024.08", endDate: "2025.08",
description: [ description: [
{ type: "text", content: "负责数据收集、分析、模型训练与测试、算法部署等全流程工作,注重数据准确性与细节管理;" }, { type: "text", content: "4负责数据收集、分析、模型训练与测试、算法部署等全流程工作,注重数据准确性与细节管理;" },
{ type: "text", content: "参与割草机算法研发工作,包括分类、检测、分割、跟踪等模块,具备快速学习新技术领域的能力;" }, { type: "text", content: "参与割草机算法研发工作,包括分类、检测、分割、跟踪等模块,具备快速学习新技术领域的能力;" },
{ type: "text", content: "撰写技术文档与测试报告,进行跨部门沟通协作,确保项目按时交付并符合质量标准。" }, { type: "text", content: "撰写技术文档与测试报告,进行跨部门沟通协作,确保项目按时交付并符合质量标准。" },
], ],
}, },
{ {
id: "cX5EGRDD", id: "cX5EGRDD",
companyName: "江西卓云深圳研发中心", companyName: "江西卓云深圳研发中心2",
position: "图像算法工程师", position: "图像算法工程师",
startDate: "2023.12", startDate: "2020.12",
endDate: "2024.02", endDate: "2021.02",
description: [ description: [
{ type: "text", content: "运用Python进行数据处理与分析,开发部署应用程序,具备较强的逻辑分析和数据整理能力;" }, { type: "text", content: "2运用Python进行数据处理与分析,开发部署应用程序,具备较强的逻辑分析和数据整理能力;" },
{ type: "text", content: "独立完成项目全生命周期管理,包括数据收集、标注、模型训练、部署及性能调优,能应对重复性工作并保证准确性;" }, { type: "text", content: "独立完成项目全生命周期管理,包括数据收集、标注、模型训练、部署及性能调优,能应对重复性工作并保证准确性;" },
{ type: "text", content: "熟练掌握Office办公软件及数据库工具,具备文献检索与信息整理经验,工作态度认真负责。" }, { type: "text", content: "熟练掌握Office办公软件及数据库工具,具备文献检索与信息整理经验,工作态度认真负责。" },
], ],
}, },
{ {
id: "EoNkjAF2", id: "EoNkjAF2",
companyName: "深圳乐动机器人科技有限公司2", companyName: "深圳乐动机器人科技有限公司3",
position: "机器人感知算法工程师", position: "机器人感知算法工程师",
startDate: "2024.05", startDate: "2022.05",
endDate: "2024.08", endDate: "2023.08",
description: [ description: [
{ type: "text", content: "负责数据收集、分析、模型训练与测试、算法部署等全流程工作,注重数据准确性与细节管理;" }, { type: "text", content: "3负责数据收集、分析、模型训练与测试、算法部署等全流程工作,注重数据准确性与细节管理;" },
{ type: "text", content: "参与割草机算法研发工作,包括分类、检测、分割、跟踪等模块,具备快速学习新技术领域的能力;" }, { type: "text", content: "参与割草机算法研发工作,包括分类、检测、分割、跟踪等模块,具备快速学习新技术领域的能力;" },
{ type: "text", content: "撰写技术文档与测试报告,进行跨部门沟通协作,确保项目按时交付并符合质量标准。" }, { type: "text", content: "撰写技术文档与测试报告,进行跨部门沟通协作,确保项目按时交付并符合质量标准。" },
], ],
}, },
{ {
id: "cX5EGRDA", id: "mX5EGRDA",
companyName: "江西卓云深圳研发中心2", companyName: "江西卓云深圳研发中心1",
position: "图像算法工程师", position: "图像算法工程师",
startDate: "2023.12", startDate: "2018.12",
endDate: "2024.02", endDate: "2019.02",
description: [ description: [
{ type: "text", content: "运用Python进行数据处理与分析,开发部署应用程序,具备较强的逻辑分析和数据整理能力;" }, { type: "text", content: "1运用Python进行数据处理与分析,开发部署应用程序,具备较强的逻辑分析和数据整理能力;" },
{ type: "text", content: "独立完成项目全生命周期管理,包括数据收集、标注、模型训练、部署及性能调优,能应对重复性工作并保证准确性;" }, { type: "text", content: "独立完成项目全生命周期管理,包括数据收集、标注、模型训练、部署及性能调优,能应对重复性工作并保证准确性;" },
{ type: "text", content: "熟练掌握Office办公软件及数据库工具,具备文献检索与信息整理经验,工作态度认真负责。" }, { type: "text", content: "熟练掌握Office办公软件及数据库工具,具备文献检索与信息整理经验,工作态度认真负责。" },
], ],
@@ -382,9 +386,9 @@ export function getMockResumeData(): ResumeData {
id: "q966EOx2", id: "q966EOx2",
companyName: "", companyName: "",
projectName: "微信小程序开发与测试", projectName: "微信小程序开发与测试",
role: "", role: "小程序工程师",
startDate: "2022.01", startDate: "2022.01",
endDate: "", endDate: "2023.01",
description: [ description: [
{ type: "text", content: "负责小程序功能模块的开发与测试,严格遵循开发规范和质量标准,确保系统稳定运行" }, { type: "text", content: "负责小程序功能模块的开发与测试,严格遵循开发规范和质量标准,确保系统稳定运行" },
{ type: "text", content: "参与需求分析与文档撰写工作,具备良好的书面表达能力和跨部门沟通协调能力" }, { type: "text", content: "参与需求分析与文档撰写工作,具备良好的书面表达能力和跨部门沟通协调能力" },
@@ -395,9 +399,9 @@ export function getMockResumeData(): ResumeData {
id: "KVSEaywu", id: "KVSEaywu",
companyName: "", companyName: "",
projectName: "多人游戏聊天网页开发", projectName: "多人游戏聊天网页开发",
role: "", role: "前端工程师",
startDate: "2022.08", startDate: "2022.08",
endDate: "", endDate: "2023.03",
description: [ description: [
{ type: "text", content: "负责网页功能开发与用户交互模块设计,具备快速学习新技术和解决问题的能力" }, { type: "text", content: "负责网页功能开发与用户交互模块设计,具备快速学习新技术和解决问题的能力" },
{ type: "text", content: "熟练使用Office等办公软件进行项目文档管理和数据整理工作" }, { type: "text", content: "熟练使用Office等办公软件进行项目文档管理和数据整理工作" },
@@ -408,9 +412,9 @@ export function getMockResumeData(): ResumeData {
id: "KQtH64TE", id: "KQtH64TE",
companyName: "", companyName: "",
projectName: "基于SEIR模型的疫情大数据追踪预测应用研究", projectName: "基于SEIR模型的疫情大数据追踪预测应用研究",
role: "", role: "工程师",
startDate: "2022.12", startDate: "2022.12",
endDate: "", endDate: "2023.08",
description: [ description: [
{ type: "text", content: "参与疫情数据监测与分析项目,运用数据模型进行风险评估和趋势预测" }, { type: "text", content: "参与疫情数据监测与分析项目,运用数据模型进行风险评估和趋势预测" },
{ type: "text", content: "负责大量数据的收集、整理与分析工作,具备较强的逻辑分析能力和数据处理能力" }, { type: "text", content: "负责大量数据的收集、整理与分析工作,具备较强的逻辑分析能力和数据处理能力" },
+256 -3
View File
@@ -1,5 +1,5 @@
/** /**
* 日期选择器填写模块 * 日期选择器填写模块(支持年月日或年月的时间选择)
* 负责:通用日期选择器的日历面板操作 * 负责:通用日期选择器的日历面板操作
* *
* 核心思路: * 核心思路:
@@ -785,6 +785,231 @@ async function navigateToYearMonth(
return finalYear === targetYear && finalMonth === targetMonth return finalYear === targetYear && finalMonth === targetMonth
} }
// ============ 月份面板检测与点击 ============
/**
* 检测当前弹出的是否为月份选择面板,如果是则导航年份并点击目标月份
*
* 月份面板特征:
* - 包含12个格子,文字为 "一月"~"十二月" 或 "1月"~"12月" 或 "Jan"~"Dec"
* - 有年份显示和前后翻页按钮
* - 没有星期行和1~31的日期格子
*
* 【注意】复用 detectNavButtons 的按钮探测逻辑(点击观察年份变化)来找年份加减按钮
*/
async function tryFillMonthPanel(
targetYear: number,
targetMonth: number,
labelText: string,
fillValue: string,
inputElement: HTMLElement
): Promise<boolean> {
// 在页面中查找可见的月份面板
const panelSelectors = [
'[class*="month-panel"]', '[class*="month-picker"]',
'[class*="picker-panel"]', '[class*="calendar-month"]',
'[class*="picker-body"]', '[class*="picker-content"]',
'[class*="popover"]', '[class*="popper"]', '[class*="popup"]',
'[class*="dropdown"]', '[class*="overlay"]',
]
let monthPanel: HTMLElement | null = null
let monthCells: HTMLElement[] = []
for (const sel of panelSelectors) {
const panels = document.querySelectorAll(sel)
for (const panel of Array.from(panels)) {
const htmlPanel = panel as HTMLElement
if (!isVisible(htmlPanel)) continue
const cells = findMonthCells(htmlPanel)
if (cells.length >= 10) {
monthPanel = htmlPanel
monthCells = cells
break
}
}
if (monthPanel) break
}
// 兜底:在 body 直接子元素中找
if (!monthPanel) {
for (const child of Array.from(document.body.children)) {
const htmlChild = child as HTMLElement
if (!isVisible(htmlChild) || htmlChild.offsetHeight < 50) continue
const cells = findMonthCells(htmlChild)
if (cells.length >= 10) {
monthPanel = htmlChild
monthCells = cells
break
}
}
}
if (!monthPanel || monthCells.length === 0) {
console.log(`OfferPie: [datePicker] 未检测到月份面板`)
return false
}
console.log(`OfferPie: [datePicker] ✅ 检测到月份面板,共 ${monthCells.length} 个月份格子`)
// ---- 分析月份面板头部,找到年份显示和候选按钮 ----
// 月份格子区域作为边界(头部在月份格子之上)
const monthGridArea = monthCells[0].closest("table, tbody, [class*='body'], [class*='content'], div") as HTMLElement || monthCells[0].parentElement as HTMLElement
// 用 analyzeHeaderArea 分析头部(复用已有逻辑)
const headerInfo = analyzeHeaderArea(monthPanel, monthGridArea)
const currentYear = headerInfo.currentYear
console.log(`OfferPie: [datePicker] 月份面板当前年份: ${currentYear},目标年份: ${targetYear}`)
// ---- 用按钮探测逻辑找年份加减按钮(点击观察年份变化) ----
if (currentYear !== 0 && currentYear !== targetYear && headerInfo.headerButtons.length > 0) {
// 构造一个临时的 CalendarPanelInfo 用于 detectNavButtons
const tempPanel: CalendarPanelInfo = {
panelRoot: monthPanel,
weekdayRow: monthGridArea, // 用月份格子区域作为边界
dayGridArea: monthGridArea,
yearLabel: headerInfo.yearLabel,
monthLabel: null, // 月份面板里没有独立的月份显示标签
currentYear: currentYear,
currentMonth: 0,
headerButtons: headerInfo.headerButtons,
}
// 探测导航按钮(只关心年份按钮)
const nav = await detectNavButtons(tempPanel)
// 导航到目标年份
if (nav.yearPrev || nav.yearNext) {
const yearDiff = targetYear - (readCurrentYear(tempPanel) || currentYear)
const yearBtn = yearDiff > 0 ? nav.yearNext : nav.yearPrev
if (yearBtn) {
const steps = Math.abs(yearDiff)
console.log(`OfferPie: [datePicker] 月份面板年份导航: ${yearDiff > 0 ? "+" : ""}${yearDiff}`)
for (let i = 0; i < steps && i < 50; i++) {
yearBtn.click()
await delay(150)
const newYear = readCurrentYear(tempPanel)
if (newYear === targetYear) break
}
}
} else if (nav.monthPrev || nav.monthNext) {
// 有些月份面板的按钮被探测为"月份按钮"(因为点击后年份变了12个月=1年)
// 这种情况用月份按钮来导航年份
const yearDiff = targetYear - (readCurrentYear(tempPanel) || currentYear)
const btn = yearDiff > 0 ? nav.monthNext : nav.monthPrev
if (btn) {
const steps = Math.abs(yearDiff)
for (let i = 0; i < steps && i < 50; i++) {
btn.click()
await delay(150)
const newYear = readCurrentYear(tempPanel)
if (newYear === targetYear) break
}
}
}
// 导航后重新获取月份格子(DOM 可能更新了)
monthCells = findMonthCells(monthPanel)
}
// ---- 点击目标月份格子 ----
for (const cell of monthCells) {
const cellMonth = getMonthFromCell(cell)
if (cellMonth === targetMonth) {
const cls = ((cell as HTMLElement).className || "") + " " + ((cell.parentElement as HTMLElement)?.className || "")
if (cls.includes("disabled")) continue
console.log(`OfferPie: [datePicker] 📅 点击月份格子: ${targetMonth}`)
// 找最内层叶子节点点击(兼容不同组件库)
let deepest: HTMLElement = cell
const inner = cell.querySelector("[class*='inner'], [class*='content'], span, a")
if (inner && isVisible(inner)) deepest = inner as HTMLElement
deepest.click()
await delay(100)
if (deepest !== cell) cell.click()
await delay(300)
await closePopup(inputElement)
console.log(`OfferPie: ✅ [月份选择器] 已选择 "${labelText}" = "${fillValue}" (${targetYear}${targetMonth}月)`)
return true
}
}
console.log(`OfferPie: [datePicker] ❌ 月份面板中未找到 ${targetMonth}月 的格子`)
return false
}
/**
* 在容器中查找月份格子元素
* 月份格子特征:文字为 "一月"~"十二月" / "1月"~"12月" / "Jan"~"Dec" / 纯数字1~12
*/
function findMonthCells(container: HTMLElement): HTMLElement[] {
const cells: HTMLElement[] = []
// 常见月份格子选择器
const selectors = [
'[class*="month-panel-cell"]', '[class*="month-cell"]',
'[class*="picker-cell"]', 'td[class*="cell"]',
'[class*="month-table"] td', '[class*="month-body"] td',
'[role="gridcell"]',
]
for (const sel of selectors) {
const candidates = container.querySelectorAll(sel)
const validCells: HTMLElement[] = []
for (const el of Array.from(candidates)) {
if (!isVisible(el)) continue
const month = getMonthFromCell(el as HTMLElement)
if (month !== null) validCells.push(el as HTMLElement)
}
if (validCells.length >= 10) return validCells
}
// 兜底:遍历所有可见的短文本元素
const allEls = container.querySelectorAll("td, div, span, a")
const fallbackCells: HTMLElement[] = []
for (const el of Array.from(allEls)) {
if (!isVisible(el)) continue
const month = getMonthFromCell(el as HTMLElement)
if (month !== null) {
// 确保不是年份数字(排除 2020~2030 等)
const text = getFullText(el)
if (extractYear(text) !== null) continue
fallbackCells.push(el as HTMLElement)
}
}
// 去重(如果父子元素都匹配,只保留最内层)
const filtered = fallbackCells.filter((el) => {
return !fallbackCells.some((other) => other !== el && el.contains(other))
})
if (filtered.length >= 10) return filtered
return []
}
/**
* 从格子元素中提取月份数字
* 支持:一月~十二月、1月~12月、Jan~Dec、纯数字1~12
*/
function getMonthFromCell(el: HTMLElement): number | null {
const text = getFullText(el).trim()
// 中文月份:一月~十二月
for (const [zh, num] of Object.entries(ZH_MONTH_NUMS)) {
if (text === zh + "月" || text === zh) return num
}
// 数字月份:1月~12月 或 01~12
const numMatch = text.match(/^0?(\d{1,2})月?$/)
if (numMatch) {
const n = parseInt(numMatch[1], 10)
if (n >= 1 && n <= 12) return n
}
// 英文月份
const lower = text.toLowerCase()
for (const [key, val] of Object.entries(EN_MONTH_MAP)) {
if (lower === key || lower === key.slice(0, 3)) return val
}
return null
}
// ============ 点击日期格子 ============ // ============ 点击日期格子 ============
/** /**
@@ -871,12 +1096,24 @@ function parseDateValue(fillValue: string): { year: number; month: number; day:
return { year: parseInt(sepMatch[1], 10), month: parseInt(sepMatch[2], 10), day: parseInt(sepMatch[3], 10) } return { year: parseInt(sepMatch[1], 10), month: parseInt(sepMatch[2], 10), day: parseInt(sepMatch[3], 10) }
} }
// YYYY-MM / YYYY.MM / YYYY/MM(只有年月,day=0 表示不需要点日期格子)
const yearMonthMatch = fillValue.match(/^(\d{4})[\/\-.](\d{1,2})$/)
if (yearMonthMatch) {
return { year: parseInt(yearMonthMatch[1], 10), month: parseInt(yearMonthMatch[2], 10), day: 0 }
}
// YYYY年MM月DD日 // YYYY年MM月DD日
const zhMatch = fillValue.match(/(\d{4})年(\d{1,2})月(\d{1,2})日?/) const zhMatch = fillValue.match(/(\d{4})年(\d{1,2})月(\d{1,2})日?/)
if (zhMatch) { if (zhMatch) {
return { year: parseInt(zhMatch[1], 10), month: parseInt(zhMatch[2], 10), day: parseInt(zhMatch[3], 10) } return { year: parseInt(zhMatch[1], 10), month: parseInt(zhMatch[2], 10), day: parseInt(zhMatch[3], 10) }
} }
// YYYY年MM月(只有年月)
const zhYMMatch = fillValue.match(/(\d{4})年(\d{1,2})月/)
if (zhYMMatch) {
return { year: parseInt(zhYMMatch[1], 10), month: parseInt(zhYMMatch[2], 10), day: 0 }
}
// YYYYMMDD // YYYYMMDD
const compactMatch = fillValue.match(/^(\d{4})(\d{2})(\d{2})$/) const compactMatch = fillValue.match(/^(\d{4})(\d{2})(\d{2})$/)
if (compactMatch) { if (compactMatch) {
@@ -929,12 +1166,19 @@ export async function fillDatePicker(field: MatchedFormField): Promise<boolean>
} }
const { year: targetYear, month: targetMonth, day: targetDay } = dateInfo const { year: targetYear, month: targetMonth, day: targetDay } = dateInfo
console.log(`OfferPie: [datePicker] 目标日期: ${targetYear}${targetMonth}${targetDay}`) console.log(`OfferPie: [datePicker] 目标日期: ${targetYear}${targetMonth}${targetDay === 0 ? "(仅年月)" : targetDay + "日"}`)
// ---- 步骤2:写入日期字符串(触发面板联动) ---- // ---- 步骤2:写入日期字符串(触发面板联动) ----
forceSetValue(inputElement, fillValue) forceSetValue(inputElement, fillValue)
await delay(500) await delay(500)
// ---- 步骤2.5:如果只有年月(day=0),检测是否为月份面板 ----
if (targetDay === 0) {
const monthPanelResult = await tryFillMonthPanel(targetYear, targetMonth, labelText, fillValue, inputElement as HTMLElement)
if (monthPanelResult) return true
// 如果不是月份面板,继续走日期面板逻辑(day 默认选1号)
}
// ---- 步骤3:分析日历面板结构 ---- // ---- 步骤3:分析日历面板结构 ----
const panel = analyzeCalendarPanel() const panel = analyzeCalendarPanel()
if (!panel) { if (!panel) {
@@ -960,7 +1204,16 @@ export async function fillDatePicker(field: MatchedFormField): Promise<boolean>
console.warn("OfferPie: [datePicker] 导航到目标年月失败,仍尝试点击日期格子") console.warn("OfferPie: [datePicker] 导航到目标年月失败,仍尝试点击日期格子")
} }
// ---- 步骤6:点击目标日期格子 ---- // ---- 步骤6:点击目标日期格子(如果 day=0 表示只选年月,跳过日期点击) ----
if (targetDay === 0) {
// 只需要年月,不需要点日期格子。尝试点击月份面板中的月份
// 如果导航成功了,可能面板已经关闭(某些组件选完月份自动关闭)
await delay(200)
await closePopup(inputElement as HTMLElement)
console.log(`OfferPie: ✅ [日期] 已导航到 "${labelText}" = "${fillValue}" (${targetYear}${targetMonth}月)`)
return true
}
await delay(200) await delay(200)
const clicked = await clickDayCell(panel, targetDay, labelText, fillValue, inputElement as HTMLElement) const clicked = await clickDayCell(panel, targetDay, labelText, fillValue, inputElement as HTMLElement)
if (clicked) return true if (clicked) return true
File diff suppressed because it is too large Load Diff
+241
View File
@@ -222,3 +222,244 @@ export function matchFormFields(
return results return results
} }
// ============ 指定 DOM 范围内的字段匹配 ============
/**
* 在指定 DOM 范围内匹配表单字段(用于经历区块内的逐段匹配)
* 只匹配指定 section 的标签,并限定搜索范围在 startEl 和 endEl 之间
*
* 【核心改进】如果提供了 containerEl,则只在容器内搜索,确保不跨段
*
* @param lang - 页面语言
* @param section - 要匹配的经历分区(education/work/internship/project/competition
* @param sectionIndex - 该段经历在排序后数组中的索引
* @param startEl - 范围起始元素(该段经历的起始标志)
* @param endEl - 范围结束元素(下一段经历的起始标志或下一个大标题,null 表示到页面底部)
* @param usedInputs - 已使用的 input 集合(避免重复匹配)
* @param containerEl - 该段经历的容器 DOM 元素(优先使用,精确限定范围)
* @returns 匹配到的字段列表
*/
export function matchFormFieldsInRange(
lang: "zh" | "en",
section: string,
sectionIndex: number,
startEl: Element,
endEl: Element | null,
usedInputs: Set<Element> = new Set(),
containerEl: Element | null = null
): MatchedFormField[] {
const results: MatchedFormField[] = []
// 取该 section 对应的标签,同时加入通用标签(如实习经历里的"公司名称"属于 work section
let sectionLabels = JOB_FORM_LABELS.filter((item) => item.section === section)
// 对于实习经历,页面上可能用通用的"公司名称"而不是"实习公司",需要加入 work 的公司相关标签
if (section === "internship") {
const workCompanyLabels = JOB_FORM_LABELS.filter((item) => item.section === "work" && (item.key === "workCompany" || item.key === "workPosition"))
// 映射为 internship 的字段
const mappedLabels = workCompanyLabels.map((item) => ({
...item,
section: "internship" as any,
resumeField: item.key === "workCompany" ? "companyName" : "position",
}))
sectionLabels = [...sectionLabels, ...mappedLabels]
}
// 获取范围内的候选元素
// 【核心】如果有 containerEl,直接在容器内搜索(精确,不跨段)
// 否则 fallback 到 DOM 位置比较方式
const rangeElements: Element[] = []
if (containerEl) {
// 在容器内搜索所有候选标签元素
const containerCandidates = containerEl.querySelectorAll(
"label, span, div, td, th, p, legend, dt, h1, h2, h3, h4, h5, h6"
)
for (const el of Array.from(containerCandidates)) {
rangeElements.push(el)
}
} else {
// fallback:用 DOM 位置比较
const allCandidates = document.body.querySelectorAll(
"label, span, div, td, th, p, legend, dt, h1, h2, h3, h4, h5, h6"
)
for (const el of Array.from(allCandidates)) {
const afterStart = startEl === el || (startEl.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING)
if (!afterStart) continue
if (endEl && !(endEl.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_PRECEDING)) continue
rangeElements.push(el)
}
}
for (const item of sectionLabels) {
const labels = lang === "zh" ? item.zh : item.en
const sortedLabels = [...labels].sort((a, b) => b.length - a.length)
for (const candidateEl of rangeElements) {
// 只用元素自身的直接文本匹配(不用 fullText,避免匹配到包含多个标签文字的大容器)
const directText = Array.from(candidateEl.childNodes)
.filter((node) => node.nodeType === Node.TEXT_NODE)
.map((node) => node.textContent?.trim())
.filter(Boolean)
.join(" ")
if (!directText) continue
const matchLabel = sortedLabels.find(
(label) => directText.includes(label) && directText.length < label.length + 20
)
if (matchLabel) {
// 检测单选按钮组
let isRadioType = false
let radioContainer: Element | null = null
const closestFormItem = candidateEl.closest(".form-item, .form-group, .el-form-item, .ant-form-item")
if (closestFormItem) {
const radioInItem = closestFormItem.querySelector('input[type="radio"]')
if (radioInItem) { isRadioType = true; radioContainer = closestFormItem }
}
// 非 radio 类型,正常找 input
let inputEl: HTMLInputElement | HTMLTextAreaElement | null = null
let inputType: "text" | "radio" | "picker" | "textarea" = isRadioType ? "radio" : "text"
if (!isRadioType) {
inputEl = findNearestInput(candidateEl)
if (inputEl) inputEl = fixSpecialLabelInput(item.key, inputEl, candidateEl, usedInputs)
// 【核心】如果有容器限定,验证找到的 input 必须在容器内
if (inputEl && containerEl && !containerEl.contains(inputEl)) inputEl = null
if (inputEl && usedInputs.has(inputEl)) continue
if (inputEl) {
usedInputs.add(inputEl)
if (inputEl.tagName === "TEXTAREA") inputType = "textarea"
}
}
const btnEl = inputEl ? findNearbyButton(inputEl) : null
console.log(` [matchInRange] 匹配: "${matchLabel}" (${item.key}) → 标签元素: <${candidateEl.tagName.toLowerCase()}> "${candidateEl.textContent?.trim()?.substring(0, 20)}" | input: ${inputEl ? `<${inputEl.tagName.toLowerCase()} placeholder="${inputEl.getAttribute("placeholder") || ""}">` : "null"}`)
results.push({
key: item.key, section: item.section as any, resumeField: item.resumeField,
sectionIndex, labelText: matchLabel,
labelElement: candidateEl, labelSelector: buildSelector(candidateEl),
inputElement: inputEl, inputSelector: inputEl ? buildSelector(inputEl) : "",
buttonElement: btnEl, buttonSelector: btnEl ? buildSelector(btnEl) : "",
inputType, radioContainer: isRadioType ? radioContainer : null,
isPicker: false, pickerDropdownElement: null, pickerDropdownSelector: "", fillValue: "",
})
break // 每种标签在一段经历内只匹配一次
}
}
}
// 按 DOM 顺序排序
results.sort((a, b) => {
const pos = a.labelElement.compareDocumentPosition(b.labelElement)
if (pos & Node.DOCUMENT_POSITION_FOLLOWING) return -1
if (pos & Node.DOCUMENT_POSITION_PRECEDING) return 1
return 0
})
return results
}
/**
* 匹配非经历区域的表单字段(只匹配 section="main" 的标签)
* 排除经历区域内的元素,避免重复匹配
*
* @param rootEl - 搜索根元素
* @param lang - 页面语言
* @param excludeRanges - 要排除的 DOM 范围列表(经历区块范围)
* @param usedInputs - 已使用的 input 集合
* @returns 匹配到的字段列表
*/
export function matchMainFields(
rootEl: Element = document.body,
lang: "zh" | "en",
excludeRanges: { start: Element; end: Element | null }[] = [],
usedInputs: Set<Element> = new Set()
): MatchedFormField[] {
const results: MatchedFormField[] = []
// 只取 main section 的标签
const mainLabels = JOB_FORM_LABELS.filter((item) => item.section === "main")
const candidateEls = rootEl.querySelectorAll(
"label, span, div, td, th, p, legend, dt, h1, h2, h3, h4, h5, h6"
)
for (const item of mainLabels) {
const labels = lang === "zh" ? item.zh : item.en
const sortedLabels = [...labels].sort((a, b) => b.length - a.length)
for (const candidateEl of Array.from(candidateEls)) {
// 检查是否在排除范围内
let inExcludeRange = false
for (const range of excludeRanges) {
const afterStart = range.start === candidateEl || (range.start.compareDocumentPosition(candidateEl) & Node.DOCUMENT_POSITION_FOLLOWING)
const beforeEnd = !range.end || (range.end.compareDocumentPosition(candidateEl) & Node.DOCUMENT_POSITION_PRECEDING)
if (afterStart && beforeEnd) {
inExcludeRange = true
break
}
}
if (inExcludeRange) continue
const directText = Array.from(candidateEl.childNodes)
.filter((node) => node.nodeType === Node.TEXT_NODE)
.map((node) => node.textContent?.trim())
.filter(Boolean)
.join(" ")
const fullText = candidateEl.textContent?.trim() || ""
const matchLabel = sortedLabels.find(
(label) =>
(directText && directText.includes(label) && directText.length < label.length + 20) ||
(fullText.includes(label) && fullText.length < label.length + 20)
)
if (matchLabel) {
let isRadioType = false
let radioContainer: Element | null = null
const closestFormItem = candidateEl.closest(".form-item, .form-group, .el-form-item, .ant-form-item")
if (closestFormItem) {
const radioInItem = closestFormItem.querySelector('input[type="radio"]')
if (radioInItem) { isRadioType = true; radioContainer = closestFormItem }
}
let inputEl: HTMLInputElement | HTMLTextAreaElement | null = null
let inputType: "text" | "radio" | "picker" | "textarea" = isRadioType ? "radio" : "text"
if (!isRadioType) {
inputEl = findNearestInput(candidateEl)
if (inputEl) inputEl = fixSpecialLabelInput(item.key, inputEl, candidateEl, usedInputs)
if (inputEl && usedInputs.has(inputEl)) continue
if (inputEl) {
usedInputs.add(inputEl)
if (inputEl.tagName === "TEXTAREA") inputType = "textarea"
}
}
const btnEl = inputEl ? findNearbyButton(inputEl) : null
results.push({
key: item.key, section: item.section, resumeField: item.resumeField,
sectionIndex: 0, labelText: matchLabel,
labelElement: candidateEl, labelSelector: buildSelector(candidateEl),
inputElement: inputEl, inputSelector: inputEl ? buildSelector(inputEl) : "",
buttonElement: btnEl, buttonSelector: btnEl ? buildSelector(btnEl) : "",
inputType, radioContainer: isRadioType ? radioContainer : null,
isPicker: false, pickerDropdownElement: null, pickerDropdownSelector: "", fillValue: "",
})
break // main 字段每种只匹配一次
}
}
}
// 按 DOM 顺序排序
results.sort((a, b) => {
const pos = a.labelElement.compareDocumentPosition(b.labelElement)
if (pos & Node.DOCUMENT_POSITION_FOLLOWING) return -1
if (pos & Node.DOCUMENT_POSITION_PRECEDING) return 1
return 0
})
return results
}