经历识别添加

This commit is contained in:
xuxin
2026-05-09 18:56:54 +08:00
parent 1430b38b20
commit 888d4450f1
3 changed files with 707 additions and 16 deletions
+11 -11
View File
@@ -12,6 +12,7 @@ import { detectPickerField } from "~lib/pickerDetector"
import { detectAndUploadResume } from "~lib/resumeUpload"
import { getMockResumeData } from "~lib/constants"
import { getResumeFieldValue } from "~lib/resumeDataHelper"
import { locateExperienceSections, expandExperienceSections } from "~lib/experienceSection"
import type { MatchedFormField, ResumeData } from "~lib/types"
import "./SidebarPanel.scss"
@@ -63,18 +64,17 @@ export function SidebarPanel({ onClose }: SidebarPanelProps) {
setResumeData(currentResumeData)
console.log(`===== OfferPie: 已加载简历数据,教育${currentResumeData.education.length}段 工作${currentResumeData.work.length}段 实习${currentResumeData.internship.length}段 项目${currentResumeData.project.length}段 竞赛${currentResumeData.competition.length}段 =====`)
// 4.1 检测并上传简历文件
const resumeUrl = "https://offerpie.oss-cn-guangzhou.aliyuncs.com/%E4%BA%8E%E5%A4%A7%E6%98%A5.pdf"
const uploaded = await detectAndUploadResume(resumeUrl)
console.log(`===== OfferPie: 简历上传 ${uploaded ? "成功" : "跳过(未找到上传按钮或失败)"} =====`)
if (uploaded) await delay(1000) // 等待网站解析简历
// // 4.1 检测并上传简历文件
// const resumeUrl = "https://offerpie.oss-cn-guangzhou.aliyuncs.com/%E4%BA%8E%E5%A4%A7%E6%98%A5.pdf"
// const uploaded = await detectAndUploadResume(resumeUrl)
// console.log(`===== OfferPie: 简历上传 ${uploaded ? "成功" : "跳过(未找到上传按钮或失败)"} =====`)
// if (uploaded) await delay(1000) // 等待网站解析简历
// 4.5 查找教育背景(教育经历)、实习经历、工作经历、项目经历、竞赛经历位置和添加按钮,根据要填的简历数据添加相应的经历条
// 4.5 定位经历区块并统计已展开段
const sectionResults = locateExperienceSections(document.body, lang)
// 4.6 对比简历数据段数,点击添加按钮补足不够的段数
const expandedResults = await expandExperienceSections(sectionResults, currentResumeData, lang)
// TODO: 后续实现步骤:
// 1. 遍历 EXPERIENCE_SECTION_CONFIGS,在 DOM 中查找每种经历的区块标题元素
// 2. 对比简历数据中该经历的段数 vs 页面已展开的段数
// 3. 如果简历数据段数 > 页面已展开段数,查找并点击"添加"按钮补足差额
// 4. 每次点击添加按钮后等待 DOM 更新,重新计数确认
// 5. 对5种经历的标签关系数据做特殊标记(后续完善)
// 6. 所有经历段数添加完毕后,再进入统一的字段匹配和填写流程
@@ -151,7 +151,7 @@ export function SidebarPanel({ onClose }: SidebarPanelProps) {
<div className="op-container">
{/* 顶部操作栏:反馈、设置、关闭按钮 */}
<div className="op-header">
<span className="op-header-link">3</span>
<span className="op-header-link">5</span>
<span className="op-header-link"></span>
{/* 关闭按钮:点击后隐藏侧边栏 */}
<button className="op-close-btn" onClick={onClose}>
+44 -5
View File
@@ -224,7 +224,7 @@ export const EXPERIENCE_SECTION_CONFIGS: ExperienceSectionConfig[] = [
zh: ["教育背景", "教育经历", "学历信息", "教育信息"],
en: ["Education", "Education Background", "Academic Background", "Education History"],
coreFieldKey: "school",
addButtonZh: ["添加", "新增", "添加教育", "添加教育经历", "新增教育经历", "+ 添加"],
addButtonZh: ["添加", "新增", "添加教育", "添加教育经历", "新增教育经历", "+ 添加", "+ 新增"],
addButtonEn: ["Add", "Add Education", "Add More", "+ Add"],
},
{
@@ -232,7 +232,7 @@ export const EXPERIENCE_SECTION_CONFIGS: ExperienceSectionConfig[] = [
zh: ["工作经历", "工作经验", "工作信息"],
en: ["Work Experience", "Employment History", "Work History", "Professional Experience"],
coreFieldKey: "workCompany",
addButtonZh: ["添加", "新增", "添加工作", "添加工作经历", "新增工作经历", "+ 添加"],
addButtonZh: ["添加", "新增", "添加工作", "添加工作经历", "新增工作经历", "+ 添加", "+ 新增"],
addButtonEn: ["Add", "Add Work", "Add Experience", "Add More", "+ Add"],
},
{
@@ -240,7 +240,7 @@ export const EXPERIENCE_SECTION_CONFIGS: ExperienceSectionConfig[] = [
zh: ["实习经历", "实习经验", "实习信息"],
en: ["Internship", "Internship Experience", "Intern Experience"],
coreFieldKey: "internCompany",
addButtonZh: ["添加", "新增", "添加实习", "添加实习经历", "新增实习经历", "+ 添加"],
addButtonZh: ["添加", "新增", "添加实习", "添加实习经历", "新增实习经历", "+ 添加", "+ 新增"],
addButtonEn: ["Add", "Add Internship", "Add More", "+ Add"],
},
{
@@ -248,7 +248,7 @@ export const EXPERIENCE_SECTION_CONFIGS: ExperienceSectionConfig[] = [
zh: ["项目经历", "项目经验", "项目信息"],
en: ["Project", "Project Experience", "Projects"],
coreFieldKey: "projectName",
addButtonZh: ["添加", "新增", "添加项目", "添加项目经历", "新增项目经历", "+ 添加"],
addButtonZh: ["添加", "新增", "添加项目", "添加项目经历", "新增项目经历", "+ 添加", "+ 新增"],
addButtonEn: ["Add", "Add Project", "Add More", "+ Add"],
},
{
@@ -256,7 +256,7 @@ export const EXPERIENCE_SECTION_CONFIGS: ExperienceSectionConfig[] = [
zh: ["竞赛经历", "获奖经历", "竞赛信息", "获奖情况"],
en: ["Competition", "Awards", "Competitions & Awards", "Contest Experience"],
coreFieldKey: "competitionName",
addButtonZh: ["添加", "新增", "添加竞赛", "添加竞赛经历", "新增竞赛经历", "+ 添加"],
addButtonZh: ["添加", "新增", "添加竞赛", "添加竞赛经历", "新增竞赛经历", "+ 添加", "+ 新增"],
addButtonEn: ["Add", "Add Competition", "Add Award", "Add More", "+ Add"],
},
]
@@ -310,6 +310,21 @@ export function getMockResumeData(): ResumeData {
{ type: "text", content: "熟练使用Office办公软件(Word、Excel、PPT)进行数据整理、报告撰写与汇报展示,注重细节与准确性" },
],
},
{
id: "AntTOPmj",
school: "华南师范大学2",
major: "人工智能",
degree: "本科",
studyType: "统招",
startDate: "2021.09",
endDate: "2025.07",
description: [
{ type: "text", content: "大一:高等数学、Python程序设计、面向对象程序设计、计算机体系结构、线性代数、离散数学等,培养严谨的逻辑思维能力" },
{ type: "text", content: "大二:Java、软件工程导论、概率论与数理统计、数据结构与算法、Web开发、数据库原理、操作系统等,具备数据管理与分析基础" },
{ type: "text", content: "大三:人工智能基础、计算机网络、计算机语言等,掌握数据挖掘与模式识别方法" },
{ type: "text", content: "熟练使用Office办公软件(Word、Excel、PPT)进行数据整理、报告撰写与汇报展示,注重细节与准确性" },
],
},
],
work: [],
internship: [
@@ -337,6 +352,30 @@ export function getMockResumeData(): ResumeData {
{ type: "text", content: "熟练掌握Office办公软件及数据库工具,具备文献检索与信息整理经验,工作态度认真负责。" },
],
},
{
id: "EoNkjAF2",
companyName: "深圳乐动机器人科技有限公司2",
position: "机器人感知算法工程师",
startDate: "2024.05",
endDate: "2024.08",
description: [
{ type: "text", content: "负责数据收集、分析、模型训练与测试、算法部署等全流程工作,注重数据准确性与细节管理;" },
{ type: "text", content: "参与割草机算法研发工作,包括分类、检测、分割、跟踪等模块,具备快速学习新技术领域的能力;" },
{ type: "text", content: "撰写技术文档与测试报告,进行跨部门沟通协作,确保项目按时交付并符合质量标准。" },
],
},
{
id: "cX5EGRDA",
companyName: "江西卓云深圳研发中心2",
position: "图像算法工程师",
startDate: "2023.12",
endDate: "2024.02",
description: [
{ type: "text", content: "运用Python进行数据处理与分析,开发部署应用程序,具备较强的逻辑分析和数据整理能力;" },
{ type: "text", content: "独立完成项目全生命周期管理,包括数据收集、标注、模型训练、部署及性能调优,能应对重复性工作并保证准确性;" },
{ type: "text", content: "熟练掌握Office办公软件及数据库工具,具备文献检索与信息整理经验,工作态度认真负责。" },
],
},
],
project: [
{
+652
View File
@@ -0,0 +1,652 @@
/**
* 经历区块识别、段数统计与添加按钮点击模块
*
* 整体流程:
* 1. 定位页面中5大经历区块的大标题
* 2. 根据 TitleSignature 找出页面所有同结构大标题,确定管辖范围边界
* 3. 在管辖范围内统计已展开段数(核心字段标签+输入框组合数量)
* 4. 对比简历数据段数,不够则查找并点击"添加"按钮补足
* 5. 每次点击后重新确认段数,记录新增段的 DOM 结构信息
*/
import { EXPERIENCE_SECTION_CONFIGS, JOB_FORM_LABELS } from "./constants"
import { delay } from "./autofill"
import type { ExperienceSection, ExperienceSectionConfig, ResumeData } from "./types"
// ====================================================================
// 一、类型定义
// ====================================================================
/** 大标题的 HTML 标签 + CSS 类名特征 */
export interface TitleSignature {
/** 第一层有类名的祖先:标签名 + 完整类名 */
layer1: { tag: string; className: string } | null
/** 第二层有类名的祖先:标签名 + 完整类名 */
layer2: { tag: string; className: string } | null
}
/** 页面中所有大标题的信息(包括非经历标题) */
interface PageTitle {
element: Element
text: string
signature: TitleSignature
}
/** 经历区块定位结果 */
export interface ExperienceSectionLocateResult {
section: ExperienceSection
titleElement: Element | null
titleText: string
titleSignature: TitleSignature
/** 页面已展开的段数 */
expandedCount: number
/** 每段经历的 DOM 范围信息(用于后续填写时定位) */
segmentRanges: SegmentRange[]
}
/** 单段经历的 DOM 范围信息 */
export interface SegmentRange {
/** 该段经历的起始标志元素(核心字段标签元素) */
startElement: Element
/** 该段经历的起始标志文字 */
startText: string
/** 该段经历是否为新增的(通过点击添加按钮产生) */
isNewlyAdded: boolean
}
// ====================================================================
// 二、大标题定位相关
// ====================================================================
/**
* 在 DOM 中查找经历区块的大标题元素
* 用 TreeWalker 深度优先遍历,找到直接包含标题文字的最内层元素
*/
function findSectionTitle(
rootEl: Element,
lang: "zh" | "en",
config: ExperienceSectionConfig
): { titleElement: Element; titleText: string } | null {
const keywords = lang === "zh" ? config.zh : config.en
const walker = document.createTreeWalker(rootEl, NodeFilter.SHOW_ELEMENT)
let node: Node | null = walker.nextNode()
while (node) {
const el = node as Element
const directText = Array.from(el.childNodes)
.filter((child) => child.nodeType === Node.TEXT_NODE)
.map((child) => child.textContent?.trim())
.filter(Boolean)
.join("")
if (directText) {
for (const keyword of keywords) {
if (directText.includes(keyword) && directText.length < keyword.length + 10) {
return { titleElement: el, titleText: directText }
}
}
}
node = walker.nextNode()
}
return null
}
/**
* 提取大标题元素的 TitleSignature
* 从标题元素的父级开始向上查找两层有 CSS 类名的祖先
*/
function extractTitleSignature(titleEl: Element): TitleSignature {
const signature: TitleSignature = { layer1: null, layer2: null }
let layerFound = 0
let current: Element | null = titleEl.parentElement
while (current && layerFound < 2) {
const cls = current.className && typeof current.className === "string" ? current.className.trim() : ""
if (cls) {
if (layerFound === 0) {
signature.layer1 = { tag: current.tagName.toLowerCase(), className: cls }
} else {
signature.layer2 = { tag: current.tagName.toLowerCase(), className: cls }
}
layerFound++
}
current = current.parentElement
}
return signature
}
/**
* 根据参考 TitleSignature,在页面中找出所有具有相同 layer2 类名的大标题
* 用于确定所有大标题的位置(包括非经历标题如"更新说明"、"授权文本"等)
*/
function findAllTitlesWithSameSignature(
rootEl: Element,
refSignature: TitleSignature
): PageTitle[] {
const results: PageTitle[] = []
if (!refSignature.layer2) return results
const layer2Classes = refSignature.layer2.className.split(/\s+/)
let containers: Element[]
try {
const selector = `.${CSS.escape(layer2Classes[0])}`
containers = Array.from(rootEl.querySelectorAll(selector))
} catch {
return results
}
for (const container of containers) {
const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT)
let node: Node | null = walker.nextNode()
let foundTitle: PageTitle | null = null
while (node) {
const el = node as Element
const directText = Array.from(el.childNodes)
.filter((child) => child.nodeType === Node.TEXT_NODE)
.map((child) => child.textContent?.trim())
.filter(Boolean)
.join("")
if (directText && directText.length > 0 && directText.length < 20 && el.children.length === 0) {
const sig = extractTitleSignature(el)
if (sig.layer2 && sig.layer2.className === refSignature.layer2!.className) {
foundTitle = { element: el, text: directText, signature: sig }
break
}
}
node = walker.nextNode()
}
if (foundTitle) results.push(foundTitle)
}
// 按 DOM 顺序排列
results.sort((a, b) => {
const pos = a.element.compareDocumentPosition(b.element)
if (pos & Node.DOCUMENT_POSITION_FOLLOWING) return -1
if (pos & Node.DOCUMENT_POSITION_PRECEDING) return 1
return 0
})
return results
}
// ====================================================================
// 三、段数统计相关
// ====================================================================
/** 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]), [class*='select'], [class*='picker']"
/** 每种经历类型的通用关联标签关键词(fallback 用) */
const SECTION_FALLBACK_KEYWORDS: Record<ExperienceSection, { zh: string[]; en: string[] }[]> = {
education: [
{ zh: ["学校", "学校名称", "毕业院校", "院校", "毕业学校"], en: ["School", "University", "College", "Institution"] },
],
work: [
{ zh: ["公司", "公司名称", "单位名称", "工作单位"], en: ["Company", "Company Name", "Employer"] },
],
internship: [
{ zh: ["公司", "公司名称", "单位名称", "实习公司", "实习单位"], en: ["Company", "Company Name", "Intern Company"] },
],
project: [
{ zh: ["项目名称", "项目名", "项目"], en: ["Project Name", "Project", "Project Title"] },
],
competition: [
{ zh: ["竞赛名称", "比赛名称", "竞赛"], en: ["Competition Name", "Contest Name", "Competition"] },
],
}
/**
* 获取管辖范围内的所有候选标签元素
*/
function getRangeElements(titleEl: Element, nextTitleEl: Element | null): Element[] {
const allCandidates = document.body.querySelectorAll("label, span, div, td, th, p, dt, legend")
const rangeElements: Element[] = []
for (const el of Array.from(allCandidates)) {
if (!(titleEl.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING)) continue
if (nextTitleEl && !(nextTitleEl.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_PRECEDING)) continue
rangeElements.push(el)
}
return rangeElements
}
/**
* 在给定元素列表中,数"关键词文本 + 输入框"组合的数量,并返回匹配到的标签元素
*/
function findLabelInputCombos(elements: Element[], keywords: string[]): Element[] {
const matched: Element[] = []
for (const el of elements) {
const directText = Array.from(el.childNodes)
.filter((node) => node.nodeType === Node.TEXT_NODE)
.map((node) => node.textContent?.trim())
.filter(Boolean)
.join("")
if (!directText) continue
let isMatch = false
for (const keyword of keywords) {
if (directText.includes(keyword) && directText.length < keyword.length + 10) {
isMatch = true
break
}
}
if (!isMatch) continue
if (hasNearbyInput(el)) matched.push(el)
}
return matched
}
/**
* 判断标签元素附近是否有输入框
*/
function hasNearbyInput(labelEl: Element): boolean {
// 策略1:后续兄弟元素
let sibling = labelEl.nextElementSibling
let siblingCount = 0
while (sibling && siblingCount < 3) {
if (sibling.querySelector(INPUT_SEL) || sibling.matches(INPUT_SEL)) return true
sibling = sibling.nextElementSibling
siblingCount++
}
// 策略2:向上找父级容器
let parent: Element | null = labelEl.parentElement
for (let i = 0; i < 4 && parent; i++) {
const inputs = parent.querySelectorAll(INPUT_SEL)
if (inputs.length > 0) return true
parent = parent.parentElement
}
return false
}
/**
* 统计经历区块已展开段数
* 返回段数和匹配到的核心标签元素列表
*/
function countExpandedInRange(
titleEl: Element,
nextTitleEl: Element | null,
config: ExperienceSectionConfig,
lang: "zh" | "en"
): { count: number; matchedElements: Element[]; usedKeywords: string[] } {
const rangeElements = getRangeElements(titleEl, nextTitleEl)
// 第一轮:用该 section 自己的标签关键词
const sectionLabels = JOB_FORM_LABELS.filter((item) => item.section === config.section)
const coreLabel = sectionLabels.find((item) => item.key === config.coreFieldKey)
const orderedLabels = coreLabel
? [coreLabel, ...sectionLabels.filter((item) => item.key !== config.coreFieldKey)]
: sectionLabels
for (const labelItem of orderedLabels) {
const keywords = lang === "zh" ? labelItem.zh : labelItem.en
const matched = findLabelInputCombos(rangeElements, keywords)
if (matched.length > 0) {
return { count: matched.length, matchedElements: matched, usedKeywords: keywords }
}
}
// 第二轮:用 fallback 关键词
const fallbacks = SECTION_FALLBACK_KEYWORDS[config.section]
if (fallbacks) {
for (const fb of fallbacks) {
const keywords = lang === "zh" ? fb.zh : fb.en
const matched = findLabelInputCombos(rangeElements, keywords)
if (matched.length > 0) {
return { count: matched.length, matchedElements: matched, usedKeywords: keywords }
}
}
}
return { count: 0, matchedElements: [], usedKeywords: [] }
}
// ====================================================================
// 四、添加按钮查找与点击
// ====================================================================
/**
* 在经历大标题的管辖范围内查找最近的"添加"按钮
* 从大标题元素开始,在 DOM 从上到下的顺序中找第一个包含添加关键词的可点击元素
*/
function findAddButton(
titleEl: Element,
nextTitleEl: Element | null,
config: ExperienceSectionConfig,
lang: "zh" | "en"
): Element | null {
const addKeywords = lang === "zh" ? config.addButtonZh : config.addButtonEn
// 在管辖范围内查找包含添加关键词的元素
const allElements = document.body.querySelectorAll("button, a, span, div, [role='button']")
for (const el of Array.from(allElements)) {
// 必须在 titleEl 之后
if (!(titleEl.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING)) continue
// 必须在 nextTitleEl 之前
if (nextTitleEl && !(nextTitleEl.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_PRECEDING)) continue
const text = el.textContent?.trim() || ""
// 匹配添加关键词
for (const keyword of addKeywords) {
if (text.includes(keyword) && text.length < keyword.length + 10) {
return el
}
}
}
return null
}
/**
* 点击添加按钮(逐层点击,检测到段数增加立即停止)
*
* 策略:
* - 从添加文字的最内层元素开始,逐层向上点击
* - 每点一层后等待 DOM 更新,检测段数是否增加
* - 一旦某层点击后段数增加了,立即停止,不再继续往上冒泡
* - 如果5层内找到 button 标签,最多点到 button 层
* - 跳过包含输入框/富文本/radio 的层级(这些不可能是添加按钮)
*
* @param addBtnEl - 包含添加文字的元素
* @param titleEl - 当前经历大标题元素
* @param nextTitleEl - 下一个大标题元素(范围边界)
* @param config - 经历区块配置
* @param lang - 页面语言
* @returns 点击后是否成功增加了段数
*/
async function clickAddButton(
addBtnEl: Element,
titleEl: Element,
nextTitleEl: Element | null,
config: ExperienceSectionConfig,
lang: "zh" | "en"
): Promise<boolean> {
// 找到包含添加文字的最内层元素
let innerEl: Element = addBtnEl
while (innerEl.children.length === 1) {
const child = innerEl.children[0]
if (child.textContent?.trim() === addBtnEl.textContent?.trim()) {
innerEl = child
} else {
break
}
}
// 从最内层向外收集要点击的层级(最多5层)
const clickTargets: HTMLElement[] = []
let current: Element | null = innerEl
for (let i = 0; i < 5 && current; i++) {
// 跳过包含输入框/富文本/radio 的层级
const hasFormEl = current.querySelector("input, textarea, [type='radio'], [contenteditable='true']")
if (!hasFormEl) {
clickTargets.push(current as HTMLElement)
}
// 如果当前层是 button,收集后停止向上
if (current.tagName.toLowerCase() === "button") break
current = current.parentElement
}
// 记录点击前的段数
const beforeCount = countExpandedInRange(titleEl, nextTitleEl, config, lang).count
// 逐层点击,每层点击后检测段数是否增加
for (const target of clickTargets) {
target.click()
await delay(400) // 等待 DOM 更新
// 检测段数是否增加
const afterCount = countExpandedInRange(titleEl, nextTitleEl, config, lang).count
if (afterCount > beforeCount) {
// 段数增加了,停止冒泡
return true
}
}
// 所有层都点了但段数没变化
return false
}
// ====================================================================
// 五、段数补足主流程
// ====================================================================
/**
* 定位页面中所有经历区块并统计已展开段数(不含添加操作)
* 这是第一步:只做定位和统计,返回结果供后续使用
*/
export function locateExperienceSections(
rootEl: Element = document.body,
lang: "zh" | "en"
): ExperienceSectionLocateResult[] {
console.log("===== OfferPie: 开始定位经历区块 =====")
// 定位5种经历的大标题
const located: { config: ExperienceSectionConfig; titleElement: Element; titleText: string }[] = []
const notFound: ExperienceSectionConfig[] = []
for (const config of EXPERIENCE_SECTION_CONFIGS) {
const result = findSectionTitle(rootEl, lang, config)
if (result) {
located.push({ config, titleElement: result.titleElement, titleText: result.titleText })
} else {
notFound.push(config)
}
}
if (located.length === 0) {
console.log(" ❌ 未找到任何经历区块大标题")
console.log("===== OfferPie: 经历区块定位完毕 =====")
return notFound.map((config) => ({
section: config.section, titleElement: null, titleText: "",
titleSignature: { layer1: null, layer2: null }, expandedCount: 0, segmentRanges: [],
}))
}
// 用第一个找到的大标题的 TitleSignature 找出页面所有同结构大标题
const refSignature = extractTitleSignature(located[0].titleElement)
const allPageTitles = findAllTitlesWithSameSignature(rootEl, refSignature)
console.log(` --- 页面所有大标题(共 ${allPageTitles.length} 个,按 DOM 顺序)---`)
for (const pt of allPageTitles) {
console.log(` 标题: "${pt.text}" | TitleSignature: layer1=${pt.signature.layer1 ? `<${pt.signature.layer1.tag} class="${pt.signature.layer1.className}">` : "null"} layer2=${pt.signature.layer2 ? `<${pt.signature.layer2.tag} class="${pt.signature.layer2.className}">` : "null"}`)
}
console.log(` --- 大标题列表结束 ---`)
// 对每个经历大标题统计段数
const results: ExperienceSectionLocateResult[] = []
for (const { config, titleElement, titleText } of located) {
const titleSignature = extractTitleSignature(titleElement)
const idx = allPageTitles.findIndex((pt) => pt.element === titleElement)
const nextTitleEl = idx >= 0 && idx < allPageTitles.length - 1 ? allPageTitles[idx + 1].element : null
const { count, matchedElements, usedKeywords } = countExpandedInRange(titleElement, nextTitleEl, config, lang)
console.log(
` [${config.section}] ✅ 标题: "${titleText}"` +
` | 范围边界: "${allPageTitles[idx + 1]?.text || "页面底部"}"` +
` | 计数关键词: (${usedKeywords.join("/")})` +
` | 标签+输入框组合 ${count}`
)
console.log(` → 已展开段数: ${count}`)
// 记录每段经历的起始标志
const segmentRanges: SegmentRange[] = matchedElements.map((el) => ({
startElement: el,
startText: el.textContent?.trim() || "",
isNewlyAdded: false,
}))
results.push({ section: config.section, titleElement, titleText, titleSignature, expandedCount: count, segmentRanges })
}
// 未找到的区块
for (const config of notFound) {
console.log(` [${config.section}] ❌ 未找到区块标题(关键词: ${(lang === "zh" ? config.zh : config.en).join("/")}`)
results.push({
section: config.section, titleElement: null, titleText: "",
titleSignature: { layer1: null, layer2: null }, expandedCount: 0, segmentRanges: [],
})
}
console.log("===== OfferPie: 经历区块定位完毕 =====")
return results
}
/**
* 补足经历段数:对比简历数据,点击添加按钮补足不够的段数
*
* 流程:
* 1. 对每种经历,对比简历数据段数 vs 页面已展开段数
* 2. 如果简历段数 > 页面段数,查找并点击"添加"按钮
* 3. 每次点击后等待 DOM 更新,重新统计段数确认是否增加
* 4. 记录新增段的 DOM 信息
*
* @param locateResults - locateExperienceSections 的返回结果
* @param resumeData - 简历数据
* @param lang - 页面语言
* @returns 更新后的定位结果(含新增段信息)
*/
export async function expandExperienceSections(
locateResults: ExperienceSectionLocateResult[],
resumeData: ResumeData,
lang: "zh" | "en"
): Promise<ExperienceSectionLocateResult[]> {
console.log("===== OfferPie: 开始补足经历段数 =====")
// 重新获取页面所有大标题(用于确定范围边界)
const firstLocated = locateResults.find((r) => r.titleElement)
if (!firstLocated || !firstLocated.titleElement) {
console.log(" ❌ 无经历大标题,跳过补足")
console.log("===== OfferPie: 经历段数补足完毕 =====")
return locateResults
}
const refSignature = extractTitleSignature(firstLocated.titleElement)
let allPageTitles = findAllTitlesWithSameSignature(document.body, refSignature)
for (const result of locateResults) {
if (!result.titleElement) continue
// 获取简历数据中该经历的段数
const resumeCount = (resumeData[result.section] as unknown[])?.length || 0
if (resumeCount <= result.expandedCount) {
console.log(` [${result.section}] 简历${resumeCount}段 ≤ 页面${result.expandedCount}段,无需添加`)
continue
}
const needAdd = resumeCount - result.expandedCount
console.log(` [${result.section}] 简历${resumeCount}段 > 页面${result.expandedCount}段,需添加 ${needAdd}`)
// 找到对应的 config
const config = EXPERIENCE_SECTION_CONFIGS.find((c) => c.section === result.section)
if (!config) continue
// 逐次点击添加按钮
for (let addIdx = 0; addIdx < needAdd; addIdx++) {
// 每次点击前重新统计当前段数,确认是否真的还需要添加
allPageTitles = findAllTitlesWithSameSignature(document.body, refSignature)
const checkIdx = allPageTitles.findIndex((pt) => pt.element === result.titleElement)
const checkNextEl = checkIdx >= 0 && checkIdx < allPageTitles.length - 1 ? allPageTitles[checkIdx + 1].element : null
const currentResult = countExpandedInRange(result.titleElement!, checkNextEl, config, lang)
result.expandedCount = currentResult.count
if (result.expandedCount >= resumeCount) {
console.log(` ✅ 当前段数${result.expandedCount}已达到简历段数${resumeCount},无需继续添加`)
// 更新 segmentRanges
result.segmentRanges = currentResult.matchedElements.map((el, i) => ({
startElement: el,
startText: el.textContent?.trim() || "",
isNewlyAdded: i >= (resumeCount - needAdd), // 超出初始段数的都标记为新增
}))
break
}
// 每次点击前重新获取范围边界(因为 DOM 可能已变化)
allPageTitles = findAllTitlesWithSameSignature(document.body, refSignature)
const idx = allPageTitles.findIndex((pt) => pt.element === result.titleElement)
const nextTitleEl = idx >= 0 && idx < allPageTitles.length - 1 ? allPageTitles[idx + 1].element : null
// 查找添加按钮(管辖范围内从上到下第一个)
const addBtn = findAddButton(result.titleElement, nextTitleEl, config, lang)
if (!addBtn) {
console.log(` ❌ 第${addIdx + 1}次:未找到添加按钮,停止`)
break
}
console.log(` → 第${addIdx + 1}次:找到添加按钮 "${addBtn.textContent?.trim()}",点击中...`)
// 记录点击前的段数
const beforeCount = countExpandedInRange(result.titleElement, nextTitleEl, config, lang).count
// 点击添加按钮(逐层点击,检测到段数增加立即停止)
await clickAddButton(addBtn, result.titleElement, nextTitleEl, config, lang)
await delay(300) // 额外等待确保 DOM 稳定
// 重新获取范围边界并统计段数
allPageTitles = findAllTitlesWithSameSignature(document.body, refSignature)
const newIdx = allPageTitles.findIndex((pt) => pt.element === result.titleElement)
const newNextTitleEl = newIdx >= 0 && newIdx < allPageTitles.length - 1 ? allPageTitles[newIdx + 1].element : null
const afterResult = countExpandedInRange(result.titleElement, newNextTitleEl, config, lang)
const afterCount = afterResult.count
if (afterCount > beforeCount) {
console.log(` ✅ 第${addIdx + 1}次:段数 ${beforeCount}${afterCount},添加成功`)
// 记录新增段的信息
const newElements = afterResult.matchedElements.filter(
(el) => !result.segmentRanges.some((seg) => seg.startElement === el)
)
for (const newEl of newElements) {
result.segmentRanges.push({
startElement: newEl,
startText: newEl.textContent?.trim() || "",
isNewlyAdded: true,
})
}
result.expandedCount = afterCount
// 点击后立即检查是否已达到目标段数
if (result.expandedCount >= resumeCount) {
console.log(` ✅ 当前段数${result.expandedCount}已达到简历段数${resumeCount},停止添加`)
break
}
} else {
console.log(` ⚠️ 第${addIdx + 1}次:段数未变化 (${beforeCount}${afterCount}),可能点击无效`)
// 多等一会再试一次确认
await delay(500)
const retryResult = countExpandedInRange(result.titleElement, newNextTitleEl, config, lang)
if (retryResult.count > beforeCount) {
console.log(` ✅ 第${addIdx + 1}次(重试):段数 ${beforeCount}${retryResult.count},添加成功`)
const newElements = retryResult.matchedElements.filter(
(el) => !result.segmentRanges.some((seg) => seg.startElement === el)
)
for (const newEl of newElements) {
result.segmentRanges.push({
startElement: newEl,
startText: newEl.textContent?.trim() || "",
isNewlyAdded: true,
})
}
result.expandedCount = retryResult.count
// 重试后也检查是否已达到目标
if (result.expandedCount >= resumeCount) {
console.log(` ✅ 当前段数${result.expandedCount}已达到简历段数${resumeCount},停止添加`)
break
}
} else {
console.log(` ❌ 第${addIdx + 1}次:重试后段数仍未变化,停止添加`)
break
}
}
}
}
// 打印最终结果
console.log("===== OfferPie: 经历段数补足结果 =====")
for (const r of locateResults) {
const resumeCount = (resumeData[r.section] as unknown[])?.length || 0
console.log(
` [${r.section}] 简历数据${resumeCount}段 | 页面已展开${r.expandedCount}` +
` | 段落信息: ${r.segmentRanges.map((s) => `"${s.startText}"${s.isNewlyAdded ? "(新增)" : ""}`).join(", ") || "无"}`
)
}
console.log("===== OfferPie: 经历段数补足完毕 =====")
return locateResults
}