AI接口联调
This commit is contained in:
@@ -14,3 +14,15 @@ export function healthCheck() {
|
|||||||
export function getCustomizeResume(jobId: number) {
|
export function getCustomizeResume(jobId: number) {
|
||||||
return http.get<ResumeData>('/job/customize-resume', { params: { job_id: String(jobId) } })
|
return http.get<ResumeData>('/job/customize-resume', { params: { job_id: String(jobId) } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 表单AI生成答案 */
|
||||||
|
export function getFormFillAnswer(params: { jobId?: number; label: string; reference?: string; type: string }) {
|
||||||
|
return http.post<string>('/browser-plug/form-fill-answer', {
|
||||||
|
body: {
|
||||||
|
jobId: params.jobId || 0,
|
||||||
|
label: params.label,
|
||||||
|
reference: params.reference || "",
|
||||||
|
type: params.type,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import { getCookieValue } from "~utils/cookie"
|
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 { fillMatchedField, delay, isSearchPickerField, fillSearchPickerField, isTimePeriodField, isTimeSingleField, fillTimePeriodField, fillTimeSingleField } from "~lib/autofill"
|
||||||
import { extractDomStructure, detectPageLanguage, isJobApplicationForm } from "~lib/dom"
|
import { extractDomStructure, detectPageLanguage, isJobApplicationForm } from "~lib/dom"
|
||||||
import { matchFormFields, matchFormFieldsInRange, matchMainFields } from "~lib/formMatcher"
|
import { matchFormFields, matchFormFieldsInRange, matchMainFields } from "~lib/formMatcher"
|
||||||
@@ -15,7 +15,7 @@ import { detectAndUploadResume } from "~lib/resumeUpload"
|
|||||||
import { getMockResumeData2 } from "~lib/constants"
|
import { getMockResumeData2 } from "~lib/constants"
|
||||||
import { getResumeFieldValue } from "~lib/resumeDataHelper"
|
import { getResumeFieldValue } from "~lib/resumeDataHelper"
|
||||||
import { locateExperienceSections, expandExperienceSections, sortExperienceByTime, relocateSegmentContainer } from "~lib/experienceSection"
|
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"
|
import "./SidebarPanel.scss"
|
||||||
|
|
||||||
/** 侧边栏面板的 Props */
|
/** 侧边栏面板的 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}段 =====`)
|
console.log(`===== OfferPie: 已加载简历数据,教育${currentResumeData.education.length}段 工作${currentResumeData.work.length}段 实习${currentResumeData.internship.length}段 项目${currentResumeData.project.length}段 竞赛${currentResumeData.competition.length}段 =====`)
|
||||||
|
|
||||||
// 4.1 检测并上传简历文件
|
// 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)
|
const uploaded = await detectAndUploadResume(resumeUrl)
|
||||||
console.log(`===== OfferPie: 简历上传 ${uploaded ? "成功" : "跳过(未找到上传按钮或失败)"} =====`)
|
console.log(`===== OfferPie: 简历上传 ${uploaded ? "成功" : "跳过(未找到上传按钮或失败)"} =====`)
|
||||||
if (uploaded) await delay(1000) // 等待网站解析简历
|
if (uploaded) await delay(1000) // 等待网站解析简历
|
||||||
@@ -109,6 +109,32 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
|
|||||||
// 4.6 对比简历数据段数,点击添加按钮补足不够的段数
|
// 4.6 对比简历数据段数,点击添加按钮补足不够的段数
|
||||||
const expandedResults = await expandExperienceSections(sectionResults, currentResumeData, lang)
|
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)
|
// 5. 经历数据按时间排序 + 经历区域字段匹配与填写(阶段A)
|
||||||
console.log("===== OfferPie: 阶段A - 经历区域填写 =====")
|
console.log("===== OfferPie: 阶段A - 经历区域填写 =====")
|
||||||
const usedInputs = new Set<Element>() // 全局已使用的 input 集合
|
const usedInputs = new Set<Element>() // 全局已使用的 input 集合
|
||||||
@@ -117,6 +143,19 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
|
|||||||
let lastTextInput: HTMLInputElement | HTMLTextAreaElement | null = null
|
let lastTextInput: HTMLInputElement | HTMLTextAreaElement | null = null
|
||||||
let lastTextInputIsPicker = false
|
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) {
|
for (const result of expandedResults) {
|
||||||
if (!result.titleElement || result.expandedCount === 0) continue
|
if (!result.titleElement || result.expandedCount === 0) continue
|
||||||
const section = result.section as ExperienceSection
|
const section = result.section as ExperienceSection
|
||||||
@@ -286,6 +325,8 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
|
|||||||
}
|
}
|
||||||
console.log(`===== OfferPie: 阶段A完成 成功${success} 失败${failed} 跳过${skipped} =====`)
|
console.log(`===== OfferPie: 阶段A完成 成功${success} 失败${failed} 跳过${skipped} =====`)
|
||||||
|
|
||||||
|
} // end of else (skipPhaseA)
|
||||||
|
|
||||||
// 6. 非经历区域字段匹配与填写(阶段B)
|
// 6. 非经历区域字段匹配与填写(阶段B)
|
||||||
console.log("===== OfferPie: 阶段B - 非经历区域填写 =====")
|
console.log("===== OfferPie: 阶段B - 非经历区域填写 =====")
|
||||||
const mainFields = matchMainFields(document.body, lang, excludeRanges, usedInputs)
|
const mainFields = matchMainFields(document.body, lang, excludeRanges, usedInputs)
|
||||||
@@ -294,6 +335,14 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
|
|||||||
for (const f of mainFields) {
|
for (const f of mainFields) {
|
||||||
const value = getResumeFieldValue(currentResumeData, f.section, f.sectionIndex, f.resumeField)
|
const value = getResumeFieldValue(currentResumeData, f.section, f.sectionIndex, f.resumeField)
|
||||||
if (value) f.fillValue = value
|
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 }
|
if (!f.fillValue) { skipped++; continue }
|
||||||
|
|
||||||
await detectPickerField(f, lang)
|
await detectPickerField(f, lang)
|
||||||
@@ -328,6 +377,162 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
|
|||||||
|
|
||||||
setFormFields([...mainFields])
|
setFormFields([...mainFields])
|
||||||
console.log(`===== OfferPie: 阶段B完成 总计成功${success} 失败${failed} 跳过${skipped} =====`)
|
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 {
|
} else {
|
||||||
setFormFields([])
|
setFormFields([])
|
||||||
console.log("===== OfferPie: 当前页面不是职位申请表单,跳过字段匹配 =====")
|
console.log("===== OfferPie: 当前页面不是职位申请表单,跳过字段匹配 =====")
|
||||||
|
|||||||
@@ -252,3 +252,23 @@ export interface MatchedFormField {
|
|||||||
/** 要填入的值 */
|
/** 要填入的值 */
|
||||||
fillValue: string
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user