/** * 通用模式自动填写处理逻辑 * 从 SidebarPanel.tsx 中抽离,便于后续针对特殊网站扩展不同的处理流程 * * 【规范】所有填充操作必须走 fillMatchedField 统一入口, * 选择器检测必须走 detectPickerField 统一入口, * 不要在此文件中自行编写选择器操作逻辑,必须引用 lib 中已封装的方法。 */ import { fillMatchedField, delay, forceSetValue, 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 { getResumeFieldValue } from "~lib/resumeDataHelper" import { fillDatePicker } from "~lib/datePicker" 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" import { get as storageGet, set as storageSet } from "~utils/storage" import { findLabelForInput } from "~lib/labelFinder" /** 北森模式自动填写的参数 */ export interface AutoFillBeisenParams { /** 简历数据(接口获取的) */ resumeData: ResumeData | null /** 岗位信息 */ jobInfo: JobInfo | null } /** 北森模式自动填写的返回结果 */ export interface AutoFillBeisenResult { /** 填写成功数 */ 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[] } /** * 北森模式自动填写主流程(基于通用模式微调,适配 zhiye.com 域名的北森招聘网站) * 流程:提取 DOM → 检测语言 → 判断是否表单页 → 上传简历 → 匹配+填写经历 → 匹配+填写非经历 → 收集空白字段 */ export async function handleAutoFillBeisen(params: AutoFillBeisenParams): Promise { const result: AutoFillBeisenResult = { 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() // 预匹配经历区域字段 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() // 全局已使用的 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 if (isBeisenCascadeField(f.labelText) && f.inputElement) { // 北森多级级联选择器字段(地区、籍贯、民族等) const ok = await fillBeisenCascadePicker(f.inputElement, f.labelText, f.fillValue) 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(150) 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 } // 北森多级级联选择器字段优先处理(地区、籍贯、民族等) if (isBeisenCascadeField(f.labelText) && f.inputElement) { console.log(` [${f.key}] "${f.labelText}" → 北森级联选择器 | fillValue: "${f.fillValue}"`) const ok = await fillBeisenCascadePicker(f.inputElement, f.labelText, f.fillValue) if (ok) { result.success++ } else { result.failed++ } await delay(150) 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() 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 } { // 统计每个标签文字出现的次数 const labelCounts = new Map() const labelElements = new Map() // 每个标签文字对应的所有 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() 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() 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() // 阶段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 } // 查找标签(使用统一封装的 labelFinder) const titleElementSet = new Set(allTitles.map((t) => t.element)) const { labelText: detectedLabel } = findLabelForInput(inp, titleElementSet) const labelText = detectedLabel // 非经历类型:跳过简历格式字段 // 【注意】简历格式字段通过 resumeFormatInputs(阶段A/B实际匹配到的input元素集合)精确跳过 // 不用标签文字匹配跳过,因为同一标签名可能出现在不同大标题下(如"最高学历"在个人信息 vs 教育经历) // resumeFormatInputs 已在上方 if(!isExpType) 块中判断,此处无需重复 collectedFields.push({ label: labelText, value: inputEl.value?.trim() || "", inputEl: inp }) } // ============ 北森特有:收集 phoenix-radio-group 单选组字段 ============ // DOM 结构:form-item > form-item__title > form-item__text(标签文字) // > form-item__control > phoenix-radio-group > phoenix-radio-group__radioItem(选项) // 选中判断:radioItem 子级有 phoenix-radio--checked 类名 { const radioGroups = document.body.querySelectorAll(".phoenix-radio-group") for (const rg of Array.from(radioGroups)) { // 范围检查:radio group 必须在当前大标题范围内 const afterTitle = !!(titleEl.compareDocumentPosition(rg) & Node.DOCUMENT_POSITION_FOLLOWING) const beforeNext = !nextTitleEl || !!(nextTitleEl.compareDocumentPosition(rg) & Node.DOCUMENT_POSITION_PRECEDING) if (!afterTitle || !beforeNext) continue // 非经历类型:跳过已被阶段B2填充的(greenTwo 背景) if (!isExpType) { const rgBgColor = (rg as HTMLElement).style.backgroundColor if (rgBgColor === "#b7ffc5" || rgBgColor === "rgb(183, 255, 197)") continue } // 找标签文字:从 form-item__control 的同级前面的 form-item__title 里取 form-item__text let radioLabel = "" const controlEl = rg.closest(".form-item__control") if (controlEl && controlEl.parentElement) { const titleDiv = controlEl.parentElement.querySelector(".form-item__title .form-item__text") if (titleDiv) { const directText = Array.from(titleDiv.childNodes) .filter((n) => n.nodeType === Node.TEXT_NODE) .map((n) => n.textContent?.trim()) .filter(Boolean) .join("") if (directText && directText.length <= 30) radioLabel = directText } } if (!radioLabel) continue // 获取当前选中值:找 phoenix-radio--checked 的 radioItem 的文字 let selectedValue = "" const radioItems = rg.querySelectorAll(".phoenix-radio-group__radioItem") for (const item of Array.from(radioItems)) { const checkedEl = item.querySelector(".phoenix-radio--checked") if (checkedEl) { selectedValue = item.textContent?.trim() || "" break } } collectedFields.push({ label: radioLabel, value: selectedValue, inputEl: rg as unknown as Element }) } } 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() 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 输出完毕 =====") // 存 chrome.storage 缓存,包含简历名字和未填字段数据(跨域名共享,按简历名区分) const resumeName = currentResumeData?.main?.resumeName || currentResumeData?.main?.name || "" try { const existing = await storageGet("offerpie_unfilled_form") let cacheData: any = null if (existing && existing.resumeName === resumeName && existing.unfilledFormData) { // 同一份简历,用新数据替换同名 section(保留其他平台的 section) const oldSections = existing.unfilledFormData as any[] const newSections = unfilledFormData as any[] for (const newSec of newSections) { const oldSecIdx = oldSections.findIndex((s: any) => s.title === newSec.title) if (oldSecIdx < 0) { oldSections.push(newSec) } else { oldSections[oldSecIdx] = newSec } } existing.unfilledFormData = oldSections existing.timestamp = Date.now() cacheData = existing } // 没有已有缓存或不是同一份简历 → 新建 if (!cacheData) { cacheData = { resumeName, unfilledFormData, timestamp: Date.now(), } } await storageSet("offerpie_unfilled_form", cacheData) console.log(`===== OfferPie: 已缓存未填字段数据(简历: "${resumeName}") =====`) } catch (e) { console.warn("OfferPie: chrome.storage 缓存失败", e) } } return result } // ============================================================ // 阶段B2:从 localStorage 缓存填写 unfilledFormData 有值字段 // 【独立模块】可以通过注释 handleAutoFillBeisen 中的调用行来禁用 // ============================================================ /** 阶段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(["education", "work", "internship", "project", "competition"]) /** 阶段B2 参数 */ interface FillCachedDataParams { lang: "zh" | "en" resumeName: string usedInputs: Set 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 ): 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 ): 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 { 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 { // 构造 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 填写 phoenix-radio-group 单选组字段 * 在指定大标题范围内,通过标签文字找到对应的 radio group,点击匹配选项 * * DOM 结构: * form-item > form-item__title > form-item__text(标签文字,含 ::before 红*伪类) * > form-item__control > phoenix-radio-group > phoenix-radio-group__radioItem * > phoenix-radio(选中时带 phoenix-radio--checked 类名) * * 操作方式:点击 radioItem 的子级和子子级标签来选中 * 选中判断:radioItem 子级出现 phoenix-radio--checked 类名 * 变色:成功 → phoenix-radio-group 容器 backgroundColor 设为 greenTwo */ async function b2FillBeisenRadioGroup( labelText: string, fillValue: string, titleEl: Element, nextTitleEl: Element | null ): Promise { // 1. 在大标题范围内找到所有 phoenix-radio-group const radioGroups = document.body.querySelectorAll(".phoenix-radio-group") for (const rg of Array.from(radioGroups)) { // 范围检查 const afterTitle = !!(titleEl.compareDocumentPosition(rg) & Node.DOCUMENT_POSITION_FOLLOWING) const beforeNext = !nextTitleEl || !!(nextTitleEl.compareDocumentPosition(rg) & Node.DOCUMENT_POSITION_PRECEDING) if (!afterTitle || !beforeNext) continue // 2. 找标签文字:从 form-item__control 的同级前面的 form-item__title 里取 form-item__text let radioLabel = "" const controlEl = rg.closest(".form-item__control") if (controlEl && controlEl.parentElement) { const titleDiv = controlEl.parentElement.querySelector(".form-item__title .form-item__text") if (titleDiv) { const directText = Array.from(titleDiv.childNodes) .filter((n) => n.nodeType === Node.TEXT_NODE) .map((n) => n.textContent?.trim()) .filter(Boolean) .join("") if (directText && directText.length <= 30) radioLabel = directText } } // 标签文字必须严格全名匹配 if (radioLabel !== labelText) continue // 3. 找到匹配的 radioItem 并点击 const radioItems = rg.querySelectorAll(".phoenix-radio-group__radioItem") for (const item of Array.from(radioItems)) { const itemText = item.textContent?.trim() || "" if (itemText !== fillValue) continue // 点击 radioItem 的子级标签和子子级标签来选中 const childEl = item.firstElementChild as HTMLElement | null if (childEl) { childEl.click() await delay(50) // 子子级也点一下确保触发 const grandChildEl = childEl.firstElementChild as HTMLElement | null if (grandChildEl) { grandChildEl.click() await delay(50) } } else { // fallback:直接点击 radioItem 自身 ;(item as HTMLElement).click() await delay(50) } // 4. 验证选中结果:检查子级是否出现 phoenix-radio--checked 类名 await delay(100) const checkedEl = item.querySelector(".phoenix-radio--checked") if (checkedEl) { // 选中成功,radio group 容器变 greenTwo ;(rg as HTMLElement).style.backgroundColor = "#b7ffc6" console.log(` [B2-北森单选] "${labelText}" 选中 "${fillValue}" ✓`) return true } else { console.log(` [B2-北森单选] "${labelText}" 点击 "${fillValue}" 但未检测到 checked 状态`) return false } } // 没找到匹配的选项文字 console.log(` [B2-北森单选] "${labelText}" 未找到选项 "${fillValue}"`) return false } // 没找到匹配标签的 radio group return false } /** * 阶段B2 主流程:从缓存读取 unfilledFormData,填写有值的字段 */ async function handleFillCachedData(params: FillCachedDataParams): Promise { const { lang, resumeName, usedInputs, expandedResults, sectionResults } = params const result: FillCachedDataResult = { success: 0, failed: 0, skipped: 0 } const cacheData = await storageGet("offerpie_unfilled_form") if (!cacheData) { console.log("===== OfferPie: 阶段B2 - 无缓存数据,跳过 =====") 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 if (isBeisenCascadeField(field.label)) { // 北森多级级联选择器字段(地区、籍贯、民族等) const ok = await fillBeisenCascadePicker(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() 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 if (isBeisenCascadeField(field.label)) { // 北森多级级联选择器字段(地区、籍贯、民族等) const ok = await fillBeisenCascadePicker(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) { // 时间/日期字段走阶段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 if (isBeisenCascadeField(field.label)) { // 北森多级级联选择器字段(地区、籍贯、民族等) const ok = await fillBeisenCascadePicker(inputEl, field.label, field.value) if (ok) { result.success++; usedInputs.add(inputEl); setFieldHighlight(inputEl, "greenTwo") } else { result.failed++ } } else { // 普通 input/textarea 字段 const ok = await b2FillSingleField(inputEl, field.label, field.value, lang) if (ok) { result.success++; usedInputs.add(inputEl); setFieldHighlight(inputEl, "greenTwo") } else { result.failed++ } } } else { // 找不到 input,尝试北森 phoenix-radio-group 单选组 const radioOk = await b2FillBeisenRadioGroup(field.label, field.value, titleEl, nextTitleEl) if (radioOk) { result.success++ } else { result.skipped++ } } await delay(100) } } } console.log(`===== OfferPie: 阶段B2完成 成功${result.success} 失败${result.failed} 跳过${result.skipped} =====`) return result } // ============================================================ // 北森多级级联选择器(cascade picker)检测与填写 // ============================================================ /** * 北森多级级联选择器关键字 * 字段标签中包含以下关键字的,可能是级联选择器字段 */ const BEISEN_CASCADE_KEYWORDS = ["地区", "居住地", "籍贯", "户籍", "地点", "所在地", "民族", "地址", "现居"] /** * 判断字段标签是否可能是北森多级级联选择器字段 * 通过标签文字中是否含有地区/籍贯/民族等关键字来判断 */ export function isBeisenCascadeField(labelText: string): boolean { return BEISEN_CASCADE_KEYWORDS.some((kw) => labelText.includes(kw)) } /** * 北森多级级联选择器填写方法 * * 操作流程: * 1. 点击字段对应的 input,等待弹出 DOM 面板 * 2. 在页面中找到 common-unmodeled-layer 类名的面板元素 * 3. 在面板内找到 phoenix-input__input 类名的 input 输入框 * 4. 先点击 clear-select-data 清空已选内容 * 5. 在输入框中输入要填写的值,等待 50ms * 6. 检测选项集合位置(两种情况): * - 情况1: left-container 下找 area-data-container,其子级 div 就是选项 * - 情况2: left-container 下找 list-data-container,其第1子div的第1子div的第1子div是选项集合 * 7. 在选项集合的第一个 div 里找 icon-container 的 span 并点击选中 * 8. 找 area-footer-button 里的确定按钮(phoenix-button / phoenix-button__wraper / phoenix-button__content 三级都点一下) * * @param inputEl - 字段对应的 input 元素(表单里那个输入框,用于点击触发弹出面板) * @param labelText - 字段标签文字(用于日志) * @param fillValue - 要填写的值 * @returns 是否填写成功 */ export async function fillBeisenCascadePicker( inputEl: HTMLInputElement | HTMLTextAreaElement, labelText: string, fillValue: string ): Promise { if (!inputEl || !fillValue) return false console.log(`OfferPie: [北森级联] "${labelText}" 开始填写 "${fillValue}"`) // 1. 点击 input 触发弹出面板 inputEl.scrollIntoView({ block: "center", behavior: "instant" }) ;(inputEl as HTMLElement).click() inputEl.focus() await delay(400) // 2. 在页面中找 common-unmodeled-layer 面板(取最后一个,即最新弹出的) const allPanels = document.querySelectorAll(".common-unmodeled-layer") const panelEl = allPanels.length > 0 ? (allPanels[allPanels.length - 1] as HTMLElement) : null if (!panelEl) { console.log(`OfferPie: [北森级联] "${labelText}" 未找到 common-unmodeled-layer 面板`) return false } console.log(`OfferPie: [北森级联] "${labelText}" 找到级联面板 (共${allPanels.length}个,取最后一个)`) // 3. 在面板内找搜索输入框(优先 phoenix-input__input,fallback 到面板内任意可见 input) let searchInput = panelEl.querySelector("input.phoenix-input__input") as HTMLInputElement | null if (!searchInput) { // fallback:在面板内找所有非 hidden 的 input,取第一个可见的 const allPanelInputs = panelEl.querySelectorAll("input:not([type='hidden'])") console.log(`OfferPie: [北森级联] "${labelText}" phoenix-input__input 未找到,尝试 fallback (面板内共${allPanelInputs.length}个input)`) for (const inp of Array.from(allPanelInputs)) { const htmlInp = inp as HTMLInputElement if (htmlInp.offsetHeight > 0 && htmlInp.offsetWidth > 0) { searchInput = htmlInp break } } } if (!searchInput) { console.log(`OfferPie: [北森级联] "${labelText}" 面板内未找到搜索输入框,面板 innerHTML 前200字: ${panelEl.innerHTML.substring(0, 200)}`) return false } // 4. 先点击 clear-select-data 清空已选内容 const clearBtn = panelEl.querySelector(".clear-select-data") as HTMLElement | null if (clearBtn) { clearBtn.click() await delay(200) // 等待清空操作生效 console.log(`OfferPie: [北森级联] "${labelText}" 已清空已选内容`) } // 5. 在搜索输入框中输入要填写的值 searchInput.focus() forceSetValue(searchInput, fillValue) searchInput.dispatchEvent(new Event("input", { bubbles: true })) searchInput.dispatchEvent(new Event("change", { bubbles: true })) await delay(300) // 等待搜索结果刷新(太短会导致选项列表未更新,选到错误选项) // 6. 检测选项集合位置(两种情况) // 在 left-container 下查找 const leftContainer = panelEl.querySelector(".left-container") as HTMLElement | null if (!leftContainer) { console.log(`OfferPie: [北森级联] "${labelText}" 面板内未找到 left-container`) return false } let optionClicked = false // 情况1: area-data-container const areaDataContainer = leftContainer.querySelector(".area-data-container") as HTMLElement | null if (areaDataContainer) { console.log(`OfferPie: [北森级联] "${labelText}" 检测到情况1 (area-data-container)`) const firstOptionDiv = areaDataContainer.querySelector(":scope > div") as HTMLElement | null if (firstOptionDiv) { const iconSpan = firstOptionDiv.querySelector(".icon-container") as HTMLElement | null if (iconSpan) { iconSpan.click() const svgEl = iconSpan.querySelector("svg") as unknown as HTMLElement | null if (svgEl) svgEl.dispatchEvent(new MouseEvent("click", { bubbles: true })) optionClicked = true await delay(200) console.log(`OfferPie: [北森级联] "${labelText}" 已点击第一个选项的 icon-container + svg`) } } } // 情况2: list-data-container if (!optionClicked) { const listDataContainer = leftContainer.querySelector(".list-data-container") as HTMLElement | null if (listDataContainer) { console.log(`OfferPie: [北森级联] "${labelText}" 检测到情况2 (list-data-container)`) // list-data-container > 第1子div > 第1子div > 第1子div = 选项集合标签 const level1 = listDataContainer.firstElementChild as HTMLElement | null const level2 = level1?.firstElementChild as HTMLElement | null const level3 = level2?.firstElementChild as HTMLElement | null if (level3) { // 选项集合的第一个子div const firstOption = level3.firstElementChild as HTMLElement | null if (firstOption) { const iconSpan = firstOption.querySelector(".icon-container") as HTMLElement | null if (iconSpan) { iconSpan.click() // 同时点击 icon-container 里面的 svg const svgEl = iconSpan.querySelector("svg") as unknown as HTMLElement | null if (svgEl) svgEl.dispatchEvent(new MouseEvent("click", { bubbles: true })) optionClicked = true await delay(200) console.log(`OfferPie: [北森级联] "${labelText}" 已点击第一个选项的 icon-container + svg`) } } } } } if (!optionClicked) { console.log(`OfferPie: [北森级联] "${labelText}" 未找到可点击的选项`) return false } // 8. 找确定按钮并点击(兼容两种 footer 类名:area-footer-button / selector-footer-button) // 【注意】footer 下有"取消"和"确定"两个按钮,querySelector 会取到第一个(取消), // 所以必须通过文字"确定"精确定位 或 取最后一个 phoenix-button 才能点到确定按钮 const footerBtn = (panelEl.querySelector(".area-footer-button") || panelEl.querySelector(".selector-footer-button")) as HTMLElement | null if (footerBtn) { // 策略1:通过 phoenix-button__content 文字"确定"精确定位 const allContents = footerBtn.querySelectorAll(".phoenix-button__content") let confirmContent: HTMLElement | null = null for (const el of Array.from(allContents)) { if (el.textContent?.trim() === "确定") { confirmContent = el as HTMLElement break } } if (confirmContent) { confirmContent.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true })) confirmContent.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true })) confirmContent.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })) // 同时点击它的父级 wraper 和祖父级 button const wraper = confirmContent.parentElement as HTMLElement | null if (wraper) { wraper.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true })) wraper.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true })) wraper.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })) } const btn = wraper?.parentElement as HTMLElement | null if (btn) { btn.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true })) btn.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true })) btn.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })) } } // 策略2:取最后一个 phoenix-button 也点击一下 const allBtns = footerBtn.querySelectorAll(".phoenix-button") if (allBtns.length > 0) { const lastBtn = allBtns[allBtns.length - 1] as HTMLElement lastBtn.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true })) lastBtn.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true })) lastBtn.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })) // 最后一个 button 里的 wraper 和 content 也点 const lastWraper = lastBtn.querySelector(".phoenix-button__wraper") as HTMLElement | null if (lastWraper) { lastWraper.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true })) lastWraper.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true })) lastWraper.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })) } const lastContent = lastBtn.querySelector(".phoenix-button__content") as HTMLElement | null if (lastContent) { lastContent.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true })) lastContent.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true })) lastContent.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })) } } await delay(300) console.log(`OfferPie: [北森级联] "${labelText}" 已点击确定按钮`) } else { console.log(`OfferPie: [北森级联] "${labelText}" 未找到确定按钮`) return false } console.log(`OfferPie: ✅ [北森级联] "${labelText}" = "${fillValue}" 填写成功`) return true }