1511 lines
68 KiB
TypeScript
1511 lines
68 KiB
TypeScript
/**
|
||
* 通用模式自动填写处理逻辑
|
||
* 从 SidebarPanel.tsx 中抽离,便于后续针对特殊网站扩展不同的处理流程
|
||
*
|
||
* 【规范】所有填充操作必须走 fillMatchedField 统一入口,
|
||
* 选择器检测必须走 detectPickerField 统一入口,
|
||
* 不要在此文件中自行编写选择器操作逻辑,必须引用 lib 中已封装的方法。
|
||
*/
|
||
|
||
import { fillMatchedField, delay, isSearchPickerField, fillSearchPickerField, isTimePeriodField, isTimeSingleField, fillTimePeriodField, fillTimeSingleField } from "~lib/autofill"
|
||
import { extractDomStructure, detectPageLanguage, isJobApplicationForm, buildSelector } from "~lib/dom"
|
||
import { matchFormFieldsInRange, matchMainFields } from "~lib/formMatcher"
|
||
import { detectPickerField } from "~lib/pickerDetector"
|
||
import { detectAndUploadResume } from "~lib/resumeUpload"
|
||
import { getMockResumeData2, JOB_FORM_LABELS } from "~lib/constants"
|
||
import { fillDatePicker } from "~lib/datePicker"
|
||
import { getResumeFieldValue } from "~lib/resumeDataHelper"
|
||
import { locateExperienceSections, expandExperienceSections, sortExperienceByTime, relocateSegmentContainer, getAllPageTitles, findAddButton, clickAddButton } from "~lib/experienceSection"
|
||
import type { ExperienceSectionLocateResult } from "~lib/experienceSection"
|
||
import type { MatchedFormField, ResumeData, ExperienceSection, JobInfo, UnmatchedFormField } from "~lib/types"
|
||
import { setFieldHighlight, isRequiredField } from "~lib/formStyle"
|
||
|
||
/** 通用自动填写的参数 */
|
||
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[]
|
||
/** 经历段落定位结果(用于 fillStats 分段展示) */
|
||
sectionResults: ExperienceSectionLocateResult[]
|
||
/** 经历段落展开结果(用于 fillStats 分段展示) */
|
||
expandedResults: ExperienceSectionLocateResult[]
|
||
}
|
||
|
||
/**
|
||
* 通用模式自动填写主流程
|
||
* 流程:提取 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: [],
|
||
sectionResults: [],
|
||
expandedResults: [],
|
||
}
|
||
|
||
// 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)
|
||
|
||
// 存入 result 供外部使用(如 fillStats 分段展示)
|
||
result.sectionResults = sectionResults
|
||
result.expandedResults = expandedResults
|
||
|
||
// 4.65 【预匹配+标绿】经历段落添加完成后,一次性预匹配所有有简历数据的字段并标绿背景
|
||
// 使用临时 usedInputs,不影响后续正式流程的匹配
|
||
{
|
||
const tempUsedInputs = new Set<Element>()
|
||
// 预匹配经历区域字段
|
||
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
|
||
|
||
const sortedIndices = sortExperienceByTime(sectionData)
|
||
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 allTitles = expandedResults.filter((r) => r.titleElement)
|
||
const currentIdx = allTitles.findIndex((r) => r.titleElement === expResult.titleElement)
|
||
const nextExpResult = currentIdx >= 0 && currentIdx < allTitles.length - 1 ? allTitles[currentIdx + 1] : null
|
||
const segEndEl = nextSegment?.startElement || nextExpResult?.titleElement || null
|
||
|
||
const segFields = matchFormFieldsInRange(lang, section, dataIdx, segStartEl, segEndEl, tempUsedInputs, segment.containerElement)
|
||
for (const f of segFields) {
|
||
const value = getResumeFieldValue(currentResumeData, f.section, dataIdx, f.resumeField)
|
||
if (value && f.inputElement) {
|
||
setFieldHighlight(f.inputElement, "green")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// 预匹配非经历区域字段(main section)
|
||
const tempExcludeRanges: { start: Element; end: Element | null }[] = []
|
||
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
|
||
tempExcludeRanges.push({ start: titleEl, end: nextExpResult?.titleElement || null })
|
||
}
|
||
const tempMainFields = matchMainFields(document.body, lang, tempExcludeRanges, tempUsedInputs)
|
||
for (const f of tempMainFields) {
|
||
const value = getResumeFieldValue(currentResumeData, f.section, f.sectionIndex, f.resumeField)
|
||
if (value && f.inputElement) {
|
||
setFieldHighlight(f.inputElement, "green")
|
||
}
|
||
}
|
||
console.log("===== OfferPie: 预匹配完成,已对有简历数据的字段标绿 =====")
|
||
}
|
||
|
||
// 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已处理字段收集(用于末尾统计) */
|
||
const phaseAFields: { labelText: string; inputElement: Element | null; filled: boolean; fillValue: string; section: string; segmentIndex: number }[] = []
|
||
|
||
// 如果网站已自动填写经历,跳过阶段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) { phaseAFields.push({ labelText: f.labelText, inputElement: f.inputElement, filled: false, fillValue: "", section: f.section, segmentIndex: segIdx }); 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) {
|
||
phaseAFields.push({ labelText: f.labelText, inputElement: f.inputElement, filled: false, fillValue: "", section: f.section, segmentIndex: segIdx }); result.skipped++; continue
|
||
} else if (isSearchPickerField(f.key)) {
|
||
const ok = await fillSearchPickerField(f)
|
||
if (ok) { result.success++ } else { result.failed++ }
|
||
phaseAFields.push({ labelText: f.labelText, inputElement: f.inputElement, filled: ok, fillValue: f.fillValue, section: f.section, segmentIndex: segIdx })
|
||
await delay(100)
|
||
continue
|
||
} else {
|
||
await detectPickerField(f, lang)
|
||
const ok = await fillMatchedField(f)
|
||
if (ok) { result.success++ } else { result.failed++ }
|
||
}
|
||
|
||
// 收集阶段A已处理字段(用于末尾統計)
|
||
phaseAFields.push({ labelText: f.labelText, inputElement: f.inputElement, filled: !!f.fillValue, fillValue: f.fillValue, section: f.section, segmentIndex: segIdx })
|
||
|
||
// 关闭残留弹窗
|
||
if (lastTextInput && !lastTextInputIsPicker) {
|
||
;(lastTextInput as HTMLElement).click()
|
||
lastTextInput.focus()
|
||
await delay(50)
|
||
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(150)
|
||
|
||
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(50)
|
||
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(150)
|
||
|
||
if (!f.isPicker && f.inputElement) lastTextInput = f.inputElement
|
||
}
|
||
|
||
result.formFields = [...mainFields]
|
||
console.log(`===== OfferPie: 阶段B完成 总计成功${result.success} 失败${result.failed} 跳过${result.skipped} =====`)
|
||
|
||
// 6.5 阶段B2 - 从缓存填写之前保存的 unfilledFormData 有值字段
|
||
// 【独立步骤】注释下面这段即可禁用阶段B2
|
||
{
|
||
const resumeName = currentResumeData?.main?.resumeName || currentResumeData?.main?.name || ""
|
||
const b2Result = await handleFillCachedData({
|
||
lang, resumeName, usedInputs, expandedResults, sectionResults,
|
||
})
|
||
result.success += b2Result.success
|
||
result.failed += b2Result.failed
|
||
result.skipped += b2Result.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} 个空白字段 =====`)
|
||
|
||
// 8. 【标红/标黄】+ 9.【统计打印】
|
||
// 全面扫描页面所有大标题范围内的输入框,对未被阶段A/B/C处理的字段也纳入统计和高亮
|
||
// 经历类型区块按段分组,标记段落索引
|
||
{
|
||
// 先对阶段C已收集的字段标红/标黄
|
||
for (const uf of result.unmatchedFields) {
|
||
const required = isRequiredField(uf.labelElement, uf.inputElement)
|
||
if (required) {
|
||
setFieldHighlight(uf.inputElement, "red")
|
||
} else {
|
||
setFieldHighlight(uf.inputElement, "yellow")
|
||
}
|
||
}
|
||
|
||
const allTitles = getAllPageTitles(sectionResults)
|
||
if (allTitles.length > 0) {
|
||
console.log("===== OfferPie: 填写结果统计(按大标题分组) =====")
|
||
|
||
const processedInputs = new Set<Element>()
|
||
|
||
type FieldStat = {
|
||
labelText: string
|
||
inputElement: Element | null
|
||
color: "green" | "red" | "yellow"
|
||
filled: boolean
|
||
source: "A" | "B" | "C" | "D"
|
||
segmentIndex: number
|
||
}
|
||
|
||
// 阶段A字段(带段落索引)
|
||
const allFieldStats: FieldStat[] = []
|
||
for (const f of phaseAFields) {
|
||
allFieldStats.push({ labelText: f.labelText, inputElement: f.inputElement, color: "green", filled: f.filled, source: "A", segmentIndex: f.segmentIndex })
|
||
if (f.inputElement) processedInputs.add(f.inputElement)
|
||
}
|
||
// 阶段B字段(只有简历数据非空的才标绿,否则走红/黄判断)
|
||
for (const f of mainFields) {
|
||
const hasResumeValue = !!f.fillValue
|
||
const filled = !!(f.fillValue && f.inputElement?.value)
|
||
if (hasResumeValue) {
|
||
allFieldStats.push({ labelText: f.labelText, inputElement: f.inputElement, color: "green", filled, source: "B", segmentIndex: 0 })
|
||
} else {
|
||
// 简历数据为空的字段,走必填检测标红/标黄,并补上背景色
|
||
const required = isRequiredField(f.labelElement, f.inputElement)
|
||
const color = required ? "red" : "yellow"
|
||
setFieldHighlight(f.inputElement, color)
|
||
allFieldStats.push({ labelText: f.labelText, inputElement: f.inputElement, color, filled: false, source: "B", segmentIndex: 0 })
|
||
}
|
||
if (f.inputElement) processedInputs.add(f.inputElement)
|
||
}
|
||
// 阶段C字段
|
||
for (const uf of result.unmatchedFields) {
|
||
const required = isRequiredField(uf.labelElement, uf.inputElement)
|
||
allFieldStats.push({ labelText: uf.labelText, inputElement: uf.inputElement, color: required ? "red" : "yellow", filled: false, source: "C", segmentIndex: 0 })
|
||
if (uf.inputElement) processedInputs.add(uf.inputElement)
|
||
}
|
||
|
||
// 常量
|
||
const STAT_EXCLUDE_LABEL_TEXTS = ["请输入", "输入", ":", ":", "请选择", "选择", "*", "必填"]
|
||
const INPUT_SEL_STAT = "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 FORM_ITEM_SELS = [
|
||
".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']",
|
||
]
|
||
|
||
/** 5大经历类型的 section 名集合(用于判断是否能从 expandedResults 取段落信息) */
|
||
const FIVE_EXP_SECTIONS = new Set(["education", "work", "internship", "project", "competition"])
|
||
|
||
/**
|
||
* 对非5大经历的其他经历类型区块,通过标签重复次数检测段数
|
||
* 思路:一段经历里标签是一组固定模式(如名称+开始时间+结束时间+描述),
|
||
* 统计范围内每个标签文字出现的次数,出现次数的最大公约数就是段数
|
||
* 用重复次数最多的标签文字作为分段标记,按 DOM 顺序划分段落
|
||
*/
|
||
function detectSegmentsForOtherExp(
|
||
titleEl: Element,
|
||
nextTitleEl: Element | null,
|
||
fieldsInRange: FieldStat[]
|
||
): { segmentCount: number; fieldSegMap: Map<Element, number> } {
|
||
// 统计每个标签文字出现的次数
|
||
const labelCounts = new Map<string, number>()
|
||
const labelElements = new Map<string, Element[]>() // 每个标签文字对应的所有 inputElement(按 DOM 顺序)
|
||
|
||
for (const f of fieldsInRange) {
|
||
if (!f.inputElement) continue
|
||
const count = labelCounts.get(f.labelText) || 0
|
||
labelCounts.set(f.labelText, count + 1)
|
||
const arr = labelElements.get(f.labelText) || []
|
||
arr.push(f.inputElement)
|
||
labelElements.set(f.labelText, arr)
|
||
}
|
||
|
||
// 找出出现次数最多的标签 → 作为段数依据
|
||
let maxCount = 1
|
||
let markerLabel = ""
|
||
for (const [label, count] of labelCounts) {
|
||
if (count > maxCount) {
|
||
maxCount = count
|
||
markerLabel = label
|
||
}
|
||
}
|
||
|
||
if (maxCount <= 1) {
|
||
// 所有标签只出现1次 → 只有1段经历
|
||
const fieldSegMap = new Map<Element, number>()
|
||
for (const f of fieldsInRange) {
|
||
if (f.inputElement) fieldSegMap.set(f.inputElement, 0)
|
||
}
|
||
return { segmentCount: 1, fieldSegMap }
|
||
}
|
||
|
||
// 用 markerLabel 的出现位置来划分段落边界
|
||
const markerInputs = labelElements.get(markerLabel) || []
|
||
// markerInputs 按 DOM 顺序排列,每个 markerInput 标记一段经历的开始
|
||
const fieldSegMap = new Map<Element, number>()
|
||
|
||
for (const f of fieldsInRange) {
|
||
if (!f.inputElement) continue
|
||
// 找到离这个 input 最近的前一个 marker(或和它属于同一段)
|
||
let segIdx = 0
|
||
for (let i = markerInputs.length - 1; i >= 0; i--) {
|
||
const markerPos = markerInputs[i].compareDocumentPosition(f.inputElement)
|
||
// f.inputElement 在 markerInputs[i] 之后或就是它本身
|
||
if ((markerPos & Node.DOCUMENT_POSITION_FOLLOWING) || markerInputs[i] === f.inputElement) {
|
||
segIdx = i
|
||
break
|
||
}
|
||
}
|
||
fieldSegMap.set(f.inputElement, segIdx)
|
||
}
|
||
|
||
return { segmentCount: maxCount, fieldSegMap }
|
||
}
|
||
|
||
/**
|
||
* 查找标签文字的辅助函数
|
||
*/
|
||
function findLabelForInput(inp: Element): { labelText: string; labelElement: Element | null } {
|
||
let labelText = ""
|
||
let labelElement: Element | null = null
|
||
let container: Element | null = null
|
||
for (const sel of FORM_ITEM_SELS) {
|
||
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 (STAT_EXCLUDE_LABEL_TEXTS.some((ex) => directText === ex)) continue
|
||
if (el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) {
|
||
labelText = directText
|
||
labelElement = el
|
||
break
|
||
}
|
||
}
|
||
}
|
||
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 && !STAT_EXCLUDE_LABEL_TEXTS.some((ex) => text === ex)) {
|
||
labelText = text
|
||
labelElement = prev
|
||
break
|
||
}
|
||
prev = prev.previousElementSibling
|
||
}
|
||
}
|
||
if (!labelText) labelText = (inp as HTMLInputElement).getAttribute("placeholder") || "(未知字段)"
|
||
return { labelText, labelElement }
|
||
}
|
||
|
||
// 按大标题分组统计
|
||
for (let tIdx = 0; tIdx < allTitles.length; tIdx++) {
|
||
const titleEl = allTitles[tIdx].element
|
||
const titleText = allTitles[tIdx].text
|
||
const nextTitleEl = tIdx < allTitles.length - 1 ? allTitles[tIdx + 1].element : null
|
||
|
||
// 判断是否为5大经历区块(直接从 expandedResults 取段落信息)
|
||
const expResult = expandedResults.find((r) => r.titleElement === titleEl)
|
||
const isFiveExp = !!expResult && FIVE_EXP_SECTIONS.has(expResult.section)
|
||
|
||
// 找出已处理字段中属于此标题范围内的
|
||
const fieldsInSection: FieldStat[] = allFieldStats.filter((f) => {
|
||
if (!f.inputElement) return false
|
||
const afterTitle = titleEl === f.inputElement || !!(titleEl.compareDocumentPosition(f.inputElement) & Node.DOCUMENT_POSITION_FOLLOWING)
|
||
const beforeNext = !nextTitleEl || !!(nextTitleEl.compareDocumentPosition(f.inputElement) & Node.DOCUMENT_POSITION_PRECEDING)
|
||
return afterTitle && beforeNext
|
||
})
|
||
|
||
// 【补扫】在此标题范围内查找未被任何阶段处理的 input
|
||
const allInputsInRange = document.body.querySelectorAll(INPUT_SEL_STAT)
|
||
for (const inp of Array.from(allInputsInRange)) {
|
||
if (processedInputs.has(inp)) continue
|
||
const afterTitle = titleEl === inp || !!(titleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING)
|
||
const beforeNext = !nextTitleEl || !!(nextTitleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_PRECEDING)
|
||
if (!afterTitle || !beforeNext) continue
|
||
|
||
const inputEl = inp as HTMLInputElement | HTMLTextAreaElement
|
||
const alreadyHasValue = !!(inputEl.value && inputEl.value.trim().length > 0)
|
||
const { labelText, labelElement } = findLabelForInput(inp)
|
||
|
||
let color: "green" | "red" | "yellow"
|
||
if (alreadyHasValue) {
|
||
color = "green"
|
||
} else {
|
||
const required = isRequiredField(labelElement, inputEl)
|
||
color = required ? "red" : "yellow"
|
||
setFieldHighlight(inputEl, color)
|
||
}
|
||
|
||
fieldsInSection.push({ labelText, inputElement: inp, color, filled: alreadyHasValue, source: "D", segmentIndex: 0 })
|
||
processedInputs.add(inp)
|
||
}
|
||
|
||
if (fieldsInSection.length === 0) {
|
||
console.log(` 📂 "${titleText}" — 无匹配字段`)
|
||
continue
|
||
}
|
||
|
||
// 确定经历类型和段数
|
||
let isExpType = false
|
||
let segmentCount = 0
|
||
|
||
if (isFiveExp && expResult) {
|
||
// 5大经历:直接用 expandedResults 的 segmentRanges
|
||
isExpType = true
|
||
segmentCount = expResult.segmentRanges.length
|
||
// 更新阶段D补扫字段的 segmentIndex(用 containerElement.contains 判断归属)
|
||
for (const f of fieldsInSection) {
|
||
if (!f.inputElement) continue
|
||
if (f.source === "D" || f.source === "C") {
|
||
for (let sIdx = 0; sIdx < expResult.segmentRanges.length; sIdx++) {
|
||
const seg = expResult.segmentRanges[sIdx]
|
||
if (seg.containerElement && seg.containerElement.contains(f.inputElement)) {
|
||
f.segmentIndex = sIdx
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
// 非5大经历:先检测是否有"添加"按钮,有添加按钮才是经历类型
|
||
const addBtnKeywords = ["添加", "新增", "Add", "增加"]
|
||
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT)
|
||
let wNode: Node | null = walker.nextNode()
|
||
let hasAddBtn = false
|
||
while (wNode) {
|
||
const el = wNode as Element
|
||
const afterT = !!(titleEl.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING)
|
||
const beforeN = !nextTitleEl || !!(nextTitleEl.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_PRECEDING)
|
||
if (afterT && beforeN) {
|
||
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 < 20 && addBtnKeywords.some((k) => directText.includes(k))) {
|
||
hasAddBtn = true
|
||
break
|
||
}
|
||
}
|
||
wNode = walker.nextNode()
|
||
}
|
||
|
||
if (hasAddBtn) {
|
||
// 有添加按钮 → 是经历类型,通过标签重复次数确定段数
|
||
isExpType = true
|
||
const { segmentCount: detectedCount, fieldSegMap } = detectSegmentsForOtherExp(titleEl, nextTitleEl, fieldsInSection)
|
||
segmentCount = detectedCount
|
||
for (const f of fieldsInSection) {
|
||
if (f.inputElement && fieldSegMap.has(f.inputElement)) {
|
||
f.segmentIndex = fieldSegMap.get(f.inputElement)!
|
||
}
|
||
}
|
||
}
|
||
// 没有添加按钮 → 不是经历类型,isExpType 保持 false
|
||
}
|
||
|
||
const greenCount = fieldsInSection.filter((f) => f.color === "green").length
|
||
const redCount = fieldsInSection.filter((f) => f.color === "red").length
|
||
const yellowCount = fieldsInSection.filter((f) => f.color === "yellow").length
|
||
const filledCount = fieldsInSection.filter((f) => f.filled).length
|
||
|
||
const expLabel = isExpType ? ` | 📑经历类型(${segmentCount}段)` : ""
|
||
console.log(` 📂 "${titleText}" — 总计 ${fieldsInSection.length} 个字段 | 已填 ${filledCount} | 🟢简历有数据 ${greenCount} | 🔴必填未填 ${redCount} | 🟡非必填未填 ${yellowCount}${expLabel}`)
|
||
|
||
// 打印字段详情
|
||
if (isExpType && segmentCount > 0) {
|
||
for (let sIdx = 0; sIdx < segmentCount; sIdx++) {
|
||
const segFields = fieldsInSection.filter((f) => f.segmentIndex === sIdx)
|
||
if (segFields.length === 0) continue
|
||
console.log(` --- 第${sIdx + 1}段 ---`)
|
||
for (const f of segFields) {
|
||
const colorIcon = f.color === "green" ? "🟢" : f.color === "red" ? "🔴" : "🟡"
|
||
const filledStr = f.filled ? "✅已填" : "⬜未填"
|
||
const sourceLabel = f.source === "A" ? "经历填写" : f.source === "B" ? "基础信息填写" : f.source === "C" ? "空白字段收集" : "补扫发现"
|
||
console.log(` ${colorIcon} "${f.labelText}" | ${filledStr} | 来源: ${sourceLabel}`)
|
||
}
|
||
}
|
||
} else {
|
||
for (const f of fieldsInSection) {
|
||
const colorIcon = f.color === "green" ? "🟢" : f.color === "red" ? "🔴" : "🟡"
|
||
const filledStr = f.filled ? "✅已填" : "⬜未填"
|
||
const sourceLabel = f.source === "A" ? "经历填写" : f.source === "B" ? "基础信息填写" : f.source === "C" ? "空白字段收集" : "补扫发现"
|
||
console.log(` ${colorIcon} "${f.labelText}" | ${filledStr} | 来源: ${sourceLabel}`)
|
||
}
|
||
}
|
||
}
|
||
|
||
console.log("===== OfferPie: 统计打印完毕 =====")
|
||
}
|
||
}
|
||
|
||
// 10. 【生成未填字段 JSON】按大标题顺序,收集简历数据格式之外的未填字段
|
||
// 排除阶段A/B(简历数据格式内的字段),只收集阶段C/D中未填写的字段
|
||
{
|
||
const allTitles = getAllPageTitles(sectionResults)
|
||
const FIVE_EXP_SECTIONS_JSON = new Set(["education", "work", "internship", "project", "competition"])
|
||
|
||
/** 判断某个字段是否属于简历数据格式(JOB_FORM_LABELS 里 resumeField 非空的) */
|
||
const resumeFormatInputs = new Set<Element>()
|
||
// 阶段A和B的所有 input 都是简历格式字段
|
||
for (const f of phaseAFields) { if (f.inputElement) resumeFormatInputs.add(f.inputElement) }
|
||
for (const f of mainFields) { if (f.inputElement) resumeFormatInputs.add(f.inputElement) }
|
||
|
||
type UnfilledSection = {
|
||
title: string
|
||
isExperience: boolean
|
||
formItems: { label: string; value: string }[] | { label: string; value: string }[][]
|
||
}
|
||
|
||
const unfilledFormData: UnfilledSection[] = []
|
||
|
||
if (allTitles.length > 0) {
|
||
const INPUT_SEL_JSON = "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 FORM_ITEM_SELS_JSON = [
|
||
".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']",
|
||
]
|
||
const JSON_EXCLUDE_LABELS = ["请输入", "输入", ":", ":", "请选择", "选择", "*", "必填"]
|
||
|
||
for (let tIdx = 0; tIdx < allTitles.length; tIdx++) {
|
||
const titleEl = allTitles[tIdx].element
|
||
const titleText = allTitles[tIdx].text
|
||
const nextTitleEl = tIdx < allTitles.length - 1 ? allTitles[tIdx + 1].element : null
|
||
|
||
// 判断是否为经历类型(5大经历 或 有添加按钮的非5大经历)
|
||
const expResult = expandedResults.find((r) => r.titleElement === titleEl)
|
||
const isFiveExp = !!expResult && FIVE_EXP_SECTIONS_JSON.has(expResult.section)
|
||
let isExpType = isFiveExp
|
||
|
||
// 非5大经历检测添加按钮
|
||
if (!isFiveExp) {
|
||
const addBtnKeywords = ["添加", "新增", "Add", "增加"]
|
||
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT)
|
||
let wNode: Node | null = walker.nextNode()
|
||
while (wNode) {
|
||
const el = wNode as Element
|
||
const afterT = !!(titleEl.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING)
|
||
const beforeN = !nextTitleEl || !!(nextTitleEl.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_PRECEDING)
|
||
if (afterT && beforeN) {
|
||
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 < 20 && addBtnKeywords.some((k) => directText.includes(k))) {
|
||
isExpType = true
|
||
break
|
||
}
|
||
}
|
||
wNode = walker.nextNode()
|
||
}
|
||
}
|
||
|
||
// 收集范围内的 input
|
||
const allInputs = document.body.querySelectorAll(INPUT_SEL_JSON)
|
||
type FieldItem = { label: string; value: string; inputEl: Element }
|
||
const collectedFields: FieldItem[] = []
|
||
|
||
for (const inp of Array.from(allInputs)) {
|
||
// 范围检查
|
||
const afterTitle = !!(titleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING)
|
||
const beforeNext = !nextTitleEl || !!(nextTitleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_PRECEDING)
|
||
if (!afterTitle || !beforeNext) continue
|
||
|
||
const inputEl = inp as HTMLInputElement | HTMLTextAreaElement
|
||
|
||
// 非经历类型:跳过阶段A/B已成功填充的字段(绿色背景标记)
|
||
// greenTwo(#b7ffc6)是阶段B2填的unfilledFormData字段,不跳过,可以收集
|
||
if (!isExpType) {
|
||
if (resumeFormatInputs.has(inp)) continue
|
||
const bgColor = inputEl.style.backgroundColor
|
||
if (bgColor === "#b7ffc5" || bgColor === "rgb(183, 255, 197)") continue
|
||
}
|
||
|
||
// 查找标签
|
||
let labelText = ""
|
||
let container: Element | null = null
|
||
for (const sel of FORM_ITEM_SELS_JSON) {
|
||
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 (JSON_EXCLUDE_LABELS.some((ex) => directText === ex)) continue
|
||
if (el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) {
|
||
labelText = directText
|
||
break
|
||
}
|
||
}
|
||
}
|
||
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 && !JSON_EXCLUDE_LABELS.some((ex) => text === ex)) {
|
||
labelText = text
|
||
break
|
||
}
|
||
prev = prev.previousElementSibling
|
||
}
|
||
}
|
||
if (!labelText) labelText = inputEl.getAttribute("placeholder") || "(未知字段)"
|
||
|
||
// 非经历类型:跳过简历格式字段
|
||
// 【注意】简历格式字段通过 resumeFormatInputs(阶段A/B实际匹配到的input元素集合)精确跳过
|
||
// 不用标签文字匹配跳过,因为同一标签名可能出现在不同大标题下(如"最高学历"在个人信息 vs 教育经历)
|
||
// resumeFormatInputs 已在上方 if(!isExpType) 块中判断,此处无需重复
|
||
|
||
collectedFields.push({ label: labelText, value: inputEl.value?.trim() || "", inputEl: inp })
|
||
}
|
||
|
||
if (collectedFields.length === 0) continue
|
||
|
||
// 按经历类型分组
|
||
if (isExpType) {
|
||
let segmentCount = 1
|
||
|
||
if (isFiveExp && expResult) {
|
||
// 5大经历用 containerElement 分段
|
||
segmentCount = expResult.segmentRanges.length || 1
|
||
const segments: { label: string; value: string }[][] = []
|
||
for (let sIdx = 0; sIdx < segmentCount; sIdx++) {
|
||
const seg = expResult.segmentRanges[sIdx]
|
||
const segFields = seg?.containerElement
|
||
? collectedFields.filter((f) => seg.containerElement!.contains(f.inputEl))
|
||
: collectedFields
|
||
segments.push(segFields.map((f) => ({ label: f.label, value: f.value })))
|
||
}
|
||
const nonEmptySegments = segments.filter((s) => s.length > 0)
|
||
if (nonEmptySegments.length > 0) {
|
||
unfilledFormData.push({ title: titleText, isExperience: true, formItems: nonEmptySegments })
|
||
}
|
||
} else {
|
||
// 非5大经历用标签重复计数分段
|
||
const labelCounts = new Map<string, number>()
|
||
for (const f of collectedFields) {
|
||
labelCounts.set(f.label, (labelCounts.get(f.label) || 0) + 1)
|
||
}
|
||
let maxCount = 1
|
||
let markerLabel = ""
|
||
for (const [label, count] of labelCounts) {
|
||
if (count > maxCount) { maxCount = count; markerLabel = label }
|
||
}
|
||
segmentCount = maxCount
|
||
|
||
if (segmentCount <= 1) {
|
||
unfilledFormData.push({ title: titleText, isExperience: true, formItems: [collectedFields.map((f) => ({ label: f.label, value: f.value }))] })
|
||
} else {
|
||
const markerPositions = collectedFields
|
||
.map((f, idx) => f.label === markerLabel ? idx : -1)
|
||
.filter((idx) => idx >= 0)
|
||
const segments: { label: string; value: string }[][] = []
|
||
for (let i = 0; i < markerPositions.length; i++) {
|
||
const start = markerPositions[i]
|
||
const end = i < markerPositions.length - 1 ? markerPositions[i + 1] : collectedFields.length
|
||
segments.push(collectedFields.slice(start, end).map((f) => ({ label: f.label, value: f.value })))
|
||
}
|
||
unfilledFormData.push({ title: titleText, isExperience: true, formItems: segments })
|
||
}
|
||
}
|
||
} else {
|
||
// 非经历类型:只存未填字段
|
||
unfilledFormData.push({
|
||
title: titleText,
|
||
isExperience: false,
|
||
formItems: collectedFields.map((f) => ({ label: f.label, value: f.value })),
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
console.log("===== OfferPie: 网站未填表单字段数据(JSON) =====")
|
||
// console.log(JSON.stringify(unfilledFormData, null, 2))
|
||
console.log("===== OfferPie: JSON 输出完毕 =====")
|
||
|
||
// 存 localStorage 缓存,包含简历名字和未填字段数据
|
||
// 更新规则:以第一次存的版本为基准,可以添加字段、更新 value,但不删除已有字段
|
||
const resumeName = currentResumeData?.main?.resumeName || currentResumeData?.main?.name || ""
|
||
try {
|
||
const existingRaw = localStorage.getItem("offerpie_unfilled_form")
|
||
let cacheData: any = null
|
||
|
||
if (existingRaw) {
|
||
const existing = JSON.parse(existingRaw)
|
||
if (existing.resumeName === resumeName && existing.unfilledFormData) {
|
||
// 同一份简历,合并更新(不删除已有字段,只添加新字段和更新 value)
|
||
const oldSections = existing.unfilledFormData as any[]
|
||
const newSections = unfilledFormData as any[]
|
||
|
||
// 遍历新数据,对每个 section 做合并
|
||
for (const newSec of newSections) {
|
||
const oldSec = oldSections.find((s: any) => s.title === newSec.title)
|
||
if (!oldSec) {
|
||
// 新增的 section,直接追加
|
||
oldSections.push(newSec)
|
||
continue
|
||
}
|
||
|
||
if (newSec.isExperience) {
|
||
// 经历类型:按段合并
|
||
const oldSegments = oldSec.formItems as any[][]
|
||
const newSegments = newSec.formItems as any[][]
|
||
for (let sIdx = 0; sIdx < newSegments.length; sIdx++) {
|
||
if (sIdx >= oldSegments.length) {
|
||
// 新增的段,直接追加
|
||
oldSegments.push(newSegments[sIdx])
|
||
continue
|
||
}
|
||
// 已有的段:合并字段
|
||
const oldFields = oldSegments[sIdx]
|
||
const newFields = newSegments[sIdx]
|
||
for (const newField of newFields) {
|
||
const oldField = oldFields.find((f: any) => f.label === newField.label)
|
||
if (oldField) {
|
||
// 已有字段:如果新值非空则更新
|
||
if (newField.value) oldField.value = newField.value
|
||
} else {
|
||
// 新字段:追加
|
||
oldFields.push(newField)
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
// 非经历类型:按字段合并
|
||
const oldFields = oldSec.formItems as any[]
|
||
const newFields = newSec.formItems as any[]
|
||
for (const newField of newFields) {
|
||
const oldField = oldFields.find((f: any) => f.label === newField.label)
|
||
if (oldField) {
|
||
if (newField.value) oldField.value = newField.value
|
||
} else {
|
||
oldFields.push(newField)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
existing.unfilledFormData = oldSections
|
||
existing.timestamp = Date.now()
|
||
cacheData = existing
|
||
}
|
||
}
|
||
|
||
// 没有已有缓存或不是同一份简历 → 新建
|
||
if (!cacheData) {
|
||
cacheData = {
|
||
resumeName,
|
||
unfilledFormData,
|
||
timestamp: Date.now(),
|
||
}
|
||
}
|
||
|
||
localStorage.setItem("offerpie_unfilled_form", JSON.stringify(cacheData))
|
||
console.log(`===== OfferPie: 已缓存未填字段数据(简历: "${resumeName}") =====`)
|
||
} catch (e) {
|
||
console.warn("OfferPie: localStorage 缓存失败", e)
|
||
}
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
|
||
// ============================================================
|
||
// 阶段B2:从 localStorage 缓存填写 unfilledFormData 有值字段
|
||
// 【独立模块】可以通过注释 handleAutoFillCommon 中的调用行来禁用
|
||
// ============================================================
|
||
|
||
/** 阶段B2 表单项容器选择器 */
|
||
const B2_FORM_ITEM_SELS = [
|
||
".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']",
|
||
]
|
||
|
||
/** 阶段B2 input 选择器 */
|
||
const B2_INPUT_SEL = "input:not([type='hidden']):not([type='submit']):not([type='button']):not([type='checkbox']):not([type='radio']):not([type='file']):not([disabled]), textarea:not([disabled])"
|
||
|
||
/** 阶段B2 排除的标签文字 */
|
||
const B2_EXCLUDE_LABELS = ["请输入", "输入", ":", ":", "请选择", "选择", "*", "必填"]
|
||
|
||
/** 阶段B2 5大简历经历的 section 名集合 */
|
||
const B2_FIVE_EXP_SECTIONS = new Set<string>(["education", "work", "internship", "project", "competition"])
|
||
|
||
/** 阶段B2 参数 */
|
||
interface FillCachedDataParams {
|
||
lang: "zh" | "en"
|
||
resumeName: string
|
||
usedInputs: Set<Element>
|
||
expandedResults: ExperienceSectionLocateResult[]
|
||
sectionResults: ExperienceSectionLocateResult[]
|
||
}
|
||
|
||
/** 阶段B2 结果 */
|
||
interface FillCachedDataResult {
|
||
success: number
|
||
failed: number
|
||
skipped: number
|
||
}
|
||
|
||
/**
|
||
* 阶段B2:在指定大标题范围内,通过标签文字查找对应的 input 元素
|
||
*/
|
||
function b2FindInputByLabel(
|
||
labelText: string,
|
||
titleEl: Element,
|
||
nextTitleEl: Element | null,
|
||
usedInputs: Set<Element>
|
||
): HTMLInputElement | HTMLTextAreaElement | null {
|
||
const allInputs = document.body.querySelectorAll(B2_INPUT_SEL)
|
||
for (const inp of Array.from(allInputs)) {
|
||
if (usedInputs.has(inp)) continue
|
||
const afterTitle = !!(titleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING)
|
||
const beforeNext = !nextTitleEl || !!(nextTitleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_PRECEDING)
|
||
if (!afterTitle || !beforeNext) continue
|
||
|
||
let container: Element | null = null
|
||
for (const sel of B2_FORM_ITEM_SELS) {
|
||
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 (B2_EXCLUDE_LABELS.some((ex) => directText === ex)) continue
|
||
// 标签文字必须严格全名匹配,不走 includes
|
||
if (directText === labelText &&
|
||
(el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING)) {
|
||
return inp as HTMLInputElement | HTMLTextAreaElement
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* 阶段B2:在指定容器内,通过标签文字查找对应的 input 元素
|
||
*/
|
||
function b2FindInputByLabelInContainer(
|
||
labelText: string,
|
||
containerEl: Element,
|
||
usedInputs: Set<Element>
|
||
): HTMLInputElement | HTMLTextAreaElement | null {
|
||
const allInputs = containerEl.querySelectorAll(B2_INPUT_SEL)
|
||
for (const inp of Array.from(allInputs)) {
|
||
if (usedInputs.has(inp)) continue
|
||
let formItem: Element | null = null
|
||
for (const sel of B2_FORM_ITEM_SELS) {
|
||
formItem = inp.closest(sel)
|
||
if (formItem) break
|
||
}
|
||
if (formItem) {
|
||
const labelEls = formItem.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 (B2_EXCLUDE_LABELS.some((ex) => directText === ex)) continue
|
||
// 标签文字必须严格全名匹配,不走 includes
|
||
if (directText === labelText &&
|
||
(el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING)) {
|
||
return inp as HTMLInputElement | HTMLTextAreaElement
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* 阶段B2:构造 MatchedFormField 并填写单个字段
|
||
* 【规范】走 detectPickerField + fillMatchedField 统一入口
|
||
*/
|
||
async function b2FillSingleField(
|
||
inputEl: HTMLInputElement | HTMLTextAreaElement,
|
||
labelText: string,
|
||
fillValue: string,
|
||
lang: "zh" | "en"
|
||
): Promise<boolean> {
|
||
if (inputEl.value && inputEl.value.trim().length > 0) return false
|
||
|
||
const field: MatchedFormField = {
|
||
key: "", section: "main", resumeField: "",
|
||
sectionIndex: 0, labelText,
|
||
labelElement: inputEl.previousElementSibling || inputEl.parentElement || inputEl,
|
||
labelSelector: "",
|
||
inputElement: inputEl,
|
||
inputSelector: buildSelector(inputEl),
|
||
buttonElement: null, buttonSelector: "",
|
||
inputType: inputEl.tagName === "TEXTAREA" ? "textarea" : "text",
|
||
radioContainer: null,
|
||
isPicker: false,
|
||
pickerDropdownElement: null, pickerDropdownSelector: "",
|
||
fillValue,
|
||
}
|
||
|
||
await detectPickerField(field, lang)
|
||
return await fillMatchedField(field)
|
||
}
|
||
|
||
/** 时间/日期相关的标签关键字 */
|
||
const B2_DATE_TIME_KEYWORDS = ["时间", "日期", "日期时间", "开始", "结束", "起始", "截止", "入职", "离职", "毕业"]
|
||
|
||
/**
|
||
* 判断标签名是否为时间/日期类字段
|
||
* 标签文字中包含时间/日期相关关键字即认为是时间字段
|
||
*/
|
||
function b2IsDateTimeLabel(label: string): boolean {
|
||
return B2_DATE_TIME_KEYWORDS.some((kw) => label.includes(kw))
|
||
}
|
||
|
||
/**
|
||
* 阶段B2:填写时间/日期字段
|
||
* 走和阶段A同款的流程:直接点击 input → fillDatePicker
|
||
* 不经过 detectPickerField(避免方式3主动点击导致 toggle 问题)
|
||
* 不经过 fillPickerField(避免步骤1-2再次点击关闭弹出层)
|
||
*
|
||
* 引用方式和阶段A的 fillTimePeriodField 情况A2 一致:
|
||
* input.focus() → input.click() → delay → fillDatePicker(field)
|
||
*/
|
||
async function b2FillDateTimeField(
|
||
inputEl: HTMLInputElement | HTMLTextAreaElement,
|
||
labelText: string,
|
||
fillValue: string
|
||
): Promise<boolean> {
|
||
// 构造 MatchedFormField(和阶段A一样,isPicker=true,不设 pickerDropdownElement)
|
||
const field: MatchedFormField = {
|
||
key: "", section: "main", resumeField: "",
|
||
sectionIndex: 0, labelText,
|
||
labelElement: inputEl.previousElementSibling || inputEl.parentElement || inputEl,
|
||
labelSelector: "",
|
||
inputElement: inputEl,
|
||
inputSelector: buildSelector(inputEl),
|
||
buttonElement: null, buttonSelector: "",
|
||
inputType: "text",
|
||
radioContainer: null,
|
||
isPicker: true,
|
||
pickerDropdownElement: null, pickerDropdownSelector: "",
|
||
fillValue,
|
||
}
|
||
|
||
// 和阶段A同样的方式:点击 input 展开日期面板,然后直接调 fillDatePicker
|
||
inputEl.focus()
|
||
;(inputEl as HTMLElement).click()
|
||
await delay(150)
|
||
|
||
const ok = await fillDatePicker(field)
|
||
if (ok) {
|
||
console.log(`OfferPie: ✅ [B2-时间字段] "${labelText}" = "${fillValue}" 填写成功`)
|
||
} else {
|
||
console.log(`OfferPie: ❌ [B2-时间字段] "${labelText}" = "${fillValue}" 填写失败`)
|
||
}
|
||
return ok
|
||
}
|
||
|
||
/**
|
||
* 阶段B2 主流程:从缓存读取 unfilledFormData,填写有值的字段
|
||
*/
|
||
async function handleFillCachedData(params: FillCachedDataParams): Promise<FillCachedDataResult> {
|
||
const { lang, resumeName, usedInputs, expandedResults, sectionResults } = params
|
||
const result: FillCachedDataResult = { success: 0, failed: 0, skipped: 0 }
|
||
|
||
const cachedRaw = localStorage.getItem("offerpie_unfilled_form")
|
||
if (!cachedRaw) {
|
||
console.log("===== OfferPie: 阶段B2 - 无缓存数据,跳过 =====")
|
||
return result
|
||
}
|
||
|
||
let cacheData: any
|
||
try { cacheData = JSON.parse(cachedRaw) } catch { return result }
|
||
|
||
if (cacheData.resumeName !== resumeName) {
|
||
console.log(`===== OfferPie: 阶段B2 - 缓存简历名"${cacheData.resumeName}"与当前"${resumeName}"不匹配,跳过 =====`)
|
||
return result
|
||
}
|
||
|
||
const unfilledFormData = cacheData.unfilledFormData as {
|
||
title: string
|
||
isExperience: boolean
|
||
formItems: { label: string; value: string }[] | { label: string; value: string }[][]
|
||
}[]
|
||
|
||
if (!unfilledFormData || unfilledFormData.length === 0) {
|
||
console.log("===== OfferPie: 阶段B2 - 缓存中无字段数据,跳过 =====")
|
||
return result
|
||
}
|
||
|
||
console.log("===== OfferPie: 阶段B2 - 填写缓存数据 =====")
|
||
|
||
const allTitles = getAllPageTitles(sectionResults)
|
||
if (allTitles.length === 0) {
|
||
console.log(" ❌ 未找到页面大标题,跳过")
|
||
return result
|
||
}
|
||
|
||
for (const sectionData of unfilledFormData) {
|
||
const titleInfo = allTitles.find((t) => t.text === sectionData.title)
|
||
if (!titleInfo) {
|
||
console.log(` [B2] "${sectionData.title}" 未在页面找到对应大标题,跳过`)
|
||
continue
|
||
}
|
||
const titleEl = titleInfo.element
|
||
const titleIdx = allTitles.indexOf(titleInfo)
|
||
const nextTitleEl = titleIdx < allTitles.length - 1 ? allTitles[titleIdx + 1].element : null
|
||
|
||
const expResult = expandedResults.find((r) => r.titleElement === titleEl)
|
||
const isFiveExp = !!expResult && B2_FIVE_EXP_SECTIONS.has(expResult.section)
|
||
|
||
if (sectionData.isExperience) {
|
||
const segments = sectionData.formItems as { label: string; value: string }[][]
|
||
|
||
if (isFiveExp && expResult) {
|
||
console.log(` [B2] "${sectionData.title}" (5大经历) 缓存${segments.length}段`)
|
||
for (let sIdx = 0; sIdx < segments.length; sIdx++) {
|
||
const seg = expResult.segmentRanges[sIdx]
|
||
if (!seg || !seg.containerElement) continue
|
||
const fields = segments[sIdx]
|
||
for (const field of fields) {
|
||
if (!field.value) { result.skipped++; continue }
|
||
const inputEl = b2FindInputByLabelInContainer(field.label, seg.containerElement, usedInputs)
|
||
if (!inputEl) { result.skipped++; continue }
|
||
// 时间/日期字段走阶段A同款流程:直接点击 input → fillDatePicker,不经过 detectPickerField
|
||
if (b2IsDateTimeLabel(field.label)) {
|
||
const ok = await b2FillDateTimeField(inputEl, field.label, field.value)
|
||
if (ok) { result.success++; usedInputs.add(inputEl); setFieldHighlight(inputEl, "greenTwo") } else { result.failed++ }
|
||
} else {
|
||
const ok = await b2FillSingleField(inputEl, field.label, field.value, lang)
|
||
if (ok) { result.success++; usedInputs.add(inputEl); setFieldHighlight(inputEl, "greenTwo") } else { result.failed++ }
|
||
}
|
||
await delay(100)
|
||
}
|
||
}
|
||
} else {
|
||
console.log(` [B2] "${sectionData.title}" (其他经历) 缓存${segments.length}段`)
|
||
|
||
// 检测当前页面已有几段
|
||
const allInputsInRange = document.body.querySelectorAll(B2_INPUT_SEL)
|
||
const labelsInRange: string[] = []
|
||
for (const inp of Array.from(allInputsInRange)) {
|
||
const afterTitle = !!(titleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING)
|
||
const beforeNext = !nextTitleEl || !!(nextTitleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_PRECEDING)
|
||
if (!afterTitle || !beforeNext) continue
|
||
let container: Element | null = null
|
||
for (const sel of B2_FORM_ITEM_SELS) { container = inp.closest(sel); if (container) break }
|
||
if (!container) continue
|
||
const labelEls = container.querySelectorAll("label, span, div, td, th, p, legend, dt")
|
||
for (const el of Array.from(labelEls)) {
|
||
const t = Array.from(el.childNodes).filter((n) => n.nodeType === Node.TEXT_NODE).map((n) => n.textContent?.trim()).filter(Boolean).join("")
|
||
if (t && t.length <= 30 && !B2_EXCLUDE_LABELS.some((ex) => t === ex) && (el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING)) {
|
||
labelsInRange.push(t)
|
||
break
|
||
}
|
||
}
|
||
}
|
||
const labelCounts = new Map<string, number>()
|
||
for (const l of labelsInRange) labelCounts.set(l, (labelCounts.get(l) || 0) + 1)
|
||
let currentSegCount = 1
|
||
for (const count of labelCounts.values()) { if (count > currentSegCount) currentSegCount = count }
|
||
|
||
const needAdd = segments.length - currentSegCount
|
||
if (needAdd > 0) {
|
||
const tempConfig = {
|
||
section: "project" as ExperienceSection,
|
||
zh: [sectionData.title],
|
||
en: [sectionData.title],
|
||
coreFieldKey: "",
|
||
addButtonZh: ["添加", "新增", "增加"],
|
||
addButtonEn: ["Add", "New"],
|
||
}
|
||
for (let i = 0; i < needAdd; i++) {
|
||
const addBtn = findAddButton(titleEl, nextTitleEl, tempConfig, lang)
|
||
if (!addBtn) { console.log(` [B2] "${sectionData.title}" 未找到添加按钮`); break }
|
||
const beforeCount = Array.from(document.body.querySelectorAll(B2_INPUT_SEL)).filter((inp) => {
|
||
const after = !!(titleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING)
|
||
const before = !nextTitleEl || !!(nextTitleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_PRECEDING)
|
||
return after && before
|
||
}).length
|
||
const clicked = await clickAddButton(addBtn, titleEl, nextTitleEl, beforeCount)
|
||
if (!clicked) { console.log(` [B2] "${sectionData.title}" 点击添加按钮无效`); break }
|
||
await delay(150)
|
||
}
|
||
}
|
||
|
||
for (let sIdx = 0; sIdx < segments.length; sIdx++) {
|
||
const fields = segments[sIdx]
|
||
for (const field of fields) {
|
||
if (!field.value) { result.skipped++; continue }
|
||
const inputEl = b2FindInputByLabel(field.label, titleEl, nextTitleEl, usedInputs)
|
||
if (!inputEl) { result.skipped++; continue }
|
||
// 时间/日期字段走阶段A同款流程:直接点击 input → fillDatePicker,不经过 detectPickerField
|
||
if (b2IsDateTimeLabel(field.label)) {
|
||
const ok = await b2FillDateTimeField(inputEl, field.label, field.value)
|
||
if (ok) { result.success++; usedInputs.add(inputEl); setFieldHighlight(inputEl, "greenTwo") } else { result.failed++ }
|
||
} else {
|
||
const ok = await b2FillSingleField(inputEl, field.label, field.value, lang)
|
||
if (ok) { result.success++; usedInputs.add(inputEl); setFieldHighlight(inputEl, "greenTwo") } else { result.failed++ }
|
||
}
|
||
await delay(100)
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
const fields = sectionData.formItems as { label: string; value: string }[]
|
||
console.log(` [B2] "${sectionData.title}" (非经历) 缓存${fields.length}个字段`)
|
||
for (const field of fields) {
|
||
if (!field.value) { result.skipped++; continue }
|
||
const inputEl = b2FindInputByLabel(field.label, titleEl, nextTitleEl, usedInputs)
|
||
if (!inputEl) { result.skipped++; continue }
|
||
// 时间/日期字段走阶段A同款流程:直接点击 input → fillDatePicker,不经过 detectPickerField
|
||
if (b2IsDateTimeLabel(field.label)) {
|
||
const ok = await b2FillDateTimeField(inputEl, field.label, field.value)
|
||
if (ok) { result.success++; usedInputs.add(inputEl); setFieldHighlight(inputEl, "greenTwo") } else { result.failed++ }
|
||
} else {
|
||
const ok = await b2FillSingleField(inputEl, field.label, field.value, lang)
|
||
if (ok) { result.success++; usedInputs.add(inputEl); setFieldHighlight(inputEl, "greenTwo") } else { result.failed++ }
|
||
}
|
||
await delay(100)
|
||
}
|
||
}
|
||
}
|
||
|
||
console.log(`===== OfferPie: 阶段B2完成 成功${result.success} 失败${result.failed} 跳过${result.skipped} =====`)
|
||
return result
|
||
} |