增加其他3种特殊平台模式就,处理下表单下拉组件输入框不绑定选项值识别故障问题
This commit is contained in:
@@ -127,6 +127,7 @@ src/
|
||||
- `delay` 函数接收延时等级参数,统一管理延时时长:
|
||||
- `delay("low")` — 低延时(微等待,如点击后极短暂停)
|
||||
- `delay("mid")` — 中延时(等待 DOM 更新、弹出层渲染等)
|
||||
- `delay("midH")` — 中延时偏高(稍微加长版等待 DOM 更新、弹出层渲染等)
|
||||
- `delay("high")` — 高延时(等待接口返回、搜索结果、动画完成等)
|
||||
- `delay("max")` — 特殊超长延时(谨慎使用)
|
||||
- 具体毫秒数只在 `src/utils/delay.ts` 中统一设置,调用方只使用等级名称
|
||||
|
||||
@@ -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,13 +497,26 @@ 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 })
|
||||
: isMoka
|
||||
? await handleAutoFillMoka({ resumeData, jobInfo })
|
||||
: isFeishu
|
||||
? await handleAutoFillFeishu({ resumeData, jobInfo })
|
||||
: isHotjob
|
||||
? await handleAutoFillHotjob({ resumeData, jobInfo })
|
||||
: await handleAutoFillCommon({ resumeData, jobInfo })
|
||||
|
||||
// 将处理器返回的结果同步到组件状态
|
||||
@@ -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
@@ -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、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<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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 场景:特殊超长延时(谨慎使用)
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user