增加其他3种特殊平台模式就,处理下表单下拉组件输入框不绑定选项值识别故障问题

This commit is contained in:
2026-08-04 15:49:45 +08:00
parent bc8ef50bd1
commit c9fb0c5644
9 changed files with 5218 additions and 10 deletions
+1
View File
@@ -127,6 +127,7 @@ src/
- `delay` 函数接收延时等级参数,统一管理延时时长:
- `delay("low")` — 低延时(微等待,如点击后极短暂停)
- `delay("mid")` — 中延时(等待 DOM 更新、弹出层渲染等)
- `delay("midH")` — 中延时偏高(稍微加长版等待 DOM 更新、弹出层渲染等)
- `delay("high")` — 高延时(等待接口返回、搜索结果、动画完成等)
- `delay("max")` — 特殊超长延时(谨慎使用)
- 具体毫秒数只在 `src/utils/delay.ts` 中统一设置,调用方只使用等级名称
+119 -7
View File
@@ -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<Set<string>>(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<string> }) => {
const startScanLoop = (params: { siteMode?: "beisen" | "moka" | "feishu" | "hotjob"; sectionResults?: any[]; expandedResults?: any[]; excludeTexts?: Set<string> }) => {
stopScanLoop()
const hasFullData = !!(params.sectionResults && params.expandedResults)
/**
* 同步构建排除集合:合并 params.excludeTexts + 当前 resumeData 中的所有值
* 避免将已填入表单的简历值(如"汉族"、"广州"等)误认为表单标签
*/
const buildExcludeTexts = (): Set<string> => {
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)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+461 -1
View File
@@ -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<Element>,
_applyHighlight: boolean
): FieldStat[] {
const results: FieldStat[] = []
// TODO: 摩卡单选组扫描逻辑(参考 scanBeisenRadioGroups 实现)
// 等确认摩卡平台单选组的 DOM 结构和类名后实现
return results
}
/**
* 摩卡特殊规则:扫描摩卡平台级联选择器字段
* 通过字段标签关键字(地区、籍贯、民族等)匹配,检测是否存在级联选择器组件
*
* 【待实现】目前为占位函数,等确认摩卡平台实际 DOM 结构后填写具体逻辑
*/
function scanMokaCascadePickers(
_titleEl: Element,
_nextTitleEl: Element | null,
_processedInputs: Set<Element>,
_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<Element>,
_applyHighlight: boolean
): FieldStat[] {
const results: FieldStat[] = []
// TODO: 飞书单选组扫描逻辑(参考 scanBeisenRadioGroups 实现)
return results
}
/**
* 飞书特殊规则:扫描飞书平台级联选择器字段
* 【待实现】目前为占位函数,等确认飞书平台实际 DOM 结构后填写具体逻辑
*/
function scanFeishuCascadePickers(
_titleEl: Element,
_nextTitleEl: Element | null,
_processedInputs: Set<Element>,
_applyHighlight: boolean
): FieldStat[] {
const results: FieldStat[] = []
// TODO: 飞书级联选择器扫描逻辑(参考 scanBeisenCascadePickers 实现)
return results
}
// ====================================================================
// 二-D、Hotjobhotjob.cn)特殊网站规则区
// ====================================================================
/**
* Hotjobhotjob.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<Element>,
_applyHighlight: boolean
): FieldStat[] {
const results: FieldStat[] = []
// TODO: Hotjob单选组扫描逻辑(参考 scanBeisenRadioGroups 实现)
return results
}
/**
* Hotjob特殊规则:扫描Hotjob平台级联选择器字段
* 【待实现】目前为占位函数,等确认Hotjob平台实际 DOM 结构后填写具体逻辑
*/
function scanHotjobCascadePickers(
_titleEl: Element,
_nextTitleEl: Element | null,
_processedInputs: Set<Element>,
_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>
): 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<string>
*
* 【注意】此方法会主动点击 input 触发弹出层,只应在填写完成后调用一次,不可在循环中反复调用
*/
export async function collectPickerOptionTexts(fieldStats: TitleStat[]): Promise<Set<string>> {
const optionTexts = new Set<string>()
// 从已识别字段中提取需要处理的 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<Element>()
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<string>, 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()
}
}
+35
View File
@@ -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<string>
*/
export function buildResumeExcludeTexts(resumeData: ResumeData | null | undefined): Set<string> {
const set = new Set<string>()
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<string, any>)) {
if (typeof val === "string" && val.trim()) set.add(val.trim())
}
}
}
return set
}
+2 -1
View File
@@ -4,12 +4,13 @@
*/
/** 延时等级 */
export type DelayLevel = "low" | "mid" | "high" | "max"
export type DelayLevel = "low" | "mid" | "midH" | "high" | "max"
/** 各等级对应的毫秒数(统一在此处调整) */
const DELAY_MS: Record<DelayLevel, number> = {
low: 10, // 原<100ms 场景:微等待,点击后极短暂停
mid: 30, // 原100~300ms 场景:等待 DOM 更新、弹出层渲染
midH: 35, // 原100~300ms 场景:等待 DOM 更新、弹出层渲染
high: 500, // 原>300ms 场景:等待接口返回、搜索结果、动画完成
max: 2000, // >2000ms 场景:特殊超长延时(谨慎使用)
}
+1 -1
View File
File diff suppressed because one or more lines are too long