初始化

This commit is contained in:
zk
2026-05-09 10:12:21 +08:00
commit 4e36c82bc4
22 changed files with 13508 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
# 依赖
node_modules
# Plasmo 构建缓存
.plasmo
# 构建产物
build
dist
# 日志
*.log
# 编辑器/IDE
.idea
.vscode
.kiro
*.swp
*.swo
# 系统文件
.DS_Store
Thumbs.db
# 环境变量
.env
.env.*
# 打包产物
*.zip
*.crx
*.pem
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
semi: false,
singleQuote: true,
trailingComma: "none",
printWidth: 100
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

+9986
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
{
"name": "offerpie-extension",
"displayName": "OfferPie Assistant",
"version": "0.1.0",
"description": "智能求职助手 - 自动化投递简历",
"scripts": {
"dev": "plasmo dev",
"build": "plasmo build",
"package": "plasmo package"
},
"dependencies": {
"@arco-design/web-react": "^2.66.14",
"plasmo": "^0.90.5",
"react": "^19.2.5",
"react-dom": "^19.2.5"
},
"devDependencies": {
"@types/chrome": "^0.0.318",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"typescript": "^5.8.3"
},
"manifest": {
"permissions": [
"activeTab",
"storage",
"tabs"
],
"host_permissions": [
"<all_urls>"
],
"action": {}
}
}
+19
View File
@@ -0,0 +1,19 @@
/**
* Background Service Worker
* 插件的后台服务,负责:
* 1. 监听插件图标点击事件,通知 Content Script 切换侧边栏
* 2. 后续可扩展:定时任务调度、跨页面状态管理、与后端的长连接等
*/
export {}
/**
* 监听插件图标点击事件
* 由于 manifest 中 action 没有设置 default_popup
* 点击图标会触发此事件,向当前活动标签页发送切换侧边栏的消息
*/
chrome.action.onClicked.addListener(async (tab) => {
if (tab.id) {
chrome.tabs.sendMessage(tab.id, { type: "TOGGLE_SIDEBAR" })
}
})
+253
View File
@@ -0,0 +1,253 @@
$primary: #000;
$bg-card: #fafafa;
$radius-lg: 20px;
$radius-md: 14px;
$radius-sm: 12px;
.op-container {
position: fixed;
top: 10px;
right: 10px;
width: 384px;
max-height: calc(100vh - 20px);
background: #fff;
border-radius: $radius-lg;
padding: 20px 20px 24px;
font-family: 'Inter', 'PingFang SC', 'Microsoft YaHei', sans-serif;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.12), 0 0 0 0.5px rgba(0, 0, 0, 0.06);
z-index: 2147483647;
display: flex;
flex-direction: column;
overflow-y: auto;
color: $primary;
font-size: 14px;
}
.op-header {
display: flex;
justify-content: flex-end;
align-items: center;
margin-bottom: 4px;
gap: 12px;
&-link {
font-size: 13px;
color: #333;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 3px;
}
}
.op-close-btn {
background: none;
border: none;
cursor: pointer;
padding: 2px;
display: flex;
align-items: center;
color: #333;
}
.op-title {
font-size: 22px;
font-weight: 700;
margin-bottom: 16px;
}
.op-job {
&-card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
background: $bg-card;
border-radius: $radius-md;
margin-bottom: 24px;
}
&-icon {
width: 40px;
height: 40px;
border-radius: 10px;
background: #f0f0f0;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
&-info {
flex: 1;
min-width: 0;
}
&-title {
font-size: 15px;
font-weight: 600;
line-height: 20px;
}
&-meta {
font-size: 12px;
color: #888;
margin-top: 2px;
line-height: 16px;
}
}
.op-match-score {
flex-shrink: 0;
}
.op-autofill-btn {
width: 100%;
height: 52px;
background: $primary;
color: #fff;
border: none;
border-radius: $radius-md;
font-size: 20px;
font-weight: 700;
cursor: pointer;
margin-bottom: 12px;
transition: opacity 0.2s;
&:hover { opacity: 0.85; }
&:disabled { opacity: 0.7; cursor: not-allowed; }
}
.op-credits {
&-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding: 0 2px;
}
&-text {
font-size: 13px;
font-weight: 600;
}
&-link {
font-size: 13px;
font-weight: 500;
text-decoration: underline;
text-underline-offset: 3px;
cursor: pointer;
}
}
.op-section-label {
font-size: 14px;
font-weight: 600;
margin-bottom: 10px;
}
.op-resume {
&-card {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
background: $bg-card;
border-radius: $radius-sm;
margin-bottom: 12px;
}
&-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: #e8e8e8;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 600;
color: #555;
flex-shrink: 0;
}
&-info {
flex: 1;
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
min-width: 0;
}
&-name {
font-size: 13px;
font-weight: 600;
}
&-tag {
font-size: 11px;
border: 1px solid #ddd;
border-radius: 10px;
padding: 1px 8px;
line-height: 18px;
white-space: nowrap;
}
&-change {
font-size: 14px;
font-weight: 600;
cursor: pointer;
flex-shrink: 0;
}
}
.op-optimize-btn {
width: 100%;
height: 48px;
background: #f0faf5;
color: $primary;
border: none;
border-radius: $radius-sm;
font-size: 17px;
font-weight: 600;
cursor: pointer;
margin-bottom: 20px;
transition: background 0.2s;
&:hover { background: #e0f5ec; }
}
.op-progress {
&-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
&-label {
font-size: 13px;
font-weight: 600;
}
&-value {
font-size: 14px;
font-weight: 700;
}
&-bar-bg {
width: 100%;
height: 6px;
background: #f0f0f0;
border-radius: 3px;
}
&-bar-fill {
width: 50%;
height: 100%;
background: $primary;
border-radius: 3px;
transition: width 0.3s;
}
}
+241
View File
@@ -0,0 +1,241 @@
/**
* 侧边栏面板组件
* 插件的主操作界面,固定在浏览器右上角
* 包含:职位信息卡片、自动填写按钮、简历管理、填写进度等功能区域
*/
import { useState } from "react"
import { fillMatchedField, delay } from "~lib/autofill"
import { extractDomStructure, detectPageLanguage, isJobApplicationForm } from "~lib/dom"
import { matchFormFields } from "~lib/formMatcher"
import { detectPickerField } from "~lib/pickerDetector"
import { detectAndUploadResume } from "~lib/resumeUpload"
import { getMockResumeData } from "~lib/constants"
import { getResumeFieldValue } from "~lib/resumeDataHelper"
import type { MatchedFormField, ResumeData } from "~lib/types"
import "./SidebarPanel.scss"
/** 侧边栏面板的 Props */
interface SidebarPanelProps {
/** 关闭面板的回调函数 */
onClose: () => void
}
export function SidebarPanel({ onClose }: SidebarPanelProps) {
/** 是否正在执行自动填写 */
const [filling, setFilling] = useState(false)
/** 页面语言类型:中文 / 英文 */
const [pageLang, setPageLang] = useState<"zh" | "en">("zh")
/** 是否为职位申请表单页面 */
const [isFormPage, setIsFormPage] = useState(false)
/** 匹配到的表单字段列表 */
const [formFields, setFormFields] = useState<MatchedFormField[]>([])
/** 当前使用的简历数据 */
const [resumeData, setResumeData] = useState<ResumeData | null>(null)
/**
* 自动填写按钮点击处理
* 流程:提取 DOM → 检测语言 → 判断是否表单页 → 检测简历上传 → 匹配字段 → 识别选择器 → 填充测试数据
*/
const handleAutoFill = async () => {
setFilling(true)
try {
// 1. 提取 DOM 结构
const domStructure = extractDomStructure()
console.log("===== OfferPie: 完整 DOM 树结构 =====")
console.log(domStructure)
console.log(`===== OfferPie: 结构总长度 ${domStructure.length} 字符 =====`)
// 2. 检测页面语言,更新到页面参数
const lang = detectPageLanguage(domStructure)
setPageLang(lang)
console.log(`===== OfferPie: 页面语言检测结果 = ${lang} =====`)
// 3. 判断是否为职位申请表单页面
const isForm = isJobApplicationForm(document.body, lang)
setIsFormPage(isForm)
console.log(`===== OfferPie: 是否为职位申请表单页面 = ${isForm} =====`)
if (isForm) {
// 4. 获取简历数据(当前使用 mock 数据,后续替换为接口调用)
// TODO: 替换为真实接口 → const res = await javaApi.get<{resume: ..., education: ..., ...}>("/resume/detail")
const currentResumeData = getMockResumeData()
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.5 查找教育背景(教育经历)、实习经历、工作经历、项目经历、竞赛经历位置和添加按钮,根据要填的简历数据添加相应的经历条数
// TODO: 后续实现步骤:
// 1. 遍历 EXPERIENCE_SECTION_CONFIGS,在 DOM 中查找每种经历的区块标题元素
// 2. 对比简历数据中该经历的段数 vs 页面已展开的段数
// 3. 如果简历数据段数 > 页面已展开段数,查找并点击"添加"按钮补足差额
// 4. 每次点击添加按钮后等待 DOM 更新,重新计数确认
// 5. 对5种经历的标签关系数据做特殊标记(后续完善)
// 6. 所有经历段数添加完毕后,再进入统一的字段匹配和填写流程
// 5. 匹配表单字段
const fields = matchFormFields(document.body, lang)
console.log(`===== OfferPie: 匹配到 ${fields.length} 个表单字段 =====`)
// 6. 逐个:检测选择器 → 填充数据 → 立即填写(一个个来,避免多个选择器同时打开)
let success = 0, failed = 0, skipped = 0
// 记录上一个非 picker 的输入框,用于每次填完后点击它来关闭残留弹窗
let lastTextInput: HTMLInputElement | HTMLTextAreaElement | null = null
for (const f of fields) {
// 从简历数据中获取填写值(根据 section + sectionIndex + resumeField 定位)
if (currentResumeData) {
const value = getResumeFieldValue(currentResumeData, f.section, f.sectionIndex, f.resumeField)
if (value) f.fillValue = value
}
if (!f.fillValue) { skipped++; continue }
// 检测选择器类型(会点击展开再关闭)
await detectPickerField(f, lang)
console.log(
` [${f.key}] "${f.labelText}" → input: ${f.inputSelector || "未找到"}` +
` | type: ${f.inputType}` +
` | isPicker: ${f.isPicker}` +
` | pickerSelector: ${f.pickerDropdownSelector || "无"}` +
` | fillValue: "${f.fillValue}"`
)
// 立即填写这个字段
const ok = await fillMatchedField(f)
if (ok) { success++ } else { failed++ }
// 每个字段填完后,点击上一个非 picker 输入框来关闭残留弹窗
if (lastTextInput) {
;(lastTextInput as HTMLElement).click()
lastTextInput.focus()
await delay(100)
lastTextInput.blur()
} else {
// 兜底:只按 Escape 关闭弹窗,不点击任何元素(避免误触链接导致页面跳转)
if (document.activeElement instanceof HTMLElement) {
document.activeElement.dispatchEvent(
new KeyboardEvent("keydown", { key: "Escape", code: "Escape", keyCode: 27, bubbles: true })
)
document.activeElement.blur()
}
document.body.click()
}
await delay(300)
// 更新上一个非 picker 输入框的记录
if (!f.isPicker && f.inputElement) {
lastTextInput = f.inputElement
}
}
setFormFields(fields)
console.log(`===== OfferPie: 填写结果 成功${success} 失败${failed} 跳过${skipped} =====`)
} else {
setFormFields([])
console.log("===== OfferPie: 当前页面不是职位申请表单,跳过字段匹配 =====")
}
} catch (e) {
console.error("OfferPie: 获取页面结构失败", e)
}
// 1秒后恢复按钮状态
setTimeout(() => setFilling(false), 1000)
}
return (
<div className="op-container">
{/* 顶部操作栏:反馈、设置、关闭按钮 */}
<div className="op-header">
<span className="op-header-link"></span>
<span className="op-header-link"></span>
{/* 关闭按钮:点击后隐藏侧边栏 */}
<button className="op-close-btn" onClick={onClose}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
<circle cx="12" cy="12" r="10" />
<path d="M9 12h6M12 9l3 3-3 3" />
</svg>
</button>
</div>
{/* 插件标题 */}
<div className="op-title"></div>
{/* 职位信息卡片:展示当前页面识别到的职位信息和匹配度 */}
<div className="op-job-card">
{/* 职位图标 */}
<div className="op-job-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="#666">
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
</svg>
</div>
{/* 职位名称和公司信息 */}
<div className="op-job-info">
<div className="op-job-title"></div>
<div className="op-job-meta"> ·  </div>
</div>
{/* 匹配度环形进度条 */}
<div className="op-match-score">
<svg width="48" height="48" viewBox="0 0 48 48">
{/* 背景圆环 */}
<circle cx="24" cy="24" r="20" fill="none" stroke="#f0f0f0" strokeWidth="3" />
{/* 进度圆环:60% 匹配度 */}
<circle cx="24" cy="24" r="20" fill="none" stroke="#000" strokeWidth="3"
strokeDasharray={`${0.6 * 2 * Math.PI * 20} ${2 * Math.PI * 20}`}
strokeLinecap="round" transform="rotate(-90 24 24)" />
{/* 百分比文字 */}
<text x="24" y="26" textAnchor="middle" fontSize="13" fontWeight="700" fill="#000">60%</text>
</svg>
</div>
</div>
{/* 自动填写按钮:点击后获取页面结构,后续会调用 AI 接口自动填表 */}
<button className="op-autofill-btn" onClick={handleAutoFill} disabled={filling}>
{filling ? "分析中..." : "自动填写"}
</button>
{/* 使用次数信息 */}
<div className="op-credits-row">
<span className="op-credits-text">4</span>
<span className="op-credits-link"></span>
</div>
{/* 简历区域 */}
<div className="op-section-label"></div>
{/* 简历卡片:展示当前选中的简历信息 */}
<div className="op-resume-card">
{/* 简历头像 */}
<div className="op-resume-avatar">B</div>
{/* 简历名称和标签 */}
<div className="op-resume-info">
<span className="op-resume-name">-</span>
<span className="op-resume-tag"></span>
<span className="op-resume-tag"></span>
</div>
{/* 更改简历按钮 */}
<span className="op-resume-change"></span>
</div>
{/* 针对性优化简历按钮 */}
<button className="op-optimize-btn"></button>
{/* 填写进度区域 */}
<div className="op-progress-row">
<span className="op-progress-label"></span>
<span className="op-progress-value">50%</span>
</div>
{/* 进度条 */}
<div className="op-progress-bar-bg">
<div className="op-progress-bar-fill" />
</div>
</div>
)
}
+56
View File
@@ -0,0 +1,56 @@
/**
* Content Script - 侧边栏入口
* 该脚本会被注入到所有网页中,负责渲染侧边栏面板
* 使用 Plasmo 的 Content Script UI 功能,通过 Shadow DOM 实现样式隔离
*/
import styleText from "data-text:~components/SidebarPanel.scss"
import type { PlasmoCSConfig, PlasmoGetStyle } from "plasmo"
import { useEffect, useState } from "react"
import { SidebarPanel } from "~components/SidebarPanel"
/** Content Script 配置:匹配所有网页 */
export const config: PlasmoCSConfig = {
matches: ["<all_urls>"]
}
/**
* 将 SCSS 编译后的样式注入到 Shadow DOM 中
* 这样插件的样式不会影响宿主页面,也不会被宿主页面的样式影响
*/
export const getStyle: PlasmoGetStyle = () => {
const style = document.createElement("style")
style.textContent = styleText
return style
}
/**
* 侧边栏根组件
* 监听来自 Background Service Worker 的消息,控制面板的显示/隐藏
*/
function Sidebar() {
/** 侧边栏是否可见 */
const [visible, setVisible] = useState(false)
useEffect(() => {
/**
* 消息监听器:接收来自 background 或 popup 的消息
* 当收到 TOGGLE_SIDEBAR 消息时,切换侧边栏的显示状态
*/
const handler = (message: any) => {
if (message.type === "TOGGLE_SIDEBAR") {
setVisible((prev) => !prev)
}
}
chrome.runtime.onMessage.addListener(handler)
// 组件卸载时移除监听器,防止内存泄漏
return () => chrome.runtime.onMessage.removeListener(handler)
}, [])
// 不可见时不渲染任何内容
if (!visible) return null
return <SidebarPanel onClose={() => setVisible(false)} />
}
export default Sidebar
+70
View File
@@ -0,0 +1,70 @@
/**
* 后端 API 通信模块
* 封装了与 Java 后端和 Python AI 后端的 REST API 请求方法
* 注意:项目有两个后端,新增接口时务必确认是哪个后端的
*/
/** Java 后端基础地址 */
const BASE_URL = "http://localhost:8080/api"
/** Python AI 后端基础地址 */
const AI_BASE_URL = "http://localhost:5000/api"
/** 请求配置选项 */
interface ApiOptions {
/** 请求方法,默认 GET */
method?: string
/** 请求体数据 */
body?: unknown
/** 自定义请求头 */
headers?: Record<string, string>
}
/**
* 通用请求方法
* @param baseUrl - 后端基础地址
* @param path - 接口路径,如 /user/info
* @param options - 请求配置
* @returns 解析后的 JSON 响应数据
* @throws 当响应状态码非 2xx 时抛出错误
*/
async function request<T>(baseUrl: string, path: string, options: ApiOptions = {}): Promise<T> {
const { method = "GET", body, headers = {} } = options
const res = await fetch(`${baseUrl}${path}`, {
method,
headers: {
"Content-Type": "application/json",
...headers
},
body: body ? JSON.stringify(body) : undefined
})
if (!res.ok) {
throw new Error(`API Error: ${res.status} ${res.statusText}`)
}
return res.json()
}
/**
* Java 后端接口
* 用于用户管理、简历数据、投递记录等业务接口
*/
export const javaApi = {
/** 发送 GET 请求到 Java 后端 */
get: <T>(path: string) => request<T>(BASE_URL, path),
/** 发送 POST 请求到 Java 后端 */
post: <T>(path: string, body: unknown) => request<T>(BASE_URL, path, { method: "POST", body })
}
/**
* Python AI 后端接口
* 用于页面结构分析、智能填表、简历优化等 AI 功能接口
*/
export const aiApi = {
/** 发送 GET 请求到 Python AI 后端 */
get: <T>(path: string) => request<T>(AI_BASE_URL, path),
/** 发送 POST 请求到 Python AI 后端 */
post: <T>(path: string, body: unknown) => request<T>(AI_BASE_URL, path, { method: "POST", body })
}
+239
View File
@@ -0,0 +1,239 @@
/**
* 自动填写核心模块
* 负责:字段填写入口、值写入、单选填写、选择器填写、弹窗关闭、批量填写
*/
import type { MatchedFormField } from "./types"
import { findAndClickOptionInVisiblePopups, clickBestOptionInDropdown } from "./pickerFill"
import { fillDatePicker } from "./datePicker"
// ============ 工具函数 ============
/** 延时工具函数 */
export function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
/**
* 通用的值写入方法(适配各种前端框架的受控组件)
* 优先用 execCommand 模拟真实输入,失败则 fallback 到原生 setter
*/
export function forceSetValue(el: HTMLInputElement | HTMLTextAreaElement, value: string) {
if (el instanceof HTMLInputElement && el.type === "file") {
console.warn("OfferPie: 跳过 file 类型 input")
return
}
el.focus()
if (el.value) el.select()
const inserted = document.execCommand("insertText", false, value)
if (!inserted || el.value !== value) {
const isTextarea = el instanceof HTMLTextAreaElement
const setter = Object.getOwnPropertyDescriptor(
isTextarea ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype, "value"
)?.set
setter?.call(el, value)
el.dispatchEvent(new InputEvent("input", { bubbles: true, cancelable: true, inputType: "insertText", data: value }))
el.dispatchEvent(new Event("change", { bubbles: true }))
}
el.dispatchEvent(new Event("blur", { bubbles: true }))
}
/** 关闭弹出层 */
export async function closePopup(inputEl: HTMLElement) {
inputEl.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", code: "Escape", keyCode: 27, bubbles: true }))
await delay(100)
inputEl.blur()
inputEl.dispatchEvent(new Event("blur", { bubbles: true }))
await delay(100)
document.body.click()
await delay(200)
}
// ============ 字段填写 ============
/** 填写单个匹配到的表单字段 */
export async function fillMatchedField(field: MatchedFormField): Promise<boolean> {
const { labelText, inputElement, isPicker, fillValue, inputType, radioContainer } = field
if (!fillValue) { console.warn(`OfferPie: ⏭ "${labelText}" 无填写值,跳过`); return false }
try {
if (inputType === "radio" && radioContainer) return fillRadioField(radioContainer, labelText, fillValue)
if (!inputElement) { console.warn(`OfferPie: ❌ "${labelText}" 未找到 input 元素,跳过`); return false }
if (inputElement instanceof HTMLInputElement && inputElement.type === "file") { console.warn(`OfferPie: ⏭ "${labelText}" 是文件上传框,跳过`); return false }
if (isPicker) return await fillPickerField(field)
inputElement.focus()
await delay(50)
forceSetValue(inputElement, fillValue)
console.log(`OfferPie: ✅ [输入框] 已填写 "${labelText}" = "${fillValue}"`)
return true
} catch (err) { console.error(`OfferPie: ❌ 填写 "${labelText}" 出错`, err); return false }
}
/** 填写单选按钮组 */
function fillRadioField(container: Element, labelText: string, fillValue: string): boolean {
const radioSelectors = ['[class*="radio"]', 'label', '[role="radio"]', 'input[type="radio"]']
for (const sel of radioSelectors) {
for (const option of Array.from(container.querySelectorAll(sel))) {
const text = option.textContent?.trim() || ""
if (text === fillValue || text.includes(fillValue)) {
const clickTarget = (option.closest('[class*="radio"]') || option) as HTMLElement
clickTarget.click()
console.log(`OfferPie: ✅ [单选] 已选择 "${labelText}" = "${fillValue}" (点击 "${text}")`)
return true
}
}
}
for (const el of Array.from(container.querySelectorAll("*"))) {
const text = el.textContent?.trim() || ""
if (text === fillValue && (el as HTMLElement).offsetHeight > 0) {
;(el as HTMLElement).click()
console.log(`OfferPie: ✅ [单选] 已选择 "${labelText}" = "${fillValue}"`)
return true
}
}
console.warn(`OfferPie: ❌ [单选] "${labelText}" 未找到选项 "${fillValue}"`)
return false
}
/**
* 【重要】填写选择器类型字段
* 核心流程:
* 1. 记录点击前的 DOM 快照(body 子元素 + 所有可见弹出层)
* 2. 点击 input 及其单子标签父级链来展开选择器
* 3. 对比点击前后 DOM 差异,找到新增的弹出层
* 4. 在弹出层中用模糊匹配找到目标选项并点击
* 5. 找不到弹出层时 fallback 到全局可见弹出层搜索和 input 父级搜索
* 6. 最终兜底:写入值 + Enter 确认
*
* 【注意】不要删除 DOM 差异对比逻辑,这是自建组件选择器识别的核心机制
*/
async function fillPickerField(field: MatchedFormField): Promise<boolean> {
const { labelText, inputElement, fillValue } = field
if (!inputElement) return false
const isDateValue = /^\d{4}-\d{2}-\d{2}$/.test(fillValue)
// ---- 步骤1:记录点击前的 DOM 快照 ----
const POPUP_SELECTORS = [
'[class*="dropdown"]', '[class*="popup"]', '[class*="popper"]',
'[class*="picker-panel"]', '[class*="overlay"]', '[class*="popover"]',
'[class*="select-dropdown"]', '[class*="cascader"]',
'[class*="menu"][class*="content"]',
'[role="listbox"]', '[role="menu"]',
]
const beforeBodyChildren = new Set(Array.from(document.body.children))
const beforeVisiblePopups = new Set<Element>()
for (const sel of POPUP_SELECTORS) {
document.querySelectorAll(sel).forEach((el) => {
if ((el as HTMLElement).offsetHeight > 0 && (el as HTMLElement).offsetWidth > 0) beforeVisiblePopups.add(el)
})
}
// ---- 步骤2:点击展开选择器(input + 单子标签父级链) ----
const clickTargets: HTMLElement[] = [inputElement as HTMLElement]
let current: HTMLElement | null = inputElement as HTMLElement
for (let i = 0; i < 5 && current?.parentElement; i++) {
const parentEl = current.parentElement
if (parentEl.children.length <= 2) {
clickTargets.push(parentEl)
current = parentEl
} else {
break
}
}
// 从外到内逐个点击(外层容器可能绑了事件)
for (let i = clickTargets.length - 1; i >= 0; i--) {
clickTargets[i].click()
}
inputElement.focus()
await delay(300)
if (isDateValue) return await fillDatePicker(field)
// ---- 步骤3:对比 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:新变为可见的弹出层
if (!dropdownEl) {
for (const sel of POPUP_SELECTORS) {
for (const el of Array.from(document.querySelectorAll(sel))) {
const htmlEl = el as HTMLElement
if (htmlEl.offsetHeight > 10 && htmlEl.offsetWidth > 0 && !beforeVisiblePopups.has(el)) {
dropdownEl = htmlEl
break
}
}
if (dropdownEl) break
}
}
// ---- 步骤4:在弹出层中查找匹配选项 ----
if (dropdownEl) {
console.log(`OfferPie: [fillPicker] "${labelText}" 找到弹出层: ${dropdownEl.tagName}.${(dropdownEl.className || "").toString().split(" ")[0]}`)
if (clickBestOptionInDropdown(dropdownEl, fillValue)) {
await delay(300); await closePopup(inputElement)
console.log(`OfferPie: ✅ [选择器] 已选择 "${labelText}" = "${fillValue}"`)
return true
}
}
// ---- 步骤5fallback - 全局可见弹出层搜索 ----
if (findAndClickOptionInVisiblePopups(fillValue, labelText)) {
await delay(300); await closePopup(inputElement)
console.log(`OfferPie: ✅ [选择器] 已选择 "${labelText}" = "${fillValue}"`)
return true
}
// ---- 步骤5bfallback - input 父级附近搜索弹出层 ----
let parentEl: Element | null = inputElement.parentElement
for (let i = 0; i < 8 && parentEl; i++) {
const dropdowns = parentEl.querySelectorAll('[class*="dropdown"], [class*="select-dropdown"], [class*="option"], [role="listbox"]')
for (const dd of Array.from(dropdowns)) {
const htmlDd = dd as HTMLElement
if (htmlDd.offsetHeight > 10 && htmlDd.offsetWidth > 0) {
if (clickBestOptionInDropdown(htmlDd, fillValue)) {
await delay(300); await closePopup(inputElement)
console.log(`OfferPie: ✅ [选择器] 已选择 "${labelText}" = "${fillValue}"`)
return true
}
}
}
parentEl = parentEl.parentElement
}
// ---- 步骤6:写入值触发搜索,再尝试一次 ----
forceSetValue(inputElement, fillValue)
await delay(500)
if (findAndClickOptionInVisiblePopups(fillValue, labelText)) {
await delay(300); await closePopup(inputElement)
console.log(`OfferPie: ✅ [选择器] 已选择 "${labelText}" = "${fillValue}"`)
return true
}
// ---- 步骤7:兜底 Enter 确认 ----
inputElement.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", code: "Enter", keyCode: 13, bubbles: true }))
await delay(300)
await closePopup(inputElement)
console.log(`OfferPie: ⚠️ [选择器] 未找到匹配选项,已兜底填写 "${labelText}" = "${fillValue}"`)
return true
}
/** 批量填写所有匹配到的表单字段 */
export async function fillAllFields(fields: MatchedFormField[]): Promise<{ total: number; success: number; failed: number; skipped: number }> {
const result = { total: fields.length, success: 0, failed: 0, skipped: 0 }
console.log(`===== OfferPie: 开始自动填写,共 ${fields.length} 个字段 =====`)
for (const field of fields) {
if (!field.fillValue) { result.skipped++; continue }
if (field.inputType !== "radio" && !field.inputElement) { result.skipped++; continue }
const ok = await fillMatchedField(field)
if (ok) result.success++; else result.failed++
await delay(200)
}
console.log(`===== OfferPie: 自动填写完毕 总计 ${result.total} | 成功 ${result.success} | 失败 ${result.failed} | 跳过 ${result.skipped} =====`)
return result
}
+385
View File
@@ -0,0 +1,385 @@
/**
* 常量数据模块
* 包含表单标签数组、测试填写数据、UI组件库选择器配置、特殊标签修正配置等所有常量
*/
import type { FormLabelItem, UILibPickerConfig, ExperienceSectionConfig, ResumeData } from "./types"
// ============ 职位申请常用信息标签(中英文) ============
/**
* 职位申请表单常用信息标签数组
* 包含个人信息、联系方式、教育背景、求职意向等常见字段
*/
export const JOB_FORM_LABELS: FormLabelItem[] = [
// ---- 个人基本信息(主表) ----
{ key: "name", zh: ["姓名", "姓 名", "真实姓名", "名字"], en: ["Name", "Full Name", "Your Name", "Legal Name"], section: "main", resumeField: "name" },
{ key: "gender", zh: ["性别"], en: ["Gender", "Sex"], section: "main", resumeField: "" },
{ key: "birthday", zh: ["出生日期", "出生年月", "生日", "出生日期"], en: ["Date of Birth", "Birthday", "Birth Date", "DOB"], section: "main", resumeField: "" },
{ key: "idType", zh: ["证件类型"], en: ["ID Type", "Document Type"], section: "main", resumeField: "" },
{ key: "idNumber", zh: ["身份证号", "身份证", "证件号码", "证件号"], en: ["ID Number", "National ID", "ID Card Number"], section: "main", resumeField: "" },
{ key: "nationality", zh: ["国籍"], en: ["Nationality", "Country of Citizenship"], section: "main", resumeField: "" },
{ key: "ethnicity", zh: ["民族"], en: ["Ethnicity", "Race"], section: "main", resumeField: "" },
{ key: "maritalStatus", zh: ["婚姻状况", "婚姻"], en: ["Marital Status", "Marriage Status"], section: "main", resumeField: "" },
{ key: "hasChildren", zh: ["有无子女", "子女"], en: ["Children", "Has Children"], section: "main", resumeField: "" },
{ key: "politicalStatus", zh: ["政治面貌"], en: ["Political Status", "Political Affiliation"], section: "main", resumeField: "" },
{ key: "nativePlace", zh: ["籍贯", "户籍", "户口所在地"], en: ["Native Place", "Hometown", "Place of Origin"], section: "main", resumeField: "" },
{ key: "height", zh: ["身高", "身高(cm)"], en: ["Height"], section: "main", resumeField: "" },
{ key: "weight", zh: ["体重", "体重(kg)"], en: ["Weight"], section: "main", resumeField: "" },
{ key: "avatarUrl", zh: ["头像", "照片", "个人照片"], en: ["Avatar", "Photo", "Profile Photo"], section: "main", resumeField: "avatarUrl" },
{ key: "freshGraduate", zh: ["应届生", "应届生标识", "是否应届"], en: ["Fresh Graduate", "New Graduate", "Recent Graduate"], section: "main", resumeField: "" },
// ---- 联系方式(主表) ----
{ key: "phone", zh: ["手机号", "手机", "电话", "联系电话", "手机号码", "移动电话", "手机类型"], en: ["Phone", "Mobile", "Phone Number", "Cell Phone", "Telephone", "Contact Number"], section: "main", resumeField: "mobileNumber" },
{ key: "email", zh: ["邮箱", "电子邮箱", "邮件", "电子邮件"], en: ["Email", "E-mail", "Email Address"], section: "main", resumeField: "email" },
{ key: "address", zh: ["地址", "通讯地址", "联系地址", "现居住地", "家庭住址", "现住址"], en: ["Address", "Current Address", "Mailing Address", "Location", "Home Address"], section: "main", resumeField: "city" },
{ key: "zipCode", zh: ["邮编", "邮政编码"], en: ["Zip Code", "Postal Code", "ZIP"], section: "main", resumeField: "" },
{ key: "wechatNumber", zh: ["微信号", "微信", "微信账号"], en: ["WeChat", "WeChat ID", "WeChat Number"], section: "main", resumeField: "wechatNumber" },
{ key: "qq", zh: ["QQ", "QQ号", "QQ号码"], en: ["QQ", "QQ Number"], section: "main", resumeField: "" },
{ key: "emergencyContact", zh: ["紧急联系人", "紧急联系人姓名"], en: ["Emergency Contact", "Emergency Contact Name"], section: "main", resumeField: "" },
{ key: "emergencyPhone", zh: ["紧急联系方式", "紧急联系电话", "紧急联系人电话"], en: ["Emergency Phone", "Emergency Contact Number"], section: "main", resumeField: "" },
{ key: "portfolioUrl", zh: ["作品集", "作品链接", "个人作品", "作品集链接"], en: ["Portfolio", "Portfolio URL", "Portfolio Link", "Work Samples"], section: "main", resumeField: "portfolioUrl" },
// ---- 教育经历(数组) ----
{ key: "school", zh: ["学校", "学校名称", "毕业院校", "院校", "毕业学校"], en: ["School", "University", "College", "Institution", "School Name"], section: "education", resumeField: "school" },
{ key: "schoolLocation", zh: ["学校所在地", "学校所在城市"], en: ["School Location", "Campus Location"], section: "education", resumeField: "" },
{ key: "major", zh: ["专业", "专业名称", "所学专业", "主修课程"], en: ["Major", "Field of Study", "Specialization", "Program"], section: "education", resumeField: "major" },
{ key: "secondMajor", zh: ["第二专业", "辅修专业", "辅修"], en: ["Second Major", "Minor", "Double Major"], section: "education", resumeField: "" },
{ key: "degree", zh: ["学历", "学位", "最高学历", "最高学位"], en: ["Degree", "Education Level", "Qualification", "Education"], section: "education", resumeField: "degree" },
{ key: "studyType", zh: ["学习形式", "就读方式", "全日制/非全日制", "学习方式"], en: ["Study Type", "Study Mode", "Full-time/Part-time"], section: "education", resumeField: "studyType" },
{ key: "graduationDate", zh: ["毕业时间", "毕业日期", "毕业年份"], en: ["Graduation Date", "Graduation Year", "Expected Graduation"], section: "education", resumeField: "endDate" },
{ key: "eduStartDate", zh: ["入学时间", "开始时间"], en: ["Start Date", "Enrollment Date", "From"], section: "education", resumeField: "startDate" },
{ key: "eduEndDate", zh: ["结束时间", "毕业时间"], en: ["End Date", "To", "Until"], section: "education", resumeField: "endDate" },
{ key: "eduDescription", zh: ["在校经历", "教育描述", "在校描述"], en: ["Education Description", "Academic Description"], section: "education", resumeField: "description" },
{ key: "gpa", zh: ["GPA", "绩点", "成绩", "平均分"], en: ["GPA", "Grade Point Average", "Academic Score"], section: "education", resumeField: "" },
{ key: "graduationThesis", zh: ["毕业论文", "毕业设计", "毕业论文/设计题目"], en: ["Thesis", "Graduation Thesis", "Dissertation"], section: "education", resumeField: "" },
// ---- 求职意向(主表) ----
{ key: "expectedSalary", zh: ["期望薪资", "期望月薪", "薪资要求", "期望薪酬", "薪酬", "薪资", "年薪", "月薪", "薪酬待遇", "期望年薪"], en: ["Expected Salary", "Desired Salary", "Salary Expectation", "Compensation"], section: "main", resumeField: "" },
{ key: "expectedPosition", zh: ["期望职位", "意向岗位", "应聘职位", "期望月薪/年薪"], en: ["Expected Position", "Desired Position", "Job Title", "Position Applied"], section: "main", resumeField: "targetPosition" },
{ key: "expectedCity", zh: ["期望城市", "工作城市", "意向城市", "期望工作地点", "期望工作地"], en: ["Preferred City", "Work Location", "Desired Location", "Preferred Location"], section: "main", resumeField: "city" },
{ key: "availableDate", zh: ["到岗时间", "入职时间", "可到岗日期"], en: ["Available Date", "Start Date", "Earliest Start Date", "Availability"], section: "main", resumeField: "" },
{ key: "workYears", zh: ["工作年限", "工作经验", "工作年限(年)", "期望工作地"], en: ["Work Experience", "Years of Experience", "Work Years"], section: "main", resumeField: "" },
{ key: "jobAcceptAdjust", zh: ["是否接受岗位调剂", "接受调剂", "服从调剂"], en: ["Accept Job Adjustment", "Open to Relocation", "Flexible Position"], section: "main", resumeField: "" },
{ key: "idCardNumber", zh: ["证件号", "证件号码"], en: ["ID Card Number", "Certificate Number"], section: "main", resumeField: "" },
// ---- 工作经历(数组) ----
{ key: "workCompany", zh: ["公司", "公司名称", "单位名称", "工作单位", "公司全称"], en: ["Company", "Company Name", "Employer", "Organization"], section: "work", resumeField: "companyName" },
{ key: "workCompanyShort", zh: ["公司简称"], en: ["Company Abbreviation", "Short Name"], section: "work", resumeField: "" },
{ key: "workCompanyScale", zh: ["公司规模"], en: ["Company Size", "Company Scale"], section: "work", resumeField: "" },
{ key: "workPosition", zh: ["职位", "职位名称", "岗位", "担任职务", "职位名称"], en: ["Job Title", "Position", "Title", "Role"], section: "work", resumeField: "position" },
{ key: "workStartDate", zh: ["开始时间", "入职时间"], en: ["Start Date", "From"], section: "work", resumeField: "startDate" },
{ key: "workEndDate", zh: ["结束时间", "离职时间"], en: ["End Date", "To"], section: "work", resumeField: "endDate" },
{ key: "workDescription", zh: ["工作描述", "工作内容", "职责描述", "岗位职责", "职位描述"], en: ["Job Description", "Responsibilities", "Description", "Duties"], section: "work", resumeField: "description" },
// ---- 实习经历(数组) ----
{ key: "internCompany", zh: ["实习公司", "实习单位"], en: ["Intern Company", "Internship Company"], section: "internship", resumeField: "companyName" },
{ key: "internPosition", zh: ["实习职位", "实习岗位"], en: ["Intern Position", "Internship Role"], section: "internship", resumeField: "position" },
{ key: "internStartDate", zh: ["实习开始时间"], en: ["Internship Start Date"], section: "internship", resumeField: "startDate" },
{ key: "internEndDate", zh: ["实习结束时间"], en: ["Internship End Date"], section: "internship", resumeField: "endDate" },
{ key: "internDescription", zh: ["实习描述", "实习内容"], en: ["Internship Description", "Intern Duties"], section: "internship", resumeField: "description" },
// ---- 项目经历(数组) ----
{ key: "projectName", zh: ["项目名称", "项目名", "项目", "项目名称"], en: ["Project Name", "Project", "Project Title"], section: "project", resumeField: "projectName" },
{ key: "projectCompany", zh: ["所属公司", "项目所属公司", "项目公司"], en: ["Project Company", "Company"], section: "project", resumeField: "companyName" },
{ key: "projectRole", zh: ["担任角色", "项目角色", "角色"], en: ["Role", "Project Role", "Your Role"], section: "project", resumeField: "role" },
{ key: "projectStartDate", zh: ["项目开始时间"], en: ["Project Start Date"], section: "project", resumeField: "startDate" },
{ key: "projectEndDate", zh: ["项目结束时间"], en: ["Project End Date"], section: "project", resumeField: "endDate" },
{ key: "projectDescription", zh: ["项目描述", "项目内容", "项目职责"], en: ["Project Description", "Project Details", "Project Responsibilities"], section: "project", resumeField: "description" },
// ---- 竞赛经历(数组) ----
{ key: "competitionName", zh: ["竞赛名称", "比赛名称", "竞赛"], en: ["Competition Name", "Contest Name", "Competition"], section: "competition", resumeField: "competitionName" },
{ key: "competitionAward", zh: ["获奖情况", "奖项", "获奖"], en: ["Award", "Prize", "Achievement"], section: "competition", resumeField: "award" },
{ key: "competitionDate", zh: ["获奖时间", "比赛时间"], en: ["Award Date", "Competition Date"], section: "competition", resumeField: "awardDate" },
{ key: "competitionDescription", zh: ["竞赛描述", "比赛描述"], en: ["Competition Description", "Contest Details"], section: "competition", resumeField: "description" },
// ---- 技能与证书(主表) ----
{ key: "skills", zh: ["技能", "专业技能", "技能特长", "专业特长"], en: ["Skills", "Technical Skills", "Competencies"], section: "main", resumeField: "skills" },
{ key: "language", zh: ["语言水平", "语言能力", "外语能力", "英语等级", "英语等级成绩", "英语水平", "外语水平", "语言考试"], en: ["Language Proficiency", "English Level", "Language Skills", "Foreign Language", "Language Level"], section: "main", resumeField: "" },
{ key: "certificate", zh: ["证书", "资格证书", "获得证书"], en: ["Certificate", "Certification", "License", "Credentials"], section: "main", resumeField: "certificates" },
// ---- 自我评价与附加信息(主表) ----
{ key: "selfIntro", zh: ["自我评价", "自我介绍", "个人简介", "个人总结", "自我描述"], en: ["Self Introduction", "About Me", "Summary", "Personal Statement", "Profile"], section: "main", resumeField: "summary" },
{ key: "hobby", zh: ["兴趣爱好", "爱好", "健身爱好", "个人爱好"], en: ["Hobbies", "Interests", "Hobbies & Interests"], section: "main", resumeField: "" },
{ key: "specialty", zh: ["特长", "个人特长"], en: ["Specialty", "Strengths", "Personal Strengths"], section: "main", resumeField: "" },
{ key: "rewardPunishment", zh: ["奖惩情况", "奖惩", "奖励情况"], en: ["Rewards & Punishments", "Awards & Disciplinary"], section: "main", resumeField: "" },
{ key: "academicWork", zh: ["学术专著", "学术著作"], en: ["Academic Works", "Publications"], section: "main", resumeField: "" },
{ key: "academicPaper", zh: ["学术论文", "论文发表"], en: ["Academic Papers", "Research Papers", "Thesis"], section: "main", resumeField: "" },
{ key: "patent", zh: ["专利发明", "专利", "发明专利"], en: ["Patents", "Inventions", "Patent"], section: "main", resumeField: "" },
{ key: "coverLetter", zh: ["求职信", "自荐信"], en: ["Cover Letter", "Application Letter"], section: "main", resumeField: "" },
{ key: "referral", zh: ["推荐人", "内推人", "推荐渠道", "渠道"], en: ["Referral", "Referred By", "How did you hear", "Source"], section: "main", resumeField: "" },
{ key: "agreement", zh: ["本人保证", "同意条款", "隐私协议", "声明"], en: ["Agreement", "I agree", "Terms", "Declaration", "Consent"], section: "main", resumeField: "" },
]
// ============ 测试数据 ============
/** 根据标签 key 生成的测试填写数据(中英文各一份),用于开发调试阶段 */
export const TEST_FILL_DATA: Record<string, { zh: string; en: string }> = {
name: { zh: "李华", en: "Hua Li" },
gender: { zh: "男", en: "Male" },
birthday: { zh: "2002-03-15", en: "2002-03-15" },
idType: { zh: "身份证", en: "ID Card" },
idNumber: { zh: "110101200203150011", en: "110101200203150011" },
nationality: { zh: "中国", en: "China" },
ethnicity: { zh: "汉族", en: "Han" },
maritalStatus: { zh: "未婚", en: "Single" },
hasChildren: { zh: "无", en: "No" },
politicalStatus: { zh: "共青团员", en: "Communist Youth League" },
nativePlace: { zh: "北京", en: "Beijing" },
height: { zh: "175", en: "175" },
weight: { zh: "70", en: "70" },
avatarUrl: { zh: "", en: "" },
freshGraduate: { zh: "是", en: "Yes" },
phone: { zh: "13800138000", en: "13800138000" },
email: { zh: "lihua@example.com", en: "lihua@example.com" },
address: { zh: "北京市海淀区中关村大街1号", en: "No.1 Zhongguancun Street, Haidian, Beijing" },
zipCode: { zh: "100080", en: "100080" },
wechatNumber: { zh: "lihua_wx", en: "lihua_wx" },
qq: { zh: "123456789", en: "123456789" },
emergencyContact: { zh: "李明", en: "Ming Li" },
emergencyPhone: { zh: "13900139000", en: "13900139000" },
portfolioUrl: { zh: "https://portfolio.example.com", en: "https://portfolio.example.com" },
school: { zh: "北京大学", en: "Peking University" },
schoolLocation: { zh: "北京", en: "Beijing" },
major: { zh: "计算机科学与技术", en: "Computer Science" },
secondMajor: { zh: "数学与应用数学", en: "Mathematics" },
degree: { zh: "硕士", en: "Master" },
studyType: { zh: "全日制", en: "Full-time" },
graduationDate: { zh: "2024-07-01", en: "2024-07-01" },
eduStartDate: { zh: "2020-09-01", en: "2020-09-01" },
eduEndDate: { zh: "2024-07-01", en: "2024-07-01" },
eduDescription: { zh: "主修计算机科学核心课程", en: "Majored in core CS courses" },
gpa: { zh: "3.8", en: "3.8" },
graduationThesis: { zh: "基于深度学习的文本分类研究", en: "Text Classification Based on Deep Learning" },
expectedSalary: { zh: "15000", en: "15000" },
expectedPosition: { zh: "前端开发工程师", en: "Frontend Developer" },
expectedCity: { zh: "北京", en: "Beijing" },
availableDate: { zh: "2024-08-01", en: "2024-08-01" },
workYears: { zh: "3", en: "3" },
jobAcceptAdjust: { zh: "是", en: "Yes" },
idCardNumber: { zh: "110101200203150011", en: "110101200203150011" },
workCompany: { zh: "字节跳动", en: "ByteDance" },
workCompanyShort: { zh: "字节", en: "ByteDance" },
workCompanyScale: { zh: "10000人以上", en: "10000+" },
workPosition: { zh: "前端开发工程师", en: "Frontend Developer" },
workStartDate: { zh: "2023-06-01", en: "2023-06-01" },
workEndDate: { zh: "2023-09-01", en: "2023-09-01" },
workDescription: { zh: "负责活动页面开发与性能优化", en: "Developed campaign pages and optimized performance" },
internCompany: { zh: "腾讯", en: "Tencent" },
internPosition: { zh: "前端实习生", en: "Frontend Intern" },
internStartDate: { zh: "2022-07-01", en: "2022-07-01" },
internEndDate: { zh: "2022-09-30", en: "2022-09-30" },
internDescription: { zh: "参与小程序开发", en: "Participated in mini-program development" },
projectName: { zh: "智能简历填写助手", en: "Smart Resume Filler" },
projectCompany: { zh: "字节跳动", en: "ByteDance" },
projectRole: { zh: "前端负责人", en: "Frontend Lead" },
projectStartDate: { zh: "2023-03-01", en: "2023-03-01" },
projectEndDate: { zh: "2023-06-01", en: "2023-06-01" },
projectDescription: { zh: "负责项目前端架构设计与开发", en: "Led frontend architecture design and development" },
competitionName: { zh: "ACM程序设计大赛", en: "ACM Programming Contest" },
competitionAward: { zh: "金奖", en: "Gold Award" },
competitionDate: { zh: "2023-07-01", en: "2023-07-01" },
competitionDescription:{ zh: "团队协作完成算法竞赛", en: "Team collaboration in algorithm competition" },
skills: { zh: "React, TypeScript, Node.js", en: "React, TypeScript, Node.js" },
language: { zh: "CET-6 550分", en: "CET-6 550" },
certificate: { zh: "软件设计师", en: "Software Designer Certificate" },
selfIntro: { zh: "热爱前端开发,有丰富的项目经验", en: "Passionate frontend developer with solid project experience" },
hobby: { zh: "篮球、游泳、阅读", en: "Basketball, Swimming, Reading" },
specialty: { zh: "全栈开发、算法竞赛", en: "Full-stack Development, Algorithm Competition" },
rewardPunishment: { zh: "校级优秀学生", en: "Outstanding Student Award" },
academicWork: { zh: "", en: "" },
academicPaper: { zh: "", en: "" },
patent: { zh: "", en: "" },
coverLetter: { zh: "您好,我对贵公司的岗位非常感兴趣", en: "Dear Hiring Manager, I am very interested in this position" },
referral: { zh: "学校招聘会", en: "Campus Job Fair" },
agreement: { zh: "同意", en: "I agree" },
}
// ============ 特殊标签 input 修正配置 ============
/**
* 特殊标签的 placeholder 关键词配置
* key: 标签的 keyplaceholders: 该标签对应 input 的 placeholder 应包含的关键词
*/
export const SPECIAL_LABEL_PLACEHOLDERS: Record<string, string[]> = {
/** 证件号码:前面常有证件类型 selectfindNearestInput 容易找到 select 的 input */
idNumber: ["证件号码", "证件号", "身份证号", "身份证", "ID Number", "ID Card"],
}
// ============ UI 组件库选择器类名数据 ============
/** 常用 PC 端 UI 组件库的各类选择器类名数据 */
export const UI_LIB_PICKER_CONFIGS: UILibPickerConfig[] = [
{ libName: "Element UI", dropdownClasses: ["el-select-dropdown", "el-date-picker", "el-picker-panel", "el-cascader-panel", "el-time-panel", "el-date-range-picker", "el-popper", "el-autocomplete-suggestion"], optionItemClasses: ["el-select-dropdown__item", "el-date-table td", "el-cascader-node", "el-time-spinner__item"], triggerClasses: ["el-select", "el-date-editor", "el-cascader", "el-time-select", "el-autocomplete"] },
{ libName: "Ant Design", dropdownClasses: ["ant-select-dropdown", "ant-picker-dropdown", "ant-cascader-dropdown", "ant-picker-panel", "ant-select-tree-dropdown", "ant-dropdown"], optionItemClasses: ["ant-select-item-option", "ant-picker-cell-inner", "ant-cascader-menu-item", "ant-select-tree-treenode"], triggerClasses: ["ant-select", "ant-picker", "ant-cascader", "ant-select-selector"] },
{ libName: "Arco Design", dropdownClasses: ["arco-select-popup", "arco-picker-container", "arco-trigger-popup", "arco-cascader-popup", "arco-datepicker-container"], optionItemClasses: ["arco-select-option", "arco-picker-cell-inner", "arco-cascader-option"], triggerClasses: ["arco-select", "arco-picker", "arco-cascader", "arco-select-view"] },
{ libName: "TDesign", dropdownClasses: ["t-select__dropdown", "t-date-picker__panel", "t-popup", "t-cascader__popup", "t-time-picker__panel"], optionItemClasses: ["t-select-option", "t-date-picker__cell-inner", "t-cascader__item"], triggerClasses: ["t-select", "t-date-picker", "t-cascader", "t-input__wrap"] },
{ libName: "Naive UI", dropdownClasses: ["n-base-select-menu", "n-date-panel", "n-cascader-menu", "n-time-picker-panel", "n-auto-complete-menu"], optionItemClasses: ["n-base-select-option", "n-date-panel-date", "n-cascader-option"], triggerClasses: ["n-select", "n-date-picker", "n-cascader", "n-auto-complete"] },
{ libName: "iView / View UI", dropdownClasses: ["ivu-select-dropdown", "ivu-date-picker-transfer", "ivu-cascader-transfer", "ivu-time-picker-transfer"], optionItemClasses: ["ivu-select-item", "ivu-date-picker-cells-cell", "ivu-cascader-menu-item"], triggerClasses: ["ivu-select", "ivu-date-picker", "ivu-cascader"] },
{ libName: "Vuetify", dropdownClasses: ["v-menu__content", "v-picker", "v-date-picker-table", "v-select-list", "v-autocomplete__content"], optionItemClasses: ["v-list-item", "v-date-picker-table__current", "v-btn--active"], triggerClasses: ["v-select", "v-text-field", "v-autocomplete", "v-input"] },
{ libName: "MUI", dropdownClasses: ["MuiPopover-root", "MuiMenu-paper", "MuiAutocomplete-popper", "MuiPickersPopper-root", "MuiPaper-root"], optionItemClasses: ["MuiMenuItem-root", "MuiAutocomplete-option", "MuiPickersDay-root"], triggerClasses: ["MuiSelect-select", "MuiAutocomplete-root", "MuiInputBase-root"] },
{ libName: "Semi Design", dropdownClasses: ["semi-select-option-list", "semi-datepicker-panel-container", "semi-cascader-option-lists", "semi-popover-wrapper"], optionItemClasses: ["semi-select-option", "semi-datepicker-day", "semi-cascader-option"], triggerClasses: ["semi-select", "semi-datepicker", "semi-cascader", "semi-input-wrapper"] },
]
// ============ 经历区块标题关键词配置 ============
/**
* 5大经历区块的标题关键词配置
* 用于在页面 DOM 中识别经历区块位置、统计已展开段数、查找添加按钮
*/
export const EXPERIENCE_SECTION_CONFIGS: ExperienceSectionConfig[] = [
{
section: "education",
zh: ["教育背景", "教育经历", "学历信息", "教育信息"],
en: ["Education", "Education Background", "Academic Background", "Education History"],
coreFieldKey: "school",
addButtonZh: ["添加", "新增", "添加教育", "添加教育经历", "新增教育经历", "+ 添加"],
addButtonEn: ["Add", "Add Education", "Add More", "+ Add"],
},
{
section: "work",
zh: ["工作经历", "工作经验", "工作信息"],
en: ["Work Experience", "Employment History", "Work History", "Professional Experience"],
coreFieldKey: "workCompany",
addButtonZh: ["添加", "新增", "添加工作", "添加工作经历", "新增工作经历", "+ 添加"],
addButtonEn: ["Add", "Add Work", "Add Experience", "Add More", "+ Add"],
},
{
section: "internship",
zh: ["实习经历", "实习经验", "实习信息"],
en: ["Internship", "Internship Experience", "Intern Experience"],
coreFieldKey: "internCompany",
addButtonZh: ["添加", "新增", "添加实习", "添加实习经历", "新增实习经历", "+ 添加"],
addButtonEn: ["Add", "Add Internship", "Add More", "+ Add"],
},
{
section: "project",
zh: ["项目经历", "项目经验", "项目信息"],
en: ["Project", "Project Experience", "Projects"],
coreFieldKey: "projectName",
addButtonZh: ["添加", "新增", "添加项目", "添加项目经历", "新增项目经历", "+ 添加"],
addButtonEn: ["Add", "Add Project", "Add More", "+ Add"],
},
{
section: "competition",
zh: ["竞赛经历", "获奖经历", "竞赛信息", "获奖情况"],
en: ["Competition", "Awards", "Competitions & Awards", "Contest Experience"],
coreFieldKey: "competitionName",
addButtonZh: ["添加", "新增", "添加竞赛", "添加竞赛经历", "新增竞赛经历", "+ 添加"],
addButtonEn: ["Add", "Add Competition", "Add Award", "Add More", "+ Add"],
},
]
// ============ Mock 简历数据(模拟接口返回) ============
/**
* 模拟后端接口返回的完整简历数据
* 后续会替换为真实接口调用:javaApi.get<ResumeData>("/resume/detail")
*/
export function getMockResumeData(): ResumeData {
return {
main: {
avatarUrl: "",
name: "洪赫",
email: "1908796496@qq.com",
mobileNumber: "13713539828",
city: "",
wechatNumber: "13713539828",
portfolioUrl: "",
skills: ["数据分析", "模型训练", "模型测试", "算法部署", "图像识别", "目标识别", "目标检测", "Python"],
certificates: ["英语四级", "雅思6分"],
summary: "专业能力:掌握药学、医学相关理论知识,熟练使用Office办公软件(Word、Excel、PPT)进行数据整理和文档撰写,python熟练,了解数据库搭建和管理(sql),具备医学文献检索和数据分析能力。语言能力:英语四级,雅思6分,中英文书面与口头表达能力较强,能够进行跨部门沟通。普通话标准、流利。性格特质:工作态度认真,注重细节,有强烈的好奇心和学习能力,专业课排前20%,具备较强的逻辑分析能力,责任心强,团队合作意识强,能适应阶段性高强度工作。兴趣爱好:篮球、游戏、音乐等,参加院系篮球队、校院音乐节以及歌唱比赛并取得不错名次。",
},
education: [
{
id: "qLVFVDxh",
school: "香港理工大学",
major: "数据科学与分析",
degree: "硕士",
studyType: "全日制",
startDate: "2025.09",
endDate: "2026.12",
description: [
{ type: "text", content: "系统学习数据分析、统计建模、数据挖掘等核心课程,具备扎实的数据整理与逻辑分析能力" },
{ type: "text", content: "掌握数据库管理、医学文献检索工具使用方法,能够高效处理和分析大规模数据" },
],
},
{
id: "AntTOPmb",
school: "华南师范大学",
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: [
{
id: "EoNkjAF1",
companyName: "深圳乐动机器人科技有限公司",
position: "机器人感知算法工程师",
startDate: "2024.05",
endDate: "2024.08",
description: [
{ type: "text", content: "负责数据收集、分析、模型训练与测试、算法部署等全流程工作,注重数据准确性与细节管理;" },
{ type: "text", content: "参与割草机算法研发工作,包括分类、检测、分割、跟踪等模块,具备快速学习新技术领域的能力;" },
{ type: "text", content: "撰写技术文档与测试报告,进行跨部门沟通协作,确保项目按时交付并符合质量标准。" },
],
},
{
id: "cX5EGRDD",
companyName: "江西卓云深圳研发中心",
position: "图像算法工程师",
startDate: "2023.12",
endDate: "2024.02",
description: [
{ type: "text", content: "运用Python进行数据处理与分析,开发部署应用程序,具备较强的逻辑分析和数据整理能力;" },
{ type: "text", content: "独立完成项目全生命周期管理,包括数据收集、标注、模型训练、部署及性能调优,能应对重复性工作并保证准确性;" },
{ type: "text", content: "熟练掌握Office办公软件及数据库工具,具备文献检索与信息整理经验,工作态度认真负责。" },
],
},
],
project: [
{
id: "q966EOx2",
companyName: "",
projectName: "微信小程序开发与测试",
role: "",
startDate: "2022.01",
endDate: "",
description: [
{ type: "text", content: "负责小程序功能模块的开发与测试,严格遵循开发规范和质量标准,确保系统稳定运行" },
{ type: "text", content: "参与需求分析与文档撰写工作,具备良好的书面表达能力和跨部门沟通协调能力" },
{ type: "text", content: "注重细节管理,对测试数据进行系统整理与分析,保证项目交付的准确性" },
],
},
{
id: "KVSEaywu",
companyName: "",
projectName: "多人游戏聊天网页开发",
role: "",
startDate: "2022.08",
endDate: "",
description: [
{ type: "text", content: "负责网页功能开发与用户交互模块设计,具备快速学习新技术和解决问题的能力" },
{ type: "text", content: "熟练使用Office等办公软件进行项目文档管理和数据整理工作" },
{ type: "text", content: "具备团队合作精神,能够配合完成阶段性高强度工作任务" },
],
},
{
id: "KQtH64TE",
companyName: "",
projectName: "基于SEIR模型的疫情大数据追踪预测应用研究",
role: "",
startDate: "2022.12",
endDate: "",
description: [
{ type: "text", content: "参与疫情数据监测与分析项目,运用数据模型进行风险评估和趋势预测" },
{ type: "text", content: "负责大量数据的收集、整理与分析工作,具备较强的逻辑分析能力和数据处理能力" },
{ type: "text", content: "撰写项目研究报告,具备文献检索和专业资料查阅能力,工作态度认真负责" },
{ type: "text", content: "对公共卫生安全监测工作有初步认知,理解数据监测在风险管理中的重要性" },
],
},
],
competition: [],
}
}
File diff suppressed because it is too large Load Diff
+95
View File
@@ -0,0 +1,95 @@
/**
* DOM 工具模块
* 负责:DOM 结构提取、页面语言检测、表单页面判断、CSS 选择器生成
*/
import { JOB_FORM_LABELS } from "./constants"
/** 只保留的属性白名单 */
const KEEP_ATTRS = ["id", "class", "placeholder", "disabled"]
/**
* 提取当前页面的精简 DOM 树结构
* 只保留 id、class、placeholder 属性和标签内部的直接文字内容
*/
export function extractDomStructure(rootEl: Element = document.body): string {
const walk = (el: Element, depth: number = 0): string => {
const indent = " ".repeat(depth)
const tag = el.tagName.toLowerCase()
const attrs = KEEP_ATTRS
.filter((name) => el.hasAttribute(name))
.map((name) => ` ${name}="${el.getAttribute(name)}"`)
.join("")
const directText = Array.from(el.childNodes)
.filter((node) => node.nodeType === Node.TEXT_NODE)
.map((node) => node.textContent?.trim())
.filter(Boolean)
.join(" ")
const selfClosing = ["input", "img", "br", "hr", "meta", "link"].includes(tag)
if (selfClosing) return `${indent}<${tag}${attrs} />\n`
if (el.children.length === 0 && directText) return `${indent}<${tag}${attrs}>${directText}</${tag}>\n`
let result = `${indent}<${tag}${attrs}>`
if (directText) result += directText
result += "\n"
for (const child of Array.from(el.children)) result += walk(child, depth + 1)
result += `${indent}</${tag}>\n`
return result
}
return walk(rootEl)
}
/**
* 通过页面可见文字内容判断页面是中文还是英文
*/
export function detectPageLanguage(domStructure: string): "zh" | "en" {
const pageText = document.body.innerText || ""
const chineseChars = (pageText.match(/[\u4e00-\u9fff]/g) || []).length
const englishChars = (pageText.match(/[a-zA-Z]/g) || []).length
return chineseChars >= englishChars ? "zh" : "en"
}
/**
* 判断当前页面是否为职位申请表单页面
* 条件:DOM 中匹配到 ≥2 个标签数组中的标签文字,且页面 input 元素 ≥2 个
*/
export function isJobApplicationForm(rootEl: Element = document.body, lang: "zh" | "en"): boolean {
const pageText = (rootEl as HTMLElement).innerText || rootEl.textContent || ""
const inputCount = rootEl.querySelectorAll("input, textarea, select").length
if (inputCount < 2) return false
let matchedCount = 0
for (const item of JOB_FORM_LABELS) {
const labels = lang === "zh" ? item.zh : item.en
if (labels.some((label) => pageText.includes(label))) matchedCount++
if (matchedCount >= 2) return true
}
return false
}
/**
* 生成元素的唯一 CSS 选择器路径
*/
export function buildSelector(el: Element): string {
if (el.id) return `#${CSS.escape(el.id)}`
const parts: string[] = []
let current: Element | null = el
while (current && current !== document.body && current !== document.documentElement) {
let selector = current.tagName.toLowerCase()
if (current.className && typeof current.className === "string") {
const classes = current.className.trim().split(/\s+/).slice(0, 2)
if (classes.length > 0 && classes[0]) {
selector += "." + classes.map((c) => CSS.escape(c)).join(".")
}
}
const parent = current.parentElement
if (parent) {
const siblings = Array.from(parent.children).filter((s) => s.tagName === current!.tagName)
if (siblings.length > 1) {
const index = siblings.indexOf(current) + 1
selector += `:nth-child(${index})`
}
}
parts.unshift(selector)
current = current.parentElement
}
return parts.join(" > ")
}
+224
View File
@@ -0,0 +1,224 @@
/**
* 表单字段匹配模块
* 负责:在页面 DOM 中匹配标签数组对应的表单字段,查找关联的 input 元素
* 包含:标签匹配、input 查找、button 查找、特殊标签修正
*/
import type { MatchedFormField } from "./types"
import { JOB_FORM_LABELS, SPECIAL_LABEL_PLACEHOLDERS } from "./constants"
import { buildSelector } from "./dom"
// ============ 特殊标签 input 修正 ============
/**
* 对特殊标签进行 input 修正
* 当 findNearestInput 找到的 input 的 placeholder 和预期不匹配时,
* 从标签元素逐级向上查找 placeholder 匹配的 input
*/
function fixSpecialLabelInput(
key: string,
currentInput: HTMLInputElement | HTMLTextAreaElement,
labelEl: Element,
usedInputs: Set<Element>
): HTMLInputElement | HTMLTextAreaElement {
const placeholders = SPECIAL_LABEL_PLACEHOLDERS[key]
if (!placeholders) return currentInput
const ph = currentInput.getAttribute("placeholder")?.trim() || ""
if (placeholders.some((p) => ph.includes(p))) return currentInput
const INPUT_SEL = "input:not([type='hidden']):not([type='submit']):not([type='button']):not([type='checkbox']):not([type='radio']):not([type='file']):not([disabled])"
let searchParent: Element | null = labelEl.parentElement
for (let depth = 0; depth < 6 && searchParent; depth++) {
const candidates = searchParent.querySelectorAll(INPUT_SEL)
for (const candidate of Array.from(candidates)) {
if (usedInputs.has(candidate)) continue
const cph = candidate.getAttribute("placeholder")?.trim() || ""
if (placeholders.some((p) => cph.includes(p))) {
return candidate as HTMLInputElement
}
}
searchParent = searchParent.parentElement
}
return currentInput
}
// ============ 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.兜底查兄弟节点
*/
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:逐级向上查找
let parent: Element | null = labelEl.parentElement
for (let i = 0; i < 5 && parent; i++) {
const inputs = parent.querySelectorAll(INPUT_SEL)
if (inputs.length === 1) return inputs[0] as HTMLInputElement | HTMLTextAreaElement
if (inputs.length > 1) return findClosestInputAfterLabel(labelEl, parent)
parent = parent.parentElement
}
// 策略3:兜底查兄弟节点
let sibling = labelEl.nextElementSibling
while (sibling) {
if ((sibling.tagName === "INPUT" || sibling.tagName === "TEXTAREA") &&
!(sibling as HTMLInputElement).type?.match(/hidden|submit|button|checkbox|radio|file/)) {
return sibling as HTMLInputElement | HTMLTextAreaElement
}
const innerInput = sibling.querySelector(INPUT_SEL) as HTMLInputElement | HTMLTextAreaElement | null
if (innerInput) return innerInput
sibling = sibling.nextElementSibling
}
return null
}
/** 在包含多个 input 的父容器中,找到 DOM 顺序上紧跟在标签后面的第一个 input */
function findClosestInputAfterLabel(labelEl: Element, container: Element): HTMLInputElement | HTMLTextAreaElement | null {
const allInputs = Array.from(container.querySelectorAll(INPUT_SEL)) as (HTMLInputElement | HTMLTextAreaElement)[]
for (const input of allInputs) {
const position = labelEl.compareDocumentPosition(input)
if (position & Node.DOCUMENT_POSITION_FOLLOWING) return input
}
return null
}
/** 在 input 元素的同级或父级查找关联的 button */
function findNearbyButton(inputEl: Element): HTMLElement | null {
const BTN_SELECTOR = 'button, [role="button"], [class*="select"][class*="suffix"], [class*="select"][class*="arrow"], [class*="picker"][class*="icon"], [class*="calendar"][class*="icon"]'
// 查找同级兄弟元素(前后各 2 个)
let sibling = inputEl.nextElementSibling
let count = 0
while (sibling && count < 2) {
if (sibling.matches(BTN_SELECTOR)) return sibling as HTMLElement
const inner = sibling.querySelector(BTN_SELECTOR) as HTMLElement | null
if (inner) return inner
sibling = sibling.nextElementSibling
count++
}
sibling = inputEl.previousElementSibling
count = 0
while (sibling && count < 2) {
if (sibling.matches(BTN_SELECTOR)) return sibling as HTMLElement
const inner = sibling.querySelector(BTN_SELECTOR) as HTMLElement | null
if (inner) return inner
sibling = sibling.previousElementSibling
count++
}
// 向上查找父元素内的 button
let parent: Element | null = inputEl.parentElement
for (let i = 0; i < 2 && parent; i++) {
const btn = parent.querySelector('button, [role="button"]') as HTMLElement | null
if (btn && btn !== inputEl) return btn
parent = parent.parentElement
}
return null
}
// ============ 表单字段匹配主方法 ============
/**
* 匹配页面中的表单字段
* 遍历标签数组,在 DOM 中查找每个标签对应的元素和紧跟的 input 输入框
* 返回结果按 DOM 顺序排序
*/
export function matchFormFields(
rootEl: Element = document.body,
lang: "zh" | "en"
): MatchedFormField[] {
const results: MatchedFormField[] = []
const usedInputs = new Set<Element>()
const candidateEls = rootEl.querySelectorAll(
"label, span, div, td, th, p, legend, dt, h1, h2, h3, h4, h5, h6"
)
for (const item of JOB_FORM_LABELS) {
const labels = lang === "zh" ? item.zh : item.en
const sortedLabels = [...labels].sort((a, b) => b.length - a.length)
let matchCount = 0
for (const candidateEl of Array.from(candidateEls)) {
const directText = Array.from(candidateEl.childNodes)
.filter((node) => node.nodeType === Node.TEXT_NODE)
.map((node) => node.textContent?.trim())
.filter(Boolean)
.join(" ")
const fullText = candidateEl.textContent?.trim() || ""
const matchLabel = sortedLabels.find(
(label) =>
(directText && directText.includes(label) && directText.length < label.length + 20) ||
(fullText.includes(label) && fullText.length < label.length + 20)
)
if (matchLabel) {
// 检测单选按钮组
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 }
}
// 非 radio 类型,正常找 input
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"
}
}
const btnEl = inputEl ? findNearbyButton(inputEl) : null
results.push({
key: item.key, section: item.section, resumeField: item.resumeField,
sectionIndex: matchCount, labelText: matchLabel,
labelElement: candidateEl, labelSelector: buildSelector(candidateEl),
inputElement: inputEl, inputSelector: inputEl ? buildSelector(inputEl) : "",
buttonElement: btnEl, buttonSelector: btnEl ? buildSelector(btnEl) : "",
inputType, radioContainer: isRadioType ? radioContainer : null,
isPicker: false, pickerDropdownElement: null, pickerDropdownSelector: "", fillValue: "",
})
matchCount++
if (item.section === "main") break
}
}
}
// 按 DOM 顺序排序
results.sort((a, b) => {
const pos = a.labelElement.compareDocumentPosition(b.labelElement)
if (pos & Node.DOCUMENT_POSITION_FOLLOWING) return -1
if (pos & Node.DOCUMENT_POSITION_PRECEDING) return 1
return 0
})
return results
}
+186
View File
@@ -0,0 +1,186 @@
/**
* 选择器识别模块
* 负责:检测表单字段是否为选择器类型(下拉、日期、级联等)
* 检测方式:
* 1. UI组件库类名匹配(快速路径)
* 2. 提示文字检测("请选择"等)
* 3. 主动点击检测:点击 input 及其单子标签父级链,对比点击前后 DOM 变化判断是否有弹出层
*/
import type { MatchedFormField, UILibPickerConfig } from "./types"
import { UI_LIB_PICKER_CONFIGS } from "./constants"
import { delay } from "./autofill"
/** 弹出层相关的 CSS 选择器 */
const POPUP_SELECTORS = [
'[class*="dropdown"]', '[class*="popup"]', '[class*="popper"]',
'[class*="picker-panel"]', '[class*="overlay"]', '[class*="popover"]',
'[class*="select-dropdown"]', '[class*="cascader"]',
'[class*="menu"][class*="content"]',
'[role="listbox"]', '[role="menu"]',
]
/** 通过提示文字判断是否为选择器 */
function hasPickerHintText(field: MatchedFormField, lang: "zh" | "en"): boolean {
const hints = lang === "zh"
? ["请选择", "请输入并选择", "点击选择"]
: ["Please select", "Choose", "Click to select", "Pick a"]
if (field.inputElement) {
const placeholder = field.inputElement.getAttribute("placeholder") || ""
if (hints.some((h) => placeholder.includes(h))) return true
if (field.inputElement.hasAttribute("readonly")) {
let parent: Element | null = field.inputElement.parentElement
for (let i = 0; i < 3 && parent; i++) {
const cls = parent.className || ""
if (typeof cls === "string" && (
cls.includes("select") || cls.includes("picker") || cls.includes("date") ||
cls.includes("cascader") || cls.includes("dropdown") || cls.includes("calendar")
)) return true
parent = parent.parentElement
}
}
// 检查紧邻兄弟元素的文字
for (const sib of [field.inputElement.nextElementSibling, field.inputElement.previousElementSibling]) {
if (sib) {
const text = sib.textContent?.trim() || ""
if (text.length < 30 && hints.some((h) => text.includes(h))) return true
}
}
}
return false
}
/** 检查 input 附近是否有已知 UI 组件库的选择器触发区域类名 */
function matchUILibTrigger(field: MatchedFormField): UILibPickerConfig | null {
const classSet = new Set<string>()
let el: Element | null = field.inputElement || field.labelElement
for (let i = 0; i < 5 && el; i++) {
if (el.className && typeof el.className === "string") {
el.className.trim().split(/\s+/).forEach((c) => classSet.add(c))
}
el = el.parentElement
}
if (field.buttonElement?.className && typeof field.buttonElement.className === "string") {
field.buttonElement.className.trim().split(/\s+/).forEach((c) => classSet.add(c))
}
for (const lib of UI_LIB_PICKER_CONFIGS) {
if (lib.triggerClasses.some((cls) => classSet.has(cls))) return lib
}
return null
}
/** 记录当前页面所有可见弹出层和 body 直接子元素 */
function snapshotDOM(): { bodyChildren: Set<Element>; visiblePopups: Set<Element> } {
const bodyChildren = new Set(Array.from(document.body.children))
const visiblePopups = new Set<Element>()
for (const sel of POPUP_SELECTORS) {
document.querySelectorAll(sel).forEach((el) => {
const htmlEl = el as HTMLElement
if (htmlEl.offsetHeight > 0 && htmlEl.offsetWidth > 0) visiblePopups.add(el)
})
}
return { bodyChildren, visiblePopups }
}
/** 对比 DOM 快照,检测是否有新增弹出层 */
function hasNewPopup(before: ReturnType<typeof snapshotDOM>): boolean {
// 检查 body 下新增的直接子元素
for (const child of Array.from(document.body.children)) {
if (!before.bodyChildren.has(child) && (child as HTMLElement).offsetHeight > 10) return true
}
// 检查新变为可见的弹出层
for (const sel of POPUP_SELECTORS) {
const els = document.querySelectorAll(sel)
for (const el of Array.from(els)) {
const htmlEl = el as HTMLElement
if (htmlEl.offsetHeight > 10 && htmlEl.offsetWidth > 0 && !before.visiblePopups.has(el)) return true
}
}
return false
}
/** 关闭可能打开的弹出层 */
async function dismissPopup() {
if (document.activeElement instanceof HTMLElement) {
document.activeElement.dispatchEvent(
new KeyboardEvent("keydown", { key: "Escape", code: "Escape", keyCode: 27, bubbles: true })
)
document.activeElement.blur()
}
document.body.click()
await delay(200)
}
/**
* 主动点击检测:点击 input 及其单子标签父级链,对比 DOM 变化判断是否有弹出层
* 如果检测到弹出层,关闭后返回 true
*/
async function detectByClick(field: MatchedFormField): Promise<boolean> {
if (!field.inputElement) return false
// 收集要点击的元素:input 本身 + 单子标签父级链(最多5层)
const clickTargets: HTMLElement[] = [field.inputElement as HTMLElement]
let current: HTMLElement | null = field.inputElement as HTMLElement
for (let i = 0; i < 5 && current?.parentElement; i++) {
const parentEl = current.parentElement
if (parentEl.children.length <= 2) {
clickTargets.push(parentEl)
current = parentEl
} else {
break
}
}
// 逐个尝试点击,检测 DOM 变化
for (const target of clickTargets) {
const before = snapshotDOM()
target.click()
await delay(200)
if (hasNewPopup(before)) {
console.log(`OfferPie: [${field.key}] 点击 ${target.tagName}.${(target.className || "").toString().split(" ")[0]} 后检测到弹出层`)
await dismissPopup()
return true
}
}
// 没检测到弹出层,确保关闭焦点状态
await dismissPopup()
return false
}
/**
* 检测单个字段是否为数据选择器类型
* 优先级:1.UI组件库类名 → 2.提示文字 → 3.主动点击检测DOM变化
*/
export async function detectPickerField(field: MatchedFormField, lang: "zh" | "en"): Promise<void> {
if (field.inputType === "radio") return
// 方式1UI 组件库类名匹配
const matchedLib = matchUILibTrigger(field)
if (matchedLib) {
console.log(`OfferPie: [${field.key}] 匹配到 UI 组件库: ${matchedLib.libName}`)
field.isPicker = true
field.inputType = "picker"
field.pickerDropdownSelector = matchedLib.optionItemClasses.map((cls) => "." + CSS.escape(cls)).join(", ")
return
}
// 方式2:提示文字检测
if (hasPickerHintText(field, lang)) {
console.log(`OfferPie: [${field.key}] 有选择器提示文字,标记为 picker`)
field.isPicker = true
field.inputType = "picker"
return
}
// 方式3:主动点击检测 DOM 变化
const detected = await detectByClick(field)
if (detected) {
console.log(`OfferPie: [${field.key}] 通过点击检测到弹出层,标记为 picker`)
field.isPicker = true
field.inputType = "picker"
return
}
}
+256
View File
@@ -0,0 +1,256 @@
/**
* 选择器选项匹配与点击模块
* 负责:在弹出层中查找匹配选项并点击、模糊匹配评分、下拉列表检测
*/
import type { MatchedFormField } from "./types"
// ============ 模糊匹配 ============
/** 模糊匹配评分:两个字符串的相似度 */
export function fuzzyMatchScore(text: string, target: string): number {
const a = text.toLowerCase().trim()
const b = target.toLowerCase().trim()
if (!a || !b) return 0
if (a === b) return 1
if (a.includes(b)) return 0.8
if (b.includes(a)) return 0.8
// 数字范围匹配
const rangeMatch = a.match(/^(\d+)\s*[~\-—~至到]\s*(\d+)/)
if (rangeMatch && /^\d+$/.test(b)) {
const targetNum = Number(b)
if (targetNum >= Number(rangeMatch[1]) && targetNum <= Number(rangeMatch[2])) return 0.7
}
// 字符重叠率
let overlap = 0
for (const ch of b) { if (a.includes(ch)) overlap++ }
return overlap / Math.max(a.length, b.length)
}
// ============ 弹出层选项查找与点击 ============
/**
* 在弹出层 DOM 中找到最佳匹配选项并点击
* 递归遍历弹出层内所有可见的叶子文字节点做模糊匹配
*/
export function clickBestOptionInDropdown(dropdownEl: HTMLElement, fillValue: string): boolean {
const candidates: { el: HTMLElement; text: string }[] = []
const collectCandidates = (el: Element) => {
const htmlEl = el as HTMLElement
if (htmlEl.offsetHeight === 0 || htmlEl.offsetWidth === 0) return
if (htmlEl.classList?.contains("disabled") || htmlEl.classList?.contains("is-disabled") ||
htmlEl.getAttribute("aria-disabled") === "true") return
const children = Array.from(el.children)
const text = el.textContent?.trim() || ""
if (text && text.length <= 50) {
if (/^\d+\/\d+$/.test(text)) return
const hasBlockChild = children.some((c) => {
const tag = c.tagName?.toLowerCase()
return tag && !["span", "em", "i", "b", "strong", "small", "a", "img", "svg"].includes(tag)
})
if (!hasBlockChild) { candidates.push({ el: htmlEl, text }); return }
}
for (const child of children) collectCandidates(child)
}
collectCandidates(dropdownEl)
if (candidates.length === 0) return false
console.log(`OfferPie: [clickBestOption] 找到 ${candidates.length} 个候选选项,fillValue="${fillValue}"`)
candidates.forEach((c, i) => console.log(`OfferPie: 候选[${i}] "${c.text}"`))
let bestMatch: { el: HTMLElement; text: string; score: number } | null = null
for (const { el, text } of candidates) {
if (text.toLowerCase() === fillValue.toLowerCase()) {
el.click()
console.log(`OfferPie: ✅ [弹出层] 点击选项 "${text}" (精确匹配)`)
return true
}
const score = fuzzyMatchScore(text, fillValue)
if (score > 0.2 && (!bestMatch || score > bestMatch.score)) bestMatch = { el, text, score }
}
if (bestMatch) {
bestMatch.el.click()
console.log(`OfferPie: ✅ [弹出层] 点击选项 "${bestMatch.text}" (模糊匹配 score=${bestMatch.score.toFixed(2)})`)
return true
}
return false
}
/**
* 在当前页面所有可见的弹出层中查找并点击匹配选项
*/
export function findAndClickOptionInVisiblePopups(fillValue: string, labelText: string): boolean {
const popupSelectors = [
'[class*="dropdown"]', '[class*="popup"]', '[class*="popper"]',
'[class*="picker-panel"]', '[class*="overlay"]', '[class*="popover"]',
'[class*="select-dropdown"]', '[class*="cascader"]',
'[class*="menu"][class*="content"]',
'[class*="phoenix-select-dropdown"]', '[class*="phoenix-calendar"]',
'[role="listbox"]', '[role="menu"]',
]
const visiblePopups: HTMLElement[] = []
for (const sel of popupSelectors) {
document.querySelectorAll(sel).forEach((el) => {
const htmlEl = el as HTMLElement
if (htmlEl.offsetHeight > 10 && htmlEl.offsetWidth > 0) {
const isDuplicate = visiblePopups.some((existing) => existing.contains(htmlEl) || htmlEl.contains(existing))
if (!isDuplicate) visiblePopups.push(htmlEl)
}
})
}
console.log(`OfferPie: [findOption] "${labelText}" 找到 ${visiblePopups.length} 个可见弹出层`)
for (const popup of visiblePopups) {
if (clickBestOptionInDropdown(popup, fillValue)) return true
}
return false
}
// ============ 通用下拉列表检测与点击 ============
/** 在页面中查找所有可见的"同规格标签组" */
function findVisibleListGroups(): Element[][] {
const results: Element[][] = []
const visited = new Set<Element>()
const containerSelectors = [
"ul", "ol", "[role='listbox']", "[class*='list']", "[class*='dropdown']",
"[class*='select']", "[class*='menu']", "[class*='popup']", "[class*='popper']",
"[class*='virtuallist']", "[class*='virtual-list']", "div",
]
for (const sel of containerSelectors) {
const containers = document.querySelectorAll(sel)
for (const container of Array.from(containers)) {
if (visited.has(container)) continue
const htmlContainer = container as HTMLElement
if (htmlContainer.offsetHeight === 0 || htmlContainer.offsetWidth === 0) continue
const group = findSameSpecChildren(container)
if (group && group.length >= 3) { visited.add(container); results.push(group) }
}
}
results.sort((a, b) => b.length - a.length)
return results
}
function findSameSpecChildren(container: Element): Element[] | null {
const directGroup = groupBySpec(Array.from(container.children))
if (directGroup) return directGroup
for (const child of Array.from(container.children)) {
const subGroup = groupBySpec(Array.from(child.children))
if (subGroup) return subGroup
for (const grandChild of Array.from(child.children)) {
const subSubGroup = groupBySpec(Array.from(grandChild.children))
if (subSubGroup) return subSubGroup
}
}
return null
}
function groupBySpec(elements: Element[]): Element[] | null {
if (elements.length < 3) return null
const groups = new Map<string, Element[]>()
for (const el of elements) {
if ((el as HTMLElement).offsetHeight === 0 || (el as HTMLElement).offsetWidth === 0) continue
const tag = el.tagName
const firstClass = (el.className && typeof el.className === "string") ? el.className.trim().split(/\s+/)[0] || "" : ""
const key = `${tag}|${firstClass}`
if (!groups.has(key)) groups.set(key, [])
groups.get(key)!.push(el)
}
let best: Element[] | null = null
for (const [, group] of groups) {
if (group.length >= 3 && (!best || group.length > best.length)) best = group
}
return best
}
function findDeepestTextNode(el: Element): HTMLElement | null {
if (el.children.length === 0 && el.textContent?.trim()) return el as HTMLElement
const allLeaves: HTMLElement[] = []
const walk = (node: Element) => {
if (node.children.length === 0) { if (node.textContent?.trim()) allLeaves.push(node as HTMLElement); return }
for (const child of Array.from(node.children)) walk(child)
}
walk(el)
return allLeaves.length > 0 ? allLeaves[allLeaves.length - 1] : null
}
function clickItemAndParent(deepest: HTMLElement, item: Element) {
const path: HTMLElement[] = []
let current: HTMLElement | null = deepest
while (current && current !== item.parentElement) { path.unshift(current); current = current.parentElement }
for (const el of path) el.click()
}
/** 通用下拉列表检测与点击 */
export function tryClickDropdownListItem(fillValue: string): boolean {
const listGroups = findVisibleListGroups()
for (const group of listGroups) {
let bestMatch: { item: Element; text: string; deepest: HTMLElement; score: number } | null = null
for (const item of group) {
const deepest = findDeepestTextNode(item)
const text = deepest ? deepest.textContent?.trim() || "" : item.textContent?.trim() || ""
if (!text || /^\d+\/\d+$/.test(text) || text.length > 100) continue
if (text === fillValue) {
clickItemAndParent(deepest || (item as HTMLElement), item)
console.log(`OfferPie: ✅ [下拉列表] 点击选项 "${text}" (精确匹配)`)
return true
}
const score = fuzzyMatchScore(text, fillValue)
if (score > 0.2 && (!bestMatch || score > bestMatch.score)) {
bestMatch = { item, text, deepest: deepest || (item as HTMLElement), score }
}
}
if (bestMatch) {
clickItemAndParent(bestMatch.deepest, bestMatch.item)
console.log(`OfferPie: ✅ [下拉列表] 点击选项 "${bestMatch.text}" (模糊匹配 score=${bestMatch.score.toFixed(2)})`)
return true
}
}
return false
}
/** 在选择器弹出层中尝试点击与 fillValue 匹配的选项(基于 pickerDropdownSelector */
export function tryClickMatchingOption(field: MatchedFormField): boolean {
const { fillValue, pickerDropdownElement, pickerDropdownSelector } = field
let optionEls: Element[] = []
if (pickerDropdownElement && pickerDropdownSelector) {
optionEls = Array.from(pickerDropdownElement.querySelectorAll(pickerDropdownSelector))
}
if (optionEls.length === 0 && pickerDropdownSelector) {
optionEls = Array.from(document.querySelectorAll(pickerDropdownSelector)).filter((el) => (el as HTMLElement).offsetHeight > 0 && (el as HTMLElement).offsetWidth > 0)
}
if (optionEls.length === 0) {
const dropdownSelectors = [
'[class*="select-dropdown"] [class*="item"]:not([class*="disabled"])',
'[class*="dropdown"] [class*="option"]:not([class*="disabled"])',
'[class*="dropdown"] [class*="item"]:not([class*="disabled"])',
'[class*="select"] [class*="option"]:not([class*="disabled"])',
'[class*="popup"] [class*="option"]', '[class*="popper"] [class*="option"]',
'[class*="menu"] [class*="item"]', '[role="option"]', '[role="listbox"] > *',
]
for (const sel of dropdownSelectors) {
const visibleEls = Array.from(document.querySelectorAll(sel)).filter((el) => (el as HTMLElement).offsetHeight > 0 && (el as HTMLElement).offsetWidth > 0)
if (visibleEls.length > 0) { optionEls = visibleEls; break }
}
}
if (optionEls.length === 0) return false
let bestMatch: { el: Element; text: string; score: number } | null = null
for (const optionEl of optionEls) {
const text = optionEl.textContent?.trim() || ""
if (!text || /^\d+\/\d+$/.test(text) || text.length > 100) continue
if (text.toLowerCase() === fillValue.toLowerCase()) {
;(optionEl as HTMLElement).click()
console.log(`OfferPie: ✅ 点击选项 "${text}" (精确匹配)`)
return true
}
const score = fuzzyMatchScore(text, fillValue)
if (score > 0.2 && (!bestMatch || score > bestMatch.score)) bestMatch = { el: optionEl, text, score }
}
if (bestMatch) {
;(bestMatch.el as HTMLElement).click()
console.log(`OfferPie: ✅ 点击选项 "${bestMatch.text}" (模糊匹配 score=${bestMatch.score.toFixed(2)})`)
return true
}
return false
}
+65
View File
@@ -0,0 +1,65 @@
/**
* 简历数据取值辅助模块
* 负责:根据 section + sectionIndex + resumeField 从 ResumeData 中提取填写值
*/
import type { ResumeData, ResumeSection, DescriptionParagraph, ExperienceSection } from "./types"
/**
* 从简历数据中获取指定字段的填写值
* @param resumeData - 完整简历数据
* @param section - 数据分区(main / education / work / internship / project / competition
* @param sectionIndex - 经历数组中的索引(section 为经历类型时使用)
* @param resumeField - 分区内的字段名
* @returns 要填入的字符串值,取不到返回空字符串
*/
export function getResumeFieldValue(
resumeData: ResumeData,
section: ResumeSection,
sectionIndex: number,
resumeField: string
): string {
if (!resumeField) return ""
// 主表字段
if (section === "main") {
const mainData = resumeData.main
const value = (mainData as Record<string, unknown>)[resumeField]
if (value === undefined || value === null) return ""
// 数组类型字段(skills、certificates)转为逗号分隔字符串
if (Array.isArray(value)) return value.join("、")
return String(value)
}
// 经历类型字段
const sectionData = resumeData[section as ExperienceSection]
if (!Array.isArray(sectionData) || sectionIndex >= sectionData.length) return ""
const item = sectionData[sectionIndex] as Record<string, unknown>
if (!item) return ""
const value = item[resumeField]
if (value === undefined || value === null) return ""
// description 字段:将段落数组拼接为文本
if (resumeField === "description" && Array.isArray(value)) {
return (value as DescriptionParagraph[])
.map((p) => p.text || p.content || "")
.filter(Boolean)
.join("\n")
}
if (Array.isArray(value)) return value.join("、")
return String(value)
}
/**
* 获取简历数据中某个经历分区的总段数
* @param resumeData - 完整简历数据
* @param section - 经历分区
* @returns 该经历类型的总段数
*/
export function getResumeSectionCount(resumeData: ResumeData, section: ExperienceSection): number {
const sectionData = resumeData[section]
return Array.isArray(sectionData) ? sectionData.length : 0
}
+128
View File
@@ -0,0 +1,128 @@
/**
* 简历文件自动上传模块
* 负责:检测页面上的简历上传按钮,从 OSS 下载简历文件并注入到 file input
*/
import { delay } from "./autofill"
/**
* 检测页面上的简历上传标签,并自动上传简历文件
* 查找逻辑:找到文字同时包含"上传"和"简历"的标签元素
* 上传逻辑:找到关联的 input[type="file"],从 OSS 下载文件,构造 File 对象注入
*
* @param resumeUrl - 简历文件的 OSS 地址
* @returns 是否上传成功
*/
export async function detectAndUploadResume(resumeUrl: string): Promise<boolean> {
// 1. 查找简历上传标签
const allEls = document.querySelectorAll("button, div, span, a, label, p, [role='button']")
let uploadEl: HTMLElement | null = null
for (const el of Array.from(allEls)) {
const text = el.textContent?.trim() || ""
if (text.length < 20 && (
(text.includes("上传") && text.includes("简历")) ||
(text.includes("简历") && text.includes("附件")) ||
(text.includes("附件") && text.includes("简历"))
)) {
uploadEl = el as HTMLElement
break
}
}
if (!uploadEl) { console.log("OfferPie: 未找到简历上传标签"); return false }
console.log(`OfferPie: 找到简历上传标签: "${uploadEl.textContent?.trim()}" tag=${uploadEl.tagName}`)
// 2. 查找关联的 input[type="file"]
let fileInput: HTMLInputElement | null = null
// 策略1:从上传标签逐级向上找
let parent: Element | null = uploadEl
for (let i = 0; i < 5 && parent; i++) {
fileInput = parent.querySelector('input[type="file"]') as HTMLInputElement | null
if (fileInput) break
const nextSib = parent.nextElementSibling
const prevSib = parent.previousElementSibling
if (nextSib) {
fileInput = nextSib.tagName === "INPUT" && (nextSib as HTMLInputElement).type === "file"
? nextSib as HTMLInputElement : nextSib.querySelector('input[type="file"]') as HTMLInputElement | null
}
if (!fileInput && prevSib) {
fileInput = prevSib.tagName === "INPUT" && (prevSib as HTMLInputElement).type === "file"
? prevSib as HTMLInputElement : prevSib.querySelector('input[type="file"]') as HTMLInputElement | null
}
if (fileInput) break
parent = parent.parentElement
}
// 策略2:按 id/name 含 resume 的 file input
if (!fileInput) {
for (const input of Array.from(document.querySelectorAll('input[type="file"]'))) {
const id = input.id?.toLowerCase() || ""
const name = input.getAttribute("name")?.toLowerCase() || ""
if (id.includes("resume") || name.includes("resume")) { fileInput = input as HTMLInputElement; break }
}
}
// 策略3:点击上传标签及其单子标签父级来触发
if (!fileInput) {
console.log("OfferPie: 未直接找到 file input,尝试点击触发...")
const clickTargets: HTMLElement[] = [uploadEl]
let current: HTMLElement | null = uploadEl
for (let i = 0; i < 5 && current?.parentElement; i++) {
const parentEl = current.parentElement
if (parentEl.children.length === 1) { clickTargets.push(parentEl); current = parentEl } else break
}
for (const target of clickTargets) {
target.click()
await delay(300)
for (const input of Array.from(document.querySelectorAll('input[type="file"]'))) {
const id = input.id?.toLowerCase() || ""
const name = input.getAttribute("name")?.toLowerCase() || ""
if (id.includes("portrait") || id.includes("avatar") || id.includes("photo") ||
name.includes("portrait") || name.includes("avatar") || name.includes("photo")) continue
fileInput = input as HTMLInputElement
break
}
if (fileInput) { console.log(`OfferPie: 点击 ${target.tagName} 后找到 file input`); break }
}
}
// 策略4:兜底取第一个非头像的 file input
if (!fileInput) {
for (const input of Array.from(document.querySelectorAll('input[type="file"]'))) {
const id = input.id?.toLowerCase() || ""
const name = input.getAttribute("name")?.toLowerCase() || ""
if (id.includes("portrait") || id.includes("avatar") || id.includes("photo") ||
name.includes("portrait") || name.includes("avatar") || name.includes("photo")) continue
fileInput = input as HTMLInputElement
break
}
}
if (!fileInput) { console.log("OfferPie: 未找到简历文件上传的 input[type='file']"); return false }
console.log(`OfferPie: 找到 file input: id="${fileInput.id}" name="${fileInput.getAttribute("name") || ""}"`)
// 3. 从 OSS 下载并注入
try {
const response = await fetch(resumeUrl)
if (!response.ok) { console.error(`OfferPie: 下载简历失败: ${response.status}`); return false }
const blob = await response.blob()
const urlPath = new URL(resumeUrl).pathname
const fileName = decodeURIComponent(urlPath.split("/").pop() || "resume.pdf")
const ext = fileName.split(".").pop()?.toLowerCase()
const mimeMap: Record<string, string> = {
pdf: "application/pdf", doc: "application/msword",
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
ppt: "application/vnd.ms-powerpoint",
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
}
const mimeType = mimeMap[ext || ""] || blob.type || "application/octet-stream"
const file = new File([blob], fileName, { type: mimeType })
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file)
fileInput.files = dataTransfer.files
fileInput.dispatchEvent(new Event("change", { bubbles: true }))
fileInput.dispatchEvent(new Event("input", { bubbles: true }))
console.log(`OfferPie: ✅ 简历文件已上传: "${fileName}" (${(blob.size / 1024).toFixed(1)}KB)`)
return true
} catch (err) { console.error("OfferPie: 简历上传出错", err); return false }
}
+206
View File
@@ -0,0 +1,206 @@
/**
* 类型定义模块
* 包含简历数据接口、表单标签接口、匹配字段接口、UI组件库配置接口等所有共享类型
*/
// ============ 简历数据类型 ============
/** 描述段落(富文本段落) */
export interface DescriptionParagraph {
/** 段落 ID */
id?: string
/** 段落类型 */
type?: string
/** 段落内容(旧格式) */
content?: string
/** 段落文本(接口返回格式) */
text?: string
}
/** 简历主表数据 */
export interface ResumeMainData {
/** 简历 ID */
id?: string
/** 简历名称 */
resumeName?: string
/** 目标岗位 */
targetPosition?: string
/** 是否默认简历 0=否 1=是 */
isDefault?: number
/** 头像URL */
avatarUrl?: string
/** 真实姓名 */
name?: string
/** 邮箱 */
email?: string
/** 手机号码 */
mobileNumber?: string
/** 所在城市 */
city?: string
/** 微信号 */
wechatNumber?: string
/** 作品集链接 */
portfolioUrl?: string
/** 技能标签列表 */
skills?: string[]
/** 证书标签列表 */
certificates?: string[]
/** 个人概述 */
summary?: string
}
/** 教育经历项 */
export interface ResumeEducation {
id?: string
school?: string
major?: string
degree?: string
studyType?: string
startDate?: string
endDate?: string
description?: DescriptionParagraph[]
}
/** 工作经历项 */
export interface ResumeWork {
id?: string
companyName?: string
position?: string
startDate?: string
endDate?: string
description?: DescriptionParagraph[]
}
/** 实习经历项 */
export interface ResumeInternship {
id?: string
companyName?: string
position?: string
startDate?: string
endDate?: string
description?: DescriptionParagraph[]
}
/** 项目经历项 */
export interface ResumeProject {
id?: string
companyName?: string
projectName?: string
role?: string
startDate?: string
endDate?: string
description?: DescriptionParagraph[]
}
/** 竞赛经历项 */
export interface ResumeCompetition {
id?: string
competitionName?: string
award?: string
awardDate?: string
description?: DescriptionParagraph[]
}
/** 完整简历数据(主表 + 5大经历) */
export interface ResumeData {
main: ResumeMainData
education: ResumeEducation[]
work: ResumeWork[]
internship: ResumeInternship[]
project: ResumeProject[]
competition: ResumeCompetition[]
}
/** 简历数据分区类型 */
export type ResumeSection = "main" | "education" | "work" | "internship" | "project" | "competition"
/** 经历类型分区(不含 main) */
export type ExperienceSection = Exclude<ResumeSection, "main">
// ============ 经历区块配置类型 ============
/** 经历区块标题配置(用于识别页面中的经历区块和添加按钮) */
export interface ExperienceSectionConfig {
/** 经历分区标识 */
section: ExperienceSection
/** 中文区块标题关键词(用于在 DOM 中匹配区块标题) */
zh: string[]
/** 英文区块标题关键词 */
en: string[]
/** 该经历类型的核心字段 key(用于统计已展开的段数) */
coreFieldKey: string
/** 添加按钮的文字关键词(中文) */
addButtonZh: string[]
/** 添加按钮的文字关键词(英文) */
addButtonEn: string[]
}
// ============ 表单标签类型 ============
/** 标签分类接口 */
export interface FormLabelItem {
/** 标签分类名(内部标识) */
key: string
/** 中文标签(可能出现的多种写法) */
zh: string[]
/** 英文标签(可能出现的多种写法) */
en: string[]
/** 对应简历数据的分区 */
section: ResumeSection
/** 对应简历数据分区内的字段名 */
resumeField: string
}
// ============ UI 组件库配置类型 ============
/** UI 组件库选择器配置 */
export interface UILibPickerConfig {
/** 组件库名称 */
libName: string
/** 选择器容器/弹出层的 CSS 类名关键词 */
dropdownClasses: string[]
/** 最终可点击选择结果项的 CSS 类名 */
optionItemClasses: string[]
/** input 附近可能出现的选择器触发区域类名 */
triggerClasses: string[]
}
// ============ 匹配字段类型 ============
/** 匹配到的表单字段信息 */
export interface MatchedFormField {
/** 标签分类 key(如 name、phone、email */
key: string
/** 对应简历数据的分区(main / education / work 等) */
section: ResumeSection
/** 对应简历数据分区内的字段名 */
resumeField: string
/** 经历数组中的索引(section 为经历类型时使用,从 0 开始) */
sectionIndex: number
/** 匹配到的标签文字内容 */
labelText: string
/** 标签所在的 HTML 元素 */
labelElement: Element
/** 标签元素的 CSS 选择器路径 */
labelSelector: string
/** 紧跟标签后的第一个 input 输入框元素(radio 类型时为 null */
inputElement: HTMLInputElement | HTMLTextAreaElement | null
/** input 元素的 CSS 选择器路径 */
inputSelector: string
/** input 同级或父级的点击按钮元素 */
buttonElement: HTMLElement | null
/** button 元素的 CSS 选择器路径 */
buttonSelector: string
/** 表单项的输入类型 */
inputType: "text" | "radio" | "picker" | "textarea"
/** 单选按钮组的容器元素(inputType 为 radio 时使用) */
radioContainer: Element | null
/** 是否为数据选择器类型 */
isPicker: boolean
/** 数据选择器弹出层的 DOM 元素 */
pickerDropdownElement: HTMLElement | null
/** 数据选择器弹出层的 CSS 选择器路径 */
pickerDropdownSelector: string
/** 要填入的值 */
fillValue: string
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "plasmo/templates/tsconfig.base",
"compilerOptions": {
"paths": {
"~*": ["./src/*"]
},
"baseUrl": "."
},
"include": [".plasmo/index.d.ts", "./**/*.ts", "./**/*.tsx"],
"exclude": ["node_modules"]
}