diff --git a/.kiro/steering/project-guidelines.md b/.kiro/steering/project-guidelines.md index 9dbeda3..9edf671 100644 --- a/.kiro/steering/project-guidelines.md +++ b/.kiro/steering/project-guidelines.md @@ -127,6 +127,7 @@ src/ - `delay` 函数接收延时等级参数,统一管理延时时长: - `delay("low")` — 低延时(微等待,如点击后极短暂停) - `delay("mid")` — 中延时(等待 DOM 更新、弹出层渲染等) + - `delay("midH")` — 中延时偏高(稍微加长版等待 DOM 更新、弹出层渲染等) - `delay("high")` — 高延时(等待接口返回、搜索结果、动画完成等) - `delay("max")` — 特殊超长延时(谨慎使用) - 具体毫秒数只在 `src/utils/delay.ts` 中统一设置,调用方只使用等级名称 diff --git a/src/components/SidebarPanel.tsx b/src/components/SidebarPanel.tsx index 087c115..47e9eb5 100644 --- a/src/components/SidebarPanel.tsx +++ b/src/components/SidebarPanel.tsx @@ -11,10 +11,14 @@ import { getCustomizeResume } from "~api/aiApi" import { getMemberStatus } from "~api/dataApi" import { handleAutoFillCommon } from "~handlers/handleAutoFillCommon" import { handleAutoFillBeisen } from "~handlers/handleAutoFillBeisen" -import { scanPageFields, extractNonResumeFields, printFieldStats } from "~lib/fillStats" +import { handleAutoFillMoka } from "~handlers/handleAutoFillMoka" +import { handleAutoFillFeishu } from "~handlers/handleAutoFillFeishu" +import { handleAutoFillHotjob } from "~handlers/handleAutoFillHotjob" +import { scanPageFields, extractNonResumeFields, printFieldStats, collectPickerOptionTexts, getPickerDisplayValue } from "~lib/fillStats" import type { TitleStat } from "~lib/fillStats" import type { MatchedFormField, ResumeData, JobInfo } from "~lib/types" import { createChannelBridge } from "~lib/channelBridge" +import { buildResumeExcludeTexts } from "~lib/resumeDataHelper" import logoImg from "data-base64:~/../assets/logo-offerpai.png" import { config as appConfig } from "~config" import "./SidebarPanel.scss" @@ -96,6 +100,9 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps) isDragging: false, startX: 0, startY: 0, startOffsetX: 0, startOffsetY: 0, longPressTimer: null, isLongPress: false }) + /** 选择器选项文字缓存(collectPickerOptionTexts 收集后存入,handleSaveUserInput 用于排除) */ + const pickerOptionTextsRef = useRef>(new Set()) + /** 停止当前循环扫描 */ const stopScanLoop = () => { if (scanIntervalRef.current) { @@ -128,17 +135,67 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps) } /** 开启循环扫描(每秒执行一次) */ - const startScanLoop = (params: { siteMode?: "beisen"; sectionResults?: any[]; expandedResults?: any[]; excludeTexts?: Set }) => { + const startScanLoop = (params: { siteMode?: "beisen" | "moka" | "feishu" | "hotjob"; sectionResults?: any[]; expandedResults?: any[]; excludeTexts?: Set }) => { stopScanLoop() const hasFullData = !!(params.sectionResults && params.expandedResults) + + /** + * 同步构建排除集合:合并 params.excludeTexts + 当前 resumeData 中的所有值 + * 避免将已填入表单的简历值(如"汉族"、"广州"等)误认为表单标签 + */ + const buildExcludeTexts = (): Set => { + const baseSet = buildResumeExcludeTexts(resumeData) + // 合并外部传入的静态排除集合(第二次调用时由 fillResult.resumeData 构建) + if (params.excludeTexts) { + for (const t of params.excludeTexts) baseSet.add(t) + } + return baseSet + } + + /** + * 补充选择器展示值:遍历已识别字段,对 value 为空的字段调用 getPickerDisplayValue + * 从 DOM 中提取展示文字与已收集的弹出层选项比对,匹配成功则更新字段 value + */ + const fillPickerDisplayValues = (fieldStats: TitleStat[]) => { + const options = pickerOptionTextsRef.current + if (options.size === 0) return + // 北森模式(zhiye.com)的选择器值直接写在输入框里,不需要额外提取展示值 + if (window.location.hostname.includes("zhiye.com")) return + for (const ts of fieldStats) { + for (let fIdx = 0; fIdx < ts.fields.length; fIdx++) { + const f = ts.fields[fIdx] + // 已有值的跳过 + if (f.value) continue + if (!f.inputElement) continue + const inputEl = f.inputElement as HTMLElement + // input.value 有值的跳过 + if ((inputEl as HTMLInputElement).value?.trim()) continue + // 找同大标题内下一个字段的标签文字作为边界 + const nextField = ts.fields[fIdx + 1] + const nextLabelText = nextField?.labelText || null + // 调用 getPickerDisplayValue 比对 + const displayVal = getPickerDisplayValue(inputEl, f.labelText, nextLabelText, options) + if (displayVal) { + f.value = displayVal + f.filled = true + f.color = "green" + } + } + } + } + // 立即执行一次 - const stats = scanPageFields(params) + const stats = scanPageFields({ ...params, excludeTexts: buildExcludeTexts() }) + // 补充选择器展示值:对 value 为空的字段,从 DOM 中提取展示文字与选项比对 + fillPickerDisplayValues(stats) setFieldStats(stats) findUpdatedField(stats) // 初始化快照,不滚动 if (hasFullData) handleSaveUserInput() // 每秒循环 scanIntervalRef.current = setInterval(() => { - const s = scanPageFields(params) + const s = scanPageFields({ ...params, excludeTexts: buildExcludeTexts() }) + // 补充选择器展示值 + fillPickerDisplayValues(s) setFieldStats(s) if (ENABLE_AUTO_SCROLL_TO_UPDATED) { const updatedEl = findUpdatedField(s) @@ -440,14 +497,27 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps) // 检测当前页面域名,判断走哪个处理模式 const currentHost = window.location.hostname const isBeisen = currentHost.includes("zhiye.com") // 北森招聘平台域名特征 - const scanSiteMode = isBeisen ? "beisen" as const : undefined + const isMoka = currentHost.includes("mokahr.com") // 摩卡招聘平台域名特征 + const isFeishu = currentHost.includes("feishu.cn") // 飞书招聘平台域名特征 + const isHotjob = currentHost.includes("hotjob.cn") // Hotjob招聘平台域名特征 + const scanSiteMode = isBeisen ? "beisen" as const + : isMoka ? "moka" as const + : isFeishu ? "feishu" as const + : isHotjob ? "hotjob" as const + : undefined // 第一次开启循环扫描(不传 sectionResults,阶段A之前就开始展示) startScanLoop({ siteMode: scanSiteMode }) const fillResult = isBeisen ? await handleAutoFillBeisen({ resumeData, jobInfo }) - : await handleAutoFillCommon({ resumeData, jobInfo }) + : isMoka + ? await handleAutoFillMoka({ resumeData, jobInfo }) + : isFeishu + ? await handleAutoFillFeishu({ resumeData, jobInfo }) + : isHotjob + ? await handleAutoFillHotjob({ resumeData, jobInfo }) + : await handleAutoFillCommon({ resumeData, jobInfo }) // 将处理器返回的结果同步到组件状态 setPageLang(fillResult.lang) // 页面语言(影响后续标签匹配用中文还是英文) @@ -481,6 +551,22 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps) } } } + // 收集页面上所有选择器字段的下拉选项文字,合并到排除集合 + // 避免选择器已选值(如"维吾尔族")被误认为表单标签 + // 北森模式(zhiye.com)选择器值直接写在输入框里,跳过整套弹出层收集逻辑 + if (!isBeisen) { + const preFieldStats = scanPageFields({ + siteMode: scanSiteMode, + sectionResults: fillResult.sectionResults, + expandedResults: fillResult.expandedResults, + excludeTexts: scanExcludeTexts, + }) + const pickerOptionTexts = await collectPickerOptionTexts(preFieldStats) + for (const t of pickerOptionTexts) scanExcludeTexts.add(t) + // 存入 ref,供 handleSaveUserInput 使用 + pickerOptionTextsRef.current = pickerOptionTexts + } + startScanLoop({ siteMode: scanSiteMode, sectionResults: fillResult.sectionResults, @@ -508,7 +594,11 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps) // 检测当前网站模式 const currentHost = window.location.hostname - const siteMode = currentHost.includes("zhiye.com") ? "beisen" as const : undefined + const siteMode = currentHost.includes("zhiye.com") ? "beisen" as const + : currentHost.includes("mokahr.com") ? "moka" as const + : currentHost.includes("feishu.cn") ? "feishu" as const + : currentHost.includes("hotjob.cn") ? "hotjob" as const + : undefined // 从简历数据和缓存数据中提取所有已填值,构建排除集合 // 避免 findLabelForInput 把这些值文字误认为是表单字段标签 @@ -550,10 +640,32 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps) } } } + // 合并选择器选项文字(collectPickerOptionTexts 收集的下拉选项) + for (const t of pickerOptionTextsRef.current) excludeTexts.add(t) // 调用 fillStats 扫描页面所有字段 const titleStats = scanPageFields({ siteMode, excludeTexts }) + // 补充选择器展示值(与 startScanLoop 逻辑一致) + const pickerOpts = pickerOptionTextsRef.current + if (pickerOpts.size > 0) { + for (const ts of titleStats) { + for (let fIdx = 0; fIdx < ts.fields.length; fIdx++) { + const f = ts.fields[fIdx] + if (f.value) continue + if (!f.inputElement) continue + if ((f.inputElement as HTMLInputElement).value?.trim()) continue + const nextField = ts.fields[fIdx + 1] + const nextLabelText = nextField?.labelText || null + const displayVal = getPickerDisplayValue(f.inputElement as HTMLElement, f.labelText, nextLabelText, pickerOpts) + if (displayVal) { + f.value = displayVal + f.filled = true + } + } + } + } + // 提取非简历格式字段(B2阶段字段) const nonResumeData = extractNonResumeFields(titleStats) diff --git a/src/handlers/handleAutoFillFeishu.ts b/src/handlers/handleAutoFillFeishu.ts new file mode 100644 index 0000000..050d50d --- /dev/null +++ b/src/handlers/handleAutoFillFeishu.ts @@ -0,0 +1,1533 @@ +/** + * 飞书模式自动填写处理逻辑(适配 feishu.cn 域名的飞书招聘平台) + * 基于通用模式复制而来,后续在此基础上添加飞书平台特有的表单组件处理逻辑 + * + * 【规范】所有填充操作必须走 fillMatchedField 统一入口, + * 选择器检测必须走 detectPickerField 统一入口, + * 不要在此文件中自行编写选择器操作逻辑,必须引用 lib 中已封装的方法。 + */ + +import { fillMatchedField, isSearchPickerField, fillSearchPickerField, isTimePeriodField, isTimeSingleField, fillTimePeriodField, fillTimeSingleField } from "~lib/autofill" +import { delay } from "~utils/delay" +import { extractDomStructure, detectPageLanguage, isJobApplicationForm, buildSelector } from "~lib/dom" +import { matchFormFieldsInRange, matchMainFields } from "~lib/formMatcher" +import { detectPickerField } from "~lib/pickerDetector" +import { detectAndUploadResume } from "~lib/resumeUpload" +import { getMockResumeData2, JOB_FORM_LABELS } from "~lib/constants" +import { fillDatePicker } from "~lib/datePicker" +import { getResumeFieldValue } from "~lib/resumeDataHelper" +import { locateExperienceSections, expandExperienceSections, sortExperienceByTime, relocateSegmentContainer, getAllPageTitles, findAddButton, clickAddButton } from "~lib/experienceSection" +import type { ExperienceSectionLocateResult } from "~lib/experienceSection" +import type { MatchedFormField, ResumeData, ExperienceSection, JobInfo, UnmatchedFormField } from "~lib/types" +import { setFieldHighlight, isRequiredField } from "~lib/formStyle" +import { get as storageGet, set as storageSet } from "~utils/storage" +import { findLabelForInput } from "~lib/labelFinder" + +/** 飞书模式自动填写的参数 */ +export interface AutoFillFeishuParams { + /** 简历数据(接口获取的) */ + resumeData: ResumeData | null + /** 岗位信息 */ + jobInfo: JobInfo | null +} + +/** 飞书模式自动填写的返回结果 */ +export interface AutoFillFeishuResult { + /** 填写成功数 */ + success: number + /** 填写失败数 */ + failed: number + /** 跳过数 */ + skipped: number + /** 检测到的页面语言 */ + lang: "zh" | "en" + /** 是否为表单页 */ + isFormPage: boolean + /** 使用的简历数据(可能是接口数据或 mock 数据) */ + resumeData: ResumeData | null + /** 匹配到的非经历区域字段 */ + formFields: MatchedFormField[] + /** 收集到的待填写空白字段 */ + unmatchedFields: UnmatchedFormField[] + /** 经历段落定位结果(用于 fillStats 分段展示) */ + sectionResults: ExperienceSectionLocateResult[] + /** 经历段落展开结果(用于 fillStats 分段展示) */ + expandedResults: ExperienceSectionLocateResult[] +} + +/** + * 飞书模式自动填写主流程(基于通用模式,适配 feishu.cn 域名的飞书招聘平台) + * 流程:提取 DOM → 检测语言 → 判断是否表单页 → 上传简历 → 匹配+填写经历 → 匹配+填写非经历 → 收集空白字段 + * + * 【待适配】飞书平台特殊表单组件: + * - TODO: 飞书自定义单选组(如有特殊类名需在此处理) + * - TODO: 飞书级联选择器(地区、学校等多级联动) + * - TODO: 飞书日期选择器(如有自定义日期组件需特殊处理) + * - TODO: 飞书文件上传组件(简历附件上传的特殊逻辑) + */ +export async function handleAutoFillFeishu(params: AutoFillFeishuParams): Promise { + const result: AutoFillFeishuResult = { + success: 0, failed: 0, skipped: 0, + lang: "zh", isFormPage: false, + resumeData: params.resumeData, + formFields: [], + unmatchedFields: [], + sectionResults: [], + expandedResults: [], + } + + // 1. 提取 DOM 结构 + const domStructure = extractDomStructure() + console.log("===== OfferPie: 完整 DOM 树结构 =====") + console.log(domStructure) + console.log(`===== OfferPie: 结构总长度 ${domStructure.length} 字符 =====`) + + // 2. 检测页面语言 + const lang = detectPageLanguage(domStructure) + result.lang = lang + console.log(`===== OfferPie: 页面语言检测结果 = ${lang} =====`) + + // 3. 判断是否为职位申请表单页面 + const isForm = isJobApplicationForm(document.body, lang) + result.isFormPage = isForm + console.log(`===== OfferPie: 是否为职位申请表单页面 = ${isForm} =====`) + + if (!isForm) { + console.log("===== OfferPie: 当前页面不是职位申请表单,跳过字段匹配 =====") + return result + } + + // 4. 获取简历数据(优先使用接口数据,无接口数据时 fallback 到 mock) + const currentResumeData = params.resumeData || getMockResumeData2() + result.resumeData = currentResumeData + console.log(`===== OfferPie: 已加载简历数据,教育${currentResumeData.education.length}段 工作${currentResumeData.work.length}段 实习${currentResumeData.internship.length}段 项目${currentResumeData.project.length}段 竞赛${currentResumeData.competition.length}段 =====`) + + + + + + // 4.1 检测并上传简历文件 + // const resumeUrl = "https://offerpie.oss-cn-guangzhou.aliyuncs.com/%E5%AE%BE%E5%A4%95%E6%B3%95%E5%B0%BC%E4%BA%9A%E5%A4%A7%E5%AD%A6_%E4%B8%81%E5%B1%B9%E6%B6%B5.pdf" + // const uploaded = await detectAndUploadResume(resumeUrl) + // console.log(`===== OfferPie: 简历上传 ${uploaded ? "成功" : "跳过(未找到上传按钮或失败)"} =====`) + // if (uploaded) await delay("high") // 等待网站解析简历 + + // 4.5 定位经历区块并统计已展开段数 + const sectionResults = locateExperienceSections(document.body, lang) + // 4.6 对比简历数据段数,点击添加按钮补足不够的段数 + const expandedResults = await expandExperienceSections(sectionResults, currentResumeData, lang) + + // 存入 result 供外部使用(如 fillStats 分段展示) + result.sectionResults = sectionResults + result.expandedResults = expandedResults + + // 4.65 【预匹配+标绿】经历段落添加完成后,一次性预匹配所有有简历数据的字段并标绿背景 + // 使用临时 usedInputs,不影响后续正式流程的匹配 + { + const tempUsedInputs = new Set() + // 预匹配经历区域字段 + 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("mid") + continue + } else { + await detectPickerField(f, lang) + const ok = await fillMatchedField(f) + if (ok) { result.success++ } else { result.failed++ } + } + + // 收集阶段A已处理字段(用于末尾統計) + phaseAFields.push({ labelText: f.labelText, inputElement: f.inputElement, filled: !!f.fillValue, fillValue: f.fillValue, section: f.section, segmentIndex: segIdx }) + + // 关闭残留弹窗 + if (lastTextInput && !lastTextInputIsPicker) { + ;(lastTextInput as HTMLElement).click() + lastTextInput.focus() + await delay("low") + lastTextInput.blur() + } else { + if (document.activeElement instanceof HTMLElement) { + document.activeElement.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", code: "Escape", keyCode: 27, bubbles: true }) + ) + document.activeElement.blur() + } + document.body.click() + } + await delay("mid") + + if (!f.isPicker && f.inputElement && !isTimePeriodField(f.key) && !isTimeSingleField(f.key)) { + lastTextInput = f.inputElement + lastTextInputIsPicker = false + } else { + lastTextInputIsPicker = true + } + } + } + } + console.log(`===== OfferPie: 阶段A完成 成功${result.success} 失败${result.failed} 跳过${result.skipped} =====`) + + } // end of else (skipPhaseA) + + // 6. 非经历区域字段匹配与填写(阶段B) + console.log("===== OfferPie: 阶段B - 非经历区域填写 =====") + const mainFields = matchMainFields(document.body, lang, excludeRanges, usedInputs) + console.log(` 匹配到 ${mainFields.length} 个非经历字段`) + + for (const f of mainFields) { + const value = getResumeFieldValue(currentResumeData, f.section, f.sectionIndex, f.resumeField) + if (value) f.fillValue = value + + // 检测该字段是否已被网站自动填入值 + if (f.inputElement && f.inputElement.value && f.inputElement.value.trim().length > 0) { + console.log(` [${f.key}] "${f.labelText}" 已有值="${f.inputElement.value.trim()}",跳过`) + result.skipped++ + continue + } + + if (!f.fillValue) { result.skipped++; continue } + + await detectPickerField(f, lang) + + console.log( + ` [${f.key}] "${f.labelText}" → type: ${f.inputType}` + + ` | isPicker: ${f.isPicker}` + + ` | fillValue: "${f.fillValue}"` + ) + + const ok = await fillMatchedField(f) + if (ok) { result.success++ } else { result.failed++ } + + if (lastTextInput) { + ;(lastTextInput as HTMLElement).click() + lastTextInput.focus() + await delay("low") + lastTextInput.blur() + } else { + if (document.activeElement instanceof HTMLElement) { + document.activeElement.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", code: "Escape", keyCode: 27, bubbles: true }) + ) + document.activeElement.blur() + } + document.body.click() + } + await delay("mid") + + if (!f.isPicker && f.inputElement) lastTextInput = f.inputElement + } + + result.formFields = [...mainFields] + console.log(`===== OfferPie: 阶段B完成 总计成功${result.success} 失败${result.failed} 跳过${result.skipped} =====`) + + // 6.5 阶段B2 - 从缓存填写之前保存的 unfilledFormData 有值字段 + // 【独立步骤】注释下面这段即可禁用阶段B2 + { + const resumeName = currentResumeData?.main?.resumeName || currentResumeData?.main?.name || "" + // 构建阶段B中简历无值未填写的非经历字段映射(标签文字→input元素),传给B2作为 fallback 定位 + const unfilledMainFieldMap = new Map() + for (const f of mainFields) { + if (!f.fillValue && f.inputElement && !usedInputs.has(f.inputElement)) { + unfilledMainFieldMap.set(f.labelText, f.inputElement) + } + } + const b2Result = await handleFillCachedData({ + lang, resumeName, usedInputs, expandedResults, sectionResults, unfilledMainFieldMap, + }) + result.success += b2Result.success + result.failed += b2Result.failed + result.skipped += b2Result.skipped + } + + // 7. 阶段C - 收集剩余空白输入框 + console.log("===== OfferPie: 阶段C - 收集剩余空白字段 =====") + + // 需要过滤的标签文字(这些不是有效标签) + const EXCLUDE_LABEL_TEXTS = ["请输入", "输入", ":", ":", "请选择", "选择", "*", "必填"] + + // 收集非经历区域内所有空白输入框 + const allInputsOnPage = document.body.querySelectorAll( + "input:not([type='hidden']):not([type='submit']):not([type='button']):not([type='checkbox']):not([type='radio']):not([type='file']):not([disabled]), textarea:not([disabled])" + ) + + for (const inp of Array.from(allInputsOnPage)) { + const inputEl = inp as HTMLInputElement | HTMLTextAreaElement + if (usedInputs.has(inp)) continue + if (inputEl.value && inputEl.value.trim().length > 0) continue + // 跳过在经历区域范围内的 input + let inExcludeRange = false + for (const range of excludeRanges) { + const afterStart = range.start === inp || (range.start.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) + const beforeEnd = !range.end || (range.end.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_PRECEDING) + if (afterStart && beforeEnd) { inExcludeRange = true; break } + } + if (inExcludeRange) continue + + // 向上查找最近的标签文字 + let labelText = "" + let labelElement: Element | null = null + + // 策略1:查找 input 所在表单项容器内的标签 + const formItemSelectors = [ + ".form-item", ".form-group", ".form-field", + ".el-form-item", ".ant-form-item", ".ant-row", + ".arco-form-item", ".t-form-item", ".n-form-item", + "[class*='form-item']", "[class*='form-group']", "[class*='formItem']", + ] + let container: Element | null = null + for (const sel of formItemSelectors) { + container = inp.closest(sel) + if (container) break + } + if (container) { + const labelEls = container.querySelectorAll("label, span, div, td, th, p, legend, dt") + for (const el of Array.from(labelEls)) { + const directText = Array.from(el.childNodes) + .filter((n) => n.nodeType === Node.TEXT_NODE) + .map((n) => n.textContent?.trim()) + .filter(Boolean) + .join("") + if (!directText || directText.length > 30) continue + if (EXCLUDE_LABEL_TEXTS.some((ex) => directText === ex)) continue + if (el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) { + labelText = directText + labelElement = el + break + } + } + } + + // 策略2:向前查找兄弟/父级中的标签 + if (!labelText) { + let prev: Element | null = inp.previousElementSibling + for (let i = 0; i < 3 && prev; i++) { + const text = prev.textContent?.trim() || "" + if (text && text.length <= 20 && !EXCLUDE_LABEL_TEXTS.some((ex) => text === ex)) { + labelText = text + labelElement = prev + break + } + prev = prev.previousElementSibling + } + } + + if (!labelText || !labelElement) continue + + // 确定表单类型 + let formType: UnmatchedFormField["formType"] = "input" + if (inputEl.tagName === "TEXTAREA") { + formType = "textarea" + } else if (inputEl.hasAttribute("readonly") || inputEl.closest("[class*='select']") || inputEl.closest("[class*='picker']")) { + formType = "select" + } + + result.unmatchedFields.push({ + labelText, + labelElement, + inputElement: inputEl, + radioContainer: null, + formType, + isPicker: formType === "select", + fillValue: "", + alreadyFilled: false, + }) + usedInputs.add(inp) + } + + console.log(` 收集到 ${result.unmatchedFields.length} 个待填写的空白字段`) + for (const uf of result.unmatchedFields) { + console.log(` [待填写] "${uf.labelText}" | formType: ${uf.formType} | isPicker: ${uf.isPicker}`) + } + console.log(`===== OfferPie: 阶段C完成 收集到 ${result.unmatchedFields.length} 个空白字段 =====`) + + // 8. 【标红/标黄】+ 9.【统计打印】 + // 全面扫描页面所有大标题范围内的输入框,对未被阶段A/B/C处理的字段也纳入统计和高亮 + // 经历类型区块按段分组,标记段落索引 + { + // 先对阶段C已收集的字段标红/标黄 + for (const uf of result.unmatchedFields) { + const required = isRequiredField(uf.labelElement, uf.inputElement) + if (required) { + setFieldHighlight(uf.inputElement, "red") + } else { + setFieldHighlight(uf.inputElement, "yellow") + } + } + + const allTitles = getAllPageTitles(sectionResults) + if (allTitles.length > 0) { + console.log("===== OfferPie: 填写结果统计(按大标题分组) =====") + + const processedInputs = new Set() + + 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"]) + + // 从简历数据中提取所有值,构建排除集合(避免将已填值误认为标签) + const excludeTexts = new Set() + if (currentResumeData) { + const main = currentResumeData.main + if (main) { + for (const val of Object.values(main)) { + if (typeof val === "string" && val.trim()) excludeTexts.add(val.trim()) + if (Array.isArray(val)) val.forEach((v) => { if (typeof v === "string" && v.trim()) excludeTexts.add(v.trim()) }) + } + } + const expKeys: ("education" | "work" | "internship" | "project" | "competition")[] = ["education", "work", "internship", "project", "competition"] + for (const sec of expKeys) { + const items = currentResumeData[sec] + if (!Array.isArray(items)) continue + for (const item of items) { + for (const val of Object.values(item as Record)) { + if (typeof val === "string" && val.trim()) excludeTexts.add(val.trim()) + } + } + } + } + + /** 判断某个字段是否属于简历数据格式(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[] = [] + + // 构建当前大标题范围内所有已填 input 的值集合 + // 用于排除误将已填值(如"香港理工大学"、"硕士"、"2026"等)当作标签的情况 + const filledInputValues = new Set() + for (const inp of Array.from(allInputs)) { + const afterT = !!(titleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) + const beforeN = !nextTitleEl || !!(nextTitleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_PRECEDING) + if (!afterT || !beforeN) continue + const val = (inp as HTMLInputElement | HTMLTextAreaElement).value?.trim() + if (val) filledInputValues.add(val) + } + + for (const inp of Array.from(allInputs)) { + // 范围检查 + const afterTitle = !!(titleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) + const beforeNext = !nextTitleEl || !!(nextTitleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_PRECEDING) + if (!afterTitle || !beforeNext) continue + + const inputEl = inp as HTMLInputElement | HTMLTextAreaElement + + // 非经历类型:跳过阶段A/B已成功填充的字段(绿色背景标记) + // greenTwo(#b7ffc6)是阶段B2填的unfilledFormData字段,不跳过,可以收集 + if (!isExpType) { + if (resumeFormatInputs.has(inp)) continue + const bgColor = inputEl.style.backgroundColor + if (bgColor === "#b7ffc5" || bgColor === "rgb(183, 255, 197)") continue + } + + // 查找标签(使用统一封装的 labelFinder) + const titleElementSet = new Set(allTitles.map((t) => t.element)) + const { labelText: detectedLabel } = findLabelForInput(inp, titleElementSet, excludeTexts) + const labelText = detectedLabel?.trim() + + // 跳过无效标签:空、纯数字、已知排除词、或检测到的"标签"实际是某个已填 input 的值 + if (!labelText || /^\d+$/.test(labelText) || JSON_EXCLUDE_LABELS.some((ex) => labelText === ex)) continue + if (filledInputValues.has(labelText)) continue + + // 非经历类型:跳过简历格式字段 + // 【注意】简历格式字段通过 resumeFormatInputs(阶段A/B实际匹配到的input元素集合)精确跳过 + // 不用标签文字匹配跳过,因为同一标签名可能出现在不同大标题下(如"最高学历"在个人信息 vs 教育经历) + // resumeFormatInputs 已在上方 if(!isExpType) 块中判断,此处无需重复 + + collectedFields.push({ label: labelText, value: inputEl.value?.trim() || "", inputEl: inp }) + } + + if (collectedFields.length === 0) continue + + // 按经历类型分组 + if (isExpType) { + let segmentCount = 1 + + if (isFiveExp && expResult) { + // 5大经历用 containerElement 分段 + segmentCount = expResult.segmentRanges.length || 1 + const segments: { label: string; value: string }[][] = [] + for (let sIdx = 0; sIdx < segmentCount; sIdx++) { + const seg = expResult.segmentRanges[sIdx] + const segFields = seg?.containerElement + ? collectedFields.filter((f) => seg.containerElement!.contains(f.inputEl)) + : collectedFields + segments.push(segFields.map((f) => ({ label: f.label, value: f.value }))) + } + const nonEmptySegments = segments.filter((s) => s.length > 0) + if (nonEmptySegments.length > 0) { + unfilledFormData.push({ title: titleText, isExperience: true, formItems: nonEmptySegments }) + } + } else { + // 非5大经历用标签重复计数分段 + const labelCounts = new Map() + for (const f of collectedFields) { + labelCounts.set(f.label, (labelCounts.get(f.label) || 0) + 1) + } + let maxCount = 1 + let markerLabel = "" + for (const [label, count] of labelCounts) { + if (count > maxCount) { maxCount = count; markerLabel = label } + } + segmentCount = maxCount + + if (segmentCount <= 1) { + unfilledFormData.push({ title: titleText, isExperience: true, formItems: [collectedFields.map((f) => ({ label: f.label, value: f.value }))] }) + } else { + const markerPositions = collectedFields + .map((f, idx) => f.label === markerLabel ? idx : -1) + .filter((idx) => idx >= 0) + const segments: { label: string; value: string }[][] = [] + for (let i = 0; i < markerPositions.length; i++) { + const start = markerPositions[i] + const end = i < markerPositions.length - 1 ? markerPositions[i + 1] : collectedFields.length + segments.push(collectedFields.slice(start, end).map((f) => ({ label: f.label, value: f.value }))) + } + unfilledFormData.push({ title: titleText, isExperience: true, formItems: segments }) + } + } + } else { + // 非经历类型:只存未填字段 + unfilledFormData.push({ + title: titleText, + isExperience: false, + formItems: collectedFields.map((f) => ({ label: f.label, value: f.value })), + }) + } + } + } + + console.log("===== OfferPie: 网站未填表单字段数据(JSON) =====") + // console.log(JSON.stringify(unfilledFormData, null, 2)) + console.log("===== OfferPie: JSON 输出完毕 =====") + + // 存 chrome.storage 缓存,包含简历名字和未填字段数据(跨域名共享,按简历名区分) + const resumeName = currentResumeData?.main?.resumeName || currentResumeData?.main?.name || "" + try { + const existing = await storageGet("offerpie_unfilled_form") + let cacheData: any = null + + if (existing && existing.resumeName === resumeName && existing.unfilledFormData) { + // 同一份简历,按字段级合并(非空值才更新,保留旧缓存中已有的非空值不被覆盖) + const oldSections = existing.unfilledFormData as any[] + const newSections = unfilledFormData as any[] + + for (const newSec of newSections) { + const oldSecIdx = oldSections.findIndex((s: any) => s.title === newSec.title) + if (oldSecIdx < 0) { + oldSections.push(newSec) + } else { + const oldSec = oldSections[oldSecIdx] + if (newSec.isExperience && oldSec.isExperience) { + // 经历类型:按段合并,保留旧字段 + const oldSegments = oldSec.formItems as any[][] + const newSegments = newSec.formItems as any[][] + for (let sIdx = 0; sIdx < newSegments.length; sIdx++) { + if (sIdx >= oldSegments.length) { + oldSegments.push(newSegments[sIdx]) + } else { + const oldFields = oldSegments[sIdx] + const newFields = newSegments[sIdx] + for (const nf of newFields) { + const of_ = oldFields.find((f: any) => f.label === nf.label) + if (of_) { + if (nf.value) of_.value = nf.value + } else { + oldFields.push(nf) + } + } + } + } + } else if (!newSec.isExperience && !oldSec.isExperience) { + // 非经历类型:按字段合并,只有非空值才更新,旧值不丢失 + const oldFields = oldSec.formItems as any[] + const newFields = newSec.formItems as any[] + for (const nf of newFields) { + const of_ = oldFields.find((f: any) => f.label === nf.label) + if (of_) { + if (nf.value) of_.value = nf.value + } else { + oldFields.push(nf) + } + } + } else { + // 类型变化(经历↔非经历),直接替换 + oldSections[oldSecIdx] = newSec + } + } + } + + existing.unfilledFormData = oldSections + existing.timestamp = Date.now() + cacheData = existing + } + + // 没有已有缓存或不是同一份简历 → 新建 + if (!cacheData) { + cacheData = { + resumeName, + unfilledFormData, + timestamp: Date.now(), + } + } + + await storageSet("offerpie_unfilled_form", cacheData) + console.log(`===== OfferPie: 已缓存未填字段数据(简历: "${resumeName}") =====`) + } catch (e) { + console.warn("OfferPie: chrome.storage 缓存失败", e) + } + } + + return result +} + + +// ============================================================ +// 阶段B2:从 localStorage 缓存填写 unfilledFormData 有值字段 +// 【独立模块】可以通过注释 handleAutoFillFeishu 中的调用行来禁用 +// ============================================================ + +/** 阶段B2 表单项容器选择器 */ +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[] + /** 阶段B中简历无值未填写的非经历字段(标签→input映射),B2用缓存值填写这些字段 */ + unfilledMainFieldMap?: Map +} + +/** 阶段B2 结果 */ +interface FillCachedDataResult { + success: number + failed: number + skipped: number +} + +/** + * 阶段B2:在指定大标题范围内,通过标签文字查找对应的 input 元素 + */ +function b2FindInputByLabel( + labelText: string, + titleEl: Element, + nextTitleEl: Element | null, + usedInputs: Set +): HTMLInputElement | HTMLTextAreaElement | null { + const allInputs = document.body.querySelectorAll(B2_INPUT_SEL) + for (const inp of Array.from(allInputs)) { + if (usedInputs.has(inp)) continue + const afterTitle = !!(titleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) + const beforeNext = !nextTitleEl || !!(nextTitleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_PRECEDING) + if (!afterTitle || !beforeNext) continue + + let container: Element | null = null + for (const sel of B2_FORM_ITEM_SELS) { + container = inp.closest(sel) + if (container) break + } + if (container) { + const labelEls = container.querySelectorAll("label, span, div, td, th, p, legend, dt") + for (const el of Array.from(labelEls)) { + const directText = Array.from(el.childNodes) + .filter((n) => n.nodeType === Node.TEXT_NODE) + .map((n) => n.textContent?.trim()) + .filter(Boolean) + .join("") + if (!directText || directText.length > 30) continue + if (B2_EXCLUDE_LABELS.some((ex) => directText === ex)) continue + // 标签文字必须严格全名匹配,不走 includes + if (directText === labelText && + (el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING)) { + return inp as HTMLInputElement | HTMLTextAreaElement + } + } + } + } + return null +} + +/** + * 阶段B2:在指定容器内,通过标签文字查找对应的 input 元素 + */ +function b2FindInputByLabelInContainer( + labelText: string, + containerEl: Element, + usedInputs: Set +): HTMLInputElement | HTMLTextAreaElement | null { + const allInputs = containerEl.querySelectorAll(B2_INPUT_SEL) + for (const inp of Array.from(allInputs)) { + if (usedInputs.has(inp)) continue + let formItem: Element | null = null + for (const sel of B2_FORM_ITEM_SELS) { + formItem = inp.closest(sel) + if (formItem) break + } + if (formItem) { + const labelEls = formItem.querySelectorAll("label, span, div, td, th, p, legend, dt") + for (const el of Array.from(labelEls)) { + const directText = Array.from(el.childNodes) + .filter((n) => n.nodeType === Node.TEXT_NODE) + .map((n) => n.textContent?.trim()) + .filter(Boolean) + .join("") + if (!directText || directText.length > 30) continue + if (B2_EXCLUDE_LABELS.some((ex) => directText === ex)) continue + // 标签文字必须严格全名匹配,不走 includes + if (directText === labelText && + (el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING)) { + return inp as HTMLInputElement | HTMLTextAreaElement + } + } + } + } + return null +} + +/** + * 阶段B2:构造 MatchedFormField 并填写单个字段 + * 【规范】走 detectPickerField + fillMatchedField 统一入口 + */ +async function b2FillSingleField( + inputEl: HTMLInputElement | HTMLTextAreaElement, + labelText: string, + fillValue: string, + lang: "zh" | "en" +): Promise { + if (inputEl.value && inputEl.value.trim().length > 0) return false + + const field: MatchedFormField = { + key: "", section: "main", resumeField: "", + sectionIndex: 0, labelText, + labelElement: inputEl.previousElementSibling || inputEl.parentElement || inputEl, + labelSelector: "", + inputElement: inputEl, + inputSelector: buildSelector(inputEl), + buttonElement: null, buttonSelector: "", + inputType: inputEl.tagName === "TEXTAREA" ? "textarea" : "text", + radioContainer: null, + isPicker: false, + pickerDropdownElement: null, pickerDropdownSelector: "", + fillValue, + } + + await detectPickerField(field, lang) + return await fillMatchedField(field) +} + +/** 时间/日期相关的标签关键字 */ +const B2_DATE_TIME_KEYWORDS = ["时间", "日期", "日期时间", "开始", "结束", "起始", "截止", "入职", "离职", "毕业"] + +/** + * 判断标签名是否为时间/日期类字段 + * 标签文字中包含时间/日期相关关键字即认为是时间字段 + */ +function b2IsDateTimeLabel(label: string): boolean { + return B2_DATE_TIME_KEYWORDS.some((kw) => label.includes(kw)) +} + +/** + * 阶段B2:填写时间/日期字段 + * 走和阶段A同款的流程:直接点击 input → fillDatePicker + * 不经过 detectPickerField(避免方式3主动点击导致 toggle 问题) + * 不经过 fillPickerField(避免步骤1-2再次点击关闭弹出层) + * + * 引用方式和阶段A的 fillTimePeriodField 情况A2 一致: + * input.focus() → input.click() → delay → fillDatePicker(field) + */ +async function b2FillDateTimeField( + inputEl: HTMLInputElement | HTMLTextAreaElement, + labelText: string, + fillValue: string +): Promise { + // 构造 MatchedFormField(和阶段A一样,isPicker=true,不设 pickerDropdownElement) + const field: MatchedFormField = { + key: "", section: "main", resumeField: "", + sectionIndex: 0, labelText, + labelElement: inputEl.previousElementSibling || inputEl.parentElement || inputEl, + labelSelector: "", + inputElement: inputEl, + inputSelector: buildSelector(inputEl), + buttonElement: null, buttonSelector: "", + inputType: "text", + radioContainer: null, + isPicker: true, + pickerDropdownElement: null, pickerDropdownSelector: "", + fillValue, + } + + // 和阶段A同样的方式:点击 input 展开日期面板,然后直接调 fillDatePicker + inputEl.focus() + ;(inputEl as HTMLElement).click() + await delay("mid") + + const ok = await fillDatePicker(field) + if (ok) { + console.log(`OfferPie: ✅ [B2-时间字段] "${labelText}" = "${fillValue}" 填写成功`) + } else { + console.log(`OfferPie: ❌ [B2-时间字段] "${labelText}" = "${fillValue}" 填写失败`) + } + return ok +} + +/** + * 阶段B2 主流程:从缓存读取 unfilledFormData,填写有值的字段 + */ +async function handleFillCachedData(params: FillCachedDataParams): Promise { + const { lang, resumeName, usedInputs, expandedResults, sectionResults, unfilledMainFieldMap } = params + const result: FillCachedDataResult = { success: 0, failed: 0, skipped: 0 } + + const cacheData = await storageGet("offerpie_unfilled_form") + if (!cacheData) { + console.log("===== OfferPie: 阶段B2 - 无缓存数据,跳过 =====") + return result + } + + if (cacheData.resumeName !== resumeName) { + console.log(`===== OfferPie: 阶段B2 - 缓存简历名"${cacheData.resumeName}"与当前"${resumeName}"不匹配,跳过 =====`) + return result + } + + const unfilledFormData = cacheData.unfilledFormData as { + title: string + isExperience: boolean + formItems: { label: string; value: string }[] | { label: string; value: string }[][] + }[] + + if (!unfilledFormData || unfilledFormData.length === 0) { + console.log("===== OfferPie: 阶段B2 - 缓存中无字段数据,跳过 =====") + return result + } + + console.log("===== OfferPie: 阶段B2 - 填写缓存数据 =====") + + const allTitles = getAllPageTitles(sectionResults) + if (allTitles.length === 0) { + console.log(" ❌ 未找到页面大标题,跳过") + return result + } + + for (const sectionData of unfilledFormData) { + const titleInfo = allTitles.find((t) => t.text === sectionData.title) + if (!titleInfo) { + console.log(` [B2] "${sectionData.title}" 未在页面找到对应大标题,跳过`) + continue + } + const titleEl = titleInfo.element + const titleIdx = allTitles.indexOf(titleInfo) + const nextTitleEl = titleIdx < allTitles.length - 1 ? allTitles[titleIdx + 1].element : null + + const expResult = expandedResults.find((r) => r.titleElement === titleEl) + const isFiveExp = !!expResult && B2_FIVE_EXP_SECTIONS.has(expResult.section) + + if (sectionData.isExperience) { + const segments = sectionData.formItems as { label: string; value: string }[][] + + if (isFiveExp && expResult) { + console.log(` [B2] "${sectionData.title}" (5大经历) 缓存${segments.length}段`) + for (let sIdx = 0; sIdx < segments.length; sIdx++) { + const seg = expResult.segmentRanges[sIdx] + if (!seg || !seg.containerElement) continue + const fields = segments[sIdx] + for (const field of fields) { + if (!field.value) { result.skipped++; continue } + const inputEl = b2FindInputByLabelInContainer(field.label, seg.containerElement, usedInputs) + if (!inputEl) { result.skipped++; continue } + // 时间/日期字段走阶段A同款流程:直接点击 input → fillDatePicker,不经过 detectPickerField + if (b2IsDateTimeLabel(field.label)) { + const ok = await b2FillDateTimeField(inputEl, field.label, field.value) + if (ok) { result.success++; usedInputs.add(inputEl); setFieldHighlight(inputEl, "greenTwo") } else { result.failed++ } + } else { + const ok = await b2FillSingleField(inputEl, field.label, field.value, lang) + if (ok) { result.success++; usedInputs.add(inputEl); setFieldHighlight(inputEl, "greenTwo") } else { result.failed++ } + } + await delay("mid") + } + } + } else { + console.log(` [B2] "${sectionData.title}" (其他经历) 缓存${segments.length}段`) + + // 检测当前页面已有几段 + const allInputsInRange = document.body.querySelectorAll(B2_INPUT_SEL) + const labelsInRange: string[] = [] + for (const inp of Array.from(allInputsInRange)) { + const afterTitle = !!(titleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) + const beforeNext = !nextTitleEl || !!(nextTitleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_PRECEDING) + if (!afterTitle || !beforeNext) continue + 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("mid") + } + } + + for (let sIdx = 0; sIdx < segments.length; sIdx++) { + const fields = segments[sIdx] + for (const field of fields) { + if (!field.value) { result.skipped++; continue } + const inputEl = b2FindInputByLabel(field.label, titleEl, nextTitleEl, usedInputs) + if (!inputEl) { result.skipped++; continue } + // 时间/日期字段走阶段A同款流程:直接点击 input → fillDatePicker,不经过 detectPickerField + if (b2IsDateTimeLabel(field.label)) { + const ok = await b2FillDateTimeField(inputEl, field.label, field.value) + if (ok) { result.success++; usedInputs.add(inputEl); setFieldHighlight(inputEl, "greenTwo") } else { result.failed++ } + } else { + const ok = await b2FillSingleField(inputEl, field.label, field.value, lang) + if (ok) { result.success++; usedInputs.add(inputEl); setFieldHighlight(inputEl, "greenTwo") } else { result.failed++ } + } + await delay("mid") + } + } + } + } else { + const fields = sectionData.formItems as { label: string; value: string }[] + console.log(` [B2] "${sectionData.title}" (非经历) 缓存${fields.length}个字段`) + for (const field of fields) { + if (!field.value) { result.skipped++; continue } + let inputEl = b2FindInputByLabel(field.label, titleEl, nextTitleEl, usedInputs) + // fallback:如果 b2FindInputByLabel 找不到,从阶段B传入的未填简历字段映射中查找 + if (!inputEl && unfilledMainFieldMap) { + const fallbackEl = unfilledMainFieldMap.get(field.label) + if (fallbackEl && !usedInputs.has(fallbackEl)) { + inputEl = fallbackEl + console.log(` [B2] "${field.label}" 通过 unfilledMainFieldMap fallback 定位到 input`) + } + } + if (!inputEl) { result.skipped++; continue } + // 时间/日期字段走阶段A同款流程:直接点击 input → fillDatePicker,不经过 detectPickerField + if (b2IsDateTimeLabel(field.label)) { + const ok = await b2FillDateTimeField(inputEl, field.label, field.value) + if (ok) { result.success++; usedInputs.add(inputEl); setFieldHighlight(inputEl, "greenTwo") } else { result.failed++ } + } else { + const ok = await b2FillSingleField(inputEl, field.label, field.value, lang) + if (ok) { result.success++; usedInputs.add(inputEl); setFieldHighlight(inputEl, "greenTwo") } else { result.failed++ } + } + await delay("mid") + } + } + } + + console.log(`===== OfferPie: 阶段B2完成 成功${result.success} 失败${result.failed} 跳过${result.skipped} =====`) + return result +} \ No newline at end of file diff --git a/src/handlers/handleAutoFillHotjob.ts b/src/handlers/handleAutoFillHotjob.ts new file mode 100644 index 0000000..e452edf --- /dev/null +++ b/src/handlers/handleAutoFillHotjob.ts @@ -0,0 +1,1533 @@ +/** + * Hotjob模式自动填写处理逻辑(适配 hotjob.cn 域名的Hotjob招聘平台) + * 基于通用模式复制而来,后续在此基础上添加Hotjob平台特有的表单组件处理逻辑 + * + * 【规范】所有填充操作必须走 fillMatchedField 统一入口, + * 选择器检测必须走 detectPickerField 统一入口, + * 不要在此文件中自行编写选择器操作逻辑,必须引用 lib 中已封装的方法。 + */ + +import { fillMatchedField, isSearchPickerField, fillSearchPickerField, isTimePeriodField, isTimeSingleField, fillTimePeriodField, fillTimeSingleField } from "~lib/autofill" +import { delay } from "~utils/delay" +import { extractDomStructure, detectPageLanguage, isJobApplicationForm, buildSelector } from "~lib/dom" +import { matchFormFieldsInRange, matchMainFields } from "~lib/formMatcher" +import { detectPickerField } from "~lib/pickerDetector" +import { detectAndUploadResume } from "~lib/resumeUpload" +import { getMockResumeData2, JOB_FORM_LABELS } from "~lib/constants" +import { fillDatePicker } from "~lib/datePicker" +import { getResumeFieldValue } from "~lib/resumeDataHelper" +import { locateExperienceSections, expandExperienceSections, sortExperienceByTime, relocateSegmentContainer, getAllPageTitles, findAddButton, clickAddButton } from "~lib/experienceSection" +import type { ExperienceSectionLocateResult } from "~lib/experienceSection" +import type { MatchedFormField, ResumeData, ExperienceSection, JobInfo, UnmatchedFormField } from "~lib/types" +import { setFieldHighlight, isRequiredField } from "~lib/formStyle" +import { get as storageGet, set as storageSet } from "~utils/storage" +import { findLabelForInput } from "~lib/labelFinder" + +/** Hotjob模式自动填写的参数 */ +export interface AutoFillHotjobParams { + /** 简历数据(接口获取的) */ + resumeData: ResumeData | null + /** 岗位信息 */ + jobInfo: JobInfo | null +} + +/** Hotjob模式自动填写的返回结果 */ +export interface AutoFillHotjobResult { + /** 填写成功数 */ + success: number + /** 填写失败数 */ + failed: number + /** 跳过数 */ + skipped: number + /** 检测到的页面语言 */ + lang: "zh" | "en" + /** 是否为表单页 */ + isFormPage: boolean + /** 使用的简历数据(可能是接口数据或 mock 数据) */ + resumeData: ResumeData | null + /** 匹配到的非经历区域字段 */ + formFields: MatchedFormField[] + /** 收集到的待填写空白字段 */ + unmatchedFields: UnmatchedFormField[] + /** 经历段落定位结果(用于 fillStats 分段展示) */ + sectionResults: ExperienceSectionLocateResult[] + /** 经历段落展开结果(用于 fillStats 分段展示) */ + expandedResults: ExperienceSectionLocateResult[] +} + +/** + * Hotjob模式自动填写主流程(基于通用模式,适配 hotjob.cn 域名的Hotjob招聘平台) + * 流程:提取 DOM → 检测语言 → 判断是否表单页 → 上传简历 → 匹配+填写经历 → 匹配+填写非经历 → 收集空白字段 + * + * 【待适配】Hotjob平台特殊表单组件: + * - TODO: Hotjob自定义单选组(如有特殊类名需在此处理) + * - TODO: Hotjob级联选择器(地区、学校等多级联动) + * - TODO: Hotjob日期选择器(如有自定义日期组件需特殊处理) + * - TODO: Hotjob文件上传组件(简历附件上传的特殊逻辑) + */ +export async function handleAutoFillHotjob(params: AutoFillHotjobParams): Promise { + const result: AutoFillHotjobResult = { + success: 0, failed: 0, skipped: 0, + lang: "zh", isFormPage: false, + resumeData: params.resumeData, + formFields: [], + unmatchedFields: [], + sectionResults: [], + expandedResults: [], + } + + // 1. 提取 DOM 结构 + const domStructure = extractDomStructure() + console.log("===== OfferPie: 完整 DOM 树结构 =====") + console.log(domStructure) + console.log(`===== OfferPie: 结构总长度 ${domStructure.length} 字符 =====`) + + // 2. 检测页面语言 + const lang = detectPageLanguage(domStructure) + result.lang = lang + console.log(`===== OfferPie: 页面语言检测结果 = ${lang} =====`) + + // 3. 判断是否为职位申请表单页面 + const isForm = isJobApplicationForm(document.body, lang) + result.isFormPage = isForm + console.log(`===== OfferPie: 是否为职位申请表单页面 = ${isForm} =====`) + + if (!isForm) { + console.log("===== OfferPie: 当前页面不是职位申请表单,跳过字段匹配 =====") + return result + } + + // 4. 获取简历数据(优先使用接口数据,无接口数据时 fallback 到 mock) + const currentResumeData = params.resumeData || getMockResumeData2() + result.resumeData = currentResumeData + console.log(`===== OfferPie: 已加载简历数据,教育${currentResumeData.education.length}段 工作${currentResumeData.work.length}段 实习${currentResumeData.internship.length}段 项目${currentResumeData.project.length}段 竞赛${currentResumeData.competition.length}段 =====`) + + + + + + // 4.1 检测并上传简历文件 + // const resumeUrl = "https://offerpie.oss-cn-guangzhou.aliyuncs.com/%E5%AE%BE%E5%A4%95%E6%B3%95%E5%B0%BC%E4%BA%9A%E5%A4%A7%E5%AD%A6_%E4%B8%81%E5%B1%B9%E6%B6%B5.pdf" + // const uploaded = await detectAndUploadResume(resumeUrl) + // console.log(`===== OfferPie: 简历上传 ${uploaded ? "成功" : "跳过(未找到上传按钮或失败)"} =====`) + // if (uploaded) await delay("high") // 等待网站解析简历 + + // 4.5 定位经历区块并统计已展开段数 + const sectionResults = locateExperienceSections(document.body, lang) + // 4.6 对比简历数据段数,点击添加按钮补足不够的段数 + const expandedResults = await expandExperienceSections(sectionResults, currentResumeData, lang) + + // 存入 result 供外部使用(如 fillStats 分段展示) + result.sectionResults = sectionResults + result.expandedResults = expandedResults + + // 4.65 【预匹配+标绿】经历段落添加完成后,一次性预匹配所有有简历数据的字段并标绿背景 + // 使用临时 usedInputs,不影响后续正式流程的匹配 + { + const tempUsedInputs = new Set() + // 预匹配经历区域字段 + 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("mid") + continue + } else { + await detectPickerField(f, lang) + const ok = await fillMatchedField(f) + if (ok) { result.success++ } else { result.failed++ } + } + + // 收集阶段A已处理字段(用于末尾統計) + phaseAFields.push({ labelText: f.labelText, inputElement: f.inputElement, filled: !!f.fillValue, fillValue: f.fillValue, section: f.section, segmentIndex: segIdx }) + + // 关闭残留弹窗 + if (lastTextInput && !lastTextInputIsPicker) { + ;(lastTextInput as HTMLElement).click() + lastTextInput.focus() + await delay("low") + lastTextInput.blur() + } else { + if (document.activeElement instanceof HTMLElement) { + document.activeElement.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", code: "Escape", keyCode: 27, bubbles: true }) + ) + document.activeElement.blur() + } + document.body.click() + } + await delay("mid") + + if (!f.isPicker && f.inputElement && !isTimePeriodField(f.key) && !isTimeSingleField(f.key)) { + lastTextInput = f.inputElement + lastTextInputIsPicker = false + } else { + lastTextInputIsPicker = true + } + } + } + } + console.log(`===== OfferPie: 阶段A完成 成功${result.success} 失败${result.failed} 跳过${result.skipped} =====`) + + } // end of else (skipPhaseA) + + // 6. 非经历区域字段匹配与填写(阶段B) + console.log("===== OfferPie: 阶段B - 非经历区域填写 =====") + const mainFields = matchMainFields(document.body, lang, excludeRanges, usedInputs) + console.log(` 匹配到 ${mainFields.length} 个非经历字段`) + + for (const f of mainFields) { + const value = getResumeFieldValue(currentResumeData, f.section, f.sectionIndex, f.resumeField) + if (value) f.fillValue = value + + // 检测该字段是否已被网站自动填入值 + if (f.inputElement && f.inputElement.value && f.inputElement.value.trim().length > 0) { + console.log(` [${f.key}] "${f.labelText}" 已有值="${f.inputElement.value.trim()}",跳过`) + result.skipped++ + continue + } + + if (!f.fillValue) { result.skipped++; continue } + + await detectPickerField(f, lang) + + console.log( + ` [${f.key}] "${f.labelText}" → type: ${f.inputType}` + + ` | isPicker: ${f.isPicker}` + + ` | fillValue: "${f.fillValue}"` + ) + + const ok = await fillMatchedField(f) + if (ok) { result.success++ } else { result.failed++ } + + if (lastTextInput) { + ;(lastTextInput as HTMLElement).click() + lastTextInput.focus() + await delay("low") + lastTextInput.blur() + } else { + if (document.activeElement instanceof HTMLElement) { + document.activeElement.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", code: "Escape", keyCode: 27, bubbles: true }) + ) + document.activeElement.blur() + } + document.body.click() + } + await delay("mid") + + if (!f.isPicker && f.inputElement) lastTextInput = f.inputElement + } + + result.formFields = [...mainFields] + console.log(`===== OfferPie: 阶段B完成 总计成功${result.success} 失败${result.failed} 跳过${result.skipped} =====`) + + // 6.5 阶段B2 - 从缓存填写之前保存的 unfilledFormData 有值字段 + // 【独立步骤】注释下面这段即可禁用阶段B2 + { + const resumeName = currentResumeData?.main?.resumeName || currentResumeData?.main?.name || "" + // 构建阶段B中简历无值未填写的非经历字段映射(标签文字→input元素),传给B2作为 fallback 定位 + const unfilledMainFieldMap = new Map() + for (const f of mainFields) { + if (!f.fillValue && f.inputElement && !usedInputs.has(f.inputElement)) { + unfilledMainFieldMap.set(f.labelText, f.inputElement) + } + } + const b2Result = await handleFillCachedData({ + lang, resumeName, usedInputs, expandedResults, sectionResults, unfilledMainFieldMap, + }) + result.success += b2Result.success + result.failed += b2Result.failed + result.skipped += b2Result.skipped + } + + // 7. 阶段C - 收集剩余空白输入框 + console.log("===== OfferPie: 阶段C - 收集剩余空白字段 =====") + + // 需要过滤的标签文字(这些不是有效标签) + const EXCLUDE_LABEL_TEXTS = ["请输入", "输入", ":", ":", "请选择", "选择", "*", "必填"] + + // 收集非经历区域内所有空白输入框 + const allInputsOnPage = document.body.querySelectorAll( + "input:not([type='hidden']):not([type='submit']):not([type='button']):not([type='checkbox']):not([type='radio']):not([type='file']):not([disabled]), textarea:not([disabled])" + ) + + for (const inp of Array.from(allInputsOnPage)) { + const inputEl = inp as HTMLInputElement | HTMLTextAreaElement + if (usedInputs.has(inp)) continue + if (inputEl.value && inputEl.value.trim().length > 0) continue + // 跳过在经历区域范围内的 input + let inExcludeRange = false + for (const range of excludeRanges) { + const afterStart = range.start === inp || (range.start.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) + const beforeEnd = !range.end || (range.end.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_PRECEDING) + if (afterStart && beforeEnd) { inExcludeRange = true; break } + } + if (inExcludeRange) continue + + // 向上查找最近的标签文字 + let labelText = "" + let labelElement: Element | null = null + + // 策略1:查找 input 所在表单项容器内的标签 + const formItemSelectors = [ + ".form-item", ".form-group", ".form-field", + ".el-form-item", ".ant-form-item", ".ant-row", + ".arco-form-item", ".t-form-item", ".n-form-item", + "[class*='form-item']", "[class*='form-group']", "[class*='formItem']", + ] + let container: Element | null = null + for (const sel of formItemSelectors) { + container = inp.closest(sel) + if (container) break + } + if (container) { + const labelEls = container.querySelectorAll("label, span, div, td, th, p, legend, dt") + for (const el of Array.from(labelEls)) { + const directText = Array.from(el.childNodes) + .filter((n) => n.nodeType === Node.TEXT_NODE) + .map((n) => n.textContent?.trim()) + .filter(Boolean) + .join("") + if (!directText || directText.length > 30) continue + if (EXCLUDE_LABEL_TEXTS.some((ex) => directText === ex)) continue + if (el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) { + labelText = directText + labelElement = el + break + } + } + } + + // 策略2:向前查找兄弟/父级中的标签 + if (!labelText) { + let prev: Element | null = inp.previousElementSibling + for (let i = 0; i < 3 && prev; i++) { + const text = prev.textContent?.trim() || "" + if (text && text.length <= 20 && !EXCLUDE_LABEL_TEXTS.some((ex) => text === ex)) { + labelText = text + labelElement = prev + break + } + prev = prev.previousElementSibling + } + } + + if (!labelText || !labelElement) continue + + // 确定表单类型 + let formType: UnmatchedFormField["formType"] = "input" + if (inputEl.tagName === "TEXTAREA") { + formType = "textarea" + } else if (inputEl.hasAttribute("readonly") || inputEl.closest("[class*='select']") || inputEl.closest("[class*='picker']")) { + formType = "select" + } + + result.unmatchedFields.push({ + labelText, + labelElement, + inputElement: inputEl, + radioContainer: null, + formType, + isPicker: formType === "select", + fillValue: "", + alreadyFilled: false, + }) + usedInputs.add(inp) + } + + console.log(` 收集到 ${result.unmatchedFields.length} 个待填写的空白字段`) + for (const uf of result.unmatchedFields) { + console.log(` [待填写] "${uf.labelText}" | formType: ${uf.formType} | isPicker: ${uf.isPicker}`) + } + console.log(`===== OfferPie: 阶段C完成 收集到 ${result.unmatchedFields.length} 个空白字段 =====`) + + // 8. 【标红/标黄】+ 9.【统计打印】 + // 全面扫描页面所有大标题范围内的输入框,对未被阶段A/B/C处理的字段也纳入统计和高亮 + // 经历类型区块按段分组,标记段落索引 + { + // 先对阶段C已收集的字段标红/标黄 + for (const uf of result.unmatchedFields) { + const required = isRequiredField(uf.labelElement, uf.inputElement) + if (required) { + setFieldHighlight(uf.inputElement, "red") + } else { + setFieldHighlight(uf.inputElement, "yellow") + } + } + + const allTitles = getAllPageTitles(sectionResults) + if (allTitles.length > 0) { + console.log("===== OfferPie: 填写结果统计(按大标题分组) =====") + + const processedInputs = new Set() + + 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"]) + + // 从简历数据中提取所有值,构建排除集合(避免将已填值误认为标签) + const excludeTexts = new Set() + if (currentResumeData) { + const main = currentResumeData.main + if (main) { + for (const val of Object.values(main)) { + if (typeof val === "string" && val.trim()) excludeTexts.add(val.trim()) + if (Array.isArray(val)) val.forEach((v) => { if (typeof v === "string" && v.trim()) excludeTexts.add(v.trim()) }) + } + } + const expKeys: ("education" | "work" | "internship" | "project" | "competition")[] = ["education", "work", "internship", "project", "competition"] + for (const sec of expKeys) { + const items = currentResumeData[sec] + if (!Array.isArray(items)) continue + for (const item of items) { + for (const val of Object.values(item as Record)) { + if (typeof val === "string" && val.trim()) excludeTexts.add(val.trim()) + } + } + } + } + + /** 判断某个字段是否属于简历数据格式(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[] = [] + + // 构建当前大标题范围内所有已填 input 的值集合 + // 用于排除误将已填值(如"香港理工大学"、"硕士"、"2026"等)当作标签的情况 + const filledInputValues = new Set() + for (const inp of Array.from(allInputs)) { + const afterT = !!(titleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) + const beforeN = !nextTitleEl || !!(nextTitleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_PRECEDING) + if (!afterT || !beforeN) continue + const val = (inp as HTMLInputElement | HTMLTextAreaElement).value?.trim() + if (val) filledInputValues.add(val) + } + + for (const inp of Array.from(allInputs)) { + // 范围检查 + const afterTitle = !!(titleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) + const beforeNext = !nextTitleEl || !!(nextTitleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_PRECEDING) + if (!afterTitle || !beforeNext) continue + + const inputEl = inp as HTMLInputElement | HTMLTextAreaElement + + // 非经历类型:跳过阶段A/B已成功填充的字段(绿色背景标记) + // greenTwo(#b7ffc6)是阶段B2填的unfilledFormData字段,不跳过,可以收集 + if (!isExpType) { + if (resumeFormatInputs.has(inp)) continue + const bgColor = inputEl.style.backgroundColor + if (bgColor === "#b7ffc5" || bgColor === "rgb(183, 255, 197)") continue + } + + // 查找标签(使用统一封装的 labelFinder) + const titleElementSet = new Set(allTitles.map((t) => t.element)) + const { labelText: detectedLabel } = findLabelForInput(inp, titleElementSet, excludeTexts) + const labelText = detectedLabel?.trim() + + // 跳过无效标签:空、纯数字、已知排除词、或检测到的"标签"实际是某个已填 input 的值 + if (!labelText || /^\d+$/.test(labelText) || JSON_EXCLUDE_LABELS.some((ex) => labelText === ex)) continue + if (filledInputValues.has(labelText)) continue + + // 非经历类型:跳过简历格式字段 + // 【注意】简历格式字段通过 resumeFormatInputs(阶段A/B实际匹配到的input元素集合)精确跳过 + // 不用标签文字匹配跳过,因为同一标签名可能出现在不同大标题下(如"最高学历"在个人信息 vs 教育经历) + // resumeFormatInputs 已在上方 if(!isExpType) 块中判断,此处无需重复 + + collectedFields.push({ label: labelText, value: inputEl.value?.trim() || "", inputEl: inp }) + } + + if (collectedFields.length === 0) continue + + // 按经历类型分组 + if (isExpType) { + let segmentCount = 1 + + if (isFiveExp && expResult) { + // 5大经历用 containerElement 分段 + segmentCount = expResult.segmentRanges.length || 1 + const segments: { label: string; value: string }[][] = [] + for (let sIdx = 0; sIdx < segmentCount; sIdx++) { + const seg = expResult.segmentRanges[sIdx] + const segFields = seg?.containerElement + ? collectedFields.filter((f) => seg.containerElement!.contains(f.inputEl)) + : collectedFields + segments.push(segFields.map((f) => ({ label: f.label, value: f.value }))) + } + const nonEmptySegments = segments.filter((s) => s.length > 0) + if (nonEmptySegments.length > 0) { + unfilledFormData.push({ title: titleText, isExperience: true, formItems: nonEmptySegments }) + } + } else { + // 非5大经历用标签重复计数分段 + const labelCounts = new Map() + for (const f of collectedFields) { + labelCounts.set(f.label, (labelCounts.get(f.label) || 0) + 1) + } + let maxCount = 1 + let markerLabel = "" + for (const [label, count] of labelCounts) { + if (count > maxCount) { maxCount = count; markerLabel = label } + } + segmentCount = maxCount + + if (segmentCount <= 1) { + unfilledFormData.push({ title: titleText, isExperience: true, formItems: [collectedFields.map((f) => ({ label: f.label, value: f.value }))] }) + } else { + const markerPositions = collectedFields + .map((f, idx) => f.label === markerLabel ? idx : -1) + .filter((idx) => idx >= 0) + const segments: { label: string; value: string }[][] = [] + for (let i = 0; i < markerPositions.length; i++) { + const start = markerPositions[i] + const end = i < markerPositions.length - 1 ? markerPositions[i + 1] : collectedFields.length + segments.push(collectedFields.slice(start, end).map((f) => ({ label: f.label, value: f.value }))) + } + unfilledFormData.push({ title: titleText, isExperience: true, formItems: segments }) + } + } + } else { + // 非经历类型:只存未填字段 + unfilledFormData.push({ + title: titleText, + isExperience: false, + formItems: collectedFields.map((f) => ({ label: f.label, value: f.value })), + }) + } + } + } + + console.log("===== OfferPie: 网站未填表单字段数据(JSON) =====") + // console.log(JSON.stringify(unfilledFormData, null, 2)) + console.log("===== OfferPie: JSON 输出完毕 =====") + + // 存 chrome.storage 缓存,包含简历名字和未填字段数据(跨域名共享,按简历名区分) + const resumeName = currentResumeData?.main?.resumeName || currentResumeData?.main?.name || "" + try { + const existing = await storageGet("offerpie_unfilled_form") + let cacheData: any = null + + if (existing && existing.resumeName === resumeName && existing.unfilledFormData) { + // 同一份简历,按字段级合并(非空值才更新,保留旧缓存中已有的非空值不被覆盖) + const oldSections = existing.unfilledFormData as any[] + const newSections = unfilledFormData as any[] + + for (const newSec of newSections) { + const oldSecIdx = oldSections.findIndex((s: any) => s.title === newSec.title) + if (oldSecIdx < 0) { + oldSections.push(newSec) + } else { + const oldSec = oldSections[oldSecIdx] + if (newSec.isExperience && oldSec.isExperience) { + // 经历类型:按段合并,保留旧字段 + const oldSegments = oldSec.formItems as any[][] + const newSegments = newSec.formItems as any[][] + for (let sIdx = 0; sIdx < newSegments.length; sIdx++) { + if (sIdx >= oldSegments.length) { + oldSegments.push(newSegments[sIdx]) + } else { + const oldFields = oldSegments[sIdx] + const newFields = newSegments[sIdx] + for (const nf of newFields) { + const of_ = oldFields.find((f: any) => f.label === nf.label) + if (of_) { + if (nf.value) of_.value = nf.value + } else { + oldFields.push(nf) + } + } + } + } + } else if (!newSec.isExperience && !oldSec.isExperience) { + // 非经历类型:按字段合并,只有非空值才更新,旧值不丢失 + const oldFields = oldSec.formItems as any[] + const newFields = newSec.formItems as any[] + for (const nf of newFields) { + const of_ = oldFields.find((f: any) => f.label === nf.label) + if (of_) { + if (nf.value) of_.value = nf.value + } else { + oldFields.push(nf) + } + } + } else { + // 类型变化(经历↔非经历),直接替换 + oldSections[oldSecIdx] = newSec + } + } + } + + existing.unfilledFormData = oldSections + existing.timestamp = Date.now() + cacheData = existing + } + + // 没有已有缓存或不是同一份简历 → 新建 + if (!cacheData) { + cacheData = { + resumeName, + unfilledFormData, + timestamp: Date.now(), + } + } + + await storageSet("offerpie_unfilled_form", cacheData) + console.log(`===== OfferPie: 已缓存未填字段数据(简历: "${resumeName}") =====`) + } catch (e) { + console.warn("OfferPie: chrome.storage 缓存失败", e) + } + } + + return result +} + + +// ============================================================ +// 阶段B2:从 localStorage 缓存填写 unfilledFormData 有值字段 +// 【独立模块】可以通过注释 handleAutoFillHotjob 中的调用行来禁用 +// ============================================================ + +/** 阶段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[] + /** 阶段B中简历无值未填写的非经历字段(标签→input映射),B2用缓存值填写这些字段 */ + unfilledMainFieldMap?: Map +} + +/** 阶段B2 结果 */ +interface FillCachedDataResult { + success: number + failed: number + skipped: number +} + +/** + * 阶段B2:在指定大标题范围内,通过标签文字查找对应的 input 元素 + */ +function b2FindInputByLabel( + labelText: string, + titleEl: Element, + nextTitleEl: Element | null, + usedInputs: Set +): HTMLInputElement | HTMLTextAreaElement | null { + const allInputs = document.body.querySelectorAll(B2_INPUT_SEL) + for (const inp of Array.from(allInputs)) { + if (usedInputs.has(inp)) continue + const afterTitle = !!(titleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) + const beforeNext = !nextTitleEl || !!(nextTitleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_PRECEDING) + if (!afterTitle || !beforeNext) continue + + let container: Element | null = null + for (const sel of B2_FORM_ITEM_SELS) { + container = inp.closest(sel) + if (container) break + } + if (container) { + const labelEls = container.querySelectorAll("label, span, div, td, th, p, legend, dt") + for (const el of Array.from(labelEls)) { + const directText = Array.from(el.childNodes) + .filter((n) => n.nodeType === Node.TEXT_NODE) + .map((n) => n.textContent?.trim()) + .filter(Boolean) + .join("") + if (!directText || directText.length > 30) continue + if (B2_EXCLUDE_LABELS.some((ex) => directText === ex)) continue + // 标签文字必须严格全名匹配,不走 includes + if (directText === labelText && + (el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING)) { + return inp as HTMLInputElement | HTMLTextAreaElement + } + } + } + } + return null +} + +/** + * 阶段B2:在指定容器内,通过标签文字查找对应的 input 元素 + */ +function b2FindInputByLabelInContainer( + labelText: string, + containerEl: Element, + usedInputs: Set +): HTMLInputElement | HTMLTextAreaElement | null { + const allInputs = containerEl.querySelectorAll(B2_INPUT_SEL) + for (const inp of Array.from(allInputs)) { + if (usedInputs.has(inp)) continue + let formItem: Element | null = null + for (const sel of B2_FORM_ITEM_SELS) { + formItem = inp.closest(sel) + if (formItem) break + } + if (formItem) { + const labelEls = formItem.querySelectorAll("label, span, div, td, th, p, legend, dt") + for (const el of Array.from(labelEls)) { + const directText = Array.from(el.childNodes) + .filter((n) => n.nodeType === Node.TEXT_NODE) + .map((n) => n.textContent?.trim()) + .filter(Boolean) + .join("") + if (!directText || directText.length > 30) continue + if (B2_EXCLUDE_LABELS.some((ex) => directText === ex)) continue + // 标签文字必须严格全名匹配,不走 includes + if (directText === labelText && + (el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING)) { + return inp as HTMLInputElement | HTMLTextAreaElement + } + } + } + } + return null +} + +/** + * 阶段B2:构造 MatchedFormField 并填写单个字段 + * 【规范】走 detectPickerField + fillMatchedField 统一入口 + */ +async function b2FillSingleField( + inputEl: HTMLInputElement | HTMLTextAreaElement, + labelText: string, + fillValue: string, + lang: "zh" | "en" +): Promise { + if (inputEl.value && inputEl.value.trim().length > 0) return false + + const field: MatchedFormField = { + key: "", section: "main", resumeField: "", + sectionIndex: 0, labelText, + labelElement: inputEl.previousElementSibling || inputEl.parentElement || inputEl, + labelSelector: "", + inputElement: inputEl, + inputSelector: buildSelector(inputEl), + buttonElement: null, buttonSelector: "", + inputType: inputEl.tagName === "TEXTAREA" ? "textarea" : "text", + radioContainer: null, + isPicker: false, + pickerDropdownElement: null, pickerDropdownSelector: "", + fillValue, + } + + await detectPickerField(field, lang) + return await fillMatchedField(field) +} + +/** 时间/日期相关的标签关键字 */ +const B2_DATE_TIME_KEYWORDS = ["时间", "日期", "日期时间", "开始", "结束", "起始", "截止", "入职", "离职", "毕业"] + +/** + * 判断标签名是否为时间/日期类字段 + * 标签文字中包含时间/日期相关关键字即认为是时间字段 + */ +function b2IsDateTimeLabel(label: string): boolean { + return B2_DATE_TIME_KEYWORDS.some((kw) => label.includes(kw)) +} + +/** + * 阶段B2:填写时间/日期字段 + * 走和阶段A同款的流程:直接点击 input → fillDatePicker + * 不经过 detectPickerField(避免方式3主动点击导致 toggle 问题) + * 不经过 fillPickerField(避免步骤1-2再次点击关闭弹出层) + * + * 引用方式和阶段A的 fillTimePeriodField 情况A2 一致: + * input.focus() → input.click() → delay → fillDatePicker(field) + */ +async function b2FillDateTimeField( + inputEl: HTMLInputElement | HTMLTextAreaElement, + labelText: string, + fillValue: string +): Promise { + // 构造 MatchedFormField(和阶段A一样,isPicker=true,不设 pickerDropdownElement) + const field: MatchedFormField = { + key: "", section: "main", resumeField: "", + sectionIndex: 0, labelText, + labelElement: inputEl.previousElementSibling || inputEl.parentElement || inputEl, + labelSelector: "", + inputElement: inputEl, + inputSelector: buildSelector(inputEl), + buttonElement: null, buttonSelector: "", + inputType: "text", + radioContainer: null, + isPicker: true, + pickerDropdownElement: null, pickerDropdownSelector: "", + fillValue, + } + + // 和阶段A同样的方式:点击 input 展开日期面板,然后直接调 fillDatePicker + inputEl.focus() + ;(inputEl as HTMLElement).click() + await delay("mid") + + const ok = await fillDatePicker(field) + if (ok) { + console.log(`OfferPie: ✅ [B2-时间字段] "${labelText}" = "${fillValue}" 填写成功`) + } else { + console.log(`OfferPie: ❌ [B2-时间字段] "${labelText}" = "${fillValue}" 填写失败`) + } + return ok +} + +/** + * 阶段B2 主流程:从缓存读取 unfilledFormData,填写有值的字段 + */ +async function handleFillCachedData(params: FillCachedDataParams): Promise { + const { lang, resumeName, usedInputs, expandedResults, sectionResults, unfilledMainFieldMap } = params + const result: FillCachedDataResult = { success: 0, failed: 0, skipped: 0 } + + const cacheData = await storageGet("offerpie_unfilled_form") + if (!cacheData) { + console.log("===== OfferPie: 阶段B2 - 无缓存数据,跳过 =====") + return result + } + + if (cacheData.resumeName !== resumeName) { + console.log(`===== OfferPie: 阶段B2 - 缓存简历名"${cacheData.resumeName}"与当前"${resumeName}"不匹配,跳过 =====`) + return result + } + + const unfilledFormData = cacheData.unfilledFormData as { + title: string + isExperience: boolean + formItems: { label: string; value: string }[] | { label: string; value: string }[][] + }[] + + if (!unfilledFormData || unfilledFormData.length === 0) { + console.log("===== OfferPie: 阶段B2 - 缓存中无字段数据,跳过 =====") + return result + } + + console.log("===== OfferPie: 阶段B2 - 填写缓存数据 =====") + + const allTitles = getAllPageTitles(sectionResults) + if (allTitles.length === 0) { + console.log(" ❌ 未找到页面大标题,跳过") + return result + } + + for (const sectionData of unfilledFormData) { + const titleInfo = allTitles.find((t) => t.text === sectionData.title) + if (!titleInfo) { + console.log(` [B2] "${sectionData.title}" 未在页面找到对应大标题,跳过`) + continue + } + const titleEl = titleInfo.element + const titleIdx = allTitles.indexOf(titleInfo) + const nextTitleEl = titleIdx < allTitles.length - 1 ? allTitles[titleIdx + 1].element : null + + const expResult = expandedResults.find((r) => r.titleElement === titleEl) + const isFiveExp = !!expResult && B2_FIVE_EXP_SECTIONS.has(expResult.section) + + if (sectionData.isExperience) { + const segments = sectionData.formItems as { label: string; value: string }[][] + + if (isFiveExp && expResult) { + console.log(` [B2] "${sectionData.title}" (5大经历) 缓存${segments.length}段`) + for (let sIdx = 0; sIdx < segments.length; sIdx++) { + const seg = expResult.segmentRanges[sIdx] + if (!seg || !seg.containerElement) continue + const fields = segments[sIdx] + for (const field of fields) { + if (!field.value) { result.skipped++; continue } + const inputEl = b2FindInputByLabelInContainer(field.label, seg.containerElement, usedInputs) + if (!inputEl) { result.skipped++; continue } + // 时间/日期字段走阶段A同款流程:直接点击 input → fillDatePicker,不经过 detectPickerField + if (b2IsDateTimeLabel(field.label)) { + const ok = await b2FillDateTimeField(inputEl, field.label, field.value) + if (ok) { result.success++; usedInputs.add(inputEl); setFieldHighlight(inputEl, "greenTwo") } else { result.failed++ } + } else { + const ok = await b2FillSingleField(inputEl, field.label, field.value, lang) + if (ok) { result.success++; usedInputs.add(inputEl); setFieldHighlight(inputEl, "greenTwo") } else { result.failed++ } + } + await delay("mid") + } + } + } else { + console.log(` [B2] "${sectionData.title}" (其他经历) 缓存${segments.length}段`) + + // 检测当前页面已有几段 + const allInputsInRange = document.body.querySelectorAll(B2_INPUT_SEL) + const labelsInRange: string[] = [] + for (const inp of Array.from(allInputsInRange)) { + const afterTitle = !!(titleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) + const beforeNext = !nextTitleEl || !!(nextTitleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_PRECEDING) + if (!afterTitle || !beforeNext) continue + 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("mid") + } + } + + for (let sIdx = 0; sIdx < segments.length; sIdx++) { + const fields = segments[sIdx] + for (const field of fields) { + if (!field.value) { result.skipped++; continue } + const inputEl = b2FindInputByLabel(field.label, titleEl, nextTitleEl, usedInputs) + if (!inputEl) { result.skipped++; continue } + // 时间/日期字段走阶段A同款流程:直接点击 input → fillDatePicker,不经过 detectPickerField + if (b2IsDateTimeLabel(field.label)) { + const ok = await b2FillDateTimeField(inputEl, field.label, field.value) + if (ok) { result.success++; usedInputs.add(inputEl); setFieldHighlight(inputEl, "greenTwo") } else { result.failed++ } + } else { + const ok = await b2FillSingleField(inputEl, field.label, field.value, lang) + if (ok) { result.success++; usedInputs.add(inputEl); setFieldHighlight(inputEl, "greenTwo") } else { result.failed++ } + } + await delay("mid") + } + } + } + } else { + const fields = sectionData.formItems as { label: string; value: string }[] + console.log(` [B2] "${sectionData.title}" (非经历) 缓存${fields.length}个字段`) + for (const field of fields) { + if (!field.value) { result.skipped++; continue } + let inputEl = b2FindInputByLabel(field.label, titleEl, nextTitleEl, usedInputs) + // fallback:如果 b2FindInputByLabel 找不到,从阶段B传入的未填简历字段映射中查找 + if (!inputEl && unfilledMainFieldMap) { + const fallbackEl = unfilledMainFieldMap.get(field.label) + if (fallbackEl && !usedInputs.has(fallbackEl)) { + inputEl = fallbackEl + console.log(` [B2] "${field.label}" 通过 unfilledMainFieldMap fallback 定位到 input`) + } + } + if (!inputEl) { result.skipped++; continue } + // 时间/日期字段走阶段A同款流程:直接点击 input → fillDatePicker,不经过 detectPickerField + if (b2IsDateTimeLabel(field.label)) { + const ok = await b2FillDateTimeField(inputEl, field.label, field.value) + if (ok) { result.success++; usedInputs.add(inputEl); setFieldHighlight(inputEl, "greenTwo") } else { result.failed++ } + } else { + const ok = await b2FillSingleField(inputEl, field.label, field.value, lang) + if (ok) { result.success++; usedInputs.add(inputEl); setFieldHighlight(inputEl, "greenTwo") } else { result.failed++ } + } + await delay("mid") + } + } + } + + console.log(`===== OfferPie: 阶段B2完成 成功${result.success} 失败${result.failed} 跳过${result.skipped} =====`) + return result +} \ No newline at end of file diff --git a/src/handlers/handleAutoFillMoka.ts b/src/handlers/handleAutoFillMoka.ts new file mode 100644 index 0000000..140c1d9 --- /dev/null +++ b/src/handlers/handleAutoFillMoka.ts @@ -0,0 +1,1533 @@ +/** + * 摩卡模式自动填写处理逻辑(适配 mokahr.com 域名的摩卡招聘平台) + * 基于通用模式复制而来,后续在此基础上添加摩卡平台特有的表单组件处理逻辑 + * + * 【规范】所有填充操作必须走 fillMatchedField 统一入口, + * 选择器检测必须走 detectPickerField 统一入口, + * 不要在此文件中自行编写选择器操作逻辑,必须引用 lib 中已封装的方法。 + */ + +import { fillMatchedField, isSearchPickerField, fillSearchPickerField, isTimePeriodField, isTimeSingleField, fillTimePeriodField, fillTimeSingleField } from "~lib/autofill" +import { delay } from "~utils/delay" +import { extractDomStructure, detectPageLanguage, isJobApplicationForm, buildSelector } from "~lib/dom" +import { matchFormFieldsInRange, matchMainFields } from "~lib/formMatcher" +import { detectPickerField } from "~lib/pickerDetector" +import { detectAndUploadResume } from "~lib/resumeUpload" +import { getMockResumeData2, JOB_FORM_LABELS } from "~lib/constants" +import { fillDatePicker } from "~lib/datePicker" +import { getResumeFieldValue } from "~lib/resumeDataHelper" +import { locateExperienceSections, expandExperienceSections, sortExperienceByTime, relocateSegmentContainer, getAllPageTitles, findAddButton, clickAddButton } from "~lib/experienceSection" +import type { ExperienceSectionLocateResult } from "~lib/experienceSection" +import type { MatchedFormField, ResumeData, ExperienceSection, JobInfo, UnmatchedFormField } from "~lib/types" +import { setFieldHighlight, isRequiredField } from "~lib/formStyle" +import { get as storageGet, set as storageSet } from "~utils/storage" +import { findLabelForInput } from "~lib/labelFinder" + +/** 摩卡模式自动填写的参数 */ +export interface AutoFillMokaParams { + /** 简历数据(接口获取的) */ + resumeData: ResumeData | null + /** 岗位信息 */ + jobInfo: JobInfo | null +} + +/** 摩卡模式自动填写的返回结果 */ +export interface AutoFillMokaResult { + /** 填写成功数 */ + success: number + /** 填写失败数 */ + failed: number + /** 跳过数 */ + skipped: number + /** 检测到的页面语言 */ + lang: "zh" | "en" + /** 是否为表单页 */ + isFormPage: boolean + /** 使用的简历数据(可能是接口数据或 mock 数据) */ + resumeData: ResumeData | null + /** 匹配到的非经历区域字段 */ + formFields: MatchedFormField[] + /** 收集到的待填写空白字段 */ + unmatchedFields: UnmatchedFormField[] + /** 经历段落定位结果(用于 fillStats 分段展示) */ + sectionResults: ExperienceSectionLocateResult[] + /** 经历段落展开结果(用于 fillStats 分段展示) */ + expandedResults: ExperienceSectionLocateResult[] +} + +/** + * 摩卡模式自动填写主流程(基于通用模式,适配 mokahr.com 域名的摩卡招聘平台) + * 流程:提取 DOM → 检测语言 → 判断是否表单页 → 上传简历 → 匹配+填写经历 → 匹配+填写非经历 → 收集空白字段 + * + * 【待适配】摩卡平台特殊表单组件: + * - TODO: 摩卡自定义单选组(如有特殊类名需在此处理) + * - TODO: 摩卡级联选择器(地区、学校等多级联动) + * - TODO: 摩卡日期选择器(如有自定义日期组件需特殊处理) + * - TODO: 摩卡文件上传组件(简历附件上传的特殊逻辑) + */ +export async function handleAutoFillMoka(params: AutoFillMokaParams): Promise { + const result: AutoFillMokaResult = { + success: 0, failed: 0, skipped: 0, + lang: "zh", isFormPage: false, + resumeData: params.resumeData, + formFields: [], + unmatchedFields: [], + sectionResults: [], + expandedResults: [], + } + + // 1. 提取 DOM 结构 + const domStructure = extractDomStructure() + console.log("===== OfferPie: 完整 DOM 树结构 =====") + console.log(domStructure) + console.log(`===== OfferPie: 结构总长度 ${domStructure.length} 字符 =====`) + + // 2. 检测页面语言 + const lang = detectPageLanguage(domStructure) + result.lang = lang + console.log(`===== OfferPie: 页面语言检测结果 = ${lang} =====`) + + // 3. 判断是否为职位申请表单页面 + const isForm = isJobApplicationForm(document.body, lang) + result.isFormPage = isForm + console.log(`===== OfferPie: 是否为职位申请表单页面 = ${isForm} =====`) + + if (!isForm) { + console.log("===== OfferPie: 当前页面不是职位申请表单,跳过字段匹配 =====") + return result + } + + // 4. 获取简历数据(优先使用接口数据,无接口数据时 fallback 到 mock) + const currentResumeData = params.resumeData || getMockResumeData2() + result.resumeData = currentResumeData + console.log(`===== OfferPie: 已加载简历数据,教育${currentResumeData.education.length}段 工作${currentResumeData.work.length}段 实习${currentResumeData.internship.length}段 项目${currentResumeData.project.length}段 竞赛${currentResumeData.competition.length}段 =====`) + + + + + + // 4.1 检测并上传简历文件 + // const resumeUrl = "https://offerpie.oss-cn-guangzhou.aliyuncs.com/%E5%AE%BE%E5%A4%95%E6%B3%95%E5%B0%BC%E4%BA%9A%E5%A4%A7%E5%AD%A6_%E4%B8%81%E5%B1%B9%E6%B6%B5.pdf" + // const uploaded = await detectAndUploadResume(resumeUrl) + // console.log(`===== OfferPie: 简历上传 ${uploaded ? "成功" : "跳过(未找到上传按钮或失败)"} =====`) + // if (uploaded) await delay("high") // 等待网站解析简历 + + // 4.5 定位经历区块并统计已展开段数 + const sectionResults = locateExperienceSections(document.body, lang) + // 4.6 对比简历数据段数,点击添加按钮补足不够的段数 + const expandedResults = await expandExperienceSections(sectionResults, currentResumeData, lang) + + // 存入 result 供外部使用(如 fillStats 分段展示) + result.sectionResults = sectionResults + result.expandedResults = expandedResults + + // 4.65 【预匹配+标绿】经历段落添加完成后,一次性预匹配所有有简历数据的字段并标绿背景 + // 使用临时 usedInputs,不影响后续正式流程的匹配 + { + const tempUsedInputs = new Set() + // 预匹配经历区域字段 + 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("mid") + continue + } else { + await detectPickerField(f, lang) + const ok = await fillMatchedField(f) + if (ok) { result.success++ } else { result.failed++ } + } + + // 收集阶段A已处理字段(用于末尾統計) + phaseAFields.push({ labelText: f.labelText, inputElement: f.inputElement, filled: !!f.fillValue, fillValue: f.fillValue, section: f.section, segmentIndex: segIdx }) + + // 关闭残留弹窗 + if (lastTextInput && !lastTextInputIsPicker) { + ;(lastTextInput as HTMLElement).click() + lastTextInput.focus() + await delay("low") + lastTextInput.blur() + } else { + if (document.activeElement instanceof HTMLElement) { + document.activeElement.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", code: "Escape", keyCode: 27, bubbles: true }) + ) + document.activeElement.blur() + } + document.body.click() + } + await delay("mid") + + if (!f.isPicker && f.inputElement && !isTimePeriodField(f.key) && !isTimeSingleField(f.key)) { + lastTextInput = f.inputElement + lastTextInputIsPicker = false + } else { + lastTextInputIsPicker = true + } + } + } + } + console.log(`===== OfferPie: 阶段A完成 成功${result.success} 失败${result.failed} 跳过${result.skipped} =====`) + + } // end of else (skipPhaseA) + + // 6. 非经历区域字段匹配与填写(阶段B) + console.log("===== OfferPie: 阶段B - 非经历区域填写 =====") + const mainFields = matchMainFields(document.body, lang, excludeRanges, usedInputs) + console.log(` 匹配到 ${mainFields.length} 个非经历字段`) + + for (const f of mainFields) { + const value = getResumeFieldValue(currentResumeData, f.section, f.sectionIndex, f.resumeField) + if (value) f.fillValue = value + + // 检测该字段是否已被网站自动填入值 + if (f.inputElement && f.inputElement.value && f.inputElement.value.trim().length > 0) { + console.log(` [${f.key}] "${f.labelText}" 已有值="${f.inputElement.value.trim()}",跳过`) + result.skipped++ + continue + } + + if (!f.fillValue) { result.skipped++; continue } + + await detectPickerField(f, lang) + + console.log( + ` [${f.key}] "${f.labelText}" → type: ${f.inputType}` + + ` | isPicker: ${f.isPicker}` + + ` | fillValue: "${f.fillValue}"` + ) + + const ok = await fillMatchedField(f) + if (ok) { result.success++ } else { result.failed++ } + + if (lastTextInput) { + ;(lastTextInput as HTMLElement).click() + lastTextInput.focus() + await delay("low") + lastTextInput.blur() + } else { + if (document.activeElement instanceof HTMLElement) { + document.activeElement.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", code: "Escape", keyCode: 27, bubbles: true }) + ) + document.activeElement.blur() + } + document.body.click() + } + await delay("mid") + + if (!f.isPicker && f.inputElement) lastTextInput = f.inputElement + } + + result.formFields = [...mainFields] + console.log(`===== OfferPie: 阶段B完成 总计成功${result.success} 失败${result.failed} 跳过${result.skipped} =====`) + + // 6.5 阶段B2 - 从缓存填写之前保存的 unfilledFormData 有值字段 + // 【独立步骤】注释下面这段即可禁用阶段B2 + { + const resumeName = currentResumeData?.main?.resumeName || currentResumeData?.main?.name || "" + // 构建阶段B中简历无值未填写的非经历字段映射(标签文字→input元素),传给B2作为 fallback 定位 + const unfilledMainFieldMap = new Map() + for (const f of mainFields) { + if (!f.fillValue && f.inputElement && !usedInputs.has(f.inputElement)) { + unfilledMainFieldMap.set(f.labelText, f.inputElement) + } + } + const b2Result = await handleFillCachedData({ + lang, resumeName, usedInputs, expandedResults, sectionResults, unfilledMainFieldMap, + }) + result.success += b2Result.success + result.failed += b2Result.failed + result.skipped += b2Result.skipped + } + + // 7. 阶段C - 收集剩余空白输入框 + console.log("===== OfferPie: 阶段C - 收集剩余空白字段 =====") + + // 需要过滤的标签文字(这些不是有效标签) + const EXCLUDE_LABEL_TEXTS = ["请输入", "输入", ":", ":", "请选择", "选择", "*", "必填"] + + // 收集非经历区域内所有空白输入框 + const allInputsOnPage = document.body.querySelectorAll( + "input:not([type='hidden']):not([type='submit']):not([type='button']):not([type='checkbox']):not([type='radio']):not([type='file']):not([disabled]), textarea:not([disabled])" + ) + + for (const inp of Array.from(allInputsOnPage)) { + const inputEl = inp as HTMLInputElement | HTMLTextAreaElement + if (usedInputs.has(inp)) continue + if (inputEl.value && inputEl.value.trim().length > 0) continue + // 跳过在经历区域范围内的 input + let inExcludeRange = false + for (const range of excludeRanges) { + const afterStart = range.start === inp || (range.start.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) + const beforeEnd = !range.end || (range.end.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_PRECEDING) + if (afterStart && beforeEnd) { inExcludeRange = true; break } + } + if (inExcludeRange) continue + + // 向上查找最近的标签文字 + let labelText = "" + let labelElement: Element | null = null + + // 策略1:查找 input 所在表单项容器内的标签 + const formItemSelectors = [ + ".form-item", ".form-group", ".form-field", + ".el-form-item", ".ant-form-item", ".ant-row", + ".arco-form-item", ".t-form-item", ".n-form-item", + "[class*='form-item']", "[class*='form-group']", "[class*='formItem']", + ] + let container: Element | null = null + for (const sel of formItemSelectors) { + container = inp.closest(sel) + if (container) break + } + if (container) { + const labelEls = container.querySelectorAll("label, span, div, td, th, p, legend, dt") + for (const el of Array.from(labelEls)) { + const directText = Array.from(el.childNodes) + .filter((n) => n.nodeType === Node.TEXT_NODE) + .map((n) => n.textContent?.trim()) + .filter(Boolean) + .join("") + if (!directText || directText.length > 30) continue + if (EXCLUDE_LABEL_TEXTS.some((ex) => directText === ex)) continue + if (el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) { + labelText = directText + labelElement = el + break + } + } + } + + // 策略2:向前查找兄弟/父级中的标签 + if (!labelText) { + let prev: Element | null = inp.previousElementSibling + for (let i = 0; i < 3 && prev; i++) { + const text = prev.textContent?.trim() || "" + if (text && text.length <= 20 && !EXCLUDE_LABEL_TEXTS.some((ex) => text === ex)) { + labelText = text + labelElement = prev + break + } + prev = prev.previousElementSibling + } + } + + if (!labelText || !labelElement) continue + + // 确定表单类型 + let formType: UnmatchedFormField["formType"] = "input" + if (inputEl.tagName === "TEXTAREA") { + formType = "textarea" + } else if (inputEl.hasAttribute("readonly") || inputEl.closest("[class*='select']") || inputEl.closest("[class*='picker']")) { + formType = "select" + } + + result.unmatchedFields.push({ + labelText, + labelElement, + inputElement: inputEl, + radioContainer: null, + formType, + isPicker: formType === "select", + fillValue: "", + alreadyFilled: false, + }) + usedInputs.add(inp) + } + + console.log(` 收集到 ${result.unmatchedFields.length} 个待填写的空白字段`) + for (const uf of result.unmatchedFields) { + console.log(` [待填写] "${uf.labelText}" | formType: ${uf.formType} | isPicker: ${uf.isPicker}`) + } + console.log(`===== OfferPie: 阶段C完成 收集到 ${result.unmatchedFields.length} 个空白字段 =====`) + + // 8. 【标红/标黄】+ 9.【统计打印】 + // 全面扫描页面所有大标题范围内的输入框,对未被阶段A/B/C处理的字段也纳入统计和高亮 + // 经历类型区块按段分组,标记段落索引 + { + // 先对阶段C已收集的字段标红/标黄 + for (const uf of result.unmatchedFields) { + const required = isRequiredField(uf.labelElement, uf.inputElement) + if (required) { + setFieldHighlight(uf.inputElement, "red") + } else { + setFieldHighlight(uf.inputElement, "yellow") + } + } + + const allTitles = getAllPageTitles(sectionResults) + if (allTitles.length > 0) { + console.log("===== OfferPie: 填写结果统计(按大标题分组) =====") + + const processedInputs = new Set() + + 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"]) + + // 从简历数据中提取所有值,构建排除集合(避免将已填值误认为标签) + const excludeTexts = new Set() + if (currentResumeData) { + const main = currentResumeData.main + if (main) { + for (const val of Object.values(main)) { + if (typeof val === "string" && val.trim()) excludeTexts.add(val.trim()) + if (Array.isArray(val)) val.forEach((v) => { if (typeof v === "string" && v.trim()) excludeTexts.add(v.trim()) }) + } + } + const expKeys: ("education" | "work" | "internship" | "project" | "competition")[] = ["education", "work", "internship", "project", "competition"] + for (const sec of expKeys) { + const items = currentResumeData[sec] + if (!Array.isArray(items)) continue + for (const item of items) { + for (const val of Object.values(item as Record)) { + if (typeof val === "string" && val.trim()) excludeTexts.add(val.trim()) + } + } + } + } + + /** 判断某个字段是否属于简历数据格式(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[] = [] + + // 构建当前大标题范围内所有已填 input 的值集合 + // 用于排除误将已填值(如"香港理工大学"、"硕士"、"2026"等)当作标签的情况 + const filledInputValues = new Set() + for (const inp of Array.from(allInputs)) { + const afterT = !!(titleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) + const beforeN = !nextTitleEl || !!(nextTitleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_PRECEDING) + if (!afterT || !beforeN) continue + const val = (inp as HTMLInputElement | HTMLTextAreaElement).value?.trim() + if (val) filledInputValues.add(val) + } + + for (const inp of Array.from(allInputs)) { + // 范围检查 + const afterTitle = !!(titleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) + const beforeNext = !nextTitleEl || !!(nextTitleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_PRECEDING) + if (!afterTitle || !beforeNext) continue + + const inputEl = inp as HTMLInputElement | HTMLTextAreaElement + + // 非经历类型:跳过阶段A/B已成功填充的字段(绿色背景标记) + // greenTwo(#b7ffc6)是阶段B2填的unfilledFormData字段,不跳过,可以收集 + if (!isExpType) { + if (resumeFormatInputs.has(inp)) continue + const bgColor = inputEl.style.backgroundColor + if (bgColor === "#b7ffc5" || bgColor === "rgb(183, 255, 197)") continue + } + + // 查找标签(使用统一封装的 labelFinder) + const titleElementSet = new Set(allTitles.map((t) => t.element)) + const { labelText: detectedLabel } = findLabelForInput(inp, titleElementSet, excludeTexts) + const labelText = detectedLabel?.trim() + + // 跳过无效标签:空、纯数字、已知排除词、或检测到的"标签"实际是某个已填 input 的值 + if (!labelText || /^\d+$/.test(labelText) || JSON_EXCLUDE_LABELS.some((ex) => labelText === ex)) continue + if (filledInputValues.has(labelText)) continue + + // 非经历类型:跳过简历格式字段 + // 【注意】简历格式字段通过 resumeFormatInputs(阶段A/B实际匹配到的input元素集合)精确跳过 + // 不用标签文字匹配跳过,因为同一标签名可能出现在不同大标题下(如"最高学历"在个人信息 vs 教育经历) + // resumeFormatInputs 已在上方 if(!isExpType) 块中判断,此处无需重复 + + collectedFields.push({ label: labelText, value: inputEl.value?.trim() || "", inputEl: inp }) + } + + if (collectedFields.length === 0) continue + + // 按经历类型分组 + if (isExpType) { + let segmentCount = 1 + + if (isFiveExp && expResult) { + // 5大经历用 containerElement 分段 + segmentCount = expResult.segmentRanges.length || 1 + const segments: { label: string; value: string }[][] = [] + for (let sIdx = 0; sIdx < segmentCount; sIdx++) { + const seg = expResult.segmentRanges[sIdx] + const segFields = seg?.containerElement + ? collectedFields.filter((f) => seg.containerElement!.contains(f.inputEl)) + : collectedFields + segments.push(segFields.map((f) => ({ label: f.label, value: f.value }))) + } + const nonEmptySegments = segments.filter((s) => s.length > 0) + if (nonEmptySegments.length > 0) { + unfilledFormData.push({ title: titleText, isExperience: true, formItems: nonEmptySegments }) + } + } else { + // 非5大经历用标签重复计数分段 + const labelCounts = new Map() + for (const f of collectedFields) { + labelCounts.set(f.label, (labelCounts.get(f.label) || 0) + 1) + } + let maxCount = 1 + let markerLabel = "" + for (const [label, count] of labelCounts) { + if (count > maxCount) { maxCount = count; markerLabel = label } + } + segmentCount = maxCount + + if (segmentCount <= 1) { + unfilledFormData.push({ title: titleText, isExperience: true, formItems: [collectedFields.map((f) => ({ label: f.label, value: f.value }))] }) + } else { + const markerPositions = collectedFields + .map((f, idx) => f.label === markerLabel ? idx : -1) + .filter((idx) => idx >= 0) + const segments: { label: string; value: string }[][] = [] + for (let i = 0; i < markerPositions.length; i++) { + const start = markerPositions[i] + const end = i < markerPositions.length - 1 ? markerPositions[i + 1] : collectedFields.length + segments.push(collectedFields.slice(start, end).map((f) => ({ label: f.label, value: f.value }))) + } + unfilledFormData.push({ title: titleText, isExperience: true, formItems: segments }) + } + } + } else { + // 非经历类型:只存未填字段 + unfilledFormData.push({ + title: titleText, + isExperience: false, + formItems: collectedFields.map((f) => ({ label: f.label, value: f.value })), + }) + } + } + } + + console.log("===== OfferPie: 网站未填表单字段数据(JSON) =====") + // console.log(JSON.stringify(unfilledFormData, null, 2)) + console.log("===== OfferPie: JSON 输出完毕 =====") + + // 存 chrome.storage 缓存,包含简历名字和未填字段数据(跨域名共享,按简历名区分) + const resumeName = currentResumeData?.main?.resumeName || currentResumeData?.main?.name || "" + try { + const existing = await storageGet("offerpie_unfilled_form") + let cacheData: any = null + + if (existing && existing.resumeName === resumeName && existing.unfilledFormData) { + // 同一份简历,按字段级合并(非空值才更新,保留旧缓存中已有的非空值不被覆盖) + const oldSections = existing.unfilledFormData as any[] + const newSections = unfilledFormData as any[] + + for (const newSec of newSections) { + const oldSecIdx = oldSections.findIndex((s: any) => s.title === newSec.title) + if (oldSecIdx < 0) { + oldSections.push(newSec) + } else { + const oldSec = oldSections[oldSecIdx] + if (newSec.isExperience && oldSec.isExperience) { + // 经历类型:按段合并,保留旧字段 + const oldSegments = oldSec.formItems as any[][] + const newSegments = newSec.formItems as any[][] + for (let sIdx = 0; sIdx < newSegments.length; sIdx++) { + if (sIdx >= oldSegments.length) { + oldSegments.push(newSegments[sIdx]) + } else { + const oldFields = oldSegments[sIdx] + const newFields = newSegments[sIdx] + for (const nf of newFields) { + const of_ = oldFields.find((f: any) => f.label === nf.label) + if (of_) { + if (nf.value) of_.value = nf.value + } else { + oldFields.push(nf) + } + } + } + } + } else if (!newSec.isExperience && !oldSec.isExperience) { + // 非经历类型:按字段合并,只有非空值才更新,旧值不丢失 + const oldFields = oldSec.formItems as any[] + const newFields = newSec.formItems as any[] + for (const nf of newFields) { + const of_ = oldFields.find((f: any) => f.label === nf.label) + if (of_) { + if (nf.value) of_.value = nf.value + } else { + oldFields.push(nf) + } + } + } else { + // 类型变化(经历↔非经历),直接替换 + oldSections[oldSecIdx] = newSec + } + } + } + + existing.unfilledFormData = oldSections + existing.timestamp = Date.now() + cacheData = existing + } + + // 没有已有缓存或不是同一份简历 → 新建 + if (!cacheData) { + cacheData = { + resumeName, + unfilledFormData, + timestamp: Date.now(), + } + } + + await storageSet("offerpie_unfilled_form", cacheData) + console.log(`===== OfferPie: 已缓存未填字段数据(简历: "${resumeName}") =====`) + } catch (e) { + console.warn("OfferPie: chrome.storage 缓存失败", e) + } + } + + return result +} + + +// ============================================================ +// 阶段B2:从 localStorage 缓存填写 unfilledFormData 有值字段 +// 【独立模块】可以通过注释 handleAutoFillMoka 中的调用行来禁用 +// ============================================================ + +/** 阶段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[] + /** 阶段B中简历无值未填写的非经历字段(标签→input映射),B2用缓存值填写这些字段 */ + unfilledMainFieldMap?: Map +} + +/** 阶段B2 结果 */ +interface FillCachedDataResult { + success: number + failed: number + skipped: number +} + +/** + * 阶段B2:在指定大标题范围内,通过标签文字查找对应的 input 元素 + */ +function b2FindInputByLabel( + labelText: string, + titleEl: Element, + nextTitleEl: Element | null, + usedInputs: Set +): HTMLInputElement | HTMLTextAreaElement | null { + const allInputs = document.body.querySelectorAll(B2_INPUT_SEL) + for (const inp of Array.from(allInputs)) { + if (usedInputs.has(inp)) continue + const afterTitle = !!(titleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) + const beforeNext = !nextTitleEl || !!(nextTitleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_PRECEDING) + if (!afterTitle || !beforeNext) continue + + let container: Element | null = null + for (const sel of B2_FORM_ITEM_SELS) { + container = inp.closest(sel) + if (container) break + } + if (container) { + const labelEls = container.querySelectorAll("label, span, div, td, th, p, legend, dt") + for (const el of Array.from(labelEls)) { + const directText = Array.from(el.childNodes) + .filter((n) => n.nodeType === Node.TEXT_NODE) + .map((n) => n.textContent?.trim()) + .filter(Boolean) + .join("") + if (!directText || directText.length > 30) continue + if (B2_EXCLUDE_LABELS.some((ex) => directText === ex)) continue + // 标签文字必须严格全名匹配,不走 includes + if (directText === labelText && + (el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING)) { + return inp as HTMLInputElement | HTMLTextAreaElement + } + } + } + } + return null +} + +/** + * 阶段B2:在指定容器内,通过标签文字查找对应的 input 元素 + */ +function b2FindInputByLabelInContainer( + labelText: string, + containerEl: Element, + usedInputs: Set +): HTMLInputElement | HTMLTextAreaElement | null { + const allInputs = containerEl.querySelectorAll(B2_INPUT_SEL) + for (const inp of Array.from(allInputs)) { + if (usedInputs.has(inp)) continue + let formItem: Element | null = null + for (const sel of B2_FORM_ITEM_SELS) { + formItem = inp.closest(sel) + if (formItem) break + } + if (formItem) { + const labelEls = formItem.querySelectorAll("label, span, div, td, th, p, legend, dt") + for (const el of Array.from(labelEls)) { + const directText = Array.from(el.childNodes) + .filter((n) => n.nodeType === Node.TEXT_NODE) + .map((n) => n.textContent?.trim()) + .filter(Boolean) + .join("") + if (!directText || directText.length > 30) continue + if (B2_EXCLUDE_LABELS.some((ex) => directText === ex)) continue + // 标签文字必须严格全名匹配,不走 includes + if (directText === labelText && + (el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING)) { + return inp as HTMLInputElement | HTMLTextAreaElement + } + } + } + } + return null +} + +/** + * 阶段B2:构造 MatchedFormField 并填写单个字段 + * 【规范】走 detectPickerField + fillMatchedField 统一入口 + */ +async function b2FillSingleField( + inputEl: HTMLInputElement | HTMLTextAreaElement, + labelText: string, + fillValue: string, + lang: "zh" | "en" +): Promise { + if (inputEl.value && inputEl.value.trim().length > 0) return false + + const field: MatchedFormField = { + key: "", section: "main", resumeField: "", + sectionIndex: 0, labelText, + labelElement: inputEl.previousElementSibling || inputEl.parentElement || inputEl, + labelSelector: "", + inputElement: inputEl, + inputSelector: buildSelector(inputEl), + buttonElement: null, buttonSelector: "", + inputType: inputEl.tagName === "TEXTAREA" ? "textarea" : "text", + radioContainer: null, + isPicker: false, + pickerDropdownElement: null, pickerDropdownSelector: "", + fillValue, + } + + await detectPickerField(field, lang) + return await fillMatchedField(field) +} + +/** 时间/日期相关的标签关键字 */ +const B2_DATE_TIME_KEYWORDS = ["时间", "日期", "日期时间", "开始", "结束", "起始", "截止", "入职", "离职", "毕业"] + +/** + * 判断标签名是否为时间/日期类字段 + * 标签文字中包含时间/日期相关关键字即认为是时间字段 + */ +function b2IsDateTimeLabel(label: string): boolean { + return B2_DATE_TIME_KEYWORDS.some((kw) => label.includes(kw)) +} + +/** + * 阶段B2:填写时间/日期字段 + * 走和阶段A同款的流程:直接点击 input → fillDatePicker + * 不经过 detectPickerField(避免方式3主动点击导致 toggle 问题) + * 不经过 fillPickerField(避免步骤1-2再次点击关闭弹出层) + * + * 引用方式和阶段A的 fillTimePeriodField 情况A2 一致: + * input.focus() → input.click() → delay → fillDatePicker(field) + */ +async function b2FillDateTimeField( + inputEl: HTMLInputElement | HTMLTextAreaElement, + labelText: string, + fillValue: string +): Promise { + // 构造 MatchedFormField(和阶段A一样,isPicker=true,不设 pickerDropdownElement) + const field: MatchedFormField = { + key: "", section: "main", resumeField: "", + sectionIndex: 0, labelText, + labelElement: inputEl.previousElementSibling || inputEl.parentElement || inputEl, + labelSelector: "", + inputElement: inputEl, + inputSelector: buildSelector(inputEl), + buttonElement: null, buttonSelector: "", + inputType: "text", + radioContainer: null, + isPicker: true, + pickerDropdownElement: null, pickerDropdownSelector: "", + fillValue, + } + + // 和阶段A同样的方式:点击 input 展开日期面板,然后直接调 fillDatePicker + inputEl.focus() + ;(inputEl as HTMLElement).click() + await delay("mid") + + const ok = await fillDatePicker(field) + if (ok) { + console.log(`OfferPie: ✅ [B2-时间字段] "${labelText}" = "${fillValue}" 填写成功`) + } else { + console.log(`OfferPie: ❌ [B2-时间字段] "${labelText}" = "${fillValue}" 填写失败`) + } + return ok +} + +/** + * 阶段B2 主流程:从缓存读取 unfilledFormData,填写有值的字段 + */ +async function handleFillCachedData(params: FillCachedDataParams): Promise { + const { lang, resumeName, usedInputs, expandedResults, sectionResults, unfilledMainFieldMap } = params + const result: FillCachedDataResult = { success: 0, failed: 0, skipped: 0 } + + const cacheData = await storageGet("offerpie_unfilled_form") + if (!cacheData) { + console.log("===== OfferPie: 阶段B2 - 无缓存数据,跳过 =====") + return result + } + + if (cacheData.resumeName !== resumeName) { + console.log(`===== OfferPie: 阶段B2 - 缓存简历名"${cacheData.resumeName}"与当前"${resumeName}"不匹配,跳过 =====`) + return result + } + + const unfilledFormData = cacheData.unfilledFormData as { + title: string + isExperience: boolean + formItems: { label: string; value: string }[] | { label: string; value: string }[][] + }[] + + if (!unfilledFormData || unfilledFormData.length === 0) { + console.log("===== OfferPie: 阶段B2 - 缓存中无字段数据,跳过 =====") + return result + } + + console.log("===== OfferPie: 阶段B2 - 填写缓存数据 =====") + + const allTitles = getAllPageTitles(sectionResults) + if (allTitles.length === 0) { + console.log(" ❌ 未找到页面大标题,跳过") + return result + } + + for (const sectionData of unfilledFormData) { + const titleInfo = allTitles.find((t) => t.text === sectionData.title) + if (!titleInfo) { + console.log(` [B2] "${sectionData.title}" 未在页面找到对应大标题,跳过`) + continue + } + const titleEl = titleInfo.element + const titleIdx = allTitles.indexOf(titleInfo) + const nextTitleEl = titleIdx < allTitles.length - 1 ? allTitles[titleIdx + 1].element : null + + const expResult = expandedResults.find((r) => r.titleElement === titleEl) + const isFiveExp = !!expResult && B2_FIVE_EXP_SECTIONS.has(expResult.section) + + if (sectionData.isExperience) { + const segments = sectionData.formItems as { label: string; value: string }[][] + + if (isFiveExp && expResult) { + console.log(` [B2] "${sectionData.title}" (5大经历) 缓存${segments.length}段`) + for (let sIdx = 0; sIdx < segments.length; sIdx++) { + const seg = expResult.segmentRanges[sIdx] + if (!seg || !seg.containerElement) continue + const fields = segments[sIdx] + for (const field of fields) { + if (!field.value) { result.skipped++; continue } + const inputEl = b2FindInputByLabelInContainer(field.label, seg.containerElement, usedInputs) + if (!inputEl) { result.skipped++; continue } + // 时间/日期字段走阶段A同款流程:直接点击 input → fillDatePicker,不经过 detectPickerField + if (b2IsDateTimeLabel(field.label)) { + const ok = await b2FillDateTimeField(inputEl, field.label, field.value) + if (ok) { result.success++; usedInputs.add(inputEl); setFieldHighlight(inputEl, "greenTwo") } else { result.failed++ } + } else { + const ok = await b2FillSingleField(inputEl, field.label, field.value, lang) + if (ok) { result.success++; usedInputs.add(inputEl); setFieldHighlight(inputEl, "greenTwo") } else { result.failed++ } + } + await delay("mid") + } + } + } else { + console.log(` [B2] "${sectionData.title}" (其他经历) 缓存${segments.length}段`) + + // 检测当前页面已有几段 + const allInputsInRange = document.body.querySelectorAll(B2_INPUT_SEL) + const labelsInRange: string[] = [] + for (const inp of Array.from(allInputsInRange)) { + const afterTitle = !!(titleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) + const beforeNext = !nextTitleEl || !!(nextTitleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_PRECEDING) + if (!afterTitle || !beforeNext) continue + 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("mid") + } + } + + for (let sIdx = 0; sIdx < segments.length; sIdx++) { + const fields = segments[sIdx] + for (const field of fields) { + if (!field.value) { result.skipped++; continue } + const inputEl = b2FindInputByLabel(field.label, titleEl, nextTitleEl, usedInputs) + if (!inputEl) { result.skipped++; continue } + // 时间/日期字段走阶段A同款流程:直接点击 input → fillDatePicker,不经过 detectPickerField + if (b2IsDateTimeLabel(field.label)) { + const ok = await b2FillDateTimeField(inputEl, field.label, field.value) + if (ok) { result.success++; usedInputs.add(inputEl); setFieldHighlight(inputEl, "greenTwo") } else { result.failed++ } + } else { + const ok = await b2FillSingleField(inputEl, field.label, field.value, lang) + if (ok) { result.success++; usedInputs.add(inputEl); setFieldHighlight(inputEl, "greenTwo") } else { result.failed++ } + } + await delay("mid") + } + } + } + } else { + const fields = sectionData.formItems as { label: string; value: string }[] + console.log(` [B2] "${sectionData.title}" (非经历) 缓存${fields.length}个字段`) + for (const field of fields) { + if (!field.value) { result.skipped++; continue } + let inputEl = b2FindInputByLabel(field.label, titleEl, nextTitleEl, usedInputs) + // fallback:如果 b2FindInputByLabel 找不到,从阶段B传入的未填简历字段映射中查找 + if (!inputEl && unfilledMainFieldMap) { + const fallbackEl = unfilledMainFieldMap.get(field.label) + if (fallbackEl && !usedInputs.has(fallbackEl)) { + inputEl = fallbackEl + console.log(` [B2] "${field.label}" 通过 unfilledMainFieldMap fallback 定位到 input`) + } + } + if (!inputEl) { result.skipped++; continue } + // 时间/日期字段走阶段A同款流程:直接点击 input → fillDatePicker,不经过 detectPickerField + if (b2IsDateTimeLabel(field.label)) { + const ok = await b2FillDateTimeField(inputEl, field.label, field.value) + if (ok) { result.success++; usedInputs.add(inputEl); setFieldHighlight(inputEl, "greenTwo") } else { result.failed++ } + } else { + const ok = await b2FillSingleField(inputEl, field.label, field.value, lang) + if (ok) { result.success++; usedInputs.add(inputEl); setFieldHighlight(inputEl, "greenTwo") } else { result.failed++ } + } + await delay("mid") + } + } + } + + console.log(`===== OfferPie: 阶段B2完成 成功${result.success} 失败${result.failed} 跳过${result.skipped} =====`) + return result +} \ No newline at end of file diff --git a/src/lib/fillStats.ts b/src/lib/fillStats.ts index 4c9bb8f..3ed54a0 100644 --- a/src/lib/fillStats.ts +++ b/src/lib/fillStats.ts @@ -14,6 +14,7 @@ import type { ExperienceSectionLocateResult } from "./experienceSection" import type { MatchedFormField, UnmatchedFormField } from "./types" import { isRequiredField, setFieldHighlight } from "./formStyle" import { findLabelForInput } from "./labelFinder" +import { delay } from "~utils/delay" // ==================================================================== // 一、类型定义 @@ -94,7 +95,7 @@ export interface ScanPageFieldsParams { /** 页面语言(不传时自动检测) */ lang?: "zh" | "en" /** 特殊网站模式(不传则只走通用规则) */ - siteMode?: "beisen" + siteMode?: "beisen" | "moka" | "feishu" | "hotjob" /** 是否设置高亮背景色(默认false,统计模式下可设为true) */ applyHighlight?: boolean /** 需要排除的文字集合(简历数据/缓存中的已填值,传给 findLabelForInput 避免将已填值误认为标签) */ @@ -302,6 +303,204 @@ function scanBeisenCascadePickers( return results } +// ==================================================================== +// 二-B、摩卡(mokahr.com)特殊网站规则区 +// ==================================================================== + +/** + * 摩卡(mokahr.com)特殊表单组件规则 + * 包含摩卡招聘平台自定义组件的类名和取值逻辑 + * + * 【待适配】后续在此配置摩卡平台特有的表单组件规则: + * - TODO: 摩卡自定义单选组(类名待确认) + * - TODO: 摩卡级联选择器(地区、学校等多级联动) + * - TODO: 摩卡自定义下拉选择器(如有特殊弹出层类名) + */ +const MOKA_RULES = { + /** 摩卡单选组配置(待实际抓取页面确认类名后填写) */ + radioGroup: { + /** 单选组容器选择器 */ + groupSelector: "", // TODO: 待确认摩卡单选组容器类名 + /** 单选项选择器 */ + itemSelector: "", // TODO: 待确认摩卡单选项类名 + /** 选中状态类名 */ + checkedClass: "", // TODO: 待确认摩卡选中状态类名 + /** 标签标题容器 */ + titleSelector: "", // TODO: 待确认摩卡标签标题容器 + /** 表单控件容器 */ + controlSelector: "", // TODO: 待确认摩卡表单控件容器 + }, + /** 摩卡级联选择器配置(待实际抓取页面确认类名后填写) */ + cascadePicker: { + /** 级联选择器关键字(字段标签含这些关键字时可能是级联选择器) */ + keywords: ["地区", "居住地", "籍贯", "户籍", "地点", "所在地", "民族", "地址", "现居"], + /** 级联面板外层类名 */ + panelClass: "", // TODO: 待确认摩卡级联面板类名 + /** 表单控件容器 */ + controlSelector: "", // TODO: 待确认摩卡表单控件容器 + /** 标签标题容器 */ + titleSelector: "", // TODO: 待确认摩卡标签标题容器 + }, +} + +/** + * 摩卡特殊规则:扫描摩卡平台自定义单选组字段 + * 在指定大标题范围内,找到所有摩卡单选组,提取标签和选中值 + * + * 【待实现】目前为占位函数,等确认摩卡平台实际 DOM 结构后填写具体逻辑 + */ +function scanMokaRadioGroups( + _titleEl: Element, + _nextTitleEl: Element | null, + _processedInputs: Set, + _applyHighlight: boolean +): FieldStat[] { + const results: FieldStat[] = [] + // TODO: 摩卡单选组扫描逻辑(参考 scanBeisenRadioGroups 实现) + // 等确认摩卡平台单选组的 DOM 结构和类名后实现 + return results +} + +/** + * 摩卡特殊规则:扫描摩卡平台级联选择器字段 + * 通过字段标签关键字(地区、籍贯、民族等)匹配,检测是否存在级联选择器组件 + * + * 【待实现】目前为占位函数,等确认摩卡平台实际 DOM 结构后填写具体逻辑 + */ +function scanMokaCascadePickers( + _titleEl: Element, + _nextTitleEl: Element | null, + _processedInputs: Set, + _applyHighlight: boolean +): FieldStat[] { + const results: FieldStat[] = [] + // TODO: 摩卡级联选择器扫描逻辑(参考 scanBeisenCascadePickers 实现) + // 等确认摩卡平台级联选择器的 DOM 结构和类名后实现 + return results +} + +// ==================================================================== +// 二-C、飞书(feishu.cn)特殊网站规则区 +// ==================================================================== + +/** + * 飞书(feishu.cn)特殊表单组件规则 + * 包含飞书招聘平台自定义组件的类名和取值逻辑 + * + * 【待适配】后续在此配置飞书平台特有的表单组件规则: + * - TODO: 飞书自定义单选组(类名待确认) + * - TODO: 飞书级联选择器(地区、学校等多级联动) + * - TODO: 飞书自定义下拉选择器(如有特殊弹出层类名) + */ +const FEISHU_RULES = { + /** 飞书单选组配置(待实际抓取页面确认类名后填写) */ + radioGroup: { + groupSelector: "", // TODO: 待确认飞书单选组容器类名 + itemSelector: "", // TODO: 待确认飞书单选项类名 + checkedClass: "", // TODO: 待确认飞书选中状态类名 + titleSelector: "", // TODO: 待确认飞书标签标题容器 + controlSelector: "", // TODO: 待确认飞书表单控件容器 + }, + /** 飞书级联选择器配置(待实际抓取页面确认类名后填写) */ + cascadePicker: { + keywords: ["地区", "居住地", "籍贯", "户籍", "地点", "所在地", "民族", "地址", "现居"], + panelClass: "", // TODO: 待确认飞书级联面板类名 + controlSelector: "", // TODO: 待确认飞书表单控件容器 + titleSelector: "", // TODO: 待确认飞书标签标题容器 + }, +} + +/** + * 飞书特殊规则:扫描飞书平台自定义单选组字段 + * 【待实现】目前为占位函数,等确认飞书平台实际 DOM 结构后填写具体逻辑 + */ +function scanFeishuRadioGroups( + _titleEl: Element, + _nextTitleEl: Element | null, + _processedInputs: Set, + _applyHighlight: boolean +): FieldStat[] { + const results: FieldStat[] = [] + // TODO: 飞书单选组扫描逻辑(参考 scanBeisenRadioGroups 实现) + return results +} + +/** + * 飞书特殊规则:扫描飞书平台级联选择器字段 + * 【待实现】目前为占位函数,等确认飞书平台实际 DOM 结构后填写具体逻辑 + */ +function scanFeishuCascadePickers( + _titleEl: Element, + _nextTitleEl: Element | null, + _processedInputs: Set, + _applyHighlight: boolean +): FieldStat[] { + const results: FieldStat[] = [] + // TODO: 飞书级联选择器扫描逻辑(参考 scanBeisenCascadePickers 实现) + return results +} + +// ==================================================================== +// 二-D、Hotjob(hotjob.cn)特殊网站规则区 +// ==================================================================== + +/** + * Hotjob(hotjob.cn)特殊表单组件规则 + * 包含Hotjob招聘平台自定义组件的类名和取值逻辑 + * + * 【待适配】后续在此配置Hotjob平台特有的表单组件规则: + * - TODO: Hotjob自定义单选组(类名待确认) + * - TODO: Hotjob级联选择器(地区、学校等多级联动) + * - TODO: Hotjob自定义下拉选择器(如有特殊弹出层类名) + */ +const HOTJOB_RULES = { + /** Hotjob单选组配置(待实际抓取页面确认类名后填写) */ + radioGroup: { + groupSelector: "", // TODO: 待确认Hotjob单选组容器类名 + itemSelector: "", // TODO: 待确认Hotjob单选项类名 + checkedClass: "", // TODO: 待确认Hotjob选中状态类名 + titleSelector: "", // TODO: 待确认Hotjob标签标题容器 + controlSelector: "", // TODO: 待确认Hotjob表单控件容器 + }, + /** Hotjob级联选择器配置(待实际抓取页面确认类名后填写) */ + cascadePicker: { + keywords: ["地区", "居住地", "籍贯", "户籍", "地点", "所在地", "民族", "地址", "现居"], + panelClass: "", // TODO: 待确认Hotjob级联面板类名 + controlSelector: "", // TODO: 待确认Hotjob表单控件容器 + titleSelector: "", // TODO: 待确认Hotjob标签标题容器 + }, +} + +/** + * Hotjob特殊规则:扫描Hotjob平台自定义单选组字段 + * 【待实现】目前为占位函数,等确认Hotjob平台实际 DOM 结构后填写具体逻辑 + */ +function scanHotjobRadioGroups( + _titleEl: Element, + _nextTitleEl: Element | null, + _processedInputs: Set, + _applyHighlight: boolean +): FieldStat[] { + const results: FieldStat[] = [] + // TODO: Hotjob单选组扫描逻辑(参考 scanBeisenRadioGroups 实现) + return results +} + +/** + * Hotjob特殊规则:扫描Hotjob平台级联选择器字段 + * 【待实现】目前为占位函数,等确认Hotjob平台实际 DOM 结构后填写具体逻辑 + */ +function scanHotjobCascadePickers( + _titleEl: Element, + _nextTitleEl: Element | null, + _processedInputs: Set, + _applyHighlight: boolean +): FieldStat[] { + const results: FieldStat[] = [] + // TODO: Hotjob级联选择器扫描逻辑(参考 scanBeisenCascadePickers 实现) + return results +} + // ==================================================================== // 三、通用辅助方法 // ==================================================================== @@ -390,6 +589,106 @@ function isBgGreenTwo(el: HTMLElement): boolean { return bg === "#b7ffc6" || bg === "rgb(183, 255, 198)" } +/** + * 获取选择器组件的展示值(从 DOM 中提取并与已知选项比对) + * + * 逻辑: + * 1. 从 input 往前(DOM 方向)到当前字段标签文字之间,查找可见文字标签的内容 + * 2. 将找到的文字和已知的弹出层选项数据比对,对得上的就是该字段的已选值 + * 3. 如果往前没找到,从 input 往后找直到下一个字段的标签位置 + * + * @param inputEl - 输入框元素 + * @param labelText - 当前字段的标签文字(如"民族") + * @param nextLabelText - 同大标题区间内下一个字段的标签文字(用于限定往后搜索的边界,null 表示无下一个字段) + * @param pickerOptions - 已收集的弹出层选项文字集合 + * @returns 匹配到的选择器展示值,找不到返回空字符串 + */ +export function getPickerDisplayValue( + inputEl: HTMLElement, + labelText: string, + nextLabelText: string | null, + pickerOptions: Set +): string { + if (pickerOptions.size === 0) return "" + + const INPUT_TAG_SET = new Set(["INPUT", "TEXTAREA", "SELECT"]) + + // ---- 往前找:从 input 逆向遍历到标签文字 ---- + let current: Node | null = inputEl + let maxSteps = 50 + while (maxSteps-- > 0) { + let prev: Node | null = null + if (current.previousSibling) { + prev = current.previousSibling + while (prev.lastChild) prev = prev.lastChild + } else { + prev = current.parentNode + } + if (!prev || prev === document.body || prev === document.documentElement) break + current = prev + + if (current.nodeType === Node.TEXT_NODE) { + const text = current.textContent?.trim() || "" + if (!text) continue + // 碰到标签文字本身就停止 + if (text === labelText) break + // 和选项比对 + if (pickerOptions.has(text)) return text + } + if (current.nodeType === Node.ELEMENT_NODE) { + const el = current as HTMLElement + // 碰到另一个 input 就停止 + if (INPUT_TAG_SET.has(el.tagName)) break + // 检查元素的直接文字 + const directText = Array.from(el.childNodes) + .filter((n) => n.nodeType === Node.TEXT_NODE) + .map((n) => n.textContent?.trim()) + .filter(Boolean) + .join("") + if (directText === labelText) break + if (directText && pickerOptions.has(directText)) return directText + } + } + + // ---- 往后找:从 input 正向遍历到下一个字段的标签 ---- + current = inputEl + maxSteps = 50 + while (maxSteps-- > 0) { + let next: Node | null = null + if (current.nextSibling) { + next = current.nextSibling + while (next.firstChild) next = next.firstChild + } else { + next = current.parentNode + if (next) next = (next as Node).nextSibling + if (next) while (next.firstChild) next = next.firstChild + } + if (!next || next === document.body || next === document.documentElement) break + current = next + + if (current.nodeType === Node.TEXT_NODE) { + const text = current.textContent?.trim() || "" + if (!text) continue + // 碰到下一个字段标签就停止 + if (nextLabelText && text === nextLabelText) break + if (pickerOptions.has(text)) return text + } + if (current.nodeType === Node.ELEMENT_NODE) { + const el = current as HTMLElement + if (INPUT_TAG_SET.has(el.tagName)) break + const directText = Array.from(el.childNodes) + .filter((n) => n.nodeType === Node.TEXT_NODE) + .map((n) => n.textContent?.trim()) + .filter(Boolean) + .join("") + if (nextLabelText && directText === nextLabelText) break + if (directText && pickerOptions.has(directText)) return directText + } + } + + return "" +} + // ==================================================================== // 四、主方法 // ==================================================================== @@ -606,6 +905,42 @@ export function scanPageFields(params: ScanPageFieldsParams): TitleStat[] { fieldsInSection.push(...cascadeFields) } + // 特殊网站规则:摩卡自定义单选组 + if (siteMode === "moka") { + const mokaRadioFields = scanMokaRadioGroups(titleEl, nextTitleEl, processedInputs, applyHighlight) + fieldsInSection.push(...mokaRadioFields) + } + + // 特殊网站规则:摩卡级联选择器(地区、籍贯、民族等) + if (siteMode === "moka") { + const mokaCascadeFields = scanMokaCascadePickers(titleEl, nextTitleEl, processedInputs, applyHighlight) + fieldsInSection.push(...mokaCascadeFields) + } + + // 特殊网站规则:飞书自定义单选组 + if (siteMode === "feishu") { + const feishuRadioFields = scanFeishuRadioGroups(titleEl, nextTitleEl, processedInputs, applyHighlight) + fieldsInSection.push(...feishuRadioFields) + } + + // 特殊网站规则:飞书级联选择器(地区、籍贯、民族等) + if (siteMode === "feishu") { + const feishuCascadeFields = scanFeishuCascadePickers(titleEl, nextTitleEl, processedInputs, applyHighlight) + fieldsInSection.push(...feishuCascadeFields) + } + + // 特殊网站规则:Hotjob自定义单选组 + if (siteMode === "hotjob") { + const hotjobRadioFields = scanHotjobRadioGroups(titleEl, nextTitleEl, processedInputs, applyHighlight) + fieldsInSection.push(...hotjobRadioFields) + } + + // 特殊网站规则:Hotjob级联选择器(地区、籍贯、民族等) + if (siteMode === "hotjob") { + const hotjobCascadeFields = scanHotjobCascadePickers(titleEl, nextTitleEl, processedInputs, applyHighlight) + fieldsInSection.push(...hotjobCascadeFields) + } + if (fieldsInSection.length === 0) { titleStats.push({ titleText, @@ -805,3 +1140,128 @@ export function extractNonResumeFields(titleStats: TitleStat[]): { return result } + +// ==================================================================== +// 选择器选项文字收集(用于构建排除集合,防止已选值被误认为标签) +// ==================================================================== + +/** + * 收集页面上所有选择器类型字段的下拉选项文字 + * 用途:将收集到的选项文字加入 excludeTexts,避免循环扫描时把选择器已选值误认为表单标签 + * + * 原理:从已识别的字段列表中筛选出 value 为空且非时间类的 input,主动点击触发弹出层 + * → DOM 差异对比检测新增弹出层 → 收集弹出层内所有可见文字 + * 如果点击后没有弹出层,说明不是选择器字段,跳过 + * + * @param fieldStats - 已识别的字段统计数据(由 scanPageFields 返回) + * @returns 包含所有选择器选项文字的 Set + * + * 【注意】此方法会主动点击 input 触发弹出层,只应在填写完成后调用一次,不可在循环中反复调用 + */ +export async function collectPickerOptionTexts(fieldStats: TitleStat[]): Promise> { + const optionTexts = new Set() + + // 从已识别字段中提取需要处理的 input 列表 + // 跳过条件:标签含"时间"两字的字段(时间选择器点击会改值) + const TIME_KEYWORDS = ["时间", "日期", "date", "time"] + /** 允许单字选项的标签关键词(如性别的"男"/"女") */ + const SINGLE_CHAR_LABELS = ["性别", "gender", "sex"] + const inputsToProcess: { el: HTMLElement; allowSingleChar: boolean }[] = [] + + for (const ts of fieldStats) { + for (const f of ts.fields) { + if (!f.inputElement) continue + const inputEl = f.inputElement as HTMLInputElement | HTMLTextAreaElement + // 跳过有值的 + if (inputEl.value && inputEl.value.trim().length > 0) continue + // 跳过不可见的 + if ((inputEl as HTMLElement).offsetHeight === 0 || (inputEl as HTMLElement).offsetWidth === 0) continue + // 跳过标签含时间关键词的字段 + const label = f.labelText?.toLowerCase() || "" + if (TIME_KEYWORDS.some((kw) => label.includes(kw))) continue + const allowSingleChar = SINGLE_CHAR_LABELS.some((kw) => label.includes(kw)) + inputsToProcess.push({ el: inputEl as HTMLElement, allowSingleChar }) + } + } + + for (const { el: inputEl, allowSingleChar } of inputsToProcess) { + // 记录点击前全页面可见元素快照 + const beforeBodyChildren = new Set(Array.from(document.body.children)) + const beforeVisibleEls = new Set() + document.querySelectorAll("*").forEach((el) => { + const htmlEl = el as HTMLElement + if (htmlEl.offsetHeight > 30 && htmlEl.offsetWidth > 30) beforeVisibleEls.add(el) + }) + + // 只点击 input/textarea 自身展开选择器(不点其他任何元素) + inputEl.click() + + // 等待弹出层渲染 + await delay("midH") + + // DOM 差异对比:找新增的弹出层 + let dropdownEl: HTMLElement | null = null + + // 策略A:body 下新增的直接子元素 + for (const child of Array.from(document.body.children)) { + if (!beforeBodyChildren.has(child) && (child as HTMLElement).offsetHeight > 10) { + dropdownEl = child as HTMLElement + break + } + } + + // 策略B:全页面扫描新变为可见的大块元素(含 ≥2 个同规格可见子元素) + if (!dropdownEl) { + document.querySelectorAll("*").forEach((el) => { + if (dropdownEl) return + const htmlEl = el as HTMLElement + if (htmlEl.offsetHeight > 30 && htmlEl.offsetWidth > 30 && !beforeVisibleEls.has(el)) { + const visibleKids = Array.from(htmlEl.children).filter( + (c) => (c as HTMLElement).offsetHeight > 0 && (c as HTMLElement).offsetWidth > 0 + ) + if (visibleKids.length >= 2) { + dropdownEl = htmlEl + } + } + }) + } + + if (dropdownEl) { + // 收集弹出层内所有可见叶子文字节点的文字 + collectVisibleTextsFromElement(dropdownEl, optionTexts, allowSingleChar) + } + + // 不主动关闭弹出层——点击下一个 input 时上一个弹出层会自动关闭 + // 每个字段处理完后等一个 midH 延时 + await delay("midH") + } + + // 循环结束后,点击 document.documentElement 关闭最后一个可能残留的弹出层 + document.documentElement.dispatchEvent(new MouseEvent("click", { bubbles: true, clientX: 0, clientY: 0 })) + + return optionTexts +} + +/** + * 递归收集指定元素内所有可见叶子节点的文字 + * 只收集有效文字(长度 2-30,至少含1个汉字或2个英文字母),排除纯数字/纯符号 + * + * @param el - 弹出层根元素 + * @param textSet - 文字收集目标集合 + * @param allowSingleChar - 是否允许单字(如性别的"男"/"女"),默认 false + */ +function collectVisibleTextsFromElement(el: HTMLElement, textSet: Set, allowSingleChar = false): void { + const minLen = allowSingleChar ? 1 : 2 + const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT) + let node: Node | null = walker.nextNode() + while (node) { + const text = node.textContent?.trim() + if (text && text.length >= minLen && text.length <= 30) { + // 至少含1个汉字或2个英文字母,排除纯数字/纯符号 + if (/[\u4e00-\u9fff]/.test(text) || /[a-zA-Z]{2,}/.test(text)) { + textSet.add(text) + } + } + node = walker.nextNode() + } +} diff --git a/src/lib/resumeDataHelper.ts b/src/lib/resumeDataHelper.ts index af16f7b..5ce9c7c 100644 --- a/src/lib/resumeDataHelper.ts +++ b/src/lib/resumeDataHelper.ts @@ -63,3 +63,38 @@ export function getResumeSectionCount(resumeData: ResumeData, section: Experienc const sectionData = resumeData[section] return Array.isArray(sectionData) ? sectionData.length : 0 } + +/** + * 从简历数据中提取所有字符串值,构建排除文字集合 + * 用途:传给 findLabelForInput / scanPageFields 的 excludeTexts 参数, + * 避免将已填入表单的简历值(如"汉族"、"广州"、"硕士"等)误认为表单标签 + * @param resumeData - 完整简历数据(可为 null,为 null 时返回空集合) + * @returns 包含简历中所有字符串值的 Set + */ +export function buildResumeExcludeTexts(resumeData: ResumeData | null | undefined): Set { + const set = new Set() + if (!resumeData) return set + + // 主表字段值 + const main = resumeData.main + if (main) { + for (const val of Object.values(main)) { + if (typeof val === "string" && val.trim()) set.add(val.trim()) + if (Array.isArray(val)) val.forEach((v) => { if (typeof v === "string" && v.trim()) set.add(v.trim()) }) + } + } + + // 5大经历字段值 + const expSections: ExperienceSection[] = ["education", "work", "internship", "project", "competition"] + for (const sec of expSections) { + const items = resumeData[sec] + if (!Array.isArray(items)) continue + for (const item of items) { + for (const val of Object.values(item as Record)) { + if (typeof val === "string" && val.trim()) set.add(val.trim()) + } + } + } + + return set +} diff --git a/src/utils/delay.ts b/src/utils/delay.ts index b93fcc9..4af4226 100644 --- a/src/utils/delay.ts +++ b/src/utils/delay.ts @@ -4,12 +4,13 @@ */ /** 延时等级 */ -export type DelayLevel = "low" | "mid" | "high" | "max" +export type DelayLevel = "low" | "mid" | "midH" | "high" | "max" /** 各等级对应的毫秒数(统一在此处调整) */ const DELAY_MS: Record = { low: 10, // 原<100ms 场景:微等待,点击后极短暂停 mid: 30, // 原100~300ms 场景:等待 DOM 更新、弹出层渲染 + midH: 35, // 原100~300ms 场景:等待 DOM 更新、弹出层渲染 high: 500, // 原>300ms 场景:等待接口返回、搜索结果、动画完成 max: 2000, // >2000ms 场景:特殊超长延时(谨慎使用) } diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo index f5e68e6..47ba067 100644 --- a/tsconfig.tsbuildinfo +++ b/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"fileNames":["./node_modules/typescript/lib/lib.es5.d.ts","./node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/typescript/lib/lib.es2021.d.ts","./node_modules/typescript/lib/lib.es2022.d.ts","./node_modules/typescript/lib/lib.es2023.d.ts","./node_modules/typescript/lib/lib.es2024.d.ts","./node_modules/typescript/lib/lib.es2025.d.ts","./node_modules/typescript/lib/lib.esnext.d.ts","./node_modules/typescript/lib/lib.dom.d.ts","./node_modules/typescript/lib/lib.dom.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/typescript/lib/lib.es2021.promise.d.ts","./node_modules/typescript/lib/lib.es2021.string.d.ts","./node_modules/typescript/lib/lib.es2021.weakref.d.ts","./node_modules/typescript/lib/lib.es2021.intl.d.ts","./node_modules/typescript/lib/lib.es2022.array.d.ts","./node_modules/typescript/lib/lib.es2022.error.d.ts","./node_modules/typescript/lib/lib.es2022.intl.d.ts","./node_modules/typescript/lib/lib.es2022.object.d.ts","./node_modules/typescript/lib/lib.es2022.string.d.ts","./node_modules/typescript/lib/lib.es2022.regexp.d.ts","./node_modules/typescript/lib/lib.es2023.array.d.ts","./node_modules/typescript/lib/lib.es2023.collection.d.ts","./node_modules/typescript/lib/lib.es2023.intl.d.ts","./node_modules/typescript/lib/lib.es2024.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2024.collection.d.ts","./node_modules/typescript/lib/lib.es2024.object.d.ts","./node_modules/typescript/lib/lib.es2024.promise.d.ts","./node_modules/typescript/lib/lib.es2024.regexp.d.ts","./node_modules/typescript/lib/lib.es2024.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2024.string.d.ts","./node_modules/typescript/lib/lib.es2025.collection.d.ts","./node_modules/typescript/lib/lib.es2025.float16.d.ts","./node_modules/typescript/lib/lib.es2025.intl.d.ts","./node_modules/typescript/lib/lib.es2025.iterator.d.ts","./node_modules/typescript/lib/lib.es2025.promise.d.ts","./node_modules/typescript/lib/lib.es2025.regexp.d.ts","./node_modules/typescript/lib/lib.esnext.array.d.ts","./node_modules/typescript/lib/lib.esnext.collection.d.ts","./node_modules/typescript/lib/lib.esnext.date.d.ts","./node_modules/typescript/lib/lib.esnext.decorators.d.ts","./node_modules/typescript/lib/lib.esnext.disposable.d.ts","./node_modules/typescript/lib/lib.esnext.error.d.ts","./node_modules/typescript/lib/lib.esnext.intl.d.ts","./node_modules/typescript/lib/lib.esnext.sharedmemory.d.ts","./node_modules/typescript/lib/lib.esnext.temporal.d.ts","./node_modules/typescript/lib/lib.esnext.typedarrays.d.ts","./node_modules/typescript/lib/lib.decorators.d.ts","./node_modules/typescript/lib/lib.decorators.legacy.d.ts","./node_modules/@types/react/global.d.ts","./node_modules/csstype/index.d.ts","./node_modules/@types/react/index.d.ts","./node_modules/plasmo/templates/plasmo.d.ts","./.plasmo/index.d.ts","./src/config.ts","./src/constants.ts","./src/utils/cookie.ts","./src/api/request.ts","./src/lib/types.ts","./src/api/aiapi.ts","./src/api/dataapi.ts","./src/background/index.ts","./src/lib/pickerfill.ts","./src/lib/datepicker.ts","./src/lib/constants.ts","./src/lib/autofill.ts","./src/lib/dom.ts","./src/lib/formmatcher.ts","./src/lib/pickerdetector.ts","./src/lib/resumeupload.ts","./src/lib/resumedatahelper.ts","./src/lib/experiencesection.ts","./src/handlers/handleautofillcommon.ts","./src/utils/storage.ts","./src/utils/auth.ts","./src/components/sidebarpanel.tsx","./node_modules/@types/react-dom/client.d.ts","./node_modules/plasmo/dist/type.d.ts","./src/contents/sidebar.tsx"],"fileIdsList":[[92],[90,91],[92,117],[95,98,99],[97],[95,96],[92,97,99,100,113],[92,93,97,99,101,116,118],[99,105,106,107,108,109,110,111,112],[99,103,104,105],[99],[99,106],[105],[99,105,106,107],[99,105,107],[99,105,106],[106],[96,101,114],[96]],"fileInfos":[{"version":"bcd24271a113971ba9eb71ff8cb01bc6b0f872a85c23fdbe5d93065b375933cd","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"3f88bedbeb09c6f5a6645cb24c7c55f1aa22d19ae96c8e6959cbd8b85a707bc6","signature":false,"impliedFormat":1},{"version":"7fe93b39b810eadd916be8db880dd7f0f7012a5cc6ffb62de8f62a2117fa6f1f","signature":false,"impliedFormat":1},{"version":"bb0074cc08b84a2374af33d8bf044b80851ccc9e719a5e202eacf40db2c31600","signature":false,"impliedFormat":1},{"version":"1a7daebe4f45fb03d9ec53d60008fbf9ac45a697fdc89e4ce218bc94b94f94d6","signature":false,"impliedFormat":1},{"version":"f94b133a3cb14a288803be545ac2683e0d0ff6661bcd37e31aaaec54fc382aed","signature":false,"impliedFormat":1},{"version":"f59d0650799f8782fd74cf73c19223730c6d1b9198671b1c5b3a38e1188b5953","signature":false,"impliedFormat":1},{"version":"8a15b4607d9a499e2dbeed9ec0d3c0d7372c850b2d5f1fb259e8f6d41d468a84","signature":false,"impliedFormat":1},{"version":"26e0fe14baee4e127f4365d1ae0b276f400562e45e19e35fd2d4c296684715e6","signature":false,"impliedFormat":1},{"version":"1e9332c23e9a907175e0ffc6a49e236f97b48838cc8aec9ce7e4cec21e544b65","signature":false,"impliedFormat":1},{"version":"3753fbc1113dc511214802a2342280a8b284ab9094f6420e7aa171e868679f91","signature":false,"impliedFormat":1},{"version":"999ca32883495a866aa5737fe1babc764a469e4cde6ee6b136a4b9ae68853e4b","signature":false,"impliedFormat":1},{"version":"17f13ecb98cbc39243f2eee1f16d45cd8ec4706b03ee314f1915f1a8b42f6984","signature":false,"impliedFormat":1},{"version":"d6b1eba8496bdd0eed6fc8a685768fe01b2da4a0388b5fe7df558290bffcf32f","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"7f57fc4404ff020bc45b9c620aff2b40f700b95fe31164024c453a5e3c163c54","signature":false,"impliedFormat":1},{"version":"eadcffda2aa84802c73938e589b9e58248d74c59cb7fcbca6474e3435ac15504","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"105ba8ff7ba746404fe1a2e189d1d3d2e0eb29a08c18dded791af02f29fb4711","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"00343ca5b2e3d48fa5df1db6e32ea2a59afab09590274a6cccb1dbae82e60c7c","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"ebd9f816d4002697cb2864bea1f0b70a103124e18a8cd9645eeccc09bdf80ab4","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"2c1afac30a01772cd2a9a298a7ce7706b5892e447bb46bdbeef720f7b5da77ad","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"7b0225f483e4fa685625ebe43dd584bb7973bbd84e66a6ba7bbe175ee1048b4f","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"c0a4b8ac6ce74679c1da2b3795296f5896e31c38e888469a8e0f99dc3305de60","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"3084a7b5f569088e0146533a00830e206565de65cae2239509168b11434cd84f","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"c5079c53f0f141a0698faa903e76cb41cd664e3efb01cc17a5c46ec2eb0bef42","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"32cafbc484dea6b0ab62cf8473182bbcb23020d70845b406f80b7526f38ae862","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"fca4cdcb6d6c5ef18a869003d02c9f0fd95df8cfaf6eb431cd3376bc034cad36","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"b93ec88115de9a9dc1b602291b85baf825c85666bf25985cc5f698073892b467","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"f5c06dcc3fe849fcb297c247865a161f995cc29de7aa823afdd75aaaddc1419b","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"b77e16112127a4b169ef0b8c3a4d730edf459c5f25fe52d5e436a6919206c4d7","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"fbffd9337146eff822c7c00acbb78b01ea7ea23987f6c961eba689349e744f8c","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"a995c0e49b721312f74fdfb89e4ba29bd9824c770bbb4021d74d2bf560e4c6bd","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"c7b3542146734342e440a84b213384bfa188835537ddbda50d30766f0593aff9","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"ce6180fa19b1cccd07ee7f7dbb9a367ac19c0ed160573e4686425060b6df7f57","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"3f02e2476bccb9dbe21280d6090f0df17d2f66b74711489415a8aa4df73c9675","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"45e3ab34c1c013c8ab2dc1ba4c80c780744b13b5676800ae2e3be27ae862c40c","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"805c86f6cca8d7702a62a844856dbaa2a3fd2abef0536e65d48732441dde5b5b","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"e42e397f1a5a77994f0185fd1466520691456c772d06bf843e5084ceb879a0ad","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"f4c2b41f90c95b1c532ecc874bd3c111865793b23aebcc1c3cbbabcd5d76ffb0","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"ab26191cfad5b66afa11b8bf935ef1cd88fabfcb28d30b2dfa6fad877d050332","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"2088bc26531e38fb05eedac2951480db5309f6be3fa4a08d2221abb0f5b4200d","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"cb9d366c425fea79716a8fb3af0d78e6b22ebbab3bd64d25063b42dc9f531c1e","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"500934a8089c26d57ebdb688fc9757389bb6207a3c8f0674d68efa900d2abb34","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"689da16f46e647cef0d64b0def88910e818a5877ca5379ede156ca3afb780ac3","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"bc21cc8b6fee4f4c2440d08035b7ea3c06b3511314c8bab6bef7a92de58a2593","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"7ca53d13d2957003abb47922a71866ba7cb2068f8d154877c596d63c359fed25","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"54725f8c4df3d900cb4dac84b64689ce29548da0b4e9b7c2de61d41c79293611","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"e5594bc3076ac29e6c1ebda77939bc4c8833de72f654b6e376862c0473199323","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"2f3eb332c2d73e729f3364fcc0c2b375e72a121e8157d25a82d67a138c83a95c","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"6f4427f9642ce8d500970e4e69d1397f64072ab73b97e476b4002a646ac743b1","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"48915f327cd1dea4d7bd358d9dc7732f58f9e1626a29cc0c05c8c692419d9bb7","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"b7bf9377723203b5a6a4b920164df22d56a43f593269ba6ae1fdc97774b68855","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"db9709688f82c9e5f65a119c64d835f906efe5f559d08b11642d56eb85b79357","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"4b25b8c874acd1a4cf8444c3617e037d444d19080ac9f634b405583fd10ce1f7","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"37be57d7c90cf1f8112ee2636a068d8fd181289f82b744160ec56a7dc158a9f5","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"a917a49ac94cd26b754ab84e113369a75d1a47a710661d7cd25e961cc797065f","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"6d3261badeb7843d157ef3e6f5d1427d0eeb0af0cf9df84a62cfd29fd47ac86e","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"195daca651dde22f2167ac0d0a05e215308119a3100f5e6268e8317d05a92526","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"8b11e4285cd2bb164a4dc09248bdec69e9842517db4ca47c1ba913011e44ff2f","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"0508571a52475e245b02bc50fa1394065a0a3d05277fbf5120c3784b85651799","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"8f9af488f510c3015af3cc8c267a9e9d96c4dd38a1fdff0e11dc5a544711415b","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"fc611fea8d30ea72c6bbfb599c9b4d393ce22e2f5bfef2172534781e7d138104","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"0bd714129fca875f7d4c477a1a392200b0bcd13fb2e80928cd334b63830ea047","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"e2c9037ae6cd2c52d80ceef0b3c5ffdb488627d71529cf4f63776daf11161c9a","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"135d5cf4d345f59f1a9caadfafcd858d3d9cc68290db616cc85797224448cccc","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"bc238c3f81c2984751932b6aab223cd5b830e0ac6cad76389e5e9d2ffc03287d","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"4a07f9b76d361f572620927e5735b77d6d2101c23cdd94383eb5b706e7b36357","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"7c4e8dc6ab834cc6baa0227e030606d29e3e8449a9f67cdf5605ea5493c4db29","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"de7ba0fd02e06cd9a5bd4ab441ed0e122735786e67dde1e849cced1cd8b46b78","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"6148e4e88d720a06855071c3db02069434142a8332cf9c182cda551adedf3156","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"d63dba625b108316a40c95a4425f8d4294e0deeccfd6c7e59d819efa19e23409","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"0568d6befee03dd435bed4fc25c4e46865b24bdcb8c563fdc21f580a2c301904","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"30d62269b05b584741f19a5369852d5d34895aa2ac4fd948956f886d15f9cc0d","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"f128dae7c44d8f35ee42e0a437000a57c9f06cc04f8b4fb42eebf44954d53dc8","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"ffbe6d7b295306b2ba88030f65b74c107d8d99bdcf596ea99c62a02f606108b0","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"996fb27b15277369c68a4ba46ed138b4e9e839a02fb4ec756f7997629242fd9f","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"79b712591b270d4778c89706ca2cfc56ddb8c3f895840e477388f1710dc5eda9","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"20884846cef428b992b9bd032e70a4ef88e349263f63aeddf04dda837a7dba26","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"5fcab789c73a97cd43828ee3cc94a61264cf24d4c44472ce64ced0e0f148bdb2","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"db59a81f070c1880ad645b2c0275022baa6a0c4f0acdc58d29d349c6efcf0903","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"673294292640f5722b700e7d814e17aaf7d93f83a48a2c9b38f33cbc940ad8b0","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"d786b48f934cbca483b3c6d0a798cb43bbb4ada283e76fb22c28e53ae05b9e69","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"1ecb8e347cb6b2a8927c09b86263663289418df375f5e68e11a0ae683776978f","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"142efd4ce210576f777dc34df121777be89eda476942d6d6663b03dcb53be3ff","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"379bc41580c2d774f82e828c70308f24a005b490c25ba34d679d84bcf05c3d9d","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"ed484fb2aa8a1a23d0277056ec3336e0a0b52f9b8d6a961f338a642faf43235d","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"4ffedae1d1c2d53fdbca1c96d3c7dda544281f7d262f99b6880634f8fd8d9820","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"83a730b125d477dd264df8ba479afab27a3dae7152b005c214ab94dc7ee44fd3","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"1ce14b81c5cc821994aa8ec1d42b220dd41b27fcc06373bce3958af7421b77d4","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"b3a048b3e9302ef9a34ef4ebb9aecfb28b66abb3bce577206a79fee559c230da","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"7e29f41b158de217f94cb9676bf9cbd0cd9b5a46e1985141ed36e075c52bf6ad","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"ac51dd7d31333793807a6abaa5ae168512b6131bd41d9c5b98477fc3b7800f9f","signature":false,"impliedFormat":1},{"version":"dc0a7f107690ee5cd8afc8dbf05c4df78085471ce16bdd9881642ec738bc81fe","signature":false,"impliedFormat":1},{"version":"97ae124daa3695481c12e517f1aadb524b12fdb1f09d67f9ecc3328c12271487","signature":false,"affectsGlobalScope":true,"impliedFormat":99},{"version":"45ccc69e3952ff11e3272e7f09c12d252ec906c8ff99cd484c24fe6ae5350a36","signature":false},{"version":"3b324d44a01bc325e2200b04630a2d574cc4db2ccc99abd6472e251d7652c40d","signature":false},{"version":"e1da2a6a5d71424a814355daf9f3f2530771d5881e102c635d149f8b6726de68","signature":false},{"version":"8cf239d69bddd9d9e9d29032f5bb6a54f662bac8096250f001eb4658f145f9d4","signature":false},{"version":"ab406429bb4d7991588ae26f169dc66ebb717d9d8d8f117d09df501bc6cb5f56","signature":false},{"version":"de5658984ccdf982a706b479eb3c39877f1baaa972f7c982d1257e1f4e3c75f0","signature":false},{"version":"b3a744c1bb0f52d9b373e1916093a36ce30072a61f5797e6500667611c9fa3d7","signature":false},{"version":"95d47b4965b90a36a40262900f1c3f398f54968f3ba1b1390aca05e405bd5b81","signature":false},{"version":"e6dda236786d3b262e21dcca8b49e8f0ac38d00c19682a95f6409fdd2acf3279","signature":false},{"version":"e995dd83c00ab5a73d3fc5129bc6c0ad53ec32f61a030e267705f922389564bc","signature":false},{"version":"95853d214d06b14f127ee7b75ba8ab123812280e5551cbf0c155f795c346f467","signature":false},{"version":"b8978babdf23697f191df0bba23c4af47952e2442480351099fb25e63e9ef15e","signature":false},{"version":"c0224c8e573299051a791a09108fb5e7f961055db7c732a444e6e63835eea663","signature":false},{"version":"ef84f5e7b6d2e425866f0dae04b1b4f9cc515665ec0f08d01989f1f0ada37d7e","signature":false},{"version":"5252145e526bdcd3f21f3fe885bb841b6785231a0e5c219506698b39434498fa","signature":false},{"version":"3d0796f893935c37d1f6fd169443c3fda72ab2fb9ece8b7c8afc420101ecc06f","signature":false},{"version":"573202f931a06a0edb63649d6fde2c3993951ca7f89d1f9588346bbc3204bd52","signature":false},{"version":"fd3f7aa0731fbda6d2dce4841867f49b50f2234e1d1ac3bf656cadd1dcb99383","signature":false},{"version":"04eeb3cae6b6f1199c0b147d4d934a92576e8b879c9f109b699f72a30c39f487","signature":false},{"version":"2d0845894f4af22aecb3d32c474293da3fecf0e9ab4b16f49611c9f6e088cc4d","signature":false},{"version":"826cc50543d683e766ca744033daa998520fcc74e3ef815f4e444523c69d47e9","signature":false},{"version":"3d529f39440637d7a89da402e3674f7e1773ca873d2d05d45b6c5784cf725bf4","signature":false},{"version":"71d56347f52fe4547764dcfd47c12cb2aab78097df8e2e9d370f679fc9e49707","signature":false},{"version":"bc03c3c352f689e38c0ddd50c39b1e65d59273991bfc8858a9e3c0ebb79c023b","signature":false,"impliedFormat":1},{"version":"032a6d45b6d1d730573abb352e355f826f42a412f02f5f37b7183df04c519bf0","signature":false,"impliedFormat":99},{"version":"10ee02e19b76a8f162f61619c64f3e19ecf9dcff5a28000f9c1dab6eb09d8e88","signature":false}],"root":[[93,116],119],"options":{"allowJs":true,"declaration":false,"declarationMap":false,"esModuleInterop":true,"inlineSources":false,"jsx":1,"module":99,"noUnusedLocals":false,"noUnusedParameters":false,"skipLibCheck":true,"strict":false,"target":99,"verbatimModuleSyntax":true},"referencedMap":[[117,1],[92,2],[118,3],[93,1],[100,4],[101,4],[98,5],[102,6],[116,7],[119,8],[113,9],[106,10],[105,11],[104,12],[107,13],[112,14],[108,15],[109,16],[103,11],[111,11],[110,17],[115,18],[97,19]],"changeFileSet":[94,117,90,92,91,118,93,88,89,14,15,17,16,2,18,19,20,21,22,23,24,25,3,26,27,4,28,32,29,30,31,33,34,35,5,36,37,38,39,6,43,40,41,42,44,7,45,50,51,46,47,48,49,8,55,52,53,54,56,9,57,58,59,61,60,62,63,10,64,65,66,11,67,68,69,70,71,72,12,73,74,75,76,77,1,78,79,13,80,81,82,83,84,85,86,87,100,101,98,102,116,95,96,119,113,106,105,104,107,112,108,109,103,111,110,99,115,97,114],"version":"6.0.3"} \ No newline at end of file +{"fileNames":["./node_modules/typescript/lib/lib.es5.d.ts","./node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/typescript/lib/lib.es2021.d.ts","./node_modules/typescript/lib/lib.es2022.d.ts","./node_modules/typescript/lib/lib.es2023.d.ts","./node_modules/typescript/lib/lib.es2024.d.ts","./node_modules/typescript/lib/lib.esnext.d.ts","./node_modules/typescript/lib/lib.dom.d.ts","./node_modules/typescript/lib/lib.dom.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/typescript/lib/lib.es2021.promise.d.ts","./node_modules/typescript/lib/lib.es2021.string.d.ts","./node_modules/typescript/lib/lib.es2021.weakref.d.ts","./node_modules/typescript/lib/lib.es2021.intl.d.ts","./node_modules/typescript/lib/lib.es2022.array.d.ts","./node_modules/typescript/lib/lib.es2022.error.d.ts","./node_modules/typescript/lib/lib.es2022.intl.d.ts","./node_modules/typescript/lib/lib.es2022.object.d.ts","./node_modules/typescript/lib/lib.es2022.string.d.ts","./node_modules/typescript/lib/lib.es2022.regexp.d.ts","./node_modules/typescript/lib/lib.es2023.array.d.ts","./node_modules/typescript/lib/lib.es2023.collection.d.ts","./node_modules/typescript/lib/lib.es2023.intl.d.ts","./node_modules/typescript/lib/lib.es2024.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2024.collection.d.ts","./node_modules/typescript/lib/lib.es2024.object.d.ts","./node_modules/typescript/lib/lib.es2024.promise.d.ts","./node_modules/typescript/lib/lib.es2024.regexp.d.ts","./node_modules/typescript/lib/lib.es2024.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2024.string.d.ts","./node_modules/typescript/lib/lib.esnext.array.d.ts","./node_modules/typescript/lib/lib.esnext.collection.d.ts","./node_modules/typescript/lib/lib.esnext.intl.d.ts","./node_modules/typescript/lib/lib.esnext.disposable.d.ts","./node_modules/typescript/lib/lib.esnext.promise.d.ts","./node_modules/typescript/lib/lib.esnext.decorators.d.ts","./node_modules/typescript/lib/lib.esnext.iterator.d.ts","./node_modules/typescript/lib/lib.esnext.float16.d.ts","./node_modules/typescript/lib/lib.esnext.error.d.ts","./node_modules/typescript/lib/lib.esnext.sharedmemory.d.ts","./node_modules/typescript/lib/lib.decorators.d.ts","./node_modules/typescript/lib/lib.decorators.legacy.d.ts","./node_modules/@types/react/global.d.ts","./node_modules/csstype/index.d.ts","./node_modules/@types/react/index.d.ts","./node_modules/plasmo/templates/plasmo.d.ts","./.plasmo/index.d.ts","./src/config.ts","./src/constants.ts","./src/utils/cookie.ts","./src/api/request.ts","./src/lib/types.ts","./src/api/aiapi.ts","./src/api/dataapi.ts","./src/background/index.ts","./src/lib/pickerfill.ts","./src/utils/delay.ts","./src/lib/datepicker.ts","./src/lib/constants.ts","./src/lib/autofill.ts","./src/lib/dom.ts","./src/lib/formmatcher.ts","./src/lib/pickerdetector.ts","./src/lib/resumeupload.ts","./src/lib/resumedatahelper.ts","./src/lib/experiencesection.ts","./src/lib/formstyle.ts","./src/utils/storage.ts","./src/lib/labelfinder.ts","./src/handlers/handleautofillbeisen.ts","./src/handlers/handleautofillcommon.ts","./src/handlers/handleautofillfeishu.ts","./src/handlers/handleautofillhotjob.ts","./src/handlers/handleautofillmoka.ts","./src/lib/channelbridge.ts","./src/lib/fillstats.ts","./src/utils/auth.ts","./src/components/sidebarpanel.tsx","./node_modules/@types/react-dom/client.d.ts","./node_modules/plasmo/dist/type.d.ts","./src/contents/sidebar.tsx","./node_modules/@types/har-format/index.d.ts","./node_modules/@types/chrome/har-format/index.d.ts","./node_modules/@types/chrome/chrome-cast/index.d.ts","./node_modules/@types/filewriter/index.d.ts","./node_modules/@types/filesystem/index.d.ts","./node_modules/@types/chrome/index.d.ts","./node_modules/@types/estree/index.d.ts","./node_modules/@types/http-cache-semantics/index.d.ts","./node_modules/@types/parse-json/index.d.ts","./node_modules/@types/react-dom/index.d.ts","./node_modules/@types/relateurl/index.d.ts","./node_modules/@types/trusted-types/lib/index.d.ts","./node_modules/@types/trusted-types/index.d.ts"],"fileIdsList":[[122],[123,124,126],[125],[85],[83,84],[133],[85,119],[88,91,92],[90],[88,89],[85,86,88,90,92,93,94,108,110,111,112,113,114,115,116],[85,86,88,90,92,94,115,118,120],[92,97,98,99,100,101,102,103,104,105,106,107,108,109],[92,96,97,98,99],[92],[92,97,100],[99],[92,97,99,101,102],[92,106,107,109],[92,99,101],[92,97,99],[97],[89,94,108],[89]],"fileInfos":[{"version":"c430d44666289dae81f30fa7b2edebf186ecc91a2d4c71266ea6ae76388792e1","affectsGlobalScope":true,"impliedFormat":1},{"version":"45b7ab580deca34ae9729e97c13cfd999df04416a79116c3bfb483804f85ded4","impliedFormat":1},{"version":"3facaf05f0c5fc569c5649dd359892c98a85557e3e0c847964caeb67076f4d75","impliedFormat":1},{"version":"e44bb8bbac7f10ecc786703fe0a6a4b952189f908707980ba8f3c8975a760962","impliedFormat":1},{"version":"5e1c4c362065a6b95ff952c0eab010f04dcd2c3494e813b493ecfd4fcb9fc0d8","impliedFormat":1},{"version":"68d73b4a11549f9c0b7d352d10e91e5dca8faa3322bfb77b661839c42b1ddec7","impliedFormat":1},{"version":"5efce4fc3c29ea84e8928f97adec086e3dc876365e0982cc8479a07954a3efd4","impliedFormat":1},{"version":"feecb1be483ed332fad555aff858affd90a48ab19ba7272ee084704eb7167569","impliedFormat":1},{"version":"ee7bad0c15b58988daa84371e0b89d313b762ab83cb5b31b8a2d1162e8eb41c2","impliedFormat":1},{"version":"27bdc30a0e32783366a5abeda841bc22757c1797de8681bbe81fbc735eeb1c10","impliedFormat":1},{"version":"8fd575e12870e9944c7e1d62e1f5a73fcf23dd8d3a321f2a2c74c20d022283fe","impliedFormat":1},{"version":"2ab096661c711e4a81cc464fa1e6feb929a54f5340b46b0a07ac6bbf857471f0","impliedFormat":1},{"version":"080941d9f9ff9307f7e27a83bcd888b7c8270716c39af943532438932ec1d0b9","affectsGlobalScope":true,"impliedFormat":1},{"version":"2e80ee7a49e8ac312cc11b77f1475804bee36b3b2bc896bead8b6e1266befb43","affectsGlobalScope":true,"impliedFormat":1},{"version":"c57796738e7f83dbc4b8e65132f11a377649c00dd3eee333f672b8f0a6bea671","affectsGlobalScope":true,"impliedFormat":1},{"version":"dc2df20b1bcdc8c2d34af4926e2c3ab15ffe1160a63e58b7e09833f616efff44","affectsGlobalScope":true,"impliedFormat":1},{"version":"515d0b7b9bea2e31ea4ec968e9edd2c39d3eebf4a2d5cbd04e88639819ae3b71","affectsGlobalScope":true,"impliedFormat":1},{"version":"0559b1f683ac7505ae451f9a96ce4c3c92bdc71411651ca6ddb0e88baaaad6a3","affectsGlobalScope":true,"impliedFormat":1},{"version":"0dc1e7ceda9b8b9b455c3a2d67b0412feab00bd2f66656cd8850e8831b08b537","affectsGlobalScope":true,"impliedFormat":1},{"version":"ce691fb9e5c64efb9547083e4a34091bcbe5bdb41027e310ebba8f7d96a98671","affectsGlobalScope":true,"impliedFormat":1},{"version":"8d697a2a929a5fcb38b7a65594020fcef05ec1630804a33748829c5ff53640d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ff2a353abf8a80ee399af572debb8faab2d33ad38c4b4474cff7f26e7653b8d","affectsGlobalScope":true,"impliedFormat":1},{"version":"fb0f136d372979348d59b3f5020b4cdb81b5504192b1cacff5d1fbba29378aa1","affectsGlobalScope":true,"impliedFormat":1},{"version":"d15bea3d62cbbdb9797079416b8ac375ae99162a7fba5de2c6c505446486ac0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"68d18b664c9d32a7336a70235958b8997ebc1c3b8505f4f1ae2b7e7753b87618","affectsGlobalScope":true,"impliedFormat":1},{"version":"eb3d66c8327153d8fa7dd03f9c58d351107fe824c79e9b56b462935176cdf12a","affectsGlobalScope":true,"impliedFormat":1},{"version":"38f0219c9e23c915ef9790ab1d680440d95419ad264816fa15009a8851e79119","affectsGlobalScope":true,"impliedFormat":1},{"version":"69ab18c3b76cd9b1be3d188eaf8bba06112ebbe2f47f6c322b5105a6fbc45a2e","affectsGlobalScope":true,"impliedFormat":1},{"version":"a680117f487a4d2f30ea46f1b4b7f58bef1480456e18ba53ee85c2746eeca012","affectsGlobalScope":true,"impliedFormat":1},{"version":"2f11ff796926e0832f9ae148008138ad583bd181899ab7dd768a2666700b1893","affectsGlobalScope":true,"impliedFormat":1},{"version":"4de680d5bb41c17f7f68e0419412ca23c98d5749dcaaea1896172f06435891fc","affectsGlobalScope":true,"impliedFormat":1},{"version":"954296b30da6d508a104a3a0b5d96b76495c709785c1d11610908e63481ee667","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac9538681b19688c8eae65811b329d3744af679e0bdfa5d842d0e32524c73e1c","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a969edff4bd52585473d24995c5ef223f6652d6ef46193309b3921d65dd4376","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e9fbd7030c440b33d021da145d3232984c8bb7916f277e8ffd3dc2e3eae2bdb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811ec78f7fefcabbda4bfa93b3eb67d9ae166ef95f9bff989d964061cbf81a0c","affectsGlobalScope":true,"impliedFormat":1},{"version":"717937616a17072082152a2ef351cb51f98802fb4b2fdabd32399843875974ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"d7e7d9b7b50e5f22c915b525acc5a49a7a6584cf8f62d0569e557c5cfc4b2ac2","affectsGlobalScope":true,"impliedFormat":1},{"version":"71c37f4c9543f31dfced6c7840e068c5a5aacb7b89111a4364b1d5276b852557","affectsGlobalScope":true,"impliedFormat":1},{"version":"576711e016cf4f1804676043e6a0a5414252560eb57de9faceee34d79798c850","affectsGlobalScope":true,"impliedFormat":1},{"version":"89c1b1281ba7b8a96efc676b11b264de7a8374c5ea1e6617f11880a13fc56dc6","affectsGlobalScope":true,"impliedFormat":1},{"version":"74f7fa2d027d5b33eb0471c8e82a6c87216223181ec31247c357a3e8e2fddc5b","affectsGlobalScope":true,"impliedFormat":1},{"version":"d6d7ae4d1f1f3772e2a3cde568ed08991a8ae34a080ff1151af28b7f798e22ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"063600664504610fe3e99b717a1223f8b1900087fab0b4cad1496a114744f8df","affectsGlobalScope":true,"impliedFormat":1},{"version":"934019d7e3c81950f9a8426d093458b65d5aff2c7c1511233c0fd5b941e608ab","affectsGlobalScope":true,"impliedFormat":1},{"version":"52ada8e0b6e0482b728070b7639ee42e83a9b1c22d205992756fe020fd9f4a47","affectsGlobalScope":true,"impliedFormat":1},{"version":"3bdefe1bfd4d6dee0e26f928f93ccc128f1b64d5d501ff4a8cf3c6371200e5e6","affectsGlobalScope":true,"impliedFormat":1},{"version":"59fb2c069260b4ba00b5643b907ef5d5341b167e7d1dbf58dfd895658bda2867","affectsGlobalScope":true,"impliedFormat":1},{"version":"639e512c0dfc3fad96a84caad71b8834d66329a1f28dc95e3946c9b58176c73a","affectsGlobalScope":true,"impliedFormat":1},{"version":"368af93f74c9c932edd84c58883e736c9e3d53cec1fe24c0b0ff451f529ceab1","affectsGlobalScope":true,"impliedFormat":1},{"version":"af3dd424cf267428f30ccfc376f47a2c0114546b55c44d8c0f1d57d841e28d74","affectsGlobalScope":true,"impliedFormat":1},{"version":"995c005ab91a498455ea8dfb63aa9f83fa2ea793c3d8aa344be4a1678d06d399","affectsGlobalScope":true,"impliedFormat":1},{"version":"959d36cddf5e7d572a65045b876f2956c973a586da58e5d26cde519184fd9b8a","affectsGlobalScope":true,"impliedFormat":1},{"version":"965f36eae237dd74e6cca203a43e9ca801ce38824ead814728a2807b1910117d","affectsGlobalScope":true,"impliedFormat":1},{"version":"3925a6c820dcb1a06506c90b1577db1fdbf7705d65b62b99dce4be75c637e26b","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a3d63ef2b853447ec4f749d3f368ce642264246e02911fcb1590d8c161b8005","affectsGlobalScope":true,"impliedFormat":1},{"version":"8cdf8847677ac7d20486e54dd3fcf09eda95812ac8ace44b4418da1bbbab6eb8","affectsGlobalScope":true,"impliedFormat":1},{"version":"8444af78980e3b20b49324f4a16ba35024fef3ee069a0eb67616ea6ca821c47a","affectsGlobalScope":true,"impliedFormat":1},{"version":"3287d9d085fbd618c3971944b65b4be57859f5415f495b33a6adc994edd2f004","affectsGlobalScope":true,"impliedFormat":1},{"version":"b4b67b1a91182421f5df999988c690f14d813b9850b40acd06ed44691f6727ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"df83c2a6c73228b625b0beb6669c7ee2a09c914637e2d35170723ad49c0f5cd4","affectsGlobalScope":true,"impliedFormat":1},{"version":"436aaf437562f276ec2ddbee2f2cdedac7664c1e4c1d2c36839ddd582eeb3d0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e3c06ea092138bf9fa5e874a1fdbc9d54805d074bee1de31b99a11e2fec239d","affectsGlobalScope":true,"impliedFormat":1},{"version":"87dc0f382502f5bbce5129bdc0aea21e19a3abbc19259e0b43ae038a9fc4e326","affectsGlobalScope":true,"impliedFormat":1},{"version":"b1cb28af0c891c8c96b2d6b7be76bd394fddcfdb4709a20ba05a7c1605eea0f9","affectsGlobalScope":true,"impliedFormat":1},{"version":"2fef54945a13095fdb9b84f705f2b5994597640c46afeb2ce78352fab4cb3279","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac77cb3e8c6d3565793eb90a8373ee8033146315a3dbead3bde8db5eaf5e5ec6","affectsGlobalScope":true,"impliedFormat":1},{"version":"56e4ed5aab5f5920980066a9409bfaf53e6d21d3f8d020c17e4de584d29600ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ece9f17b3866cc077099c73f4983bddbcb1dc7ddb943227f1ec070f529dedd1","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a6282c8827e4b9a95f4bf4f5c205673ada31b982f50572d27103df8ceb8013c","affectsGlobalScope":true,"impliedFormat":1},{"version":"1c9319a09485199c1f7b0498f2988d6d2249793ef67edda49d1e584746be9032","affectsGlobalScope":true,"impliedFormat":1},{"version":"e3a2a0cee0f03ffdde24d89660eba2685bfbdeae955a6c67e8c4c9fd28928eeb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811c71eee4aa0ac5f7adf713323a5c41b0cf6c4e17367a34fbce379e12bbf0a4","affectsGlobalScope":true,"impliedFormat":1},{"version":"51ad4c928303041605b4d7ae32e0c1ee387d43a24cd6f1ebf4a2699e1076d4fa","affectsGlobalScope":true,"impliedFormat":1},{"version":"60037901da1a425516449b9a20073aa03386cce92f7a1fd902d7602be3a7c2e9","affectsGlobalScope":true,"impliedFormat":1},{"version":"d4b1d2c51d058fc21ec2629fff7a76249dec2e36e12960ea056e3ef89174080f","affectsGlobalScope":true,"impliedFormat":1},{"version":"22adec94ef7047a6c9d1af3cb96be87a335908bf9ef386ae9fd50eeb37f44c47","affectsGlobalScope":true,"impliedFormat":1},{"version":"196cb558a13d4533a5163286f30b0509ce0210e4b316c56c38d4c0fd2fb38405","affectsGlobalScope":true,"impliedFormat":1},{"version":"73f78680d4c08509933daf80947902f6ff41b6230f94dd002ae372620adb0f60","affectsGlobalScope":true,"impliedFormat":1},{"version":"c5239f5c01bcfa9cd32f37c496cf19c61d69d37e48be9de612b541aac915805b","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e7f8264d0fb4c5339605a15daadb037bf238c10b654bb3eee14208f860a32ea","affectsGlobalScope":true,"impliedFormat":1},{"version":"782dec38049b92d4e85c1585fbea5474a219c6984a35b004963b00beb1aab538","affectsGlobalScope":true,"impliedFormat":1},{"version":"7e29f41b158de217f94cb9676bf9cbd0cd9b5a46e1985141ed36e075c52bf6ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac51dd7d31333793807a6abaa5ae168512b6131bd41d9c5b98477fc3b7800f9f","impliedFormat":1},{"version":"dc0a7f107690ee5cd8afc8dbf05c4df78085471ce16bdd9881642ec738bc81fe","impliedFormat":1},{"version":"97ae124daa3695481c12e517f1aadb524b12fdb1f09d67f9ecc3328c12271487","affectsGlobalScope":true,"impliedFormat":99},"45ccc69e3952ff11e3272e7f09c12d252ec906c8ff99cd484c24fe6ae5350a36","a73084ea238d65aebd63727e1e8cd1af509bf9ea3c6575299284af050ed8d6a1","e1da2a6a5d71424a814355daf9f3f2530771d5881e102c635d149f8b6726de68","8cf239d69bddd9d9e9d29032f5bb6a54f662bac8096250f001eb4658f145f9d4","ab406429bb4d7991588ae26f169dc66ebb717d9d8d8f117d09df501bc6cb5f56","de5658984ccdf982a706b479eb3c39877f1baaa972f7c982d1257e1f4e3c75f0","b3a744c1bb0f52d9b373e1916093a36ce30072a61f5797e6500667611c9fa3d7","1e042301d7c85cc35db0f7d98682144221796beb4b94050f591253a65c33ee5b","60d4df28a436c3dbf10e139ac8c8e8b6f130d7ff487f05597ec498e598c70d5e","e995dd83c00ab5a73d3fc5129bc6c0ad53ec32f61a030e267705f922389564bc","e200866905c7c7a1d18513b8bcb2bfaedad06a295b940a8280296ac61e173fa6","8832ec630b4c37dbdfa5d1f0463046ad4d46a376f889665cfc6eba02fdd1fd6f","8c436f3cc91991b71a20b60243663aa891a02c1ee2ad484bcecdc4572532bab2","0634c958de0c829d13c1bb6d763dc78e81b9047a184bcac59a763d42fc5b5178","ef84f5e7b6d2e425866f0dae04b1b4f9cc515665ec0f08d01989f1f0ada37d7e","d5aa90ea5469752f2e840bc0d6e52ab7487fb96732e1a82a46dcc59655b49e70","aa3f0a9151f277b0840622be103ea4d04db8af2915f42d0c454021c1bd0277bf","f59daf1aac31b3a79564be5db4e326303966c941199fb1bbdeb864f3804e7097","fd3f7aa0731fbda6d2dce4841867f49b50f2234e1d1ac3bf656cadd1dcb99383","5029ab9bd7599dbdb9e01b5c952e070662adf324500a37ffc27f25537739d2a8","cac3f1c0df3aeaaa5fc4b4e20b3c6fc5f3910edbca41bc2cd0cec69160180ab8","826cc50543d683e766ca744033daa998520fcc74e3ef815f4e444523c69d47e9","49e9ce8d1031dc5d1eb3041a951004c17ba04c13131eba2f670c4ef07380c1b1","b8dd6cf91b661cd3a0c8e477eb964d49518703beb01ff4aa08ffb65faefc2491","3579b3c69942d428e699e6099993dc72eb6ecedaca5d4e139136881757df3e17",{"version":"e287d3697c09652c28261c89289514db99006f7175a68a584d75e35ca4263565","signature":"85c1487f913a97c6ffdbafcf19bd1952cf358b8a46c43ee280e427b35d3068d1"},{"version":"f03fd6b8682bd722a2a3f3523df9c7255dc2a7c08b67647b1276784f45f463f4","signature":"f7a19b1c2b04dd0a5efbf9b1ca2f960b5efc7d175227db3bbe0c66412c063faa"},"761da3c4dfa99ec930ddd5a03c3b9c79f193645391c8195ed4cd45bd6aa1fa68","bd6636a9740b7b90622519afa0e1892861295068b51c23ff3dc8a49339b6d468",{"version":"846bebf8fc31c2c0c446ac4f8e3c58fc7676d1dbbc46b1032325409edac26751","signature":"09e2b53403a43b75c1c8b6538a30c70d348d497052ff42013e93b360f78113c7"},"3d529f39440637d7a89da402e3674f7e1773ca873d2d05d45b6c5784cf725bf4",{"version":"b47edbdd485138fdec02e23fe9bd915ed54eeeed46740dcfb8ca94907ef0fe47","signature":"806501754260337295a8bca824c05a97da548feb71f0601ce06020a2c331098a"},{"version":"bc03c3c352f689e38c0ddd50c39b1e65d59273991bfc8858a9e3c0ebb79c023b","impliedFormat":1},{"version":"032a6d45b6d1d730573abb352e355f826f42a412f02f5f37b7183df04c519bf0","impliedFormat":99},"e302a20cc01bd69ae1532934835241eafef574ac1c9a0ec6d0f4685a4ea93084",{"version":"5574d520dabc450de6be799f1791d86d71da4fb236f16e6ca21b953788bb5154","impliedFormat":1},{"version":"5f877dfc985d1fd3ac8bf4a75cd77b06c42ca608809b324c44b4151758de7189","affectsGlobalScope":true,"impliedFormat":1},{"version":"ebec11f90118b8a0d84c7d29ed4d7367af6e066b7f475c452b725b106497411a","affectsGlobalScope":true,"impliedFormat":1},{"version":"14c2fd6220654a41c53836a62ba96d4b515ae1413b0ccb31c2445fb1ae1de5de","affectsGlobalScope":true,"impliedFormat":1},{"version":"4f29c38739500cd35a2ce41d15a35e34445ca755ebb991915b5f170985a49d21","affectsGlobalScope":true,"impliedFormat":1},{"version":"39034b1ab5958e8e52fe7ebe4827b83b0a9ba243ba4c7795fc83aaedbc374949","affectsGlobalScope":true,"impliedFormat":1},{"version":"151ff381ef9ff8da2da9b9663ebf657eac35c4c9a19183420c05728f31a6761d","impliedFormat":1},{"version":"4f6ae308c5f2901f2988c817e1511520619e9025b9b12cc7cce2ab2e6ffed78a","impliedFormat":1},{"version":"916be7d770b0ae0406be9486ac12eb9825f21514961dd050594c4b250617d5a8","impliedFormat":1},{"version":"be1cc4d94ea60cbe567bc29ed479d42587bf1e6cba490f123d329976b0fe4ee5","impliedFormat":1},{"version":"d298f6aca49ec8b97f4e972aae1299d5cd2f72ac566e0f179f168f8130be1ba3","impliedFormat":1},{"version":"15fe687c59d62741b4494d5e623d497d55eb38966ecf5bea7f36e48fc3fbe15e","impliedFormat":1},{"version":"2c3b8be03577c98530ef9cb1a76e2c812636a871f367e9edf4c5f3ce702b77f8","affectsGlobalScope":true,"impliedFormat":1}],"root":[[86,118],121],"options":{"allowJs":true,"declaration":false,"declarationMap":false,"esModuleInterop":true,"inlineSources":false,"jsx":1,"module":99,"noUnusedLocals":false,"noUnusedParameters":false,"skipLibCheck":true,"strict":false,"target":99,"verbatimModuleSyntax":true},"referencedMap":[[123,1],[127,2],[126,3],[119,4],[131,4],[85,5],[134,6],[120,7],[86,4],[93,8],[94,8],[91,9],[95,10],[118,11],[121,12],[110,13],[111,13],[112,13],[113,13],[114,13],[100,14],[99,15],[98,16],[101,17],[106,18],[116,19],[102,20],[103,21],[96,15],[105,15],[104,22],[117,23],[90,24]],"affectedFilesPendingEmit":[93,94,91,95,118,88,89,121,110,111,112,113,114,100,115,99,98,101,106,116,102,107,109,103,96,105,104,92,117,90,97,108],"version":"5.9.3"} \ No newline at end of file