diff --git a/src/components/SidebarPanel.tsx b/src/components/SidebarPanel.tsx index 31936cf..f151be8 100644 --- a/src/components/SidebarPanel.tsx +++ b/src/components/SidebarPanel.tsx @@ -8,6 +8,7 @@ import { useState, useEffect } from "react" import { getCookieValue } from "~utils/cookie" import { getCustomizeResume } from "~api/aiApi" import { handleAutoFillCommon } from "~handlers/handleAutoFillCommon" +import { handleAutoFillBeisen } from "~handlers/handleAutoFillBeisen" import type { MatchedFormField, ResumeData, JobInfo } from "~lib/types" import "./SidebarPanel.scss" @@ -34,6 +35,8 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps) const [formFields, setFormFields] = useState([]) /** 当前使用的简历数据 */ const [resumeData, setResumeData] = useState(null) + /** 自动填写流程是否已完成(控制模拟提交按钮可用) */ + const [fillCompleted, setFillCompleted] = useState(false) /** 页面加载时检查 Token,有岗位信息则查询定制简历 */ useEffect(() => { @@ -64,24 +67,218 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps) /** * 自动填写按钮点击处理 - * 内部根据条件判断走 handlers/ 下具体哪个处理文件: - * - handleAutoFillCommon:通用模式(当前默认) + * 内部根据当前页面域名判断走哪个处理模式: + * - handleAutoFillBeisen:北森模式(域名包含 zhiye.com,如 avicsz.zhiye.com) + * - handleAutoFillCommon:通用模式(其他所有网站) * - 后续特殊网站会在此处加条件分支(如根据 domain 或 jobInfo 来源判断) */ const handleAutoFill = async () => { setFilling(true) try { - const fillResult = await handleAutoFillCommon({ resumeData, jobInfo }) + // 检测当前页面域名,判断走哪个处理模式 + const currentHost = window.location.hostname + const isBeisen = currentHost.includes("zhiye.com") // 北森招聘平台域名特征 + + const fillResult = isBeisen + ? await handleAutoFillBeisen({ resumeData, jobInfo }) + : await handleAutoFillCommon({ resumeData, jobInfo }) + setPageLang(fillResult.lang) setIsFormPage(fillResult.isFormPage) if (fillResult.resumeData) setResumeData(fillResult.resumeData) setFormFields(fillResult.formFields) + setFillCompleted(true) } catch (e) { console.error("OfferPie: 自动填写异常", e) } setTimeout(() => setFilling(false), 1000) } + /** + * 模拟提交:重新读取页面上 unfilledFormData 对应字段的当前值,更新到缓存 + * 按大标题范围限定搜索,避免跨区域匹配到错误字段 + */ + const handleSaveUserInput = () => { + try { + const cached = localStorage.getItem("offerpie_unfilled_form") + if (!cached) { alert("未找到缓存数据,请先执行自动填写"); return } + + const cacheData = JSON.parse(cached) + const unfilledFormData = cacheData.unfilledFormData as { + title: string + isExperience: boolean + formItems: { label: string; value: string }[] | { label: string; value: string }[][] + }[] + + const 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])" + 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']", + ] + const EXCLUDE_LABELS = ["请输入", "输入", ":", ":", "请选择", "选择", "*", "必填"] + + /** + * 在页面上按文字找到大标题元素 + */ + function findTitleElement(titleText: string): Element | null { + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT) + let node: Node | null = walker.nextNode() + while (node) { + const el = node as Element + const directText = Array.from(el.childNodes) + .filter((n) => n.nodeType === Node.TEXT_NODE) + .map((n) => n.textContent?.trim()) + .filter(Boolean) + .join("") + if (directText === titleText && el.children.length === 0) { + return el + } + node = walker.nextNode() + } + return null + } + + /** + * 在指定大标题范围内,通过标签文字查找对应 input 的值 + */ + function findInputValueByLabelInRange(labelText: string, titleEl: Element, nextTitleEl: Element | null): string { + const allInputs = document.querySelectorAll(INPUT_SEL) + 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 + + // 跳过已被阶段A/B填充的简历格式字段(背景色为绿色高亮) + // greenTwo(#b7ffc6)是阶段B2填的unfilledFormData字段,不跳过,可以更新 + const inputEl = inp as HTMLInputElement | HTMLTextAreaElement + const bgColor = inputEl.style.backgroundColor + if (bgColor === "#b7ffc5" || bgColor === "rgb(183, 255, 197)") continue + + let container: Element | null = null + for (const sel of 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 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_LABELS.some((ex) => directText === ex)) continue + // 非经历类型标签必须全名严格匹配,不走 includes + if (directText === labelText && + (el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING)) { + return inputEl.value?.trim() || "" + } + } + } + return "" + } + + /** + * 北森特有:在指定大标题范围内,通过标签文字查找 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 类名,取该 radioItem 的文字 + */ + function findRadioGroupValueByLabelInRange(labelText: string, titleEl: Element, nextTitleEl: Element | null): string { + const radioGroups = document.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 + + // 找标签文字:form-item__control 同级的 form-item__title 里的 form-item__text + const controlEl = rg.closest(".form-item__control") + if (!controlEl || !controlEl.parentElement) continue + const titleDiv = controlEl.parentElement.querySelector(".form-item__title .form-item__text") + if (!titleDiv) continue + const directText = Array.from(titleDiv.childNodes) + .filter((n) => n.nodeType === Node.TEXT_NODE) + .map((n) => n.textContent?.trim()) + .filter(Boolean) + .join("") + // 标签严格全名匹配 + if (directText !== labelText) continue + + // 找选中的 radioItem + const radioItems = rg.querySelectorAll(".phoenix-radio-group__radioItem") + for (const item of Array.from(radioItems)) { + const checkedEl = item.querySelector(".phoenix-radio--checked") + if (checkedEl) { + return item.textContent?.trim() || "" + } + } + // 找到了 radio group 但没有选中项,返回空 + return "" + } + return "" + } + + // 收集所有标题元素(按 unfilledFormData 顺序) + const titleElements: (Element | null)[] = unfilledFormData.map((s) => findTitleElement(s.title)) + + // 遍历 unfilledFormData,按大标题范围限定搜索 + let updatedCount = 0 + for (let i = 0; i < unfilledFormData.length; i++) { + const section = unfilledFormData[i] + const titleEl = titleElements[i] + if (!titleEl) continue + + // 找下一个大标题作为范围终点 + let nextTitleEl: Element | null = null + for (let j = i + 1; j < titleElements.length; j++) { + if (titleElements[j]) { nextTitleEl = titleElements[j]; break } + } + + if (section.isExperience) { + const segments = section.formItems as { label: string; value: string }[][] + for (const seg of segments) { + for (const field of seg) { + const currentValue = findInputValueByLabelInRange(field.label, titleEl, nextTitleEl) + if (currentValue && currentValue !== field.value) { + field.value = currentValue + updatedCount++ + } + } + } + } else { + const fields = section.formItems as { label: string; value: string }[] + for (const field of fields) { + // 先尝试从 input/textarea 读取值 + let currentValue = findInputValueByLabelInRange(field.label, titleEl, nextTitleEl) + // 如果没读到,尝试从北森 phoenix-radio-group 单选组读取当前选中值 + if (!currentValue) { + currentValue = findRadioGroupValueByLabelInRange(field.label, titleEl, nextTitleEl) + } + if (currentValue && currentValue !== field.value) { + field.value = currentValue + updatedCount++ + } + } + } + } + + // 更新缓存 + cacheData.unfilledFormData = unfilledFormData + cacheData.timestamp = Date.now() + localStorage.setItem("offerpie_unfilled_form", JSON.stringify(cacheData)) + alert(`已保存!更新了 ${updatedCount} 个字段的值`) + console.log("[OfferPie] 模拟提交:已更新缓存数据", cacheData) + } catch (e) { + console.error("[OfferPie] 模拟提交失败:", e) + alert("保存失败,请查看控制台") + } + } + return (
{/* 顶部操作栏:关闭按钮始终显示,反馈和设置仅登录后显示 */} @@ -183,8 +380,27 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps) {/* 更改 */}
- {/* 针对性优化简历按钮 */} - + {/* 按钮 */} + {/* 模拟提交按钮(自动填写完成后才可点击) */} + + {/* 填写进度区域 */}
diff --git a/src/config.ts b/src/config.ts index cc02b33..d92070b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,7 +3,7 @@ */ /** 当前环境,手动切换 */ -const ENV = 'dev' +const ENV = 'prod' /** 各环境配置 */ const envConfigs: Record { + const result: AutoFillBeisenResult = { + success: 0, failed: 0, skipped: 0, + lang: "zh", isFormPage: false, + resumeData: params.resumeData, + formFields: [], + unmatchedFields: [], + } + + // 1. 提取 DOM 结构 + const domStructure = extractDomStructure() + console.log("===== OfferPie: 完整 DOM 树结构 =====") + console.log(domStructure) + console.log(`===== OfferPie: 结构总长度 ${domStructure.length} 字符 =====`) + + // 2. 检测页面语言 + const lang = detectPageLanguage(domStructure) + result.lang = lang + console.log(`===== OfferPie: 页面语言检测结果 = ${lang} =====`) + + // 3. 判断是否为职位申请表单页面 + const isForm = isJobApplicationForm(document.body, lang) + result.isFormPage = isForm + console.log(`===== OfferPie: 是否为职位申请表单页面 = ${isForm} =====`) + + if (!isForm) { + console.log("===== OfferPie: 当前页面不是职位申请表单,跳过字段匹配 =====") + return result + } + + // 4. 获取简历数据(优先使用接口数据,无接口数据时 fallback 到 mock) + const currentResumeData = params.resumeData || getMockResumeData2() + result.resumeData = currentResumeData + console.log(`===== OfferPie: 已加载简历数据,教育${currentResumeData.education.length}段 工作${currentResumeData.work.length}段 实习${currentResumeData.internship.length}段 项目${currentResumeData.project.length}段 竞赛${currentResumeData.competition.length}段 =====`) + + + + + + // 4.1 检测并上传简历文件 + // const resumeUrl = "https://offerpie.oss-cn-guangzhou.aliyuncs.com/%E5%AE%BE%E5%A4%95%E6%B3%95%E5%B0%BC%E4%BA%9A%E5%A4%A7%E5%AD%A6_%E4%B8%81%E5%B1%B9%E6%B6%B5.pdf" + // const uploaded = await detectAndUploadResume(resumeUrl) + // console.log(`===== OfferPie: 简历上传 ${uploaded ? "成功" : "跳过(未找到上传按钮或失败)"} =====`) + // if (uploaded) await delay(1000) // 等待网站解析简历 + + // 4.5 定位经历区块并统计已展开段数 + const sectionResults = locateExperienceSections(document.body, lang) + // 4.6 对比简历数据段数,点击添加按钮补足不够的段数 + const expandedResults = await expandExperienceSections(sectionResults, currentResumeData, lang) + + // 4.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(200) + 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(100) + lastTextInput.blur() + } else { + if (document.activeElement instanceof HTMLElement) { + document.activeElement.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", code: "Escape", keyCode: 27, bubbles: true }) + ) + document.activeElement.blur() + } + document.body.click() + } + await delay(300) + + if (!f.isPicker && f.inputElement && !isTimePeriodField(f.key) && !isTimeSingleField(f.key)) { + lastTextInput = f.inputElement + lastTextInputIsPicker = false + } else { + lastTextInputIsPicker = true + } + } + } + } + console.log(`===== OfferPie: 阶段A完成 成功${result.success} 失败${result.failed} 跳过${result.skipped} =====`) + + } // end of else (skipPhaseA) + + // 6. 非经历区域字段匹配与填写(阶段B) + console.log("===== OfferPie: 阶段B - 非经历区域填写 =====") + const mainFields = matchMainFields(document.body, lang, excludeRanges, usedInputs) + console.log(` 匹配到 ${mainFields.length} 个非经历字段`) + + for (const f of mainFields) { + const value = getResumeFieldValue(currentResumeData, f.section, f.sectionIndex, f.resumeField) + if (value) f.fillValue = value + + // 检测该字段是否已被网站自动填入值 + if (f.inputElement && f.inputElement.value && f.inputElement.value.trim().length > 0) { + console.log(` [${f.key}] "${f.labelText}" 已有值="${f.inputElement.value.trim()}",跳过`) + result.skipped++ + continue + } + + if (!f.fillValue) { result.skipped++; continue } + + await detectPickerField(f, lang) + + console.log( + ` [${f.key}] "${f.labelText}" → type: ${f.inputType}` + + ` | isPicker: ${f.isPicker}` + + ` | fillValue: "${f.fillValue}"` + ) + + const ok = await fillMatchedField(f) + if (ok) { result.success++ } else { result.failed++ } + + if (lastTextInput) { + ;(lastTextInput as HTMLElement).click() + lastTextInput.focus() + await delay(100) + lastTextInput.blur() + } else { + if (document.activeElement instanceof HTMLElement) { + document.activeElement.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", code: "Escape", keyCode: 27, bubbles: true }) + ) + document.activeElement.blur() + } + document.body.click() + } + await delay(300) + + if (!f.isPicker && f.inputElement) lastTextInput = f.inputElement + } + + result.formFields = [...mainFields] + console.log(`===== OfferPie: 阶段B完成 总计成功${result.success} 失败${result.failed} 跳过${result.skipped} =====`) + + // 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 + } + + // 查找标签 + let labelText = "" + let container: Element | null = null + for (const sel of FORM_ITEM_SELS_JSON) { + container = inp.closest(sel) + if (container) break + } + if (container) { + const labelEls = container.querySelectorAll("label, span, div, td, th, p, legend, dt") + for (const el of Array.from(labelEls)) { + const directText = Array.from(el.childNodes) + .filter((n) => n.nodeType === Node.TEXT_NODE) + .map((n) => n.textContent?.trim()) + .filter(Boolean) + .join("") + if (!directText || directText.length > 30) continue + if (JSON_EXCLUDE_LABELS.some((ex) => directText === ex)) continue + if (el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) { + labelText = directText + break + } + } + } + if (!labelText) { + let prev: Element | null = inp.previousElementSibling + for (let i = 0; i < 3 && prev; i++) { + const text = prev.textContent?.trim() || "" + if (text && text.length <= 20 && !JSON_EXCLUDE_LABELS.some((ex) => text === ex)) { + labelText = text + break + } + prev = prev.previousElementSibling + } + } + if (!labelText) labelText = inputEl.getAttribute("placeholder") || "(未知字段)" + + // 非经历类型:跳过简历格式字段 + // 【注意】简历格式字段通过 resumeFormatInputs(阶段A/B实际匹配到的input元素集合)精确跳过 + // 不用标签文字匹配跳过,因为同一标签名可能出现在不同大标题下(如"最高学历"在个人信息 vs 教育经历) + // resumeFormatInputs 已在上方 if(!isExpType) 块中判断,此处无需重复 + + collectedFields.push({ label: labelText, value: inputEl.value?.trim() || "", inputEl: inp }) + } + + // ============ 北森特有:收集 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 输出完毕 =====") + + // 存 localStorage 缓存,包含简历名字和未填字段数据 + // 更新规则:以第一次存的版本为基准,可以添加字段、更新 value,但不删除已有字段 + const resumeName = currentResumeData?.main?.resumeName || currentResumeData?.main?.name || "" + try { + const existingRaw = localStorage.getItem("offerpie_unfilled_form") + let cacheData: any = null + + if (existingRaw) { + const existing = JSON.parse(existingRaw) + if (existing.resumeName === resumeName && existing.unfilledFormData) { + // 同一份简历,合并更新(不删除已有字段,只添加新字段和更新 value) + const oldSections = existing.unfilledFormData as any[] + const newSections = unfilledFormData as any[] + + // 遍历新数据,对每个 section 做合并 + for (const newSec of newSections) { + const oldSec = oldSections.find((s: any) => s.title === newSec.title) + if (!oldSec) { + // 新增的 section,直接追加 + oldSections.push(newSec) + continue + } + + if (newSec.isExperience) { + // 经历类型:按段合并 + const oldSegments = oldSec.formItems as any[][] + const newSegments = newSec.formItems as any[][] + for (let sIdx = 0; sIdx < newSegments.length; sIdx++) { + if (sIdx >= oldSegments.length) { + // 新增的段,直接追加 + oldSegments.push(newSegments[sIdx]) + continue + } + // 已有的段:合并字段 + const oldFields = oldSegments[sIdx] + const newFields = newSegments[sIdx] + for (const newField of newFields) { + const oldField = oldFields.find((f: any) => f.label === newField.label) + if (oldField) { + // 已有字段:如果新值非空则更新 + if (newField.value) oldField.value = newField.value + } else { + // 新字段:追加 + oldFields.push(newField) + } + } + } + } else { + // 非经历类型:按字段合并 + const oldFields = oldSec.formItems as any[] + const newFields = newSec.formItems as any[] + for (const newField of newFields) { + const oldField = oldFields.find((f: any) => f.label === newField.label) + if (oldField) { + if (newField.value) oldField.value = newField.value + } else { + oldFields.push(newField) + } + } + } + } + + existing.unfilledFormData = oldSections + existing.timestamp = Date.now() + cacheData = existing + } + } + + // 没有已有缓存或不是同一份简历 → 新建 + if (!cacheData) { + cacheData = { + resumeName, + unfilledFormData, + timestamp: Date.now(), + } + } + + localStorage.setItem("offerpie_unfilled_form", JSON.stringify(cacheData)) + console.log(`===== OfferPie: 已缓存未填字段数据(简历: "${resumeName}") =====`) + } catch (e) { + console.warn("OfferPie: localStorage 缓存失败", e) + } + } + + return result +} + + +// ============================================================ +// 阶段B2:从 localStorage 缓存填写 unfilledFormData 有值字段 +// 【独立模块】可以通过注释 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) +} + +/** + * 北森特有:阶段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(100) + // 子子级也点一下确保触发 + const grandChildEl = childEl.firstElementChild as HTMLElement | null + if (grandChildEl) { + grandChildEl.click() + await delay(100) + } + } else { + // fallback:直接点击 radioItem 自身 + ;(item as HTMLElement).click() + await delay(100) + } + + // 4. 验证选中结果:检查子级是否出现 phoenix-radio--checked 类名 + await delay(200) + 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 cachedRaw = localStorage.getItem("offerpie_unfilled_form") + if (!cachedRaw) { + console.log("===== OfferPie: 阶段B2 - 无缓存数据,跳过 =====") + return result + } + + let cacheData: any + try { cacheData = JSON.parse(cachedRaw) } catch { return result } + + if (cacheData.resumeName !== resumeName) { + console.log(`===== OfferPie: 阶段B2 - 缓存简历名"${cacheData.resumeName}"与当前"${resumeName}"不匹配,跳过 =====`) + return result + } + + const unfilledFormData = cacheData.unfilledFormData as { + title: string + isExperience: boolean + formItems: { label: string; value: string }[] | { label: string; value: string }[][] + }[] + + if (!unfilledFormData || unfilledFormData.length === 0) { + console.log("===== OfferPie: 阶段B2 - 缓存中无字段数据,跳过 =====") + return result + } + + console.log("===== OfferPie: 阶段B2 - 填写缓存数据 =====") + + const allTitles = getAllPageTitles(sectionResults) + if (allTitles.length === 0) { + console.log(" ❌ 未找到页面大标题,跳过") + return result + } + + for (const sectionData of unfilledFormData) { + const titleInfo = allTitles.find((t) => t.text === sectionData.title) + if (!titleInfo) { + console.log(` [B2] "${sectionData.title}" 未在页面找到对应大标题,跳过`) + continue + } + const titleEl = titleInfo.element + const titleIdx = allTitles.indexOf(titleInfo) + const nextTitleEl = titleIdx < allTitles.length - 1 ? allTitles[titleIdx + 1].element : null + + const expResult = expandedResults.find((r) => r.titleElement === titleEl) + const isFiveExp = !!expResult && B2_FIVE_EXP_SECTIONS.has(expResult.section) + + if (sectionData.isExperience) { + const segments = sectionData.formItems as { label: string; value: string }[][] + + if (isFiveExp && expResult) { + console.log(` [B2] "${sectionData.title}" (5大经历) 缓存${segments.length}段`) + for (let sIdx = 0; sIdx < segments.length; sIdx++) { + const seg = expResult.segmentRanges[sIdx] + if (!seg || !seg.containerElement) continue + const fields = segments[sIdx] + for (const field of fields) { + if (!field.value) { result.skipped++; continue } + const inputEl = b2FindInputByLabelInContainer(field.label, seg.containerElement, usedInputs) + if (!inputEl) { result.skipped++; continue } + 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(200) + } + } + } 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(300) + } + } + + 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 } + 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(200) + } + } + } + } 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) { + // 普通 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(200) + } + } + } + + console.log(`===== OfferPie: 阶段B2完成 成功${result.success} 失败${result.failed} 跳过${result.skipped} =====`) + return result +} \ No newline at end of file diff --git a/src/handlers/handleAutoFillCommon.ts b/src/handlers/handleAutoFillCommon.ts index 82c7308..fe44a29 100644 --- a/src/handlers/handleAutoFillCommon.ts +++ b/src/handlers/handleAutoFillCommon.ts @@ -8,14 +8,16 @@ */ import { fillMatchedField, delay, isSearchPickerField, fillSearchPickerField, isTimePeriodField, isTimeSingleField, fillTimePeriodField, fillTimeSingleField } from "~lib/autofill" -import { extractDomStructure, detectPageLanguage, isJobApplicationForm } from "~lib/dom" +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 } from "~lib/constants" +import { getMockResumeData2, JOB_FORM_LABELS } from "~lib/constants" import { getResumeFieldValue } from "~lib/resumeDataHelper" -import { locateExperienceSections, expandExperienceSections, sortExperienceByTime, relocateSegmentContainer } from "~lib/experienceSection" +import { locateExperienceSections, expandExperienceSections, sortExperienceByTime, relocateSegmentContainer, getAllPageTitles, findAddButton, clickAddButton } from "~lib/experienceSection" +import type { ExperienceSectionLocateResult } from "~lib/experienceSection" import type { MatchedFormField, ResumeData, ExperienceSection, JobInfo, UnmatchedFormField } from "~lib/types" +import { setFieldHighlight, isRequiredField } from "~lib/formStyle" /** 通用自动填写的参数 */ export interface AutoFillCommonParams { @@ -84,6 +86,10 @@ export async function handleAutoFillCommon(params: AutoFillCommonParams): Promis 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) @@ -95,6 +101,60 @@ export async function handleAutoFillCommon(params: AutoFillCommonParams): Promis // 4.6 对比简历数据段数,点击添加按钮补足不够的段数 const expandedResults = await expandExperienceSections(sectionResults, currentResumeData, lang) + // 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) { @@ -127,6 +187,8 @@ export async function handleAutoFillCommon(params: AutoFillCommonParams): Promis 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) { @@ -243,7 +305,7 @@ export async function handleAutoFillCommon(params: AutoFillCommonParams): Promis } if (ok) { result.success++ } else { result.failed++ } } else if (isTimeSingleField(f.key)) { - if (!f.fillValue) { result.skipped++; continue } + 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) @@ -251,10 +313,11 @@ export async function handleAutoFillCommon(params: AutoFillCommonParams): Promis } if (ok) { result.success++ } else { result.failed++ } } else if (!f.fillValue) { - result.skipped++; continue + 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(200) continue } else { @@ -263,6 +326,9 @@ export async function handleAutoFillCommon(params: AutoFillCommonParams): Promis 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() @@ -344,6 +410,18 @@ export async function handleAutoFillCommon(params: AutoFillCommonParams): Promis 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 - 收集剩余空白字段 =====") @@ -445,5 +523,905 @@ export async function handleAutoFillCommon(params: AutoFillCommonParams): Promis } 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 + } + + // 查找标签 + let labelText = "" + let container: Element | null = null + for (const sel of FORM_ITEM_SELS_JSON) { + container = inp.closest(sel) + if (container) break + } + if (container) { + const labelEls = container.querySelectorAll("label, span, div, td, th, p, legend, dt") + for (const el of Array.from(labelEls)) { + const directText = Array.from(el.childNodes) + .filter((n) => n.nodeType === Node.TEXT_NODE) + .map((n) => n.textContent?.trim()) + .filter(Boolean) + .join("") + if (!directText || directText.length > 30) continue + if (JSON_EXCLUDE_LABELS.some((ex) => directText === ex)) continue + if (el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) { + labelText = directText + break + } + } + } + if (!labelText) { + let prev: Element | null = inp.previousElementSibling + for (let i = 0; i < 3 && prev; i++) { + const text = prev.textContent?.trim() || "" + if (text && text.length <= 20 && !JSON_EXCLUDE_LABELS.some((ex) => text === ex)) { + labelText = text + break + } + prev = prev.previousElementSibling + } + } + if (!labelText) labelText = inputEl.getAttribute("placeholder") || "(未知字段)" + + // 非经历类型:跳过简历格式字段 + // 【注意】简历格式字段通过 resumeFormatInputs(阶段A/B实际匹配到的input元素集合)精确跳过 + // 不用标签文字匹配跳过,因为同一标签名可能出现在不同大标题下(如"最高学历"在个人信息 vs 教育经历) + // resumeFormatInputs 已在上方 if(!isExpType) 块中判断,此处无需重复 + + collectedFields.push({ label: labelText, value: inputEl.value?.trim() || "", inputEl: inp }) + } + + if (collectedFields.length === 0) continue + + // 按经历类型分组 + if (isExpType) { + let segmentCount = 1 + + if (isFiveExp && expResult) { + // 5大经历用 containerElement 分段 + segmentCount = expResult.segmentRanges.length || 1 + const segments: { label: string; value: string }[][] = [] + for (let sIdx = 0; sIdx < segmentCount; sIdx++) { + const seg = expResult.segmentRanges[sIdx] + const segFields = seg?.containerElement + ? collectedFields.filter((f) => seg.containerElement!.contains(f.inputEl)) + : collectedFields + segments.push(segFields.map((f) => ({ label: f.label, value: f.value }))) + } + const nonEmptySegments = segments.filter((s) => s.length > 0) + if (nonEmptySegments.length > 0) { + unfilledFormData.push({ title: titleText, isExperience: true, formItems: nonEmptySegments }) + } + } else { + // 非5大经历用标签重复计数分段 + const labelCounts = new Map() + for (const f of collectedFields) { + labelCounts.set(f.label, (labelCounts.get(f.label) || 0) + 1) + } + let maxCount = 1 + let markerLabel = "" + for (const [label, count] of labelCounts) { + if (count > maxCount) { maxCount = count; markerLabel = label } + } + segmentCount = maxCount + + if (segmentCount <= 1) { + unfilledFormData.push({ title: titleText, isExperience: true, formItems: [collectedFields.map((f) => ({ label: f.label, value: f.value }))] }) + } else { + const markerPositions = collectedFields + .map((f, idx) => f.label === markerLabel ? idx : -1) + .filter((idx) => idx >= 0) + const segments: { label: string; value: string }[][] = [] + for (let i = 0; i < markerPositions.length; i++) { + const start = markerPositions[i] + const end = i < markerPositions.length - 1 ? markerPositions[i + 1] : collectedFields.length + segments.push(collectedFields.slice(start, end).map((f) => ({ label: f.label, value: f.value }))) + } + unfilledFormData.push({ title: titleText, isExperience: true, formItems: segments }) + } + } + } else { + // 非经历类型:只存未填字段 + unfilledFormData.push({ + title: titleText, + isExperience: false, + formItems: collectedFields.map((f) => ({ label: f.label, value: f.value })), + }) + } + } + } + + console.log("===== OfferPie: 网站未填表单字段数据(JSON) =====") + console.log(JSON.stringify(unfilledFormData, null, 2)) + console.log("===== OfferPie: JSON 输出完毕 =====") + + // 存 localStorage 缓存,包含简历名字和未填字段数据 + // 更新规则:以第一次存的版本为基准,可以添加字段、更新 value,但不删除已有字段 + const resumeName = currentResumeData?.main?.resumeName || currentResumeData?.main?.name || "" + try { + const existingRaw = localStorage.getItem("offerpie_unfilled_form") + let cacheData: any = null + + if (existingRaw) { + const existing = JSON.parse(existingRaw) + if (existing.resumeName === resumeName && existing.unfilledFormData) { + // 同一份简历,合并更新(不删除已有字段,只添加新字段和更新 value) + const oldSections = existing.unfilledFormData as any[] + const newSections = unfilledFormData as any[] + + // 遍历新数据,对每个 section 做合并 + for (const newSec of newSections) { + const oldSec = oldSections.find((s: any) => s.title === newSec.title) + if (!oldSec) { + // 新增的 section,直接追加 + oldSections.push(newSec) + continue + } + + if (newSec.isExperience) { + // 经历类型:按段合并 + const oldSegments = oldSec.formItems as any[][] + const newSegments = newSec.formItems as any[][] + for (let sIdx = 0; sIdx < newSegments.length; sIdx++) { + if (sIdx >= oldSegments.length) { + // 新增的段,直接追加 + oldSegments.push(newSegments[sIdx]) + continue + } + // 已有的段:合并字段 + const oldFields = oldSegments[sIdx] + const newFields = newSegments[sIdx] + for (const newField of newFields) { + const oldField = oldFields.find((f: any) => f.label === newField.label) + if (oldField) { + // 已有字段:如果新值非空则更新 + if (newField.value) oldField.value = newField.value + } else { + // 新字段:追加 + oldFields.push(newField) + } + } + } + } else { + // 非经历类型:按字段合并 + const oldFields = oldSec.formItems as any[] + const newFields = newSec.formItems as any[] + for (const newField of newFields) { + const oldField = oldFields.find((f: any) => f.label === newField.label) + if (oldField) { + if (newField.value) oldField.value = newField.value + } else { + oldFields.push(newField) + } + } + } + } + + existing.unfilledFormData = oldSections + existing.timestamp = Date.now() + cacheData = existing + } + } + + // 没有已有缓存或不是同一份简历 → 新建 + if (!cacheData) { + cacheData = { + resumeName, + unfilledFormData, + timestamp: Date.now(), + } + } + + localStorage.setItem("offerpie_unfilled_form", JSON.stringify(cacheData)) + console.log(`===== OfferPie: 已缓存未填字段数据(简历: "${resumeName}") =====`) + } catch (e) { + console.warn("OfferPie: localStorage 缓存失败", e) + } + } + return result } + + +// ============================================================ +// 阶段B2:从 localStorage 缓存填写 unfilledFormData 有值字段 +// 【独立模块】可以通过注释 handleAutoFillCommon 中的调用行来禁用 +// ============================================================ + +/** 阶段B2 表单项容器选择器 */ +const B2_FORM_ITEM_SELS = [ + ".form-item", ".form-group", ".form-field", + ".el-form-item", ".ant-form-item", ".ant-row", + ".arco-form-item", ".t-form-item", ".n-form-item", + "[class*='form-item']", "[class*='form-group']", "[class*='formItem']", +] + +/** 阶段B2 input 选择器 */ +const B2_INPUT_SEL = "input:not([type='hidden']):not([type='submit']):not([type='button']):not([type='checkbox']):not([type='radio']):not([type='file']):not([disabled]), textarea:not([disabled])" + +/** 阶段B2 排除的标签文字 */ +const B2_EXCLUDE_LABELS = ["请输入", "输入", ":", ":", "请选择", "选择", "*", "必填"] + +/** 阶段B2 5大简历经历的 section 名集合 */ +const B2_FIVE_EXP_SECTIONS = new Set(["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) +} + +/** + * 阶段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 cachedRaw = localStorage.getItem("offerpie_unfilled_form") + if (!cachedRaw) { + console.log("===== OfferPie: 阶段B2 - 无缓存数据,跳过 =====") + return result + } + + let cacheData: any + try { cacheData = JSON.parse(cachedRaw) } catch { return result } + + if (cacheData.resumeName !== resumeName) { + console.log(`===== OfferPie: 阶段B2 - 缓存简历名"${cacheData.resumeName}"与当前"${resumeName}"不匹配,跳过 =====`) + return result + } + + const unfilledFormData = cacheData.unfilledFormData as { + title: string + isExperience: boolean + formItems: { label: string; value: string }[] | { label: string; value: string }[][] + }[] + + if (!unfilledFormData || unfilledFormData.length === 0) { + console.log("===== OfferPie: 阶段B2 - 缓存中无字段数据,跳过 =====") + return result + } + + console.log("===== OfferPie: 阶段B2 - 填写缓存数据 =====") + + const allTitles = getAllPageTitles(sectionResults) + if (allTitles.length === 0) { + console.log(" ❌ 未找到页面大标题,跳过") + return result + } + + for (const sectionData of unfilledFormData) { + const titleInfo = allTitles.find((t) => t.text === sectionData.title) + if (!titleInfo) { + console.log(` [B2] "${sectionData.title}" 未在页面找到对应大标题,跳过`) + continue + } + const titleEl = titleInfo.element + const titleIdx = allTitles.indexOf(titleInfo) + const nextTitleEl = titleIdx < allTitles.length - 1 ? allTitles[titleIdx + 1].element : null + + const expResult = expandedResults.find((r) => r.titleElement === titleEl) + const isFiveExp = !!expResult && B2_FIVE_EXP_SECTIONS.has(expResult.section) + + if (sectionData.isExperience) { + const segments = sectionData.formItems as { label: string; value: string }[][] + + if (isFiveExp && expResult) { + console.log(` [B2] "${sectionData.title}" (5大经历) 缓存${segments.length}段`) + for (let sIdx = 0; sIdx < segments.length; sIdx++) { + const seg = expResult.segmentRanges[sIdx] + if (!seg || !seg.containerElement) continue + const fields = segments[sIdx] + for (const field of fields) { + if (!field.value) { result.skipped++; continue } + const inputEl = b2FindInputByLabelInContainer(field.label, seg.containerElement, usedInputs) + if (!inputEl) { result.skipped++; continue } + 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(200) + } + } + } 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(300) + } + } + + 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 } + 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(200) + } + } + } + } else { + const fields = sectionData.formItems as { label: string; value: string }[] + console.log(` [B2] "${sectionData.title}" (非经历) 缓存${fields.length}个字段`) + for (const field of fields) { + if (!field.value) { result.skipped++; continue } + const inputEl = b2FindInputByLabel(field.label, titleEl, nextTitleEl, usedInputs) + if (!inputEl) { result.skipped++; continue } + 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(200) + } + } + } + + console.log(`===== OfferPie: 阶段B2完成 成功${result.success} 失败${result.failed} 跳过${result.skipped} =====`) + return result +} \ No newline at end of file diff --git a/src/lib/autofill.ts b/src/lib/autofill.ts index 1d7b50f..ff7d4cf 100644 --- a/src/lib/autofill.ts +++ b/src/lib/autofill.ts @@ -362,7 +362,7 @@ export async function fillSearchPickerField(field: MatchedFormField): Promise r.titleElement) + if (!firstLocated || !firstLocated.titleElement) return [] + + const refSignature = extractTitleSignature(firstLocated.titleElement) + let allPageTitles = findAllTitlesWithSameSignature(document.body, refSignature, firstLocated.titleElement) + + // fallback 检查 + const locatedElements = new Set(locateResults.filter((r) => r.titleElement).map((r) => r.titleElement!)) + const containsLocated = allPageTitles.some((pt) => locatedElements.has(pt.element)) + + if (!containsLocated || allPageTitles.length <= 1) { + allPageTitles = locateResults + .filter((r) => r.titleElement) + .map((r) => ({ + element: r.titleElement!, + text: r.titleText, + signature: r.titleSignature, + })) + allPageTitles.sort((a, b) => { + const pos = a.element.compareDocumentPosition(b.element) + if (pos & Node.DOCUMENT_POSITION_FOLLOWING) return -1 + if (pos & Node.DOCUMENT_POSITION_PRECEDING) return 1 + return 0 + }) + } + + return allPageTitles.map((pt) => ({ element: pt.element, text: pt.text })) +} + // ==================================================================== // 六、主流程 - 补足经历段数 // ==================================================================== diff --git a/src/lib/formMatcher.ts b/src/lib/formMatcher.ts index 73bf929..d15309a 100644 --- a/src/lib/formMatcher.ts +++ b/src/lib/formMatcher.ts @@ -154,6 +154,7 @@ export function matchFormFields( ) for (const item of JOB_FORM_LABELS) { + if (!item.resumeField) continue // 跳过简历数据中无对应字段的标签 const labels = lang === "zh" ? item.zh : item.en const sortedLabels = [...labels].sort((a, b) => b.length - a.length) let matchCount = 0 @@ -292,6 +293,7 @@ export function matchFormFieldsInRange( } for (const item of sectionLabels) { + if (!item.resumeField) continue // 跳过简历数据中无对应字段的标签 const labels = lang === "zh" ? item.zh : item.en const sortedLabels = [...labels].sort((a, b) => b.length - a.length) @@ -387,6 +389,7 @@ export function matchMainFields( ) for (const item of mainLabels) { + if (!item.resumeField) continue // 跳过简历数据中无对应字段的标签 const labels = lang === "zh" ? item.zh : item.en const sortedLabels = [...labels].sort((a, b) => b.length - a.length) diff --git a/src/lib/formStyle.ts b/src/lib/formStyle.ts new file mode 100644 index 0000000..055ea4b --- /dev/null +++ b/src/lib/formStyle.ts @@ -0,0 +1,303 @@ +/** + * 表单样式控制模块 + * 用于在自动填写过程中,对匹配到的表单字段容器设置背景色高亮反馈 + * 颜色含义:green=填写成功(淡绿色)、red=填写失败/必填未填(淡红色)、yellow=非必填跳过(淡黄色) + */ + +/** 高亮颜色类型 */ +export type HighlightColor = "green" |"greenTwo" | "red" | "yellow" + +/** 颜色映射表(淡色系,不影响文字可读性) */ +const COLOR_MAP: Record = { + green: "#b7ffc5", // 淡绿色 + greenTwo: "#b7ffc6", // 淡绿色2 + red: "#ffb7b7", // 淡红色 + yellow: "#fff", // 白色 +} + +/** 常见的表单项容器选择器(与 formMatcher.ts 保持一致) */ +const FORM_ITEM_SELECTORS = [ + ".form-item", ".form-group", ".form-field", + ".el-form-item", ".ant-form-item", ".ant-row", + ".arco-form-item", ".t-form-item", ".n-form-item", + ".ivu-form-item", ".v-input", ".MuiFormControl-root", + "[class*='form-item']", "[class*='form-group']", "[class*='formItem']", +] + +// ============ 必填红*标记检测 ============ + +/** + * 非必填字段的红*标记样式特征排除配置 + * 用于兼容不同网站:某些网站的红*标记不代表必填,通过此配置排除误判 + * + * 使用场景:特殊网站中某些 label 带红*但实际非必填,可以把这些特征加进来排除 + * + * @property containerClassExcludes - 如果表单项容器包含这些 class 片段则跳过必填判定 + * @property labelClassExcludes - 如果标签元素包含这些 class 片段则跳过必填判定 + * @property labelTextExcludes - 如果标签文字包含这些关键词则跳过必填判定 + */ +export interface RequiredMarkExcludeConfig { + /** 容器 class 排除特征(className 中包含任一则视为非必填) */ + containerClassExcludes?: string[] + /** 标签 class 排除特征 */ + labelClassExcludes?: string[] + /** 标签文字排除关键词 */ + labelTextExcludes?: string[] +} + +/** + * 检测表单字段是否有红*必填标记 + * + * 检测策略(按优先级): + * 1. input 元素自身的 required 属性 / aria-required="true" + * 2. 表单项容器 class 包含 required / is-required 等关键词 + * 3. 标签元素或其子元素中存在纯文字 "*" 的红色节点 + * 4. 标签元素或其相邻元素的 ::before / ::after 伪元素 content 包含 "*" + * + * @param labelElement - 标签 DOM 元素(从 MatchedFormField.labelElement 获取) + * @param inputElement - 输入框 DOM 元素(从 MatchedFormField.inputElement 获取) + * @param excludeConfig - 排除配置(预留给特殊网站兼容) + * @returns true=该字段有必填标记,false=未检测到必填标记 + */ +export function isRequiredField( + labelElement: Element | null, + inputElement: HTMLInputElement | HTMLTextAreaElement | null, + excludeConfig?: RequiredMarkExcludeConfig +): boolean { + // ---- 策略1:input 自身 required 属性 ---- + if (inputElement) { + if (inputElement.hasAttribute("required") || inputElement.getAttribute("aria-required") === "true") { + return true + } + } + + // ---- 找到表单项容器 ---- + const formItemContainer = findFormItemContainer(labelElement, inputElement) + + // ---- 排除配置检查 ---- + if (excludeConfig && formItemContainer) { + const containerClass = formItemContainer.className || "" + if (excludeConfig.containerClassExcludes?.some((cls) => containerClass.includes(cls))) { + return false + } + } + if (excludeConfig && labelElement) { + const labelClass = (labelElement as HTMLElement).className || "" + if (excludeConfig.labelClassExcludes?.some((cls) => labelClass.includes(cls))) { + return false + } + const labelText = labelElement.textContent?.trim() || "" + if (excludeConfig.labelTextExcludes?.some((kw) => labelText.includes(kw))) { + return false + } + } + + // ---- 策略2:容器 class 包含 required 关键词 ---- + if (formItemContainer) { + const containerClass = formItemContainer.className || "" + if (/\brequired\b|is-required|isRequired|form-item--required/.test(containerClass)) { + return true + } + } + + // ---- 策略3:标签元素内或附近的红色 "*" 文本节点 ---- + // 搜索范围:标签元素自身 + 表单项容器内的前几个子元素 + const searchElements: Element[] = [] + if (labelElement) searchElements.push(labelElement) + if (formItemContainer) { + // 在容器内寻找标签附近的小元素(span/em/i 等可能放红*的标签) + const smallEls = formItemContainer.querySelectorAll("span, em, i, sup, label") + for (const el of Array.from(smallEls)) { + // 只看标签元素前后附近的(DOM 位置接近的) + if (labelElement && isNearLabel(el, labelElement)) { + searchElements.push(el) + } + } + } + + for (const el of searchElements) { + if (hasRedAsteriskText(el)) return true + } + + // ---- 策略4:伪元素 ::before / ::after 检测 ---- + if (labelElement && labelElement instanceof HTMLElement) { + if (hasPseudoElementAsterisk(labelElement)) return true + } + // 也检查容器内的 label 标签(有些框架把伪元素放在 label 上) + if (formItemContainer) { + const labels = formItemContainer.querySelectorAll("label, [class*='label']") + for (const lbl of Array.from(labels)) { + if (lbl instanceof HTMLElement && hasPseudoElementAsterisk(lbl)) return true + } + } + + return false +} + +/** + * 查找标签/输入框所在的表单项容器 + * 与 formMatcher.ts 中 findNearestInput 使用的容器选择器一致 + */ +function findFormItemContainer( + labelElement: Element | null, + inputElement: Element | null +): Element | null { + // 优先从 labelElement 向上找 + if (labelElement) { + for (const sel of FORM_ITEM_SELECTORS) { + const container = labelElement.closest(sel) + if (container) return container + } + } + // fallback:从 inputElement 向上找 + if (inputElement) { + for (const sel of FORM_ITEM_SELECTORS) { + const container = inputElement.closest(sel) + if (container) return container + } + } + // 最后兜底:labelElement 的父级(最多向上3层) + let parent = labelElement?.parentElement || inputElement?.parentElement || null + for (let i = 0; i < 3 && parent; i++) { + if (parent !== document.body) return parent + parent = parent.parentElement + } + return null +} + +/** + * 判断元素是否在标签元素附近(DOM 距离近) + */ +function isNearLabel(el: Element, labelElement: Element): boolean { + // 如果是标签的子元素或标签本身 + if (labelElement.contains(el) || el.contains(labelElement)) return true + // 如果是标签的前后兄弟 + if (el.previousElementSibling === labelElement || el.nextElementSibling === labelElement) return true + // 如果和标签在同一个父元素内且距离不超过3个节点 + if (el.parentElement === labelElement.parentElement) return true + return false +} + +/** + * 检测元素内是否有红色的 "*" 文本 + * 遍历元素的子节点,查找包含 "*" 的文本节点,并检查其颜色是否为红色系 + */ +function hasRedAsteriskText(el: Element): boolean { + // 检查元素自身的直接文本子节点 + for (const child of Array.from(el.childNodes)) { + if (child.nodeType === Node.TEXT_NODE) { + const text = child.textContent || "" + if (text.includes("*")) { + // 检查该文本所在元素的颜色 + const color = getComputedColor(el) + if (isRedColor(color)) return true + } + } + } + // 检查子元素(如 *) + for (const child of Array.from(el.children)) { + const text = child.textContent?.trim() || "" + if (text === "*" || text === "* " || text === " *") { + const color = getComputedColor(child) + if (isRedColor(color)) return true + } + } + return false +} + +/** + * 检测元素的 ::before 或 ::after 伪元素是否包含 "*" + * 这是最常见的必填标记实现方式(如 Element UI、Ant Design) + */ +function hasPseudoElementAsterisk(el: HTMLElement): boolean { + for (const pseudo of ["::before", "::after"] as const) { + const style = window.getComputedStyle(el, pseudo) + const content = style.getPropertyValue("content") + // content 格式形如 '"*"' 或 '"* "' 或 '"\uff0a"'(全角星号) + if (content && (content.includes("*") || content.includes("\\*") || content.includes("\uff0a"))) { + // 进一步确认颜色是红色系(排除装饰性星号) + const color = style.getPropertyValue("color") + if (isRedColor(color)) return true + // 有些情况不设颜色但 content 确实是 "*",也认为是必填 + if (content.replace(/['"\\s ]/g, "") === "*") return true + } + } + return false +} + +/** 获取元素的计算颜色值 */ +function getComputedColor(el: Element): string { + if (!(el instanceof HTMLElement)) return "" + return window.getComputedStyle(el).getPropertyValue("color") +} + +/** + * 判断颜色值是否为红色系 + * 支持 rgb / rgba / hex 格式 + */ +function isRedColor(color: string): boolean { + if (!color) return false + // rgb(r, g, b) 或 rgba(r, g, b, a) 格式 + const rgbMatch = color.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/) + if (rgbMatch) { + const r = parseInt(rgbMatch[1]) + const g = parseInt(rgbMatch[2]) + const b = parseInt(rgbMatch[3]) + // 红色系:R 通道较高,G 和 B 通道较低 + return r > 150 && g < 100 && b < 100 + } + // 常见红色关键词 + if (color === "red" || color.includes("#f5222d") || color.includes("#ff4d4f") || color.includes("#e00")) return true + return false +} + +// ============ 背景高亮设置 ============ + +/** + * 设置表单字段容器的背景高亮颜色 + * 通过行内样式直接覆盖原网页 class 中的背景色 + * + * @param element - 要设置背景色的 HTML 元素(input / textarea / div 等) + * @param color - 高亮颜色:green=成功 red=失败 yellow=跳过 + */ +export function setFieldHighlight(element: Element | null, color: HighlightColor): void { + if (!element || !(element instanceof HTMLElement)) return + if (!element.isConnected) return // 元素已脱离 DOM 则跳过 + + element.style.backgroundColor = COLOR_MAP[color] +} + +/** + * 清除表单字段容器的背景高亮颜色(恢复为透明) + * + * @param element - 要清除背景色的 HTML 元素 + */ +export function clearFieldHighlight(element: Element | null): void { + if (!element || !(element instanceof HTMLElement)) return + if (!element.isConnected) return + + element.style.backgroundColor = "" +} + +/** + * 批量设置多个元素的背景高亮颜色 + * + * @param elements - 要设置的元素数组 + * @param color - 高亮颜色 + */ +export function setFieldsHighlight(elements: (Element | null)[], color: HighlightColor): void { + for (const el of elements) { + setFieldHighlight(el, color) + } +} + +/** + * 批量清除多个元素的背景高亮颜色 + * + * @param elements - 要清除的元素数组 + */ +export function clearFieldsHighlight(elements: (Element | null)[]): void { + for (const el of elements) { + clearFieldHighlight(el) + } +}