Files
offerpai_browser_plug/src/handlers/handleAutoFillFeishu.ts
T

1484 lines
68 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 飞书模式自动填写处理逻辑(适配 feishu.cn 域名的飞书招聘平台)
* 基于通用模式复制而来,后续在此基础上添加飞书平台特有的表单组件处理逻辑
*
* 【规范】所有填充操作必须走 fillMatchedField 统一入口,
* 选择器检测必须走 detectPickerField 统一入口,
* 不要在此文件中自行编写选择器操作逻辑,必须引用 lib 中已封装的方法。
*/
import { fillMatchedField, isSearchPickerField, fillSearchPickerField, isTimePeriodField, isTimeSingleField, fillTimePeriodField, fillTimeSingleField } from "~lib/autofill"
import { delay } from "~utils/delay"
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"
import { get as storageGet, set as storageSet } from "~utils/storage"
import { findLabelForInput } from "~lib/labelFinder"
/** 飞书模式自动填写的参数 */
export interface AutoFillFeishuParams {
/** 简历数据(接口获取的) */
resumeData: ResumeData | null
/** 岗位信息 */
jobInfo: JobInfo | null
}
/** 飞书模式自动填写的返回结果 */
export interface AutoFillFeishuResult {
/** 填写成功数 */
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[]
}
/**
* 飞书模式自动填写主流程(基于通用模式,适配 feishu.cn 域名的飞书招聘平台)
* 流程:提取 DOM → 检测语言 → 判断是否表单页 → 上传简历 → 匹配+填写经历 → 匹配+填写非经历 → 收集空白字段
*
* 【待适配】飞书平台特殊表单组件:
* - TODO: 飞书自定义单选组(如有特殊类名需在此处理)
* - TODO: 飞书级联选择器(地区、学校等多级联动)
* - TODO: 飞书日期选择器(如有自定义日期组件需特殊处理)
* - TODO: 飞书文件上传组件(简历附件上传的特殊逻辑)
*/
export async function handleAutoFillFeishu(params: AutoFillFeishuParams): Promise<AutoFillFeishuResult> {
const result: AutoFillFeishuResult = {
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("high") // 等待网站解析简历
// 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("mid")
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("low")
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("mid")
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("low")
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("mid")
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 || ""
// 构建阶段B中简历无值未填写的非经历字段映射(标签文字→input元素),传给B2作为 fallback 定位
const unfilledMainFieldMap = new Map<string, HTMLInputElement | HTMLTextAreaElement>()
for (const f of mainFields) {
if (!f.fillValue && f.inputElement && !usedInputs.has(f.inputElement)) {
unfilledMainFieldMap.set(f.labelText, f.inputElement)
}
}
const b2Result = await handleFillCachedData({
lang, resumeName, usedInputs, expandedResults, sectionResults, unfilledMainFieldMap,
})
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"])
// 从简历数据中提取所有值,构建排除集合(避免将已填值误认为标签)
const excludeTexts = new Set<string>()
if (currentResumeData) {
const main = currentResumeData.main
if (main) {
for (const val of Object.values(main)) {
if (typeof val === "string" && val.trim()) excludeTexts.add(val.trim())
if (Array.isArray(val)) val.forEach((v) => { if (typeof v === "string" && v.trim()) excludeTexts.add(v.trim()) })
}
}
const expKeys: ("education" | "work" | "internship" | "project" | "competition")[] = ["education", "work", "internship", "project", "competition"]
for (const sec of expKeys) {
const items = currentResumeData[sec]
if (!Array.isArray(items)) continue
for (const item of items) {
for (const val of Object.values(item as Record<string, any>)) {
if (typeof val === "string" && val.trim()) excludeTexts.add(val.trim())
}
}
}
}
/** 判断某个字段是否属于简历数据格式(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 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[] = []
// 构建当前大标题范围内所有已填 input 的值集合
// 用于排除误将已填值(如"香港理工大学"、"硕士"、"2026"等)当作标签的情况
const filledInputValues = new Set<string>()
for (const inp of Array.from(allInputs)) {
const afterT = !!(titleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING)
const beforeN = !nextTitleEl || !!(nextTitleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_PRECEDING)
if (!afterT || !beforeN) continue
const val = (inp as HTMLInputElement | HTMLTextAreaElement).value?.trim()
if (val) filledInputValues.add(val)
}
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<Element>(allTitles.map((t) => t.element))
const { labelText: detectedLabel } = findLabelForInput(inp, titleElementSet, excludeTexts)
const labelText = detectedLabel?.trim()
// 跳过无效标签:空、纯数字、已知排除词、或检测到的"标签"实际是某个已填 input 的值
if (!labelText || /^\d+$/.test(labelText) || JSON_EXCLUDE_LABELS.some((ex) => labelText === ex)) continue
if (filledInputValues.has(labelText)) continue
// 非经历类型:跳过简历格式字段
// 【注意】简历格式字段通过 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 输出完毕 =====")
// 存 chrome.storage 缓存,包含简历名字和未填字段数据(跨域名共享,按简历名区分)
const resumeName = currentResumeData?.main?.resumeName || currentResumeData?.main?.name || ""
try {
const existing = await storageGet<any>("offerpie_unfilled_form")
let cacheData: any = null
if (existing && existing.resumeName === resumeName && existing.unfilledFormData) {
// 同一份简历,按字段级合并(非空值才更新,保留旧缓存中已有的非空值不被覆盖)
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 {
const oldSec = oldSections[oldSecIdx]
if (newSec.isExperience && oldSec.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])
} else {
const oldFields = oldSegments[sIdx]
const newFields = newSegments[sIdx]
for (const nf of newFields) {
const of_ = oldFields.find((f: any) => f.label === nf.label)
if (of_) {
if (nf.value) of_.value = nf.value
} else {
oldFields.push(nf)
}
}
}
}
} else if (!newSec.isExperience && !oldSec.isExperience) {
// 非经历类型:按字段合并,只有非空值才更新,旧值不丢失
const oldFields = oldSec.formItems as any[]
const newFields = newSec.formItems as any[]
for (const nf of newFields) {
const of_ = oldFields.find((f: any) => f.label === nf.label)
if (of_) {
if (nf.value) of_.value = nf.value
} else {
oldFields.push(nf)
}
}
} 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 有值字段
// 【独立模块】可以通过注释 handleAutoFillFeishu 中的调用行来禁用
// ============================================================
/** 阶段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[]
/** 阶段B中简历无值未填写的非经历字段(标签→input映射),B2用缓存值填写这些字段 */
unfilledMainFieldMap?: Map<string, HTMLInputElement | HTMLTextAreaElement>
}
/** 阶段B2 结果 */
interface FillCachedDataResult {
success: number
failed: number
skipped: number
}
/**
* 阶段B2:在指定大标题范围内,通过标签文字查找对应的 input 元素
* 使用 findLabelForInput 统一标签定位(和缓存收集时一致),不依赖容器类名
*/
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
// 用 findLabelForInput 获取该 input 对应的标签文字,和缓存收集时逻辑一致
const { labelText: detectedLabel } = findLabelForInput(inp)
if (detectedLabel && detectedLabel.trim() === labelText) {
return inp as HTMLInputElement | HTMLTextAreaElement
}
}
return null
}
/**
* 阶段B2:在指定容器内,通过标签文字查找对应的 input 元素
* 使用 findLabelForInput 统一标签定位(和缓存收集时一致),不依赖容器类名
*/
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
// 用 findLabelForInput 获取该 input 对应的标签文字,和缓存收集时逻辑一致
const { labelText: detectedLabel } = findLabelForInput(inp)
if (detectedLabel && detectedLabel.trim() === labelText) {
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("mid")
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, unfilledMainFieldMap } = params
const result: FillCachedDataResult = { success: 0, failed: 0, skipped: 0 }
const cacheData = await storageGet<any>("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 {
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("mid")
}
}
} 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
// 用 findLabelForInput 获取标签文字,和缓存收集时逻辑一致
const { labelText: detectedLabel } = findLabelForInput(inp)
if (detectedLabel && detectedLabel.trim() && !B2_EXCLUDE_LABELS.some((ex) => detectedLabel.trim() === ex)) {
labelsInRange.push(detectedLabel.trim())
}
}
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("mid")
}
}
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("mid")
}
}
}
} 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 }
let inputEl = b2FindInputByLabel(field.label, titleEl, nextTitleEl, usedInputs)
// fallback:如果 b2FindInputByLabel 找不到,从阶段B传入的未填简历字段映射中查找
if (!inputEl && unfilledMainFieldMap) {
const fallbackEl = unfilledMainFieldMap.get(field.label)
if (fallbackEl && !usedInputs.has(fallbackEl)) {
inputEl = fallbackEl
console.log(` [B2] "${field.label}" 通过 unfilledMainFieldMap fallback 定位到 input`)
}
}
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("mid")
}
}
}
console.log(`===== OfferPie: 阶段B2完成 成功${result.success} 失败${result.failed} 跳过${result.skipped} =====`)
return result
}