From f49bd5f93cf006c9badfa85e593f38924668f9ff Mon Sep 17 00:00:00 2001 From: xuxin <15279969124@163.com> Date: Fri, 15 May 2026 19:01:13 +0800 Subject: [PATCH] =?UTF-8?q?AI=E6=8E=A5=E5=8F=A3=E8=81=94=E8=B0=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/aiApi.ts | 12 ++ src/components/SidebarPanel.tsx | 211 +++++++++++++++++++++++++++++++- src/lib/types.ts | 20 +++ 3 files changed, 240 insertions(+), 3 deletions(-) diff --git a/src/api/aiApi.ts b/src/api/aiApi.ts index 48597f8..3c557e9 100644 --- a/src/api/aiApi.ts +++ b/src/api/aiApi.ts @@ -14,3 +14,15 @@ export function healthCheck() { export function getCustomizeResume(jobId: number) { return http.get('/job/customize-resume', { params: { job_id: String(jobId) } }) } + +/** 表单AI生成答案 */ +export function getFormFillAnswer(params: { jobId?: number; label: string; reference?: string; type: string }) { + return http.post('/browser-plug/form-fill-answer', { + body: { + jobId: params.jobId || 0, + label: params.label, + reference: params.reference || "", + type: params.type, + }, + }) +} diff --git a/src/components/SidebarPanel.tsx b/src/components/SidebarPanel.tsx index 8e6cdbe..73e8775 100644 --- a/src/components/SidebarPanel.tsx +++ b/src/components/SidebarPanel.tsx @@ -6,7 +6,7 @@ import { useState, useEffect } from "react" import { getCookieValue } from "~utils/cookie" -import { getCustomizeResume } from "~api/aiApi" +import { getCustomizeResume, getFormFillAnswer } from "~api/aiApi" import { fillMatchedField, delay, isSearchPickerField, fillSearchPickerField, isTimePeriodField, isTimeSingleField, fillTimePeriodField, fillTimeSingleField } from "~lib/autofill" import { extractDomStructure, detectPageLanguage, isJobApplicationForm } from "~lib/dom" import { matchFormFields, matchFormFieldsInRange, matchMainFields } from "~lib/formMatcher" @@ -15,7 +15,7 @@ import { detectAndUploadResume } from "~lib/resumeUpload" import { getMockResumeData2 } from "~lib/constants" import { getResumeFieldValue } from "~lib/resumeDataHelper" import { locateExperienceSections, expandExperienceSections, sortExperienceByTime, relocateSegmentContainer } from "~lib/experienceSection" -import type { MatchedFormField, ResumeData, ExperienceSection, JobInfo } from "~lib/types" +import type { MatchedFormField, ResumeData, ExperienceSection, JobInfo, UnmatchedFormField } from "~lib/types" import "./SidebarPanel.scss" /** 侧边栏面板的 Props */ @@ -99,7 +99,7 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps) 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/%E4%BA%8E%E5%A4%A7%E6%98%A5.pdf" + const resumeUrl = "https://offerpie.oss-cn-guangzhou.aliyuncs.com/%E6%B4%AA%E8%B5%AB%E2%80%94%E6%95%B0%E6%8D%AE%E3%80%81AI%E7%AE%97%E6%B3%95%E5%B7%A5%E7%A8%8B%E5%B8%88.pdf" const uploaded = await detectAndUploadResume(resumeUrl) console.log(`===== OfferPie: 简历上传 ${uploaded ? "成功" : "跳过(未找到上传按钮或失败)"} =====`) if (uploaded) await delay(1000) // 等待网站解析简历 @@ -109,6 +109,32 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps) // 4.6 对比简历数据段数,点击添加按钮补足不够的段数 const expandedResults = await expandExperienceSections(sectionResults, currentResumeData, lang) + // 4.7 检测第一段经历是否已被网站自动填写(上传简历后网站可能自动解析填入) + let skipPhaseA = false + for (const result of expandedResults) { + if (!result.titleElement || result.expandedCount === 0) continue + const section = result.section as ExperienceSection + const segments = result.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 集合 @@ -117,6 +143,19 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps) let lastTextInput: HTMLInputElement | HTMLTextAreaElement | null = null let lastTextInputIsPicker = false + // 如果网站已自动填写经历,跳过阶段A,只记录排除范围 + if (skipPhaseA) { + console.log("===== OfferPie: 阶段A 已跳过(网站已自动填写经历) =====") + for (const result of expandedResults) { + if (!result.titleElement) continue + const titleEl = result.titleElement + const allTitles = expandedResults.filter((r) => r.titleElement) + const currentIdx = allTitles.findIndex((r) => r.titleElement === titleEl) + const nextResult = currentIdx >= 0 && currentIdx < allTitles.length - 1 ? allTitles[currentIdx + 1] : null + excludeRanges.push({ start: titleEl, end: nextResult?.titleElement || null }) + } + } else { + for (const result of expandedResults) { if (!result.titleElement || result.expandedCount === 0) continue const section = result.section as ExperienceSection @@ -286,6 +325,8 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps) } console.log(`===== OfferPie: 阶段A完成 成功${success} 失败${failed} 跳过${skipped} =====`) + } // end of else (skipPhaseA) + // 6. 非经历区域字段匹配与填写(阶段B) console.log("===== OfferPie: 阶段B - 非经历区域填写 =====") const mainFields = matchMainFields(document.body, lang, excludeRanges, usedInputs) @@ -294,6 +335,14 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps) 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()}",跳过`) + skipped++ + continue + } + if (!f.fillValue) { skipped++; continue } await detectPickerField(f, lang) @@ -328,6 +377,162 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps) setFormFields([...mainFields]) console.log(`===== OfferPie: 阶段B完成 总计成功${success} 失败${failed} 跳过${skipped} =====`) + + // 7. 阶段C - 收集剩余空白输入框,调用AI接口填写 + console.log("===== OfferPie: 阶段C - AI辅助填写剩余空白字段 =====") + + // 需要过滤的标签文字(这些不是有效标签) + 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])" + ) + const unmatchedFields: UnmatchedFormField[] = [] + + for (const inp of Array.from(allInputsOnPage)) { + const inputEl = inp as HTMLInputElement | HTMLTextAreaElement + // 跳过已使用的 input + if (usedInputs.has(inp)) continue + // 跳过已有值的 input + 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 + // 确保标签在 input 之前 + 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" + } + + unmatchedFields.push({ + labelText, + labelElement, + inputElement: inputEl, + radioContainer: null, + formType, + isPicker: formType === "select", + fillValue: "", + alreadyFilled: false, + }) + usedInputs.add(inp) + } + + console.log(` 收集到 ${unmatchedFields.length} 个待AI填写的空白字段`) + + // 逐个调用AI接口获取填充值并填写 + if (unmatchedFields.length > 0) { + for (const uf of unmatchedFields) { + try { + const aiAnswer = await getFormFillAnswer({ + jobId: jobInfo?.id || 0, + label: uf.labelText, + type: uf.formType, + }) + if (aiAnswer) { + uf.fillValue = String(aiAnswer) + console.log(` [AI] "${uf.labelText}" → "${uf.fillValue}" (type: ${uf.formType})`) + + // 填写到页面 + if (uf.inputElement) { + if (uf.isPicker) { + // 选择器类型:构造 MatchedFormField 走选择器填写逻辑 + const pickerField: MatchedFormField = { + key: "", section: "main", resumeField: "", sectionIndex: 0, + labelText: uf.labelText, labelElement: uf.labelElement, + labelSelector: "", inputElement: uf.inputElement, + inputSelector: "", buttonElement: null, buttonSelector: "", + inputType: "picker", radioContainer: null, + isPicker: true, pickerDropdownElement: null, + pickerDropdownSelector: "", fillValue: uf.fillValue, + } + await detectPickerField(pickerField, lang) + const ok = await fillMatchedField(pickerField) + if (ok) success++; else failed++ + } else { + // 普通输入框:直接写入 + const { forceSetValue } = await import("~lib/autofill") + uf.inputElement.focus() + await delay(50) + forceSetValue(uf.inputElement, uf.fillValue) + success++ + console.log(` [AI] ✅ 已填写 "${uf.labelText}" = "${uf.fillValue}"`) + } + } + } else { + console.log(` [AI] "${uf.labelText}" 接口未返回填充值,跳过`) + skipped++ + } + } catch (err) { + console.warn(` [AI] "${uf.labelText}" 接口调用失败:`, err) + failed++ + } + await delay(300) + } + } + + console.log(`===== OfferPie: 阶段C完成 总计成功${success} 失败${failed} 跳过${skipped} =====`) } else { setFormFields([]) console.log("===== OfferPie: 当前页面不是职位申请表单,跳过字段匹配 =====") diff --git a/src/lib/types.ts b/src/lib/types.ts index e0369a6..5e012af 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -252,3 +252,23 @@ export interface MatchedFormField { /** 要填入的值 */ fillValue: string } + +/** 未匹配到标签的空白表单字段(待AI接口处理) */ +export interface UnmatchedFormField { + /** 标签文字 */ + labelText: string + /** 标签元素 */ + labelElement: Element + /** 输入框元素 */ + inputElement: HTMLInputElement | HTMLTextAreaElement | null + /** 单选按钮容器 */ + radioContainer: Element | null + /** 表单类型:input/textarea/select/radio/checkbox */ + formType: "input" | "textarea" | "select" | "radio" | "checkbox" + /** 填充值类型(isPicker 为 true 时为 select) */ + isPicker: boolean + /** AI 返回的填充值 */ + fillValue: string + /** 是否已被网站自动填过 */ + alreadyFilled: boolean +}