优化业务逻辑和基础库的大标题区间和字段的识别精度

This commit is contained in:
2026-07-31 16:39:04 +08:00
parent 8b996772ea
commit 0c8193d230
8 changed files with 210 additions and 183 deletions
+66 -2
View File
@@ -128,7 +128,7 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
}
/** 开启循环扫描(每秒执行一次) */
const startScanLoop = (params: { siteMode?: "beisen"; sectionResults?: any[]; expandedResults?: any[] }) => {
const startScanLoop = (params: { siteMode?: "beisen"; sectionResults?: any[]; expandedResults?: any[]; excludeTexts?: Set<string> }) => {
stopScanLoop()
const hasFullData = !!(params.sectionResults && params.expandedResults)
// 立即执行一次
@@ -459,10 +459,33 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
hideFillingOverlay() // 填写完成,移除遮罩
// 停止第一次循环扫描,开启第二次循环扫描(传入 sectionResults + expandedResults,分段精确)
// 构建排除集合:从简历数据中提取所有值,避免将已填值误认为标签
const scanExcludeTexts = new Set<string>()
const scanResumeData = fillResult.resumeData || resumeData
if (scanResumeData) {
const main = scanResumeData.main
if (main) {
for (const val of Object.values(main)) {
if (typeof val === "string" && val.trim()) scanExcludeTexts.add(val.trim())
if (Array.isArray(val)) val.forEach((v) => { if (typeof v === "string" && v.trim()) scanExcludeTexts.add(v.trim()) })
}
}
const expKeys: ("education" | "work" | "internship" | "project" | "competition")[] = ["education", "work", "internship", "project", "competition"]
for (const sec of expKeys) {
const items = scanResumeData[sec]
if (!Array.isArray(items)) continue
for (const item of items) {
for (const val of Object.values(item as Record<string, any>)) {
if (typeof val === "string" && val.trim()) scanExcludeTexts.add(val.trim())
}
}
}
}
startScanLoop({
siteMode: scanSiteMode,
sectionResults: fillResult.sectionResults,
expandedResults: fillResult.expandedResults,
excludeTexts: scanExcludeTexts,
})
} catch (e) {
@@ -487,8 +510,49 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
const currentHost = window.location.hostname
const siteMode = currentHost.includes("zhiye.com") ? "beisen" as const : undefined
// 从简历数据和缓存数据中提取所有已填值,构建排除集合
// 避免 findLabelForInput 把这些值文字误认为是表单字段标签
const excludeTexts = new Set<string>()
if (resumeData) {
// 主表字段值
const main = resumeData.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()) })
}
}
// 5大经历字段值
const expSections: ("education" | "work" | "internship" | "project" | "competition")[] = ["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<string, any>)) {
if (typeof val === "string" && val.trim()) excludeTexts.add(val.trim())
}
}
}
}
// 缓存中已记住的表单字段值
if (cacheData.unfilledFormData) {
for (const sec of cacheData.unfilledFormData as any[]) {
const items = sec.formItems
if (!items) continue
if (sec.isExperience && Array.isArray(items)) {
for (const seg of items) {
if (Array.isArray(seg)) {
for (const f of seg) { if (f.value && typeof f.value === "string") excludeTexts.add(f.value.trim()) }
}
}
} else if (Array.isArray(items)) {
for (const f of items) { if (f.value && typeof f.value === "string") excludeTexts.add(f.value.trim()) }
}
}
}
// 调用 fillStats 扫描页面所有字段
const titleStats = scanPageFields({ siteMode })
const titleStats = scanPageFields({ siteMode, excludeTexts })
// 提取非简历格式字段(B2阶段字段)
const nonResumeData = extractNonResumeFields(titleStats)
+39 -2
View File
@@ -881,6 +881,28 @@ export async function handleAutoFillBeisen(params: AutoFillBeisenParams): Promis
const allTitles = getAllPageTitles(sectionResults)
const FIVE_EXP_SECTIONS_JSON = new Set(["education", "work", "internship", "project", "competition"])
// 从简历数据中提取所有值,构建排除集合(避免将已填值误认为标签)
const excludeTexts = new Set<string>()
if (currentResumeData) {
const main = currentResumeData.main
if (main) {
for (const val of Object.values(main)) {
if (typeof val === "string" && val.trim()) excludeTexts.add(val.trim())
if (Array.isArray(val)) val.forEach((v) => { if (typeof v === "string" && v.trim()) excludeTexts.add(v.trim()) })
}
}
const expKeys: ("education" | "work" | "internship" | "project" | "competition")[] = ["education", "work", "internship", "project", "competition"]
for (const sec of expKeys) {
const items = currentResumeData[sec]
if (!Array.isArray(items)) continue
for (const item of items) {
for (const val of Object.values(item as Record<string, any>)) {
if (typeof val === "string" && val.trim()) excludeTexts.add(val.trim())
}
}
}
}
/** 判断某个字段是否属于简历数据格式(JOB_FORM_LABELS 里 resumeField 非空的) */
const resumeFormatInputs = new Set<Element>()
// 阶段A和B的所有 input 都是简历格式字段
@@ -944,6 +966,17 @@ export async function handleAutoFillBeisen(params: AutoFillBeisenParams): Promis
type FieldItem = { label: string; value: string; inputEl: Element }
const collectedFields: FieldItem[] = []
// 构建当前大标题范围内所有已填 input 的值集合
// 用于排除误将已填值(如"香港理工大学"、"硕士"、"2026"等)当作标签的情况
const filledInputValues = new Set<string>()
for (const inp of Array.from(allInputs)) {
const afterT = !!(titleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING)
const beforeN = !nextTitleEl || !!(nextTitleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_PRECEDING)
if (!afterT || !beforeN) continue
const val = (inp as HTMLInputElement | HTMLTextAreaElement).value?.trim()
if (val) filledInputValues.add(val)
}
for (const inp of Array.from(allInputs)) {
// 范围检查
const afterTitle = !!(titleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING)
@@ -962,8 +995,12 @@ export async function handleAutoFillBeisen(params: AutoFillBeisenParams): Promis
// 查找标签(使用统一封装的 labelFinder
const titleElementSet = new Set<Element>(allTitles.map((t) => t.element))
const { labelText: detectedLabel } = findLabelForInput(inp, titleElementSet)
const labelText = detectedLabel
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元素集合)精确跳过
+39 -2
View File
@@ -865,6 +865,28 @@ export async function handleAutoFillCommon(params: AutoFillCommonParams): Promis
const allTitles = getAllPageTitles(sectionResults)
const FIVE_EXP_SECTIONS_JSON = new Set(["education", "work", "internship", "project", "competition"])
// 从简历数据中提取所有值,构建排除集合(避免将已填值误认为标签)
const excludeTexts = new Set<string>()
if (currentResumeData) {
const main = currentResumeData.main
if (main) {
for (const val of Object.values(main)) {
if (typeof val === "string" && val.trim()) excludeTexts.add(val.trim())
if (Array.isArray(val)) val.forEach((v) => { if (typeof v === "string" && v.trim()) excludeTexts.add(v.trim()) })
}
}
const expKeys: ("education" | "work" | "internship" | "project" | "competition")[] = ["education", "work", "internship", "project", "competition"]
for (const sec of expKeys) {
const items = currentResumeData[sec]
if (!Array.isArray(items)) continue
for (const item of items) {
for (const val of Object.values(item as Record<string, any>)) {
if (typeof val === "string" && val.trim()) excludeTexts.add(val.trim())
}
}
}
}
/** 判断某个字段是否属于简历数据格式(JOB_FORM_LABELS 里 resumeField 非空的) */
const resumeFormatInputs = new Set<Element>()
// 阶段A和B的所有 input 都是简历格式字段
@@ -928,6 +950,17 @@ export async function handleAutoFillCommon(params: AutoFillCommonParams): Promis
type FieldItem = { label: string; value: string; inputEl: Element }
const collectedFields: FieldItem[] = []
// 构建当前大标题范围内所有已填 input 的值集合
// 用于排除误将已填值(如"香港理工大学"、"硕士"、"2026"等)当作标签的情况
const filledInputValues = new Set<string>()
for (const inp of Array.from(allInputs)) {
const afterT = !!(titleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING)
const beforeN = !nextTitleEl || !!(nextTitleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_PRECEDING)
if (!afterT || !beforeN) continue
const val = (inp as HTMLInputElement | HTMLTextAreaElement).value?.trim()
if (val) filledInputValues.add(val)
}
for (const inp of Array.from(allInputs)) {
// 范围检查
const afterTitle = !!(titleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING)
@@ -946,8 +979,12 @@ export async function handleAutoFillCommon(params: AutoFillCommonParams): Promis
// 查找标签(使用统一封装的 labelFinder
const titleElementSet = new Set<Element>(allTitles.map((t) => t.element))
const { labelText: detectedLabel } = findLabelForInput(inp, titleElementSet)
const labelText = detectedLabel
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元素集合)精确跳过
+1 -1
View File
@@ -21,7 +21,7 @@ import { findNearestInput } from "./formMatcher"
import type { ExperienceSection, ExperienceSectionConfig, ResumeData } from "./types"
/** 大标题排除关键词:包含这些文字的标签不作为大标题(它们是子标题/说明文字) */
const TITLE_EXCLUDE_KEYWORDS = ["高中教育经历", "本科教育经历", "本科及以上教育经历", "填写高中", "填写本科"]
const TITLE_EXCLUDE_KEYWORDS = ["高中教育经历", "本科教育经历", "本科及以上教育经历", "填写高中", "填写本科", "必填", "选填"]
/** 基本信息大标题关键词(精确全称匹配,用于分步表单页面识别) */
const BASIC_INFO_TITLE_KEYWORDS = ["个人信息", "基本信息", "基础信息", "个人基本信息"]
+30 -2
View File
@@ -97,6 +97,8 @@ export interface ScanPageFieldsParams {
siteMode?: "beisen"
/** 是否设置高亮背景色(默认false,统计模式下可设为true) */
applyHighlight?: boolean
/** 需要排除的文字集合(简历数据/缓存中的已填值,传给 findLabelForInput 避免将已填值误认为标签) */
excludeTexts?: Set<string>
}
// ====================================================================
@@ -405,6 +407,7 @@ export function scanPageFields(params: ScanPageFieldsParams): TitleStat[] {
unmatchedFields,
siteMode,
applyHighlight = false,
excludeTexts,
} = params
// 如果没传 sectionResults,自动检测语言并重新定位大标题
@@ -520,6 +523,18 @@ export function scanPageFields(params: ScanPageFieldsParams): TitleStat[] {
// 【补扫阶段D】在此标题范围内查找未被任何阶段处理的 input
const allInputsInRange = document.body.querySelectorAll(INPUT_SEL_STAT)
// 构建当前大标题范围内所有已填 input 的值集合
// 用于排除误将已填值(如"香港理工大学"、"硕士"、"2026"等)当作标签的情况
const filledInputValuesInRange = new Set<string>()
for (const inp of Array.from(allInputsInRange)) {
const afterT = titleEl === inp || !!(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) filledInputValuesInRange.add(val)
}
for (const inp of Array.from(allInputsInRange)) {
if (processedInputs.has(inp)) continue
const afterTitle = titleEl === inp || !!(titleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING)
@@ -528,7 +543,14 @@ export function scanPageFields(params: ScanPageFieldsParams): TitleStat[] {
const inputEl = inp as HTMLInputElement | HTMLTextAreaElement
const alreadyHasValue = !!(inputEl.value && inputEl.value.trim().length > 0)
const { labelText, labelElement } = findLabelForInput(inp, titleElementSet)
const { labelText, labelElement } = findLabelForInput(inp, titleElementSet, excludeTexts)
// 跳过无效标签:纯数字、或检测到的"标签"实际是某个已填 input 的值
const trimmedLabel = labelText?.trim() || ""
if (!trimmedLabel || /^\d+$/.test(trimmedLabel) || filledInputValuesInRange.has(trimmedLabel)) {
processedInputs.add(inp)
continue
}
// 根据背景色判断来源和颜色
let color: FieldStatColor
@@ -750,7 +772,13 @@ export function extractNonResumeFields(titleStats: TitleStat[]): {
for (const ts of titleStats) {
// 只取非简历格式字段
const nonResumeFields = ts.fields.filter((f) => !f.isResumeFormat)
const nonResumeFields = ts.fields.filter((f) => {
if (f.isResumeFormat) return false
// 跳过无效标签:纯数字、或(未知字段)
const label = f.labelText?.trim()
if (!label || /^\d+$/.test(label) || label === "(未知字段)") return false
return true
})
if (nonResumeFields.length === 0) continue
if (ts.isExpType && ts.segmentCount > 0) {
+35 -7
View File
@@ -12,7 +12,10 @@
// ============ 常量 ============
/** 排除的标签文字(不视为有效标签) */
const EXCLUDE_LABEL_TEXTS = ["请输入", "输入", ":", "", "请选择", "选择", "*", "必填", "确定", "取消", "搜索", "无准确的毕业时间可填写预计毕业时间"]
const EXCLUDE_LABEL_TEXTS = ["请输入", "输入", ":", "", "请选择", "选择", "添加", "*", "必填", "确定", "取消", "搜索", "无准确的毕业时间可填写预计毕业时间","暂无选项"]
/** 排除的标签文字片段(包含这些片段的文本也不视为有效标签) */
const EXCLUDE_LABEL_INCLUDES = ["请输入","请选择"]
/** 表单项容器选择器集合(用于第一遍策略的 closest 查找) */
const FORM_ITEM_SELS = [
@@ -37,6 +40,8 @@ export function isValidLabelText(text: string): boolean {
if (!chineseChars || chineseChars.length < 2) return false
// 排除已知无意义标签
if (EXCLUDE_LABEL_TEXTS.some((ex) => trimmed === ex)) return false
// 排除包含特定片段的文字
if (EXCLUDE_LABEL_INCLUDES.some((inc) => trimmed.includes(inc))) return false
return true
}
@@ -52,9 +57,9 @@ export function isValidLabelText(text: string): boolean {
* - 碰到包含 input 的非祖先元素(前一个字段区域)
* - 最多走 80 个节点
*
* 过滤条件:跳过不含至少2个汉字的文本
* 过滤条件:跳过不含至少2个汉字的文本,跳过 excludeTexts 中的文本
*/
function walkBackwardForLabel(inp: Element, titleElements?: Set<Element>): { text: string; element: Element } | null {
function walkBackwardForLabel(inp: Element, titleElements?: Set<Element>, excludeTexts?: Set<string>): { text: string; element: Element } | null {
const INPUT_TAG_SET = new Set(["INPUT", "TEXTAREA", "SELECT"])
let maxSteps = 80
let current: Node | null = inp
@@ -86,6 +91,8 @@ function walkBackwardForLabel(inp: Element, titleElements?: Set<Element>): { tex
if (current.nodeType === Node.TEXT_NODE) {
const text = current.textContent?.trim() || ""
if (isValidLabelText(text)) {
// 跳过已填值(简历数据/缓存数据中的值不应作为标签)
if (excludeTexts && excludeTexts.has(text)) continue
const parentEl = current.parentElement
if (parentEl) {
return { text, element: parentEl }
@@ -104,6 +111,7 @@ function walkBackwardForLabel(inp: Element, titleElements?: Set<Element>): { tex
*
* @param inp - 输入框元素
* @param titleElements - 大标题元素集合(用于第二遍策略的停止边界,可选)
* @param excludeTexts - 需要排除的文字集合(简历数据/缓存中的已填值,遇到时跳过继续找,可选)
* @returns { labelText, labelElement } 标签文字和标签元素
*
* 策略流程:
@@ -114,7 +122,7 @@ function walkBackwardForLabel(inp: Element, titleElements?: Set<Element>): { tex
* 5. 第二遍:如果以上都未得到含≥2个汉字的标签,启动 walkBackwardForLabel 逆向遍历
* 6. 都找不到则返回 placeholder 或 "(未知字段)"
*/
export function findLabelForInput(inp: Element, titleElements?: Set<Element>): { labelText: string; labelElement: Element | null } {
export function findLabelForInput(inp: Element, titleElements?: Set<Element>, excludeTexts?: Set<string>): { labelText: string; labelElement: Element | null } {
let labelText = ""
let labelElement: Element | null = null
let container: Element | null = null
@@ -137,6 +145,8 @@ export function findLabelForInput(inp: Element, titleElements?: Set<Element>): {
.filter(Boolean)
.join("")
if (dt && dt.length <= 30 && !EXCLUDE_LABEL_TEXTS.some((ex) => dt === ex)) {
if (EXCLUDE_LABEL_INCLUDES.some((inc) => dt.includes(inc))) continue
if (excludeTexts && excludeTexts.has(dt)) continue
if (el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) {
hasLabelText = true
break
@@ -158,6 +168,8 @@ export function findLabelForInput(inp: Element, titleElements?: Set<Element>): {
.filter(Boolean)
.join("")
if (dt && dt.length <= 30 && !EXCLUDE_LABEL_TEXTS.some((ex) => dt === ex)) {
if (EXCLUDE_LABEL_INCLUDES.some((inc) => dt.includes(inc))) continue
if (excludeTexts && excludeTexts.has(dt)) continue
if (el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) {
found = true
break
@@ -184,6 +196,9 @@ export function findLabelForInput(inp: Element, titleElements?: Set<Element>): {
.join("")
if (!directText || directText.length > 30) continue
if (EXCLUDE_LABEL_TEXTS.some((ex) => directText === ex)) continue
if (EXCLUDE_LABEL_INCLUDES.some((inc) => directText.includes(inc))) continue
// 跳过已填值(简历数据/缓存数据中的值不应作为标签)
if (excludeTexts && excludeTexts.has(directText)) continue
if (el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) {
labelText = directText
labelElement = el
@@ -197,7 +212,12 @@ export function findLabelForInput(inp: Element, titleElements?: Set<Element>): {
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)) {
if (text && text.length <= 20 && !EXCLUDE_LABEL_TEXTS.some((ex) => text === ex) && !EXCLUDE_LABEL_INCLUDES.some((inc) => text.includes(inc))) {
// 跳过已填值
if (excludeTexts && excludeTexts.has(text)) {
prev = prev.previousElementSibling
continue
}
labelText = text
labelElement = prev
break
@@ -208,7 +228,7 @@ export function findLabelForInput(inp: Element, titleElements?: Set<Element>): {
// 【第二遍策略】如果第一遍未识别出有效标签(空或不含2个汉字),逆向遍历 DOM
if (!labelText || !isValidLabelText(labelText)) {
const foundLabel = walkBackwardForLabel(inp, titleElements)
const foundLabel = walkBackwardForLabel(inp, titleElements, excludeTexts)
if (foundLabel) {
labelText = foundLabel.text
labelElement = foundLabel.element
@@ -216,6 +236,14 @@ export function findLabelForInput(inp: Element, titleElements?: Set<Element>): {
}
// 最终 fallback:都找不到才用 placeholder 或返回"(未知字段)"
if (!labelText) labelText = (inp as HTMLInputElement).getAttribute("placeholder") || "(未知字段)"
if (!labelText) {
const placeholder = (inp as HTMLInputElement).getAttribute("placeholder") || ""
// placeholder 如果包含排除片段(如"请输入"、"请选择"),不作为标签
if (placeholder && !EXCLUDE_LABEL_INCLUDES.some((inc) => placeholder.includes(inc)) && !EXCLUDE_LABEL_TEXTS.some((ex) => placeholder === ex)) {
labelText = placeholder
} else {
labelText = "(未知字段)"
}
}
return { labelText, labelElement }
}