diff --git a/src/handlers/handleAutoFillBeisen.ts b/src/handlers/handleAutoFillBeisen.ts index 75b463b..c174fc0 100644 --- a/src/handlers/handleAutoFillBeisen.ts +++ b/src/handlers/handleAutoFillBeisen.ts @@ -483,53 +483,8 @@ export async function handleAutoFillBeisen(params: AutoFillBeisenParams): Promis } 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 - 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 - } - } + // 使用统一的 findLabelForInput 查找标签文字(纯 DOM 逆向遍历,不依赖类名) + const { labelText, labelElement } = findLabelForInput(inp) if (!labelText || !labelElement) continue @@ -620,12 +575,6 @@ export async function handleAutoFillBeisen(params: AutoFillBeisenParams): Promis // 常量 const STAT_EXCLUDE_LABEL_TEXTS = ["请输入", "输入", ":", ":", "请选择", "选择", "*", "必填"] const INPUT_SEL_STAT = "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 FORM_ITEM_SELS = [ - ".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']", - ] /** 5大经历类型的 section 名集合(用于判断是否能从 expandedResults 取段落信息) */ const FIVE_EXP_SECTIONS = new Set(["education", "work", "internship", "project", "competition"]) @@ -696,49 +645,6 @@ export async function handleAutoFillBeisen(params: AutoFillBeisenParams): Promis return { segmentCount: maxCount, fieldSegMap } } - /** - * 查找标签文字的辅助函数 - */ - function findLabelForInput(inp: Element): { labelText: string; labelElement: Element | null } { - let labelText = "" - let labelElement: Element | null = null - let container: Element | null = null - for (const sel of FORM_ITEM_SELS) { - 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 (STAT_EXCLUDE_LABEL_TEXTS.some((ex) => directText === ex)) continue - if (el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) { - labelText = directText - labelElement = el - break - } - } - } - 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 && !STAT_EXCLUDE_LABEL_TEXTS.some((ex) => text === ex)) { - labelText = text - labelElement = prev - break - } - prev = prev.previousElementSibling - } - } - if (!labelText) labelText = (inp as HTMLInputElement).getAttribute("placeholder") || "(未知字段)" - return { labelText, labelElement } - } // 按大标题分组统计 for (let tIdx = 0; tIdx < allTitles.length; tIdx++) { diff --git a/src/handlers/handleAutoFillCommon.ts b/src/handlers/handleAutoFillCommon.ts index 3bb3ae7..50929fd 100644 --- a/src/handlers/handleAutoFillCommon.ts +++ b/src/handlers/handleAutoFillCommon.ts @@ -467,53 +467,8 @@ export async function handleAutoFillCommon(params: AutoFillCommonParams): Promis } 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 - 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 - } - } + // 使用统一的 findLabelForInput 查找标签文字(纯 DOM 逆向遍历,不依赖类名) + const { labelText, labelElement } = findLabelForInput(inp) if (!labelText || !labelElement) continue @@ -604,12 +559,6 @@ export async function handleAutoFillCommon(params: AutoFillCommonParams): Promis // 常量 const STAT_EXCLUDE_LABEL_TEXTS = ["请输入", "输入", ":", ":", "请选择", "选择", "*", "必填"] const INPUT_SEL_STAT = "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 FORM_ITEM_SELS = [ - ".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']", - ] /** 5大经历类型的 section 名集合(用于判断是否能从 expandedResults 取段落信息) */ const FIVE_EXP_SECTIONS = new Set(["education", "work", "internship", "project", "competition"]) @@ -680,49 +629,6 @@ export async function handleAutoFillCommon(params: AutoFillCommonParams): Promis return { segmentCount: maxCount, fieldSegMap } } - /** - * 查找标签文字的辅助函数 - */ - function findLabelForInput(inp: Element): { labelText: string; labelElement: Element | null } { - let labelText = "" - let labelElement: Element | null = null - let container: Element | null = null - for (const sel of FORM_ITEM_SELS) { - 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 (STAT_EXCLUDE_LABEL_TEXTS.some((ex) => directText === ex)) continue - if (el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) { - labelText = directText - labelElement = el - break - } - } - } - 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 && !STAT_EXCLUDE_LABEL_TEXTS.some((ex) => text === ex)) { - labelText = text - labelElement = prev - break - } - prev = prev.previousElementSibling - } - } - if (!labelText) labelText = (inp as HTMLInputElement).getAttribute("placeholder") || "(未知字段)" - return { labelText, labelElement } - } // 按大标题分组统计 for (let tIdx = 0; tIdx < allTitles.length; tIdx++) { diff --git a/src/handlers/handleAutoFillFeishu.ts b/src/handlers/handleAutoFillFeishu.ts index ba4df3d..483d4f0 100644 --- a/src/handlers/handleAutoFillFeishu.ts +++ b/src/handlers/handleAutoFillFeishu.ts @@ -473,53 +473,8 @@ export async function handleAutoFillFeishu(params: AutoFillFeishuParams): Promis } 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 - 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 - } - } + // 使用统一的 findLabelForInput 查找标签文字(纯 DOM 逆向遍历,不依赖类名) + const { labelText, labelElement } = findLabelForInput(inp) if (!labelText || !labelElement) continue @@ -610,12 +565,6 @@ export async function handleAutoFillFeishu(params: AutoFillFeishuParams): Promis // 常量 const STAT_EXCLUDE_LABEL_TEXTS = ["请输入", "输入", ":", ":", "请选择", "选择", "*", "必填"] const INPUT_SEL_STAT = "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 FORM_ITEM_SELS = [ - ".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']", - ] /** 5大经历类型的 section 名集合(用于判断是否能从 expandedResults 取段落信息) */ const FIVE_EXP_SECTIONS = new Set(["education", "work", "internship", "project", "competition"]) @@ -686,49 +635,6 @@ export async function handleAutoFillFeishu(params: AutoFillFeishuParams): Promis return { segmentCount: maxCount, fieldSegMap } } - /** - * 查找标签文字的辅助函数 - */ - function findLabelForInput(inp: Element): { labelText: string; labelElement: Element | null } { - let labelText = "" - let labelElement: Element | null = null - let container: Element | null = null - for (const sel of FORM_ITEM_SELS) { - 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 (STAT_EXCLUDE_LABEL_TEXTS.some((ex) => directText === ex)) continue - if (el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) { - labelText = directText - labelElement = el - break - } - } - } - 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 && !STAT_EXCLUDE_LABEL_TEXTS.some((ex) => text === ex)) { - labelText = text - labelElement = prev - break - } - prev = prev.previousElementSibling - } - } - if (!labelText) labelText = (inp as HTMLInputElement).getAttribute("placeholder") || "(未知字段)" - return { labelText, labelElement } - } // 按大标题分组统计 for (let tIdx = 0; tIdx < allTitles.length; tIdx++) { diff --git a/src/handlers/handleAutoFillHotjob.ts b/src/handlers/handleAutoFillHotjob.ts index 6d7786b..642dfb1 100644 --- a/src/handlers/handleAutoFillHotjob.ts +++ b/src/handlers/handleAutoFillHotjob.ts @@ -473,53 +473,8 @@ export async function handleAutoFillHotjob(params: AutoFillHotjobParams): Promis } 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 - 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 - } - } + // 使用统一的 findLabelForInput 查找标签文字(纯 DOM 逆向遍历,不依赖类名) + const { labelText, labelElement } = findLabelForInput(inp) if (!labelText || !labelElement) continue @@ -610,12 +565,6 @@ export async function handleAutoFillHotjob(params: AutoFillHotjobParams): Promis // 常量 const STAT_EXCLUDE_LABEL_TEXTS = ["请输入", "输入", ":", ":", "请选择", "选择", "*", "必填"] const INPUT_SEL_STAT = "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 FORM_ITEM_SELS = [ - ".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']", - ] /** 5大经历类型的 section 名集合(用于判断是否能从 expandedResults 取段落信息) */ const FIVE_EXP_SECTIONS = new Set(["education", "work", "internship", "project", "competition"]) @@ -686,49 +635,6 @@ export async function handleAutoFillHotjob(params: AutoFillHotjobParams): Promis return { segmentCount: maxCount, fieldSegMap } } - /** - * 查找标签文字的辅助函数 - */ - function findLabelForInput(inp: Element): { labelText: string; labelElement: Element | null } { - let labelText = "" - let labelElement: Element | null = null - let container: Element | null = null - for (const sel of FORM_ITEM_SELS) { - 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 (STAT_EXCLUDE_LABEL_TEXTS.some((ex) => directText === ex)) continue - if (el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) { - labelText = directText - labelElement = el - break - } - } - } - 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 && !STAT_EXCLUDE_LABEL_TEXTS.some((ex) => text === ex)) { - labelText = text - labelElement = prev - break - } - prev = prev.previousElementSibling - } - } - if (!labelText) labelText = (inp as HTMLInputElement).getAttribute("placeholder") || "(未知字段)" - return { labelText, labelElement } - } // 按大标题分组统计 for (let tIdx = 0; tIdx < allTitles.length; tIdx++) { diff --git a/src/handlers/handleAutoFillMoka.ts b/src/handlers/handleAutoFillMoka.ts index 31fc76d..65fd2bb 100644 --- a/src/handlers/handleAutoFillMoka.ts +++ b/src/handlers/handleAutoFillMoka.ts @@ -492,53 +492,8 @@ export async function handleAutoFillMoka(params: AutoFillMokaParams): Promise 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 - 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 - } - } + // 使用统一的 findLabelForInput 查找标签文字(纯 DOM 逆向遍历,不依赖类名) + const { labelText, labelElement } = findLabelForInput(inp) if (!labelText || !labelElement) continue @@ -629,12 +584,6 @@ export async function handleAutoFillMoka(params: AutoFillMokaParams): Promise n.nodeType === Node.TEXT_NODE) - .map((n) => n.textContent?.trim()) - .filter(Boolean) - .join("") - if (!directText || directText.length > 30) continue - if (STAT_EXCLUDE_LABEL_TEXTS.some((ex) => directText === ex)) continue - if (el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) { - labelText = directText - labelElement = el - break - } - } - } - 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 && !STAT_EXCLUDE_LABEL_TEXTS.some((ex) => text === ex)) { - labelText = text - labelElement = prev - break - } - prev = prev.previousElementSibling - } - } - if (!labelText) labelText = (inp as HTMLInputElement).getAttribute("placeholder") || "(未知字段)" - return { labelText, labelElement } - } // 按大标题分组统计 for (let tIdx = 0; tIdx < allTitles.length; tIdx++) { diff --git a/src/lib/formMatcher.ts b/src/lib/formMatcher.ts index 8e84f7e..72fca71 100644 --- a/src/lib/formMatcher.ts +++ b/src/lib/formMatcher.ts @@ -45,33 +45,16 @@ function fixSpecialLabelInput( // ============ input 查找 ============ -/** 常见的表单项容器选择器 */ -const FORM_ITEM_SELECTORS = [ - ".form-item", ".form-group", ".form-field", - ".el-form-item", ".ant-form-item", ".ant-row", - ".arco-form-item", ".t-form-item", ".n-form-item", - ".ivu-form-item", ".v-input", ".MuiFormControl-root", - "[class*='form-item']", "[class*='form-group']", "[class*='formItem']", -] - /** input 选择器(排除隐藏、提交、按钮、复选、单选、文件类型) */ const INPUT_SEL = "input:not([type='hidden']):not([type='submit']):not([type='button']):not([type='checkbox']):not([type='radio']):not([type='file']):not([disabled]), textarea:not([disabled])" /** * 在标签元素附近查找最近的、属于同一表单项的 input/textarea 输入框 - * 策略:1.表单项容器内查找 → 2.逐级向上查找 → 3.兜底查兄弟节点 + * 策略:1.逐级向上查找含单个 input 的父级 → 2.含多个 input 时取 DOM 顺序最近的 → 3.兜底查兄弟节点 + * 不依赖任何 form-item 类名,纯 DOM 结构适配所有网站 */ export function findNearestInput(labelEl: Element): HTMLInputElement | HTMLTextAreaElement | null { - // 策略1:找标签所在的最近表单项容器 - for (const sel of FORM_ITEM_SELECTORS) { - const container = labelEl.closest(sel) - if (container) { - const input = container.querySelector(INPUT_SEL) as HTMLInputElement | HTMLTextAreaElement | null - if (input) return input - } - } - - // 策略2:逐级向上查找 + // 策略1:逐级向上查找 let parent: Element | null = labelEl.parentElement for (let i = 0; i < 5 && parent; i++) { const inputs = parent.querySelectorAll(INPUT_SEL) @@ -80,7 +63,7 @@ export function findNearestInput(labelEl: Element): HTMLInputElement | HTMLTextA parent = parent.parentElement } - // 策略3:兜底查兄弟节点 + // 策略2:兜底查兄弟节点 let sibling = labelEl.nextElementSibling while (sibling) { if ((sibling.tagName === "INPUT" || sibling.tagName === "TEXTAREA") && @@ -175,12 +158,14 @@ export function matchFormFields( if (matchLabel) { // 检测单选按钮组 + // 检测单选按钮组:从标签元素逐级向上查找含 radio 的容器 let isRadioType = false let radioContainer: Element | null = null - const closestFormItem = candidateEl.closest(".form-item, .form-group, .el-form-item, .ant-form-item") - if (closestFormItem) { - const radioInItem = closestFormItem.querySelector('input[type="radio"]') - if (radioInItem) { isRadioType = true; radioContainer = closestFormItem } + let radioSearchParent: Element | null = candidateEl.parentElement + for (let rUp = 0; rUp < 4 && radioSearchParent; rUp++) { + const radioInItem = radioSearchParent.querySelector('input[type="radio"]') + if (radioInItem) { isRadioType = true; radioContainer = radioSearchParent; break } + radioSearchParent = radioSearchParent.parentElement } // 非 radio 类型,正常找 input @@ -313,12 +298,14 @@ export function matchFormFieldsInRange( if (matchLabel) { // 检测单选按钮组 + // 检测单选按钮组:从标签元素逐级向上查找含 radio 的容器 let isRadioType = false let radioContainer: Element | null = null - const closestFormItem = candidateEl.closest(".form-item, .form-group, .el-form-item, .ant-form-item") - if (closestFormItem) { - const radioInItem = closestFormItem.querySelector('input[type="radio"]') - if (radioInItem) { isRadioType = true; radioContainer = closestFormItem } + let radioSearchParent: Element | null = candidateEl.parentElement + for (let rUp = 0; rUp < 4 && radioSearchParent; rUp++) { + const radioInItem = radioSearchParent.querySelector('input[type="radio"]') + if (radioInItem) { isRadioType = true; radioContainer = radioSearchParent; break } + radioSearchParent = radioSearchParent.parentElement } // 非 radio 类型,正常找 input @@ -420,25 +407,28 @@ export function matchMainFields( ) if (matchLabel) { + // 先尝试找 input(优先级高于 radio 检测,避免深层 DOM 结构误判) + let inputEl: HTMLInputElement | HTMLTextAreaElement | null = findNearestInput(candidateEl) + if (inputEl) inputEl = fixSpecialLabelInput(item.key, inputEl, candidateEl, usedInputs) + if (inputEl && usedInputs.has(inputEl)) continue + + // 找不到 input 时,再检测单选按钮组:从标签元素逐级向上查找含 radio 的容器 let isRadioType = false let radioContainer: Element | null = null - const closestFormItem = candidateEl.closest(".form-item, .form-group, .el-form-item, .ant-form-item") - if (closestFormItem) { - const radioInItem = closestFormItem.querySelector('input[type="radio"]') - if (radioInItem) { isRadioType = true; radioContainer = closestFormItem } + if (!inputEl) { + let radioSearchParent: Element | null = candidateEl.parentElement + for (let rUp = 0; rUp < 4 && radioSearchParent; rUp++) { + const radioInItem = radioSearchParent.querySelector('input[type="radio"]') + if (radioInItem) { isRadioType = true; radioContainer = radioSearchParent; break } + radioSearchParent = radioSearchParent.parentElement + } } - let inputEl: HTMLInputElement | HTMLTextAreaElement | null = null let inputType: "text" | "radio" | "picker" | "textarea" = isRadioType ? "radio" : "text" - if (!isRadioType) { - inputEl = findNearestInput(candidateEl) - if (inputEl) inputEl = fixSpecialLabelInput(item.key, inputEl, candidateEl, usedInputs) - if (inputEl && usedInputs.has(inputEl)) continue - if (inputEl) { - usedInputs.add(inputEl) - if (inputEl.tagName === "TEXTAREA") inputType = "textarea" - } + if (inputEl) { + usedInputs.add(inputEl) + if (inputEl.tagName === "TEXTAREA") inputType = "textarea" } const btnEl = inputEl ? findNearbyButton(inputEl) : null diff --git a/src/lib/labelFinder.ts b/src/lib/labelFinder.ts index 33f004f..0d93872 100644 --- a/src/lib/labelFinder.ts +++ b/src/lib/labelFinder.ts @@ -1,29 +1,19 @@ /** * 表单字段标签定位模块(统一封装) * 负责:根据 input/textarea 元素在 DOM 中定位其对应的标签文字 - * - * 两遍策略: - * 第一遍:通过 form-item 容器选择器定位 → 逐级向上修复 → 在容器内找标签 - * 第二遍:如果第一遍失败,沿 DOM 文档流逆向遍历找到有效中文标签 - * + * + * 核心策略:沿 DOM 文档流逆向遍历,从 input 往前找到第一个有效中文标签 + * * 所有需要"识别输入框前面的标签名字"的地方统一调用此模块 */ // ============ 常量 ============ /** 排除的标签文字(不视为有效标签) */ -const EXCLUDE_LABEL_TEXTS = ["请输入", "输入", ":", ":", "请选择", "选择", "添加", "*", "必填", "确定", "取消", "搜索", "无准确的毕业时间可填写预计毕业时间","暂无选项"] +const EXCLUDE_LABEL_TEXTS = ["请输入", "输入", ":", ":", "请选择", "选择", "添加", "*", "必填", "确定", "取消", "搜索", "无准确的毕业时间可填写预计毕业时间", "暂无选项"] /** 排除的标签文字片段(包含这些片段的文本也不视为有效标签) */ -const EXCLUDE_LABEL_INCLUDES = ["请输入","请选择"] - -/** 表单项容器选择器集合(用于第一遍策略的 closest 查找) */ -const FORM_ITEM_SELS = [ - ".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']", -] +const EXCLUDE_LABEL_INCLUDES = ["请输入", "请选择"] // ============ 工具方法 ============ @@ -35,28 +25,25 @@ export function isValidLabelText(text: string): boolean { const trimmed = text.trim() if (!trimmed) return false if (trimmed.length > 30) return false - // 必须包含至少2个汉字 const chineseChars = trimmed.match(/[\u4e00-\u9fff]/g) 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 } -// ============ 第二遍策略:DOM 逆向遍历 ============ +// ============ DOM 逆向遍历 ============ /** - * 【第二遍策略】沿 DOM 文档流往前遍历,查找 input 对应的标签文字 + * 沿 DOM 文档流往前遍历,查找 input 对应的标签文字 * 从 input 出发逆向走,逐个检查文本节点 - * + * * 停止条件: * - 碰到另一个 input/textarea/select 元素 * - 碰到大标题元素(titleElements 集合中的元素) * - 碰到包含 input 的非祖先元素(前一个字段区域) * - 最多走 80 个节点 - * + * * 过滤条件:跳过不含至少2个汉字的文本,跳过 excludeTexts 中的文本 */ function walkBackwardForLabel(inp: Element, titleElements?: Set, excludeTexts?: Set): { text: string; element: Element } | null { @@ -108,142 +95,26 @@ function walkBackwardForLabel(inp: Element, titleElements?: Set, exclud /** * 查找 input/textarea 元素对应的标签文字 - * + * * @param inp - 输入框元素 - * @param titleElements - 大标题元素集合(用于第二遍策略的停止边界,可选) + * @param titleElements - 大标题元素集合(用于逆向遍历的停止边界,可选) * @param excludeTexts - 需要排除的文字集合(简历数据/缓存中的已填值,遇到时跳过继续找,可选) * @returns { labelText, labelElement } 标签文字和标签元素 - * - * 策略流程: - * 1. closest(FORM_ITEM_SELS) 找容器 - * 2. 验证容器内是否同时有标签和输入框,不满足则逐级向上找(最多4层) - * 3. 在容器内 querySelectorAll 找 input 前面的第一个有效文字元素 - * 4. fallback: previousElementSibling 查找 - * 5. 第二遍:如果以上都未得到含≥2个汉字的标签,启动 walkBackwardForLabel 逆向遍历 - * 6. 都找不到则返回 placeholder 或 "(未知字段)" + * + * 策略:纯 DOM 逆向遍历,从 input 往前走找到第一个有效中文标签文本节点 + * 不依赖任何 form-item 类名容器,适配所有网站 */ export function findLabelForInput(inp: Element, titleElements?: Set, excludeTexts?: Set): { labelText: string; labelElement: Element | null } { - let labelText = "" - let labelElement: Element | null = null - let container: Element | null = null - for (const sel of FORM_ITEM_SELS) { - container = inp.closest(sel) - if (container) break - } - - // 【验证】container 应同时包含"标签文字"和"输入框",否则匹配层级太浅 - if (container) { - const hasInput = container.querySelector("input:not([type='hidden']):not([type='submit']):not([type='button']):not([type='checkbox']):not([type='radio']):not([type='file']), textarea") - let hasLabelText = false - if (hasInput) { - const candidates = container.querySelectorAll("label, span, div, td, th, p, legend, dt") - for (const el of Array.from(candidates)) { - if (el === inp || el.contains(inp)) continue - const dt = Array.from(el.childNodes) - .filter((n) => n.nodeType === Node.TEXT_NODE) - .map((n) => n.textContent?.trim()) - .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 - } - } - } - } - // 逐级向上找,最多 4 层 - if (!hasLabelText) { - let parent = container.parentElement - for (let up = 0; up < 4 && parent; up++) { - const parentCandidates = parent.querySelectorAll("label, span, div, td, th, p, legend, dt") - let found = false - for (const el of Array.from(parentCandidates)) { - if (el === inp || el.contains(inp)) continue - const dt = Array.from(el.childNodes) - .filter((n) => n.nodeType === Node.TEXT_NODE) - .map((n) => n.textContent?.trim()) - .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 - } - } - } - if (found) { - container = parent - break - } - parent = parent.parentElement - } - } - } - - // 在容器内找标签 - 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 - 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 - break - } - } - } - - // fallback: previousElementSibling - 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) && !EXCLUDE_LABEL_INCLUDES.some((inc) => text.includes(inc))) { - // 跳过已填值 - if (excludeTexts && excludeTexts.has(text)) { - prev = prev.previousElementSibling - continue - } - labelText = text - labelElement = prev - break - } - prev = prev.previousElementSibling - } - } - - // 【第二遍策略】如果第一遍未识别出有效标签(空或不含2个汉字),逆向遍历 DOM - if (!labelText || !isValidLabelText(labelText)) { - const foundLabel = walkBackwardForLabel(inp, titleElements, excludeTexts) - if (foundLabel) { - labelText = foundLabel.text - labelElement = foundLabel.element - } + // 直接走 DOM 逆向遍历,从 input 往前找最近的有效中文标签 + const foundLabel = walkBackwardForLabel(inp, titleElements, excludeTexts) + if (foundLabel) { + return { labelText: foundLabel.text, labelElement: foundLabel.element } } // 最终 fallback:都找不到才用 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 = "(未知字段)" - } + const placeholder = (inp as HTMLInputElement).getAttribute("placeholder") || "" + if (placeholder && !EXCLUDE_LABEL_INCLUDES.some((inc) => placeholder.includes(inc)) && !EXCLUDE_LABEL_TEXTS.some((ex) => placeholder === ex)) { + return { labelText: placeholder, labelElement: null } } - return { labelText, labelElement } + return { labelText: "(未知字段)", labelElement: null } }