重复填写添加经历段数校正、文档规范hook、调整填写速度

This commit is contained in:
2026-07-30 21:08:31 +08:00
parent 66bca74267
commit 8b996772ea
6 changed files with 161 additions and 15 deletions
+90 -1
View File
@@ -17,6 +17,7 @@
import { EXPERIENCE_SECTION_CONFIGS, JOB_FORM_LABELS } from "./constants"
import { delay } from "~utils/delay"
import { snapshotElementsInRange, diffSnapshots, findTopLevelNewElements } from "./dom"
import { findNearestInput } from "./formMatcher"
import type { ExperienceSection, ExperienceSectionConfig, ResumeData } from "./types"
/** 大标题排除关键词:包含这些文字的标签不作为大标题(它们是子标题/说明文字) */
@@ -534,6 +535,82 @@ function buildLocator(container: Element, parent: Element, nthIndex: number, tit
return { ancestor1, ancestor2, pathFromAncestor1, nthIndex, sectionTitleText: titleText }
}
/**
* 【段数校正】通过核心字段标签的重复出现次数,统计指定经历区块内已有的经历段数
*
* 原理:
* 每段经历必然包含一个核心字段(如教育经历的"学校名称"、工作经历的"公司名称")。
* 在大标题范围内统计核心字段标签出现的次数(且标签后面紧跟有效 input),
* 即可得知当前已展开了几段经历。
*
* 用途:
* 在 expandExperienceSections 进入添加循环前,用此方法校正 expandedCount
* 避免重复填写场景下因 expandedCount 初始化为1而多添加空白段。
*
* 安全退化:
* 如果核心字段标签在范围内一个都没匹配到,返回 0,
* 调用方用 Math.max 保留原值,行为和修复前一致。
*
* @param titleEl - 经历大标题元素(范围起始)
* @param nextTitleEl - 下一个大标题元素(范围结束,null 表示到页面底部)
* @param config - 当前经历类型的配置(含 coreFieldKey
* @param lang - 页面语言
* @returns 检测到的已有段数(0 表示未检测到核心字段)
*/
function countExistingSegments(
titleEl: Element,
nextTitleEl: Element | null,
config: ExperienceSectionConfig,
lang: "zh" | "en"
): number {
// 从 JOB_FORM_LABELS 中找到 coreFieldKey 对应的标签配置
const coreLabel = JOB_FORM_LABELS.find((item) => item.key === config.coreFieldKey)
if (!coreLabel) return 0
const keywords = lang === "zh" ? coreLabel.zh : coreLabel.en
// 按长度倒序,优先匹配更精确的标签(如"学校名称"优先于"学校"
const sortedKeywords = [...keywords].sort((a, b) => b.length - a.length)
// 在范围内遍历所有候选标签元素,统计核心字段标签出现次数
const candidateSelector = "label, span, div, td, th, p, legend, dt"
const allCandidates = document.body.querySelectorAll(candidateSelector)
let count = 0
for (const el of Array.from(allCandidates)) {
// 范围限定:必须在 titleEl 之后
if (!(titleEl.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING)) continue
// 范围限定:必须在 nextTitleEl 之前
if (nextTitleEl && !(nextTitleEl.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_PRECEDING)) continue
// 只用元素自身的直接文本匹配(避免匹配到包含多层子元素的大容器)
const directText = Array.from(el.childNodes)
.filter((node) => node.nodeType === Node.TEXT_NODE)
.map((node) => node.textContent?.trim())
.filter(Boolean)
.join("")
if (!directText) continue
// 匹配核心字段关键词(文字包含关键词且长度接近,防止误匹配长文本)
const matched = sortedKeywords.find(
(keyword) => directText.includes(keyword) && directText.length < keyword.length + 20
)
if (!matched) continue
// 【关键验证】标签后面必须紧跟有效 input,否则不计数(过滤说明性文字)
const inputEl = findNearestInput(el)
if (!inputEl) continue
// 验证找到的 input 也在范围内(防止跨区块匹配)
if (!(titleEl.compareDocumentPosition(inputEl) & Node.DOCUMENT_POSITION_FOLLOWING)) continue
if (nextTitleEl && !(nextTitleEl.compareDocumentPosition(inputEl) & Node.DOCUMENT_POSITION_PRECEDING)) continue
count++
}
return count
}
/**
* 检测默认第1段经历的边界
* 在大标题到下一个大标题之间,找到第一个 input,然后向上找段落容器
@@ -1059,6 +1136,19 @@ export async function expandExperienceSections(
for (const result of locateResults) {
if (!result.titleElement) continue
// 【段数校正】通过核心字段标签重复次数校正 expandedCount,防止重复填写时多添加空白段
const config = EXPERIENCE_SECTION_CONFIGS.find((c) => c.section === result.section)
if (config) {
const allPageTitles = getPageTitles(locateResults, refSignature, firstLocated.titleElement)
const idx = allPageTitles.findIndex((pt) => pt.element === result.titleElement)
const nextTitleEl = idx >= 0 && idx < allPageTitles.length - 1 ? allPageTitles[idx + 1].element : null
const detectedCount = countExistingSegments(result.titleElement, nextTitleEl, config, lang)
if (detectedCount > result.expandedCount) {
console.log(` [${result.section}] 段数校正: expandedCount ${result.expandedCount}${detectedCount}(通过核心字段标签计数)`)
result.expandedCount = detectedCount
}
}
const resumeCount = (resumeData[result.section] as unknown[])?.length || 0
if (resumeCount <= result.expandedCount) {
console.log(` [${result.section}] 简历${resumeCount}段 ≤ 页面${result.expandedCount}段,无需添加`)
@@ -1068,7 +1158,6 @@ export async function expandExperienceSections(
const needAdd = resumeCount - result.expandedCount
console.log(` [${result.section}] 简历${resumeCount}段 > 页面${result.expandedCount}段,需添加 ${needAdd}`)
const config = EXPERIENCE_SECTION_CONFIGS.find((c) => c.section === result.section)
if (!config) continue
/**
+1 -1
View File
@@ -61,7 +61,7 @@ const INPUT_SEL = "input:not([type='hidden']):not([type='submit']):not([type='bu
* 在标签元素附近查找最近的、属于同一表单项的 input/textarea 输入框
* 策略:1.表单项容器内查找 → 2.逐级向上查找 → 3.兜底查兄弟节点
*/
function findNearestInput(labelEl: Element): HTMLInputElement | HTMLTextAreaElement | null {
export function findNearestInput(labelEl: Element): HTMLInputElement | HTMLTextAreaElement | null {
// 策略1:找标签所在的最近表单项容器
for (const sel of FORM_ITEM_SELECTORS) {
const container = labelEl.closest(sel)
+2 -2
View File
@@ -8,8 +8,8 @@ export type DelayLevel = "low" | "mid" | "high" | "max"
/** 各等级对应的毫秒数(统一在此处调整) */
const DELAY_MS: Record<DelayLevel, number> = {
low: 50, // <100ms 场景:微等待,点击后极短暂停
mid: 200, // 100~300ms 场景:等待 DOM 更新、弹出层渲染
low: 10, // <100ms 场景:微等待,点击后极短暂停
mid: 50, // 100~300ms 场景:等待 DOM 更新、弹出层渲染
high: 500, // >300ms 场景:等待接口返回、搜索结果、动画完成
max: 2000, // >2000ms 场景:特殊超长延时(谨慎使用)
}