diff --git a/.kiro/hooks/check-steering-sync.kiro.hook b/.kiro/hooks/check-steering-sync.kiro.hook new file mode 100644 index 0000000..0f7ed96 --- /dev/null +++ b/.kiro/hooks/check-steering-sync.kiro.hook @@ -0,0 +1,16 @@ +{ + "enabled": true, + "name": "Steering 文档同步提醒", + "description": "当 src/lib 目录下的文件被编辑时,提醒检查 .kiro/steering/project-guidelines.md 中的项目结构和规范说明是否需要同步更新", + "version": "1", + "when": { + "type": "fileEdited", + "patterns": [ + "src/lib/*.ts" + ] + }, + "then": { + "type": "askAgent", + "prompt": "刚才修改了 lib 文件,请检查 .kiro/steering/project-guidelines.md 中的\"项目结构\"和相关规范章节是否需要同步更新(比如新增了方法、修改了核心逻辑、新增了文件等)。如果需要更新,提醒用户但不要自动修改。" + } +} \ No newline at end of file diff --git a/.kiro/hooks/protect-critical-marks.kiro.hook b/.kiro/hooks/protect-critical-marks.kiro.hook new file mode 100644 index 0000000..57d7a71 --- /dev/null +++ b/.kiro/hooks/protect-critical-marks.kiro.hook @@ -0,0 +1,16 @@ +{ + "enabled": true, + "name": "保护【注意】核心标记", + "description": "当 write 操作涉及 src/lib 文件时,检查是否有【注意】或【核心逻辑】标记被删除,防止核心逻辑被误删", + "version": "1", + "when": { + "type": "preToolUse", + "toolTypes": [ + "write" + ] + }, + "then": { + "type": "askAgent", + "prompt": "即将对文件执行写入操作。如果目标文件在 src/lib/ 目录下,请检查本次写入是否会删除任何包含【注意】或【核心逻辑】或【核心】标记的注释行。如果会删除这些标记,必须停止操作并告知用户。这些标记保护的是经过大量调试验证的核心逻辑,不可擅自移除。" + } +} \ No newline at end of file diff --git a/.kiro/steering/project-guidelines.md b/.kiro/steering/project-guidelines.md index e9b3364..9dbeda3 100644 --- a/.kiro/steering/project-guidelines.md +++ b/.kiro/steering/project-guidelines.md @@ -13,27 +13,44 @@ inclusion: always ``` src/ +├── api/ +│ ├── aiApi.ts # AI后端接口(Python后端,简历优化/AI生成等) +│ ├── dataApi.ts # 数据后端接口(Java后端,简历CRUD/岗位/会员等) +│ └── request.ts # 请求封装(axios实例、拦截器、Token注入) ├── background/ -│ └── index.ts # 插件后台脚本(Service Worker) +│ └── index.ts # 插件后台脚本(Service Worker) ├── components/ -│ ├── SidebarPanel.tsx # 侧边栏面板组件(主操作界面,自动填写入口) +│ ├── SidebarPanel.tsx # 侧边栏面板组件(主操作界面,自动填写入口,字段进度展示) │ └── SidebarPanel.scss # 侧边栏样式 ├── contents/ │ └── sidebar.tsx # Content Script 入口(注入侧边栏到页面) +├── handlers/ +│ ├── handleAutoFillCommon.ts # 通用模式自动填写(不依赖任何CSS类名,纯DOM差异对比适配所有网站) +│ └── handleAutoFillBeisen.ts # 北森模式自动填写(针对北森Phoenix UI的特殊处理,含级联选择器等) ├── lib/ -│ ├── types.ts # 所有共享类型定义(简历接口、表单标签、匹配字段、UI组件库配置) -│ ├── constants.ts # 常量数据(JOB_FORM_LABELS标签数组、TEST_FILL_DATA测试数据、UI_LIB_PICKER_CONFIGS组件库配置、SPECIAL_LABEL_PLACEHOLDERS特殊标签修正配置) -│ ├── dom.ts # DOM工具(extractDomStructure提取DOM树、detectPageLanguage语言检测、isJobApplicationForm表单页判断、buildSelector选择器生成) -│ ├── formMatcher.ts # 表单字段匹配(matchFormFields在DOM中匹配标签对应的input、findNearestInput查找input、fixSpecialLabelInput特殊标签修正) +│ ├── types.ts # 所有共享类型定义(简历接口、表单标签、匹配字段、UI组件库配置、经历区块类型) +│ ├── constants.ts # 常量数据(JOB_FORM_LABELS标签数组、EXPERIENCE_SECTION_CONFIGS经历配置、UI_LIB_PICKER_CONFIGS组件库配置) +│ ├── dom.ts # DOM工具(extractDomStructure提取DOM树、detectPageLanguage语言检测、isJobApplicationForm表单页判断、buildSelector选择器生成、snapshotElementsInRange/diffSnapshots/findTopLevelNewElements DOM快照差异对比) +│ ├── formMatcher.ts # 表单字段匹配(matchFormFields全页面匹配、matchFormFieldsInRange范围内匹配、matchMainFields非经历区域匹配、findNearestInput查找input) +│ ├── labelFinder.ts # 标签定位(findLabelForInput通过form-item容器+DOM逆向遍历两遍策略识别input对应的标签文字) │ ├── pickerDetector.ts # 选择器识别(detectPickerField检测字段是否为选择器类型,通过UI组件库类名/提示文字/主动点击DOM差异对比三种方式) -│ ├── pickerFill.ts # 选择器选项匹配与点击(fuzzyMatchScore模糊匹配、clickBestOptionInDropdown弹出层选项点击、findAndClickOptionInVisiblePopups全局弹出层搜索) -│ ├── datePicker.ts # 日期选择器填写(fillDatePicker日期填写、tryClickMonth月份点击、tryClickDay日期点击) -│ ├── autofill.ts # 自动填写核心(delay/forceSetValue工具函数、fillMatchedField字段填写入口、fillPickerField选择器填写含DOM差异对比、closePopup弹窗关闭、fillAllFields批量填写) +│ ├── pickerFill.ts # 选择器选项匹配与点击(fuzzyMatchScore模糊匹配、clickBestOptionInDropdown弹出层内选项点击、findAndClickOptionInVisiblePopups弹出层搜索) +│ ├── datePicker.ts # 日期选择器填写(fillDatePicker日期填写、detectNavButtons按钮探测、navigateToYearMonth导航、tryFillMonthPanel月份面板) +│ ├── autofill.ts # 自动填写核心(forceSetValue强制写值、fillMatchedField统一填写入口、fillPickerField选择器填写含DOM差异对比、fillSearchPickerField搜索型选择器、fillTimePeriodField/fillTimeSingleField时间字段、fillYearMonthPicker年月选择器) +│ ├── experienceSection.ts # 经历区块识别与段数管理(locateExperienceSections定位5大经历、expandExperienceSections补足段数、countExistingSegments段数校正、relocateSegmentContainer重定位、sortExperienceByTime时间排序) +│ ├── fillStats.ts # 填写统计(scanPageFields按大标题分组扫描所有字段状态、extractNonResumeFields提取非简历格式字段、printFieldStats打印统计) +│ ├── formStyle.ts # 表单样式(setFieldHighlight字段背景高亮、isRequiredField必填检测) +│ ├── resumeDataHelper.ts # 简历数据工具(getResumeFieldValue根据section/index/field获取简历值) │ ├── resumeUpload.ts # 简历上传(detectAndUploadResume检测上传按钮并从OSS下载注入文件) -│ └── api.ts # API接口模块 +│ └── channelBridge.ts # 跨域通信桥(panelBridge跨域读写数据,用于Content Script和网页端通信) +├── utils/ +│ ├── auth.ts # 认证工具(getMemberStatus会员状态查询、getCustomizeResume定制简历获取) +│ ├── cookie.ts # Cookie工具(getCookieValue从浏览器获取Cookie值) +│ ├── delay.ts # 延时工具(delay统一延时函数,按等级管理:low/mid/high/max) +│ └── storage.ts # 存储工具(chrome.storage的get/set/remove封装) └── assets/ ├── icon.png # 插件图标 - └── icon1.png # 备用图标 + └── logo-offerpai.png # OfferPai logo ``` ## 编码规范 @@ -114,3 +131,11 @@ src/ - `delay("max")` — 特殊超长延时(谨慎使用) - 具体毫秒数只在 `src/utils/delay.ts` 中统一设置,调用方只使用等级名称 - 如需调整全局延时策略,只修改 `delay.ts` 中的 `DELAY_MS` 常量即可 + +## 代码组织与可维护性规范 + +- **修改或添加新逻辑时,必须完整查看逻辑相关的所有引用和引入**,理解上下游调用关系后再动手 +- **只要能实现,新逻辑优先单独抽出为独立方法**,通过返回值向调用方提供所需结果,保持项目的解耦性和高可维护性 +- 禁止将新增逻辑直接内联到已有的大方法中导致方法膨胀、职责混乱 +- 独立方法应有清晰的中文注释说明:用途、入参、返回值、安全退化行为 +- 调用独立方法的位置需加注释说明为什么调用、结果如何使用 diff --git a/src/lib/experienceSection.ts b/src/lib/experienceSection.ts index f632216..58e45a2 100644 --- a/src/lib/experienceSection.ts +++ b/src/lib/experienceSection.ts @@ -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 /** diff --git a/src/lib/formMatcher.ts b/src/lib/formMatcher.ts index d15309a..8e84f7e 100644 --- a/src/lib/formMatcher.ts +++ b/src/lib/formMatcher.ts @@ -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) diff --git a/src/utils/delay.ts b/src/utils/delay.ts index 92d5aef..b37b616 100644 --- a/src/utils/delay.ts +++ b/src/utils/delay.ts @@ -8,8 +8,8 @@ export type DelayLevel = "low" | "mid" | "high" | "max" /** 各等级对应的毫秒数(统一在此处调整) */ const DELAY_MS: Record = { - low: 50, // <100ms 场景:微等待,点击后极短暂停 - mid: 200, // 100~300ms 场景:等待 DOM 更新、弹出层渲染 + low: 10, // <100ms 场景:微等待,点击后极短暂停 + mid: 50, // 100~300ms 场景:等待 DOM 更新、弹出层渲染 high: 500, // >300ms 场景:等待接口返回、搜索结果、动画完成 max: 2000, // >2000ms 场景:特殊超长延时(谨慎使用) }