diff --git a/assets/icon.png b/assets/icon.png index 943119c..9099ffa 100644 Binary files a/assets/icon.png and b/assets/icon.png differ diff --git a/package-lock.json b/package-lock.json index 8726044..0727048 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,12 @@ { - "name": "offferpie_google_extension", - "version": "1.0.0", + "name": "offerpie-extension", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "offferpie_google_extension", - "version": "1.0.0", - "license": "ISC", + "name": "offerpie-extension", + "version": "0.1.0", "dependencies": { "@arco-design/web-react": "^2.66.14", "plasmo": "^0.90.5", @@ -15,10 +14,11 @@ "react-dom": "^19.2.5" }, "devDependencies": { - "@types/chrome": "^0.1.40", - "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", - "typescript": "^6.0.3" + "@types/chrome": "^0.0.318", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "rimraf": "^6.1.3", + "typescript": "^5.8.3" } }, "node_modules/@ampproject/remapping": { @@ -5568,9 +5568,9 @@ } }, "node_modules/@types/chrome": { - "version": "0.1.40", - "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.1.40.tgz", - "integrity": "sha512-UnfyRAe8ORu9HSuTH0EqyOEUin3JrWW9Nl/gDXezNfTUrfIoxw+WRZgKOxGz0t5BnjbfXBnS2eCYfW2PxH1wcA==", + "version": "0.0.318", + "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.318.tgz", + "integrity": "sha512-rrtyYQ1t+g7EyG0FejE+UXQBjSGUHGh0RIdXwUT/laPo9T724NOIgXA94ns6ewmNauwijYa5ck3+dBxWnHcynQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5915,6 +5915,16 @@ "integrity": "sha512-iCvCkGFskbaYtfQ0a3GmcQCHl/Sv1GufXFGuUQ+FE+WJa7A/espLOuFIn09B944V8/ImPj71T4+rTASxO2PAuA==", "license": "MIT" }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/base-x": { "version": "3.0.11", "resolved": "https://registry.npmjs.org/base-x/-/base-x-3.0.11.tgz", @@ -5980,6 +5990,19 @@ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", "license": "ISC" }, + "node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -7019,6 +7042,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", @@ -8138,6 +8179,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -8147,6 +8204,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/mnemonic-id": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/mnemonic-id/-/mnemonic-id-3.2.7.tgz", @@ -8430,6 +8497,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -8478,6 +8552,33 @@ "node": ">=8" } }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -9011,6 +9112,26 @@ "node": ">=0.10.0" } }, + "node_modules/rimraf": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "glob": "^13.0.3", + "package-json-from-dist": "^1.0.1" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/rollup": { "version": "3.30.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.30.0.tgz", @@ -9765,9 +9886,9 @@ } }, "node_modules/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", "bin": { diff --git a/package.json b/package.json index fdbb3c7..019297e 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,11 @@ { "name": "offerpie-extension", - "displayName": "OfferPie Assistant", + "displayName": "Offer派智能投递助手", "version": "0.1.0", - "description": "智能求职助手 - 自动化投递简历", + "description": "offer派平台旗下网申投递助手 - 自动化投递简历\n官网:https://offerpai.com.cn", "scripts": { "dev": "plasmo dev", - "build": "plasmo build", + "build": "rimraf .plasmo/cache build && plasmo build && node scripts/fix-manifest.js", "package": "plasmo package" }, "dependencies": { @@ -18,6 +18,7 @@ "@types/chrome": "^0.0.318", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", + "rimraf": "^6.1.3", "typescript": "^5.8.3" }, "manifest": { diff --git a/scripts/fix-manifest.js b/scripts/fix-manifest.js new file mode 100644 index 0000000..2ab4b3f --- /dev/null +++ b/scripts/fix-manifest.js @@ -0,0 +1,84 @@ +/** + * 修正 Plasmo build 后 manifest.json 中 web_accessible_resources 引用不存在文件的问题 + * 逻辑:遍历 manifest 中所有引用的资源文件名,如果在 build 目录中不存在, + * 则尝试用「同前缀 + 同后缀」的实际文件替换,找不到则移除该条目 + */ +const fs = require("fs") +const path = require("path") + +const buildDir = path.resolve(__dirname, "../build/chrome-mv3-prod") +const manifestPath = path.join(buildDir, "manifest.json") + +if (!fs.existsSync(manifestPath)) { + console.error("[fix-manifest] manifest.json 不存在,跳过") + process.exit(0) +} + +const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8")) + +// 递归获取 build 目录下所有文件的相对路径 +function getAllFiles(dir, baseDir, result = []) { + const entries = fs.readdirSync(dir, { withFileTypes: true }) + for (const entry of entries) { + const fullPath = path.join(dir, entry.name) + if (entry.isDirectory()) { + getAllFiles(fullPath, baseDir, result) + } else { + result.push(path.relative(baseDir, fullPath).replace(/\\/g, "/")) + } + } + return result +} + +const actualFiles = getAllFiles(buildDir, buildDir) + +if (!manifest.web_accessible_resources) { + console.log("[fix-manifest] 无 web_accessible_resources,无需修正") + process.exit(0) +} + +let modified = false + +manifest.web_accessible_resources.forEach((entry) => { + if (!entry.resources) return + + const newResources = [] + for (const resource of entry.resources) { + if (actualFiles.includes(resource)) { + // 文件存在,保留 + newResources.push(resource) + } else { + // 文件不存在,尝试匹配:取文件名前缀(第一个.之前)和后缀(最后一个.之后) + const fileName = resource.split("/").pop() + const dirPart = resource.includes("/") ? resource.substring(0, resource.lastIndexOf("/") + 1) : "" + const parts = fileName.split(".") + const prefix = parts[0] // 如 sidebar + const ext = parts[parts.length - 1] // 如 css + + // 在实际文件中找同前缀同后缀的(考虑目录层级) + const match = actualFiles.find((f) => { + const fName = f.split("/").pop() + const fParts = fName.split(".") + return fParts[0] === prefix && fParts[fParts.length - 1] === ext + }) + + if (match) { + // 检查是否已经在 content_scripts.css 或其他地方被引用了(避免重复) + console.log(`[fix-manifest] 替换: ${resource} -> ${match}`) + newResources.push(match) + modified = true + } else { + console.log(`[fix-manifest] 移除不存在的资源: ${resource}`) + modified = true + } + } + } + entry.resources = newResources +}) + +if (modified) { + fs.writeFileSync(manifestPath, JSON.stringify(manifest), "utf-8") + console.log("[fix-manifest] manifest.json 已修正完成") +} else { + console.log("[fix-manifest] 所有资源文件均存在,无需修正") +} diff --git a/src/api/dataApi.ts b/src/api/dataApi.ts index 180d17f..7de3b3b 100644 --- a/src/api/dataApi.ts +++ b/src/api/dataApi.ts @@ -14,3 +14,24 @@ export function findJobBySourceUrl(sourceUrl: string) { export function checkLogin() { return http.get('/public/checkLogin') } + +// ============ 会员相关接口 ============ + +/** 会员状态响应数据 */ +export interface MemberStatusData { + /** 是否是会员 */ + isMember: boolean + /** 会员类型 1=正式 2=试用 */ + memberType?: number + /** 到期时间(毫秒时间戳) */ + expireTime?: number + /** 首次开通时间(毫秒时间戳) */ + createTime?: number + /** 最近续费时间(毫秒时间戳) */ + updateTime?: number +} + +/** 查询会员状态 */ +export function getMemberStatus() { + return http.get('/member/status') +} diff --git a/src/background/index.ts b/src/background/index.ts index c3782b1..508bf82 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -30,6 +30,40 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { }) return true } + + // ============ ChannelBridge CSL 模式:chrome.storage.local 读写 ============ + const CSL_STORAGE_KEY = 'offerpie_comm_7394028156183472' + + // CSL 写入 + if (message.type === 'OFFERPIE_CSL_PUT') { + const { domain, name, data, createTime, updateTime } = message + chrome.storage.local.get(CSL_STORAGE_KEY).then((result) => { + const list: Array<{ domain: string; value: { name: string; data: object; createTime: string; updateTime: string } }> = result[CSL_STORAGE_KEY] || [] + // 查找是否已有该条目 + const idx = list.findIndex(i => i.domain === domain && i.value?.name === name) + const record = { name, data, createTime, updateTime } + if (idx >= 0) { + list[idx].value = record + } else { + list.push({ domain, value: record }) + } + chrome.storage.local.set({ [CSL_STORAGE_KEY]: list }).then(() => { + sendResponse({ success: true }) + }) + }) + return true + } + + // CSL 读取 + if (message.type === 'OFFERPIE_CSL_GET') { + const { domain, name } = message + chrome.storage.local.get(CSL_STORAGE_KEY).then((result) => { + const list: Array<{ domain: string; value: { name: string; data: object; createTime: string; updateTime: string } }> = result[CSL_STORAGE_KEY] || [] + const item = list.find(i => i.domain === domain && i.value?.name === name) + sendResponse({ record: item?.value || null }) + }) + return true + } }) /** 插件安装事件 */ diff --git a/src/components/SidebarPanel.scss b/src/components/SidebarPanel.scss index 8b19bda..88a867e 100644 --- a/src/components/SidebarPanel.scss +++ b/src/components/SidebarPanel.scss @@ -9,7 +9,7 @@ $radius-sm: 12px; top: 10px; right: 10px; width: 380px; - min-height: 470px; + //min-height: 470px; max-height: calc(100vh - 20px); background: #fff; border-radius: $radius-lg; @@ -24,26 +24,27 @@ $radius-sm: 12px; /* 收起状态 */ &--collapsed { - width: auto; + width: 380px; min-height: auto; max-height: none; background: transparent; box-shadow: none; - border-radius: 50%; + border-radius: $radius-lg; } } -/* 收起状态的 logo 圆圈 */ -.op-collapsed-logo { - width: 60px; - height: 60px; - border-radius: 50%; - background: #fff; - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.12); +/* 收起状态:横条样式(logo + 文字 + 向下箭头) */ +.op-collapsed-bar { display: flex; align-items: center; - justify-content: center; - cursor: pointer; + gap: 8px; + padding: 14px 20px; + background: #fff; + border-radius: 12px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.12); + width: 100%; + box-sizing: border-box; + cursor: grab; transition: box-shadow 0.2s; &:hover { @@ -51,6 +52,27 @@ $radius-sm: 12px; } } +.op-collapsed-text { + font-size: 13px; + color: #555; + font-weight: 500; + white-space: nowrap; + flex: 1; +} + +.op-collapsed-arrow { + flex-shrink: 0; + color: #666; + cursor: pointer; + padding: 4px; + border-radius: 4px; + transition: background 0.15s; + + &:hover { + background: #f0f0f0; + } +} + /* 拖拽提示气泡 */ .op-drag-tooltip { position: absolute; @@ -70,7 +92,7 @@ $radius-sm: 12px; /* 内层容器:承载 padding 和滚动 */ .op-inner { - padding: 20px 20px 24px; + padding: 20px 20px 10px 20px; overflow-y: auto; flex: 1; display: flex; @@ -95,6 +117,7 @@ $radius-sm: 12px; align-items: center; margin-bottom: 12px; gap: 12px; + cursor: grab; &-left { display: flex; @@ -189,19 +212,62 @@ $radius-sm: 12px; font-weight: 700; cursor: pointer; margin-bottom: 6px; - transition: opacity 0.2s; + transition: opacity 0.2s, background 0.2s; - &:hover { opacity: 0.85; } + &--filling { + background: #6b7280; + } + + &:hover:not(:disabled) { opacity: 0.85; } &:disabled { opacity: 0.7; cursor: not-allowed; } } +/* 填写完成状态:重置按钮 + 完成提示 */ +.op-fill-done-row { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 6px; +} + +.op-reset-btn { + width: 40px; + height: 40px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + background: #f0fafa; + border: 1px solid #e0f0f0; + border-radius: 4px; + cursor: pointer; + transition: background 0.15s; + + &:hover { + background: #ddf5f5; + } +} + +.op-fill-done-label { + flex: 1; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + background: #6dd4a0; + color: #fff; + border-radius: 4px; + font-size: 16px; + font-weight: 700; +} + .op-credits { &-row { + display: flex; justify-content: space-between; align-items: center; - margin-bottom: 2px; + margin-bottom: 20px; padding: 0 2px; - text-align: center; } &-text { @@ -330,39 +396,6 @@ $radius-sm: 12px; } } -/* 未登录提示区域 */ -.op-login-prompt { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 60px 20px; -} - -.op-login-text { - font-size: 15px; - color: #555; - margin-bottom: 24px; - text-align: center; -} - -.op-login-btn { - width: 100%; - height: 48px; - background: $primary; - color: #fff; - border: none; - border-radius: $radius-md; - font-size: 16px; - font-weight: 600; - cursor: pointer; - transition: opacity 0.2s; - - &:hover { opacity: 0.85; } -} - - /* ============ 字段列表区域(待填写 / 已填写) ============ */ .op-field-section { margin-top: 16px; @@ -481,6 +514,15 @@ $radius-sm: 12px; } +/* ============ 版本号 ============ */ +.op-version { + text-align: right; + font-size: 10px; + color: #bbb; + padding: 4px 10px 6px; + user-select: none; +} + /* ============ 助手已记住的表单 弹窗 ============ */ .op-cache-modal-mask { position: fixed; @@ -674,3 +716,66 @@ $radius-sm: 12px; color: #fff; } } + +/* ============ 非会员状态 ============ */ +.op-nonmember { + padding: 0 20px 20px; + + &-divider { + width: 100%; + height: 0; + border-top: 0.5px solid rgba(100, 116, 139, 0.4); + margin-bottom: 20px; + } + + &-title { + font-size: 16px; + font-weight: 700; + line-height: 19px; + color: #132034; + margin-bottom: 6px; + } + + &-desc { + font-size: 12px; + font-weight: 400; + line-height: 15px; + color: #64748B; + margin-bottom: 24px; + } + + &-btn { + display: block; + width: 100%; + height: 40px; + background: #52CAD1; + border: none; + border-radius: 4px; + font-size: 16px; + font-weight: 600; + line-height: 40px; + text-align: center; + color: #FFFFFF; + cursor: pointer; + transition: opacity 0.2s; + + &:hover { + opacity: 0.88; + } + } + + &-refresh { + margin-top: 8px; + font-size: 12px; + font-weight: 400; + line-height: 15px; + text-align: center; + text-decoration-line: underline; + color: #64748B; + cursor: pointer; + + &:hover { + color: #475569; + } + } +} diff --git a/src/components/SidebarPanel.tsx b/src/components/SidebarPanel.tsx index 47f52d0..c3ce606 100644 --- a/src/components/SidebarPanel.tsx +++ b/src/components/SidebarPanel.tsx @@ -6,15 +6,27 @@ import { useState, useEffect, useRef, useCallback } from "react" import { getCookieValue } from "~utils/cookie" +import { get as storageGet, set as storageSet, remove as storageRemove } from "~utils/storage" import { getCustomizeResume } from "~api/aiApi" +import { getMemberStatus } from "~api/dataApi" import { handleAutoFillCommon } from "~handlers/handleAutoFillCommon" import { handleAutoFillBeisen } from "~handlers/handleAutoFillBeisen" import { scanPageFields, extractNonResumeFields, printFieldStats } from "~lib/fillStats" import type { TitleStat } from "~lib/fillStats" import type { MatchedFormField, ResumeData, JobInfo } from "~lib/types" +import { createChannelBridge } from "~lib/channelBridge" import logoImg from "data-base64:~/../assets/logo-offerpai.png" +import { config as appConfig } from "~config" import "./SidebarPanel.scss" +/** 投递链接列表数据类型(跨域通信用) */ +interface OfferpaieDeliveryLinkListData { + linkList: string[] +} + +/** 创建通信桥接实例(用于投递链接列表的跨域读写) */ +const panelBridge = createChannelBridge({ domain: appConfig.bridgeDomain, isPlugin: true }) + /** 侧边栏面板的 Props */ interface SidebarPanelProps { /** 当前标签页的来源链接 */ @@ -28,6 +40,8 @@ interface SidebarPanelProps { export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps) { /** 是否已登录(有 Token) */ const [isLoggedIn, setIsLoggedIn] = useState(null) + /** 是否为会员(null=未检测,true=会员,false=非会员) */ + const [isMember, setIsMember] = useState(null) /** 是否正在执行自动填写 */ const [filling, setFilling] = useState(false) /** 页面语言类型:中文 / 英文 */ @@ -43,7 +57,7 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps) /** 页面字段扫描统计结果(用于面板底部展示已填/待填字段列表) */ const [fieldStats, setFieldStats] = useState([]) /** 顶部状态文字 */ - const [headerStatus, setHeaderStatus] = useState("") + const [headerStatus, setHeaderStatus] = useState("准备中, 请登录到投递表单页~") /** 是否启用字段更新时自动滚动到该字段,模拟跟踪(常量开关,方便调试关闭) */ const ENABLE_AUTO_SCROLL_TO_UPDATED = true @@ -139,11 +153,12 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps) return () => { stopScanLoop() } }, []) - /** 页面加载时检测是否有足够的输入框,显示"准备就绪" */ + /** 页面加载 & SPA 路由变化时检测是否有足够的输入框,显示"准备就绪" */ useEffect(() => { + const hostDoc = document.getRootNode() === document ? document : (document.getRootNode() as ShadowRoot).ownerDocument + + /** 检测页面是否有足够的表单输入框 */ const checkInputs = () => { - // 注意:需要查询宿主页面的 DOM,而不是 Shadow DOM 内部 - const hostDoc = document.getRootNode() === document ? document : (document.getRootNode() as ShadowRoot).ownerDocument const inputs = hostDoc.querySelectorAll("input:not([type='hidden']):not([type='submit']):not([type='button']):not([type='file']), textarea") if (inputs.length > 2) { setHeaderStatus("准备就绪,请点击开始填写") @@ -151,16 +166,35 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps) } return false } - // 立即检测一次 - if (checkInputs()) return - // 页面可能还在加载,延迟重试几次 - const timer1 = setTimeout(checkInputs, 500) - const timer2 = setTimeout(checkInputs, 1500) - const timer3 = setTimeout(checkInputs, 3000) + + /** 路由变化后延迟多次重试检测(等待新页面 DOM 渲染) */ + const timers: ReturnType[] = [] + const scheduleCheck = () => { + setHeaderStatus("准备中, 请登录到投递表单页~") + if (checkInputs()) return + timers.push(setTimeout(checkInputs, 500)) + timers.push(setTimeout(checkInputs, 1500)) + timers.push(setTimeout(checkInputs, 3000)) + timers.push(setTimeout(checkInputs, 5000)) + } + + // 初次挂载时检测 + scheduleCheck() + + // URL 轮询:每秒检查一次 URL 是否变化(兼容所有 SPA 路由方式) + let lastUrl = window.location.href + const urlPollInterval = setInterval(() => { + const currentUrl = window.location.href + if (currentUrl !== lastUrl) { + lastUrl = currentUrl + console.log("[OfferPie] 检测到 URL 变化:", currentUrl) + scheduleCheck() + } + }, 1000) + return () => { - clearTimeout(timer1) - clearTimeout(timer2) - clearTimeout(timer3) + clearInterval(urlPollInterval) + timers.forEach(clearTimeout) } }, []) @@ -203,14 +237,32 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps) setLastUpdatedFieldEl(null) }, [lastUpdatedFieldEl]) - /** 页面加载时检查 Token,有岗位信息则查询定制简历 */ + /** 页面加载时检查 Token,有岗位信息则查询定制简历,并检查会员状态 */ useEffect(() => { getCookieValue("Token").then((token) => { console.log("[OfferPie] SidebarPanel 获取到的 Token:", token) setIsLoggedIn(!!token) - // 有 Token 且有岗位 ID 时,查询定制简历 - if (token && jobInfo?.id) { + if (!token) { + setHeaderStatus("请登录后继续使用") + return + } + + // 查询会员状态 + getMemberStatus().then((memberData) => { + console.log("[OfferPie] 会员状态:", memberData) + setIsMember(memberData.isMember) + if (!memberData.isMember) { + setHeaderStatus("请开通会员后继续使用") + } + }).catch((err) => { + console.warn("[OfferPie] 查询会员状态失败:", err) + setIsMember(false) + setHeaderStatus("请开通会员后继续使用") + }) + + // 有岗位 ID 时,查询定制简历 + if (jobInfo?.id) { getCustomizeResume(jobInfo.id).then((resumeRes: any) => { console.log("[OfferPie] 定制简历数据:", resumeRes) // 接口返回的 resume 字段映射为 main @@ -355,6 +407,26 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps) * 8. matchMainFields 匹配非经历区域字段(姓名/手机/邮箱等)并填充 * 9. 收集剩余未匹配的空白字段(unmatchedFields),后续交给 AI 生成答案 * + /** 重置按钮:恢复到开始填写状态,重新检测页面 */ + const handleReset = () => { + setFillCompleted(false) + // 重新检测页面输入框状态 + setHeaderStatus("准备中, 请登录到投递表单页~") + const hostDoc = document.getRootNode() === document ? document : (document.getRootNode() as ShadowRoot).ownerDocument + const inputs = hostDoc.querySelectorAll("input:not([type='hidden']):not([type='submit']):not([type='button']):not([type='file']), textarea") + if (inputs.length > 2) { + setHeaderStatus("准备就绪,请点击开始填写") + } else { + // 延迟重试 + setTimeout(() => { + const inputs2 = hostDoc.querySelectorAll("input:not([type='hidden']):not([type='submit']):not([type='button']):not([type='file']), textarea") + if (inputs2.length > 2) setHeaderStatus("准备就绪,请点击开始填写") + }, 1000) + } + } + + /** + * 自动填写主流程 * 填写完成后更新组件状态,供后续"保存用户修改"和"AI补填"使用 */ const handleAutoFill = async () => { @@ -404,14 +476,12 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps) * 模拟提交:通过 fillStats 扫描页面字段,过滤出 B2 阶段字段数据,更新到缓存 * 【注意】弹窗打开时不执行,避免覆盖用户正在编辑的数据 */ - const handleSaveUserInput = () => { + const handleSaveUserInput = async () => { // 弹窗打开时直接跳过,不更新缓存 if (showCacheModal) return try { - const cached = localStorage.getItem("offerpie_unfilled_form") - if (!cached) { alert("未找到缓存数据,请先执行自动填写"); return } - - const cacheData = JSON.parse(cached) + const cacheData = await storageGet("offerpie_unfilled_form") + if (!cacheData) { return } // 检测当前网站模式 const currentHost = window.location.hostname @@ -424,81 +494,72 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps) const nonResumeData = extractNonResumeFields(titleStats) // 更新缓存中的 unfilledFormData + // 策略:新数据中有的字段用新值替换,新数据中没有但旧数据中有的字段保留 const oldSections = cacheData.unfilledFormData as any[] || [] for (const newSec of nonResumeData) { - const oldSec = oldSections.find((s: any) => s.title === newSec.title) - if (!oldSec) { + const oldSecIdx = oldSections.findIndex((s: any) => s.title === newSec.title) + if (oldSecIdx < 0) { oldSections.push(newSec) - continue - } - - if (newSec.isExperience) { - const oldSegments = oldSec.formItems as any[][] - const newSegments = newSec.formItems as any[][] - for (let sIdx = 0; sIdx < newSegments.length; sIdx++) { - if (sIdx >= oldSegments.length) { - oldSegments.push(newSegments[sIdx]) - continue - } - const oldFields = oldSegments[sIdx] - const newFields = newSegments[sIdx] - for (const newField of newFields) { - const oldField = oldFields.find((f: any) => f.label === newField.label) - if (oldField) { - if (newField.value) oldField.value = newField.value + } else { + const oldSec = oldSections[oldSecIdx] + if (newSec.isExperience && oldSec.isExperience) { + // 经历类型:按段合并,保留旧字段 + const oldSegments = oldSec.formItems as any[][] + const newSegments = newSec.formItems as any[][] + for (let sIdx = 0; sIdx < newSegments.length; sIdx++) { + if (sIdx >= oldSegments.length) { + oldSegments.push(newSegments[sIdx]) } else { - oldFields.push(newField) + // 新字段更新值,旧字段中有但新字段中没有的保留 + const oldFields = oldSegments[sIdx] + const newFields = newSegments[sIdx] + for (const nf of newFields) { + const of_ = oldFields.find((f: any) => f.label === nf.label) + if (of_) { + if (nf.value) of_.value = nf.value + } else { + oldFields.push(nf) + } + } } } - } - } else { - const oldFields = oldSec.formItems as any[] - const newFields = newSec.formItems as any[] - for (const newField of newFields) { - const oldField = oldFields.find((f: any) => f.label === newField.label) - if (oldField) { - if (newField.value) oldField.value = newField.value - } else { - oldFields.push(newField) + } else if (!newSec.isExperience && !oldSec.isExperience) { + // 非经历类型:按字段合并,保留旧字段 + const oldFields = oldSec.formItems as any[] + const newFields = newSec.formItems as any[] + for (const nf of newFields) { + const of_ = oldFields.find((f: any) => f.label === nf.label) + if (of_) { + if (nf.value) of_.value = nf.value + } else { + oldFields.push(nf) + } } + } else { + // 类型变化,直接替换 + oldSections[oldSecIdx] = newSec } } } cacheData.unfilledFormData = oldSections cacheData.timestamp = Date.now() - localStorage.setItem("offerpie_unfilled_form", JSON.stringify(cacheData)) + await storageSet("offerpie_unfilled_form", cacheData) // 打印所有字段数据 - // console.log("===== OfferPie: handleSaveUserInput 全量字段数据 =====") printFieldStats(titleStats) - - // 打印 B2 字段数据 - // console.log("===== OfferPie: handleSaveUserInput B2阶段字段数据 =====") - // console.log(JSON.stringify(nonResumeData, null, 2)) - // console.log("===== OfferPie: B2字段数据打印完毕 =====") - - const updatedCount = nonResumeData.reduce((sum, sec) => { - if (sec.isExperience) { - return sum + (sec.formItems as any[][]).reduce((s, seg) => s + seg.filter((f: any) => f.value).length, 0) - } - return sum + (sec.formItems as any[]).filter((f: any) => f.value).length - }, 0) - // console.log(`已保存!B2字段中有值的共 ${updatedCount} 个`) } catch (e) { console.error("[OfferPie] 模拟提交失败:", e) - console.log("保存失败,请查看控制台") } } - /** 打开"助手已记住的表单"弹窗,从 localStorage 加载数据 */ - const openCacheModal = () => { + /** 打开"助手已记住的表单"弹窗,从 chrome.storage 加载数据 */ + const openCacheModal = async () => { try { - const cached = localStorage.getItem("offerpie_unfilled_form") - if (!cached) { + const cacheData = await storageGet("offerpie_unfilled_form") + if (!cacheData) { setCacheModalData([]) } else { - const cacheData = JSON.parse(cached) setCacheModalData(cacheData.unfilledFormData || []) } } catch { @@ -528,14 +589,13 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps) setCacheModalData(updated) } - /** 弹窗内保存数据到 localStorage */ - const saveCacheModalData = () => { + /** 弹窗内保存数据到 chrome.storage */ + const saveCacheModalData = async () => { try { - const cached = localStorage.getItem("offerpie_unfilled_form") - const cacheData = cached ? JSON.parse(cached) : {} + const cacheData = await storageGet("offerpie_unfilled_form") || {} cacheData.unfilledFormData = cacheModalData || [] cacheData.timestamp = Date.now() - localStorage.setItem("offerpie_unfilled_form", JSON.stringify(cacheData)) + await storageSet("offerpie_unfilled_form", cacheData) alert("已保存表单数据") } catch (e) { console.error("[OfferPie] 保存缓存数据失败:", e) @@ -544,10 +604,12 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps) } /** 弹窗内清空缓存数据(带确认) */ - const clearCacheFromModal = () => { + const clearCacheFromModal = async () => { const confirmed = window.confirm("是否清空已记录的表单数据?清空后下次填简历那些数据需要您一个个手动填。") if (!confirmed) return - localStorage.removeItem("offerpie_unfilled_form") + // 先停止循环扫描,防止 handleSaveUserInput 又把数据写回去 + stopScanLoop() + await storageRemove("offerpie_unfilled_form") setCacheModalData([]) alert("已清空缓存数据") console.log("[OfferPie] 已清空 offerpie_unfilled_form 缓存") @@ -555,11 +617,50 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps) // ============ 拖拽逻辑 ============ - /** 鼠标按下:启动长按计时 */ + /** 即时拖拽:收起状态横条 / 展开状态顶部 header,按住即可拖动(无需长按) */ + const handleInstantDragDown = useCallback((e: React.MouseEvent) => { + // 如果点击的是箭头按钮,不启动拖拽 + const target = e.target as HTMLElement + if (target.closest("button, .op-collapsed-arrow, .op-collapse-btn")) return + + e.preventDefault() + const state = dragStateRef.current + state.startX = e.clientX + state.startY = e.clientY + state.startOffsetX = dragOffset.x + state.startOffsetY = dragOffset.y + state.isDragging = true + state.isLongPress = true // 标记为已激活拖拽 + if (containerRef.current) { + containerRef.current.style.cursor = "grabbing" + } + + const handleMouseMove = (ev: MouseEvent) => { + const dx = ev.clientX - state.startX + const dy = ev.clientY - state.startY + setDragOffset({ x: state.startOffsetX + dx, y: state.startOffsetY + dy }) + if (!hasDragged) setHasDragged(true) + } + + const handleMouseUp = () => { + state.isDragging = false + state.isLongPress = false + if (containerRef.current) { + containerRef.current.style.cursor = "" + } + document.removeEventListener("mousemove", handleMouseMove) + document.removeEventListener("mouseup", handleMouseUp) + } + + document.addEventListener("mousemove", handleMouseMove) + document.addEventListener("mouseup", handleMouseUp) + }, [dragOffset, hasDragged]) + + /** 鼠标按下(非 header 区域):启动长按计时,300ms 后才可拖拽 */ const handleMouseDown = useCallback((e: React.MouseEvent) => { // 如果点击的是交互元素(按钮、链接、输入框等),不启动拖拽 const target = e.target as HTMLElement - if (target.closest("button, a, input, textarea, select, [role='button'], .op-credits-link, .op-field-item, .op-cache-modal-mask")) return + if (target.closest("button, a, input, textarea, select, [role='button'], .op-credits-link, .op-field-item, .op-cache-modal-mask, .op-header, .op-collapsed-bar")) return e.preventDefault() const state = dragStateRef.current @@ -646,50 +747,84 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps) > {/* 拖拽提示气泡 */} {showDragTooltip && ( -
长按可以拖动助手位置
+
助手位置可以拖动
)} - {/* 收起状态:只显示 logo 圆圈 */} + {/* 收起状态:显示 logo + 提示文字 + 向下箭头 */} {collapsed ? ( -
setCollapsed(false)}> - OfferPie +
+ OfferPie + {headerStatus || "Offer派助手"} + setCollapsed(false)} width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"> + +
) : ( <> {/* 填写中遮罩:覆盖整个面板,阻止用户操作 */} {filling &&
}
- {/* 新顶部:logo + 提示文字 + 收起箭头 */} -
+ {/* 新顶部:logo + 提示文字 + 收起箭头(按住即可拖动) */} +
OfferPie - {headerStatus} + {headerStatus || "Offer派助手"}
{/* 未登录状态:显示登录提示 */} {!isLoggedIn && isLoggedIn !== null && ( -
-

请先前往 Offer派 官网进行登录

+
+
+
登录 Offer 派,开启智能投递
+
登录后将自动同步你的会员状态、简历和求职信息
+
{ window.location.reload() }} + > + 已完成登录?刷新登录状态 +
)} {/* 已登录状态:显示完整功能 */} {isLoggedIn && ( <> + {/* 非会员状态:显示开通会员提示 */} + {isMember === false && ( +
+
+
智能投递助手为会员专享功能
+
开通会员后,即可自动识别并填写网申表单,减少重复操作
+ +
{ window.location.reload() }} + > + 已开通会员?刷新会员状态 +
+
+ )} + + {/* 会员状态:显示完整功能 */} + {isMember === true && ( + <> {/* 职位信息卡片 */}
@@ -718,13 +853,50 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps) )}
- {/* 自动填写按钮 */} - + {/* 自动填写按钮区域 */} + {fillCompleted ? ( +
+ +
填写完成,请检查
+
+ ) : ( + + )} - {/* 使用次数信息 */} + {/* 操作链接 */}
+ { + const cachedUrl = sessionStorage.getItem("offerpie_source_url") || sourceUrl + if (!cachedUrl) return + + try { + // 通过跨域 CSL 模式读取投递链接列表 + const result = await panelBridge.get(appConfig.bridgeDomain, "offerpaiDeliveryLinkList", true) + const linkList = result?.linkList || [] + + // 检查当前投递链接是否已在列表中 + if (!linkList.includes(cachedUrl)) { + linkList.push(cachedUrl) + } + + // 写回跨域通信(让网页端能读到) + await panelBridge.put(appConfig.bridgeDomain, "offerpaiDeliveryLinkList", { linkList }, true) + console.log("[OfferPie] 已更新投递链接列表:", linkList) + } catch (err) { + console.warn("[OfferPie] 更新投递链接列表失败:", err) + } + + // 跳转到投递链接 + window.location.href = cachedUrl + }}>打开投递链接 助手已记住的表单
@@ -864,6 +1036,12 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps) ) })()} + )} + + )} + {/* 版本号(未登录或非会员时隐藏) */} + {isLoggedIn && isMember !== false && ( +
ver: {appConfig.pluginVersion}
)}
diff --git a/src/config.ts b/src/config.ts index 324c7c1..df4c81a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -10,17 +10,25 @@ const envConfigs: Record = { dev: { dataBaseApi: 'http://127.0.0.1:8080/api', // aiBaseApi: 'http://192.168.31.133:8000', aiBaseApi: 'http://127.0.0.1:8000', cookieSourceUrl: 'http://127.0.0.1:5173', + bridgeDomain: 'test.offerpai.com.cn', + pluginVersion: '0.1.1', }, prod: { dataBaseApi: 'https://www.offerpai.com.cn/api', aiBaseApi: 'https://www.offerpai.com.cn/ai-api', cookieSourceUrl: 'https://www.offerpai.com.cn', + bridgeDomain: 'www.offerpai.com.cn', + pluginVersion: '0.1.1', }, } diff --git a/src/contents/sidebar.tsx b/src/contents/sidebar.tsx index fe52b08..9eee0d1 100644 --- a/src/contents/sidebar.tsx +++ b/src/contents/sidebar.tsx @@ -10,8 +10,77 @@ import { useEffect, useState, useRef } from "react" import { SidebarPanel } from "~components/SidebarPanel" import { getCookieValue } from "~utils/cookie" import { findJobBySourceUrl } from "~api/dataApi" +import { createChannelBridge } from "~lib/channelBridge" +import { config as appConfig } from "~config" import type { JobInfo } from "~lib/types" +// ============ 投递链接列表数据类型 ============ + +/** 投递链接列表数据(跨域通信用) */ +interface OfferpaieDeliveryLinkListData { + linkList: string[] +} + +// ============ 通信桥接:插件运行即激活 ============ + +/** 插件使用状态数据类型 */ +interface OfferpieBrowserPlugUsage { + usage: string + version: string +} + +/** 创建通信桥接实例(插件端,不启用 Web Lock) */ +const bridge = createChannelBridge({ domain: appConfig.bridgeDomain, isPlugin: true }) + +/** 插件运行时立即广播使用状态,并每1秒持续广播(让 watch 端能实时感知插件在线) */ +bridge.emit(appConfig.bridgeDomain, 'offerpieBrowserPlugUsage', { usage: 'isUsed', version: appConfig.pluginVersion }) +setInterval(() => { + bridge.emit(appConfig.bridgeDomain, 'offerpieBrowserPlugUsage', { usage: 'isUsed', version: appConfig.pluginVersion }) +}, 1000) + +/** + * 摩卡平台重定向链接处理 + * mokahr.com 平台的页面会重定向,导致当前 URL 和数据库存的原始来源链接不一致 + * 但岗位 ID(URL 最后一段,如 2381af5e-4285-4eac-815b-ba40bf586389)在两种链接里是一样的 + * 通过跨域通信从 offerpaiDeliveryLinkList 中匹配含相同 ID 的原始投递链接 + */ +async function resolveMokahrRedirectUrl(url: string): Promise { + // 仅对 mokahr.com 域名的链接生效 + if (!url.includes("mokahr.com")) return url + + // 从 URL 末尾提取岗位 ID(最后一个 / 后面的部分,去掉可能的查询参数和 hash) + const cleanUrl = url.split("?")[0].split("#").pop() || url.split("?")[0] + const segments = cleanUrl.split("/").filter(Boolean) + const mokaJobId = segments[segments.length - 1] + if (!mokaJobId) return url + + console.log("[OfferPie] 摩卡平台重定向链接处理,提取岗位ID:", mokaJobId) + + try { + // 通过跨域 CSL 模式读取投递链接列表 + const result = await bridge.get(appConfig.bridgeDomain, "offerpaiDeliveryLinkList", true) + const linkList = result?.linkList || [] + if (linkList.length === 0) { + console.log("[OfferPie] offerpaiDeliveryLinkList 为空,无法匹配原始链接") + return url + } + + // 从后往前遍历 linkList,找到第一个包含该岗位 ID 的链接 + for (let i = linkList.length - 1; i >= 0; i--) { + if (linkList[i].includes(mokaJobId)) { + console.log("[OfferPie] 匹配到摩卡平台原始来源链接:", linkList[i]) + return linkList[i] + } + } + + console.log("[OfferPie] linkList 中未找到包含该岗位ID的链接,使用当前URL") + } catch (err) { + console.warn("[OfferPie] 摩卡平台重定向链接处理失败:", err) + } + + return url +} + /** Content Script 配置:匹配所有网页 */ export const config: PlasmoCSConfig = { matches: [""] @@ -54,26 +123,36 @@ function Sidebar() { // 确定用于查询岗位信息的 URL:优先用 sessionStorage 缓存的(同标签页跳转后保留) const STORAGE_KEY = "offerpie_source_url" const cachedUrl = sessionStorage.getItem(STORAGE_KEY) - const queryUrl = cachedUrl || currentUrl + let queryUrl = cachedUrl || currentUrl - // 查询岗位信息 - findJobBySourceUrl(queryUrl).then((data) => { - console.log("[OfferPie] 岗位信息:", data) - if (data && data.id) { - setJobInfo(data) - // 查到岗位后缓存当前查询成功的 URL - sessionStorage.setItem(STORAGE_KEY, queryUrl) - // 自动打开面板 - if (!autoOpenedRef.current) { - autoOpenedRef.current = true - setVisible(true) - } - } else if (!cachedUrl) { - // 当前 URL 和缓存都没查到,不自动打开 - console.log("[OfferPie] 当前页面未匹配到岗位信息") + // 摩卡平台重定向链接处理:替换为原始来源链接 + resolveMokahrRedirectUrl(queryUrl).then((resolvedUrl) => { + if (resolvedUrl !== queryUrl) { + queryUrl = resolvedUrl + // 同步更新 sessionStorage 缓存 + sessionStorage.setItem(STORAGE_KEY, resolvedUrl) + console.log("[OfferPie] 摩卡平台链接已替换为原始来源:", resolvedUrl) } - }).catch((err) => { - console.warn("[OfferPie] 查询岗位信息失败:", err) + + // 查询岗位信息 + findJobBySourceUrl(queryUrl).then((data) => { + console.log("[OfferPie] 岗位信息:", data) + if (data && data.id) { + setJobInfo(data) + // 查到岗位后缓存当前查询成功的 URL + sessionStorage.setItem(STORAGE_KEY, queryUrl) + // 自动打开面板 + if (!autoOpenedRef.current) { + autoOpenedRef.current = true + setVisible(true) + } + } else if (!cachedUrl) { + // 当前 URL 和缓存都没查到,不自动打开 + console.log("[OfferPie] 当前页面未匹配到岗位信息") + } + }).catch((err) => { + console.warn("[OfferPie] 查询岗位信息失败:", err) + }) }) // 如果有缓存 URL 且当前 URL 不同(跳转到了表单页),也自动打开面板 diff --git a/src/handlers/handleAutoFillBeisen.ts b/src/handlers/handleAutoFillBeisen.ts index f8d2ba5..8e1370a 100644 --- a/src/handlers/handleAutoFillBeisen.ts +++ b/src/handlers/handleAutoFillBeisen.ts @@ -19,6 +19,8 @@ import { locateExperienceSections, expandExperienceSections, sortExperienceByTim import type { ExperienceSectionLocateResult } from "~lib/experienceSection" import type { MatchedFormField, ResumeData, ExperienceSection, JobInfo, UnmatchedFormField } from "~lib/types" import { setFieldHighlight, isRequiredField } from "~lib/formStyle" +import { get as storageGet, set as storageSet } from "~utils/storage" +import { findLabelForInput } from "~lib/labelFinder" /** 北森模式自动填写的参数 */ export interface AutoFillBeisenParams { @@ -957,41 +959,10 @@ export async function handleAutoFillBeisen(params: AutoFillBeisenParams): Promis if (bgColor === "#b7ffc5" || bgColor === "rgb(183, 255, 197)") continue } - // 查找标签 - let labelText = "" - let container: Element | null = null - for (const sel of FORM_ITEM_SELS_JSON) { - container = inp.closest(sel) - if (container) break - } - if (container) { - const labelEls = container.querySelectorAll("label, span, div, td, th, p, legend, dt") - for (const el of Array.from(labelEls)) { - const directText = Array.from(el.childNodes) - .filter((n) => n.nodeType === Node.TEXT_NODE) - .map((n) => n.textContent?.trim()) - .filter(Boolean) - .join("") - if (!directText || directText.length > 30) continue - if (JSON_EXCLUDE_LABELS.some((ex) => directText === ex)) continue - if (el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) { - labelText = directText - break - } - } - } - if (!labelText) { - let prev: Element | null = inp.previousElementSibling - for (let i = 0; i < 3 && prev; i++) { - const text = prev.textContent?.trim() || "" - if (text && text.length <= 20 && !JSON_EXCLUDE_LABELS.some((ex) => text === ex)) { - labelText = text - break - } - prev = prev.previousElementSibling - } - } - if (!labelText) labelText = inputEl.getAttribute("placeholder") || "(未知字段)" + // 查找标签(使用统一封装的 labelFinder) + const titleElementSet = new Set(allTitles.map((t) => t.element)) + const { labelText: detectedLabel } = findLabelForInput(inp, titleElementSet) + const labelText = detectedLabel // 非经历类型:跳过简历格式字段 // 【注意】简历格式字段通过 resumeFormatInputs(阶段A/B实际匹配到的input元素集合)精确跳过 @@ -1114,72 +1085,29 @@ export async function handleAutoFillBeisen(params: AutoFillBeisenParams): Promis console.log(JSON.stringify(unfilledFormData, null, 2)) console.log("===== OfferPie: JSON 输出完毕 =====") - // 存 localStorage 缓存,包含简历名字和未填字段数据 - // 更新规则:以第一次存的版本为基准,可以添加字段、更新 value,但不删除已有字段 + // 存 chrome.storage 缓存,包含简历名字和未填字段数据(跨域名共享,按简历名区分) const resumeName = currentResumeData?.main?.resumeName || currentResumeData?.main?.name || "" try { - const existingRaw = localStorage.getItem("offerpie_unfilled_form") + const existing = await storageGet("offerpie_unfilled_form") let cacheData: any = null - if (existingRaw) { - const existing = JSON.parse(existingRaw) - if (existing.resumeName === resumeName && existing.unfilledFormData) { - // 同一份简历,合并更新(不删除已有字段,只添加新字段和更新 value) - const oldSections = existing.unfilledFormData as any[] - const newSections = unfilledFormData as any[] + if (existing && existing.resumeName === resumeName && existing.unfilledFormData) { + // 同一份简历,用新数据替换同名 section(保留其他平台的 section) + const oldSections = existing.unfilledFormData as any[] + const newSections = unfilledFormData as any[] - // 遍历新数据,对每个 section 做合并 - for (const newSec of newSections) { - const oldSec = oldSections.find((s: any) => s.title === newSec.title) - if (!oldSec) { - // 新增的 section,直接追加 - oldSections.push(newSec) - continue - } - - if (newSec.isExperience) { - // 经历类型:按段合并 - const oldSegments = oldSec.formItems as any[][] - const newSegments = newSec.formItems as any[][] - for (let sIdx = 0; sIdx < newSegments.length; sIdx++) { - if (sIdx >= oldSegments.length) { - // 新增的段,直接追加 - oldSegments.push(newSegments[sIdx]) - continue - } - // 已有的段:合并字段 - const oldFields = oldSegments[sIdx] - const newFields = newSegments[sIdx] - for (const newField of newFields) { - const oldField = oldFields.find((f: any) => f.label === newField.label) - if (oldField) { - // 已有字段:如果新值非空则更新 - if (newField.value) oldField.value = newField.value - } else { - // 新字段:追加 - oldFields.push(newField) - } - } - } - } else { - // 非经历类型:按字段合并 - const oldFields = oldSec.formItems as any[] - const newFields = newSec.formItems as any[] - for (const newField of newFields) { - const oldField = oldFields.find((f: any) => f.label === newField.label) - if (oldField) { - if (newField.value) oldField.value = newField.value - } else { - oldFields.push(newField) - } - } - } + for (const newSec of newSections) { + const oldSecIdx = oldSections.findIndex((s: any) => s.title === newSec.title) + if (oldSecIdx < 0) { + oldSections.push(newSec) + } else { + oldSections[oldSecIdx] = newSec } - - existing.unfilledFormData = oldSections - existing.timestamp = Date.now() - cacheData = existing } + + existing.unfilledFormData = oldSections + existing.timestamp = Date.now() + cacheData = existing } // 没有已有缓存或不是同一份简历 → 新建 @@ -1191,10 +1119,10 @@ export async function handleAutoFillBeisen(params: AutoFillBeisenParams): Promis } } - localStorage.setItem("offerpie_unfilled_form", JSON.stringify(cacheData)) + await storageSet("offerpie_unfilled_form", cacheData) console.log(`===== OfferPie: 已缓存未填字段数据(简历: "${resumeName}") =====`) } catch (e) { - console.warn("OfferPie: localStorage 缓存失败", e) + console.warn("OfferPie: chrome.storage 缓存失败", e) } } @@ -1503,15 +1431,12 @@ async function handleFillCachedData(params: FillCachedDataParams): Promise("offerpie_unfilled_form") + if (!cacheData) { console.log("===== OfferPie: 阶段B2 - 无缓存数据,跳过 =====") return result } - let cacheData: any - try { cacheData = JSON.parse(cachedRaw) } catch { return result } - if (cacheData.resumeName !== resumeName) { console.log(`===== OfferPie: 阶段B2 - 缓存简历名"${cacheData.resumeName}"与当前"${resumeName}"不匹配,跳过 =====`) return result diff --git a/src/handlers/handleAutoFillCommon.ts b/src/handlers/handleAutoFillCommon.ts index a103b09..7a612b3 100644 --- a/src/handlers/handleAutoFillCommon.ts +++ b/src/handlers/handleAutoFillCommon.ts @@ -19,6 +19,8 @@ import { locateExperienceSections, expandExperienceSections, sortExperienceByTim import type { ExperienceSectionLocateResult } from "~lib/experienceSection" import type { MatchedFormField, ResumeData, ExperienceSection, JobInfo, UnmatchedFormField } from "~lib/types" import { setFieldHighlight, isRequiredField } from "~lib/formStyle" +import { get as storageGet, set as storageSet } from "~utils/storage" +import { findLabelForInput } from "~lib/labelFinder" /** 通用自动填写的参数 */ export interface AutoFillCommonParams { @@ -941,41 +943,10 @@ export async function handleAutoFillCommon(params: AutoFillCommonParams): Promis if (bgColor === "#b7ffc5" || bgColor === "rgb(183, 255, 197)") continue } - // 查找标签 - let labelText = "" - let container: Element | null = null - for (const sel of FORM_ITEM_SELS_JSON) { - container = inp.closest(sel) - if (container) break - } - if (container) { - const labelEls = container.querySelectorAll("label, span, div, td, th, p, legend, dt") - for (const el of Array.from(labelEls)) { - const directText = Array.from(el.childNodes) - .filter((n) => n.nodeType === Node.TEXT_NODE) - .map((n) => n.textContent?.trim()) - .filter(Boolean) - .join("") - if (!directText || directText.length > 30) continue - if (JSON_EXCLUDE_LABELS.some((ex) => directText === ex)) continue - if (el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) { - labelText = directText - break - } - } - } - if (!labelText) { - let prev: Element | null = inp.previousElementSibling - for (let i = 0; i < 3 && prev; i++) { - const text = prev.textContent?.trim() || "" - if (text && text.length <= 20 && !JSON_EXCLUDE_LABELS.some((ex) => text === ex)) { - labelText = text - break - } - prev = prev.previousElementSibling - } - } - if (!labelText) labelText = inputEl.getAttribute("placeholder") || "(未知字段)" + // 查找标签(使用统一封装的 labelFinder) + const titleElementSet = new Set(allTitles.map((t) => t.element)) + const { labelText: detectedLabel } = findLabelForInput(inp, titleElementSet) + const labelText = detectedLabel // 非经历类型:跳过简历格式字段 // 【注意】简历格式字段通过 resumeFormatInputs(阶段A/B实际匹配到的input元素集合)精确跳过 @@ -1049,72 +1020,29 @@ export async function handleAutoFillCommon(params: AutoFillCommonParams): Promis // console.log(JSON.stringify(unfilledFormData, null, 2)) console.log("===== OfferPie: JSON 输出完毕 =====") - // 存 localStorage 缓存,包含简历名字和未填字段数据 - // 更新规则:以第一次存的版本为基准,可以添加字段、更新 value,但不删除已有字段 + // 存 chrome.storage 缓存,包含简历名字和未填字段数据(跨域名共享,按简历名区分) const resumeName = currentResumeData?.main?.resumeName || currentResumeData?.main?.name || "" try { - const existingRaw = localStorage.getItem("offerpie_unfilled_form") + const existing = await storageGet("offerpie_unfilled_form") let cacheData: any = null - if (existingRaw) { - const existing = JSON.parse(existingRaw) - if (existing.resumeName === resumeName && existing.unfilledFormData) { - // 同一份简历,合并更新(不删除已有字段,只添加新字段和更新 value) - const oldSections = existing.unfilledFormData as any[] - const newSections = unfilledFormData as any[] + if (existing && existing.resumeName === resumeName && existing.unfilledFormData) { + // 同一份简历,用新数据替换同名 section(保留其他平台的 section) + const oldSections = existing.unfilledFormData as any[] + const newSections = unfilledFormData as any[] - // 遍历新数据,对每个 section 做合并 - for (const newSec of newSections) { - const oldSec = oldSections.find((s: any) => s.title === newSec.title) - if (!oldSec) { - // 新增的 section,直接追加 - oldSections.push(newSec) - continue - } - - if (newSec.isExperience) { - // 经历类型:按段合并 - const oldSegments = oldSec.formItems as any[][] - const newSegments = newSec.formItems as any[][] - for (let sIdx = 0; sIdx < newSegments.length; sIdx++) { - if (sIdx >= oldSegments.length) { - // 新增的段,直接追加 - oldSegments.push(newSegments[sIdx]) - continue - } - // 已有的段:合并字段 - const oldFields = oldSegments[sIdx] - const newFields = newSegments[sIdx] - for (const newField of newFields) { - const oldField = oldFields.find((f: any) => f.label === newField.label) - if (oldField) { - // 已有字段:如果新值非空则更新 - if (newField.value) oldField.value = newField.value - } else { - // 新字段:追加 - oldFields.push(newField) - } - } - } - } else { - // 非经历类型:按字段合并 - const oldFields = oldSec.formItems as any[] - const newFields = newSec.formItems as any[] - for (const newField of newFields) { - const oldField = oldFields.find((f: any) => f.label === newField.label) - if (oldField) { - if (newField.value) oldField.value = newField.value - } else { - oldFields.push(newField) - } - } - } + for (const newSec of newSections) { + const oldSecIdx = oldSections.findIndex((s: any) => s.title === newSec.title) + if (oldSecIdx < 0) { + oldSections.push(newSec) + } else { + oldSections[oldSecIdx] = newSec } - - existing.unfilledFormData = oldSections - existing.timestamp = Date.now() - cacheData = existing } + + existing.unfilledFormData = oldSections + existing.timestamp = Date.now() + cacheData = existing } // 没有已有缓存或不是同一份简历 → 新建 @@ -1126,10 +1054,10 @@ export async function handleAutoFillCommon(params: AutoFillCommonParams): Promis } } - localStorage.setItem("offerpie_unfilled_form", JSON.stringify(cacheData)) + await storageSet("offerpie_unfilled_form", cacheData) console.log(`===== OfferPie: 已缓存未填字段数据(简历: "${resumeName}") =====`) } catch (e) { - console.warn("OfferPie: localStorage 缓存失败", e) + console.warn("OfferPie: chrome.storage 缓存失败", e) } } @@ -1347,15 +1275,12 @@ async function handleFillCachedData(params: FillCachedDataParams): Promise("offerpie_unfilled_form") + if (!cacheData) { console.log("===== OfferPie: 阶段B2 - 无缓存数据,跳过 =====") return result } - let cacheData: any - try { cacheData = JSON.parse(cachedRaw) } catch { return result } - if (cacheData.resumeName !== resumeName) { console.log(`===== OfferPie: 阶段B2 - 缓存简历名"${cacheData.resumeName}"与当前"${resumeName}"不匹配,跳过 =====`) return result diff --git a/src/lib/channelBridge.ts b/src/lib/channelBridge.ts new file mode 100644 index 0000000..5264271 --- /dev/null +++ b/src/lib/channelBridge.ts @@ -0,0 +1,734 @@ +/** + * OfferPie 跨标签页通信桥接组件 + * 用于浏览器插件与网站页面之间的纯本地通信(不依赖任何后端接口) + * + * 【使用说明】 + * 1. 引入并创建实例: + * import { createChannelBridge } from './channelBridge' + * const bridge = createChannelBridge({ domain: 'offerpai.com', isPlugin: true }) + * + * 2. 发送数据(单次,同时写缓存 + 广播): + * bridge.put('offerpai.com', 'resumeStatus', { ... }) + * + * 3. 高频发送(节流写缓存,每次都广播): + * bridge.emit('offerpai.com', 'cursorPosition', { ... }) + * + * 4. 主动获取一次数据(先广播问对方,60ms超时转IndexedDB): + * const result = await bridge.get('offerpai.com', 'resumeStatus') + * + * 5. 持续监听数据变化(初始读一次缓存,后续实时接收 put/emit): + * const unwatch = bridge.watch('offerpai.com', 'resumeStatus', (data) => { ... }) + * // 取消监听 + * unwatch() + * + * 【跨域模式 useCsl】 + * 在 put/emit/get/watch 最后一个参数传 true,数据存储走 chrome.storage.local(通过 Background 中转) + * 适用于网页与插件在不同域名标签页之间的通信场景 + * 示例:bridge.put('offerpai.com', 'someData', { ... }, true) + * 注意:useCsl 模式依赖插件 Background Service Worker 在线 + * + * 6. 销毁实例(页面卸载时调用): + * bridge.destroy() + * + * 【参数说明】 + * - domain: 频道域名标识,不同网站项目用不同域名隔离 + * - isPlugin: 是否为插件环境,默认 false(非插件时会启用 Web Lock 保活) + * + * 【数据类型要求】 + * - 传递的数据必须有 TypeScript 类型定义(泛型约束 extends object) + * - 组件自动为每条数据添加 createTime 和 updateTime 字段 + * + * ============================================================ + * 【通信数据名称注册表】 + * 在此区域记录所有通信数据的名称、类型和用途说明 + * 新增数据时在此补充,删除时移除对应行 + * ------------------------------------------------------------ + * | 数据名(name) | 中文名称 | 说明 | + * | ----------------------------- | -------------- | --------------------------------- | + * | offerpieBrowserPlugUsage | 插件使用状态 | 标识插件正在运行 { usage: string, version: string } | + * | offerpaiDeliveryLinkList | 投递链接列表 | 记录要投递职位的来源链接,目前用于解决投递网站的重定向 { linkList: string[] } | + * ============================================================ + */ + +// ============ 类型定义 ============ + +/** 组件自动附加的时间戳字段 */ +export interface BridgeTimestamp { + /** 数据首次创建时间(格式:yyyy-MM-dd HH:mm:ss) */ + createTime: string + /** 数据最近更新时间(格式:yyyy-MM-dd HH:mm:ss) */ + updateTime: string +} + +/** 带时间戳的完整数据包装 */ +export type BridgeData = T & BridgeTimestamp + +/** IndexedDB 存储记录结构 */ +interface DBRecord { + /** 数据名称(唯一索引) */ + name: string + /** JSON 数据本体 */ + data: object + /** 创建时间 */ + createTime: string + /** 更新时间 */ + updateTime: string +} + +/** BroadcastChannel 消息结构 */ +interface ChannelMessage { + /** 消息类型 */ + type: 'put' | 'emit' | 'get-request' | 'get-response' | 'csl-put-request' | 'csl-put-response' | 'csl-get-request' | 'csl-get-response' + /** 数据名称 */ + name: string + /** 请求唯一ID(用于 get 请求-响应匹配) */ + id?: string + /** 数据本体(put/emit/get-response 携带) */ + data?: object + /** 创建时间 */ + createTime?: string + /** 更新时间 */ + updateTime?: string + /** 目标域名(csl 中转消息使用) */ + domain?: string +} + +/** watch 回调函数类型 */ +type WatchCallback = (data: BridgeData | null) => void + +/** 创建实例的配置参数 */ +export interface ChannelBridgeOptions { + /** 默认域名(可在方法调用时覆盖) */ + domain: string + /** 是否为插件环境,默认 false。非插件时启用 Web Lock 保活 */ + isPlugin?: boolean +} + +// ============ 常量 ============ + +/** IndexedDB 数据库名称(含固定雪花ID保证唯一性) */ +const DB_NAME = 'offerpie_comm_7394028156183472' +/** get 方法 BroadcastChannel 超时时间(毫秒) */ +const GET_TIMEOUT_MS = 60 +/** emit 方法节流写库间隔(毫秒) */ +const EMIT_THROTTLE_MS = 500 +/** chrome.storage.local 存储总 key */ +const CSL_STORAGE_KEY = 'offerpie_comm_7394028156183472' +/** Background 消息类型前缀(用于 useCsl 模式) */ +const CSL_MSG_PREFIX = 'OFFERPIE_CSL' + +// ============ 工具函数 ============ + +/** 获取当前时间的 localDateTime 格式字符串:yyyy-MM-dd HH:mm:ss */ +function nowLocalDateTime(): string { + const d = new Date() + const pad = (n: number) => String(n).padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` +} + +/** 生成简单唯一ID(用于 get 请求匹配) */ +function uid(): string { + return Date.now().toString(36) + Math.random().toString(36).slice(2, 8) +} + +// ============ chrome.storage.local 操作(useCsl 模式) ============ + +/** 检测 chrome.runtime.sendMessage 是否可用(插件环境) */ +function isChromeRuntimeAvailable(): boolean { + return typeof chrome !== 'undefined' && !!chrome.runtime && !!chrome.runtime.sendMessage +} + +/** + * 通过 Background 写入 chrome.storage.local + * 插件环境直接发 chrome.runtime.sendMessage + * 网页环境自动走 BroadcastChannel 中转给同域 Content Script 代理 + */ +function cslPut(domain: string, name: string, data: object, createTime: string, updateTime: string): Promise { + return new Promise((resolve, reject) => { + if (isChromeRuntimeAvailable()) { + // 插件环境:直接发给 Background + chrome.runtime.sendMessage( + { type: `${CSL_MSG_PREFIX}_PUT`, domain, name, data, createTime, updateTime }, + (response) => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)) + } else { + resolve() + } + } + ) + } else { + // 网页环境:通过 BroadcastChannel 请求同域 Content Script 代理 + const requestId = uid() + const ch = _getCslProxyChannel(domain) + const handler = (event: MessageEvent) => { + if (event.data.type === 'csl-put-response' && event.data.id === requestId) { + resolve() + } + } + ch.addEventListener('message', handler) + ch.postMessage({ type: 'csl-put-request', id: requestId, name, data, createTime, updateTime, domain } as ChannelMessage) + // 超时兜底 + setTimeout(() => { + ch.removeEventListener('message', handler) + resolve() // 即使超时也不阻塞 + }, 3000) + } + }) +} + +/** + * 通过 Background 从 chrome.storage.local 读取数据 + * 插件环境直接发 chrome.runtime.sendMessage + * 网页环境自动走 BroadcastChannel 中转给同域 Content Script 代理 + */ +function cslGet(domain: string, name: string): Promise { + return new Promise((resolve, reject) => { + if (isChromeRuntimeAvailable()) { + // 插件环境:直接发给 Background + chrome.runtime.sendMessage( + { type: `${CSL_MSG_PREFIX}_GET`, domain, name }, + (response) => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)) + } else { + resolve(response?.record || null) + } + } + ) + } else { + // 网页环境:通过 BroadcastChannel 请求同域 Content Script 代理 + const requestId = uid() + const ch = _getCslProxyChannel(domain) + const handler = (event: MessageEvent) => { + if (event.data.type === 'csl-get-response' && event.data.id === requestId) { + ch.removeEventListener('message', handler) + const record = event.data.data && event.data.createTime && event.data.updateTime + ? { name: event.data.name, data: event.data.data, createTime: event.data.createTime, updateTime: event.data.updateTime } + : null + resolve(record) + } + } + ch.addEventListener('message', handler) + ch.postMessage({ type: 'csl-get-request', id: requestId, name, domain } as ChannelMessage) + // 超时兜底 + setTimeout(() => { + ch.removeEventListener('message', handler) + resolve(null) + }, 3000) + } + }) +} + +/** CSL 代理频道缓存 */ +const _cslProxyChannels = new Map() + +/** 获取 CSL 代理用的 BroadcastChannel(与主频道分开,避免消息混淆) */ +function _getCslProxyChannel(domain: string): BroadcastChannel { + if (_cslProxyChannels.has(domain)) return _cslProxyChannels.get(domain)! + const ch = new BroadcastChannel(`offerpie-bridge-csl-${domain}`) + _cslProxyChannels.set(domain, ch) + return ch +} + +// ============ IndexedDB 操作 ============ + +/** 数据库连接缓存(避免重复打开和版本冲突) */ +const _dbCache = new Map>() + +/** 打开/创建 IndexedDB 数据库,按 domain 动态创建 ObjectStore,带连接缓存防并发冲突 */ +function openDB(storeName: string): Promise { + // 如果已有进行中或已完成的连接,直接复用 + if (_dbCache.has(storeName)) { + return _dbCache.get(storeName)!.then(db => { + // 检查连接是否还有效(可能被 close 了) + try { + // 尝试创建事务验证连接有效性 + if (db.objectStoreNames.contains(storeName)) { + return db + } + } catch { + // 连接已关闭,清除缓存重新打开 + _dbCache.delete(storeName) + } + return openDB(storeName) + }) + } + + const promise = new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME) + request.onupgradeneeded = () => { + const db = request.result + if (!db.objectStoreNames.contains(storeName)) { + const store = db.createObjectStore(storeName, { keyPath: 'name' }) + store.createIndex('name', 'name', { unique: true }) + } + } + request.onsuccess = () => { + const db = request.result + if (!db.objectStoreNames.contains(storeName)) { + // store 不存在,需要升版本。关闭当前连接再重开 + db.close() + _dbCache.delete(storeName) + const version = db.version + 1 + const req2 = indexedDB.open(DB_NAME, version) + req2.onupgradeneeded = () => { + const db2 = req2.result + if (!db2.objectStoreNames.contains(storeName)) { + const store = db2.createObjectStore(storeName, { keyPath: 'name' }) + store.createIndex('name', 'name', { unique: true }) + } + } + req2.onsuccess = () => resolve(req2.result) + req2.onerror = () => { _dbCache.delete(storeName); reject(req2.error) } + req2.onblocked = () => { + // 其他标签页占用数据库导致升级被阻塞,超时后放弃 + console.warn('[ChannelBridge] IndexedDB upgrade blocked, retrying...') + _dbCache.delete(storeName) + reject(new Error('IndexedDB upgrade blocked')) + } + } else { + resolve(db) + } + } + request.onerror = () => { _dbCache.delete(storeName); reject(request.error) } + request.onblocked = () => { + console.warn('[ChannelBridge] IndexedDB open blocked') + _dbCache.delete(storeName) + reject(new Error('IndexedDB open blocked')) + } + }) + + _dbCache.set(storeName, promise) + return promise +} + +/** 写入或更新一条记录到 IndexedDB */ +async function dbPut(storeName: string, record: DBRecord): Promise { + try { + const db = await openDB(storeName) + return new Promise((resolve, reject) => { + const tx = db.transaction(storeName, 'readwrite') + const store = tx.objectStore(storeName) + store.put(record) + tx.oncomplete = () => resolve() + tx.onerror = () => reject(tx.error) + }) + } catch (err) { + console.warn('[ChannelBridge] dbPut failed:', err) + } +} + +/** 从 IndexedDB 读取一条记录 */ +async function dbGet(storeName: string, name: string): Promise { + try { + const db = await openDB(storeName) + return new Promise((resolve, reject) => { + const tx = db.transaction(storeName, 'readonly') + const store = tx.objectStore(storeName) + const req = store.get(name) + req.onsuccess = () => resolve(req.result || null) + req.onerror = () => reject(req.error) + }) + } catch (err) { + console.warn('[ChannelBridge] dbGet failed:', err) + return null + } +} + +// ============ 核心:创建通信桥接实例 ============ + +export function createChannelBridge(options: ChannelBridgeOptions) { + const { domain, isPlugin = false } = options + + /** BroadcastChannel 实例缓存(按 domain 隔离频道) */ + const channels = new Map() + + /** watch 订阅回调注册表:key = `${domain}::${name}` */ + const watchers = new Map>>() + + /** emit 节流定时器:key = `${domain}::${name}` */ + const emitTimers = new Map>() + + /** emit 节流暂存最新数据:key = `${domain}::${name}` */ + const emitPending = new Map() + + /** get 请求等待队列:key = requestId */ + const getResolvers = new Map | null) => void>() + + // --- Web Lock 保活(非插件环境) --- + if (!isPlugin && typeof navigator !== 'undefined' && navigator.locks) { + navigator.locks.request('offerpie-keep-alive', () => new Promise(() => {})) + } + + /** 获取或创建指定 domain 的 BroadcastChannel */ + function getChannel(channelDomain: string): BroadcastChannel { + if (channels.has(channelDomain)) return channels.get(channelDomain)! + const ch = new BroadcastChannel(`offerpie-bridge-${channelDomain}`) + ch.onmessage = (event: MessageEvent) => handleMessage(channelDomain, event.data) + channels.set(channelDomain, ch) + return ch + } + + /** 处理收到的 BroadcastChannel 消息 */ + async function handleMessage(channelDomain: string, msg: ChannelMessage) { + const watchKey = `${channelDomain}::${msg.name}` + + if (msg.type === 'put' || msg.type === 'emit') { + // 收到对方的数据推送,触发本地 watch 回调 + const wrapped = msg.data && msg.createTime && msg.updateTime + ? { ...msg.data, createTime: msg.createTime, updateTime: msg.updateTime } as BridgeData + : null + const callbacks = watchers.get(watchKey) + if (callbacks) { + callbacks.forEach(cb => cb(wrapped)) + } + } + + if (msg.type === 'get-request' && msg.id) { + // 收到对方的 get 请求,从本地 IndexedDB 读取数据并回复 + const record = await dbGet(channelDomain, msg.name) + const ch = getChannel(channelDomain) + const response: ChannelMessage = { + type: 'get-response', + name: msg.name, + id: msg.id, + data: record?.data, + createTime: record?.createTime, + updateTime: record?.updateTime, + } + ch.postMessage(response) + } + + if (msg.type === 'get-response' && msg.id) { + // 收到对方对 get 请求的回复 + const resolver = getResolvers.get(msg.id) + if (resolver) { + getResolvers.delete(msg.id) + if (msg.data && msg.createTime && msg.updateTime) { + resolver({ ...msg.data, createTime: msg.createTime, updateTime: msg.updateTime } as BridgeData) + } else { + resolver(null) + } + } + } + } + + // 初始化默认频道监听 + getChannel(domain) + + // --- CSL 代理监听(插件环境下,监听网页端的 CSL 中转请求) --- + if (isPlugin && isChromeRuntimeAvailable()) { + const cslProxyCh = new BroadcastChannel(`offerpie-bridge-csl-${domain}`) + cslProxyCh.onmessage = async (event: MessageEvent) => { + const msg = event.data + + if (msg.type === 'csl-put-request' && msg.id) { + // 代理写入 chrome.storage.local + const targetDomain = msg.domain || domain + await new Promise((resolve) => { + chrome.runtime.sendMessage( + { type: `${CSL_MSG_PREFIX}_PUT`, domain: targetDomain, name: msg.name, data: msg.data, createTime: msg.createTime, updateTime: msg.updateTime }, + () => resolve() + ) + }) + cslProxyCh.postMessage({ type: 'csl-put-response', id: msg.id, name: msg.name } as ChannelMessage) + } + + if (msg.type === 'csl-get-request' && msg.id) { + // 代理读取 chrome.storage.local + const targetDomain = msg.domain || domain + const response = await new Promise((resolve) => { + chrome.runtime.sendMessage( + { type: `${CSL_MSG_PREFIX}_GET`, domain: targetDomain, name: msg.name }, + (res) => resolve(res) + ) + }) + const record = response?.record + cslProxyCh.postMessage({ + type: 'csl-get-response', + id: msg.id, + name: msg.name, + data: record?.data, + createTime: record?.createTime, + updateTime: record?.updateTime, + } as ChannelMessage) + } + } + } + + // --- 公开方法 --- + + /** + * put - 单次发送数据(写入 IndexedDB + BroadcastChannel 广播) + * @param targetDomain 目标域名频道 + * @param name 数据名称 + * @param data 数据本体(需有 TS 类型定义) + * @param useCsl 是否使用 chrome.storage.local 跨域模式(通过 Background 中转) + */ + async function put(targetDomain: string, name: string, data: T, useCsl?: boolean): Promise { + const now = nowLocalDateTime() + + if (useCsl) { + // 跨域模式:通过 Background 读写 chrome.storage.local + const existing = await cslGet(targetDomain, name).catch(() => null) + const createTime = existing?.createTime || now + const updateTime = now + await cslPut(targetDomain, name, data, createTime, updateTime) + // 同时广播(同域标签页也能收到) + const ch = getChannel(targetDomain) + const msg: ChannelMessage = { type: 'put', name, data, createTime, updateTime } + ch.postMessage(msg) + return + } + + // 默认模式:IndexedDB + const existing = await dbGet(targetDomain, name) + const createTime = existing?.createTime || now + const updateTime = now + + const record: DBRecord = { name, data, createTime, updateTime } + // 写入 IndexedDB + await dbPut(targetDomain, record) + // BroadcastChannel 广播 + const ch = getChannel(targetDomain) + const msg: ChannelMessage = { type: 'put', name, data, createTime, updateTime } + ch.postMessage(msg) + } + + /** + * emit - 高频发送数据(每次都广播,节流 500ms 写一次 IndexedDB) + * @param targetDomain 目标域名频道 + * @param name 数据名称 + * @param data 数据本体(需有 TS 类型定义) + * @param useCsl 是否使用 chrome.storage.local 跨域模式(通过 Background 中转) + */ + async function emit(targetDomain: string, name: string, data: T, useCsl?: boolean): Promise { + const now = nowLocalDateTime() + const key = `${targetDomain}::${name}` + + if (useCsl) { + // 跨域模式:每次广播,节流写 chrome.storage.local + let createTime: string + const pending = emitPending.get(key) + if (pending) { + createTime = pending.createTime + } else { + const existing = await cslGet(targetDomain, name).catch(() => null) + createTime = existing?.createTime || now + } + const updateTime = now + const record: DBRecord = { name, data, createTime, updateTime } + emitPending.set(key, record) + + // BroadcastChannel 每次都广播 + const ch = getChannel(targetDomain) + const msg: ChannelMessage = { type: 'emit', name, data, createTime, updateTime } + ch.postMessage(msg) + + // 节流写 chrome.storage.local + if (!emitTimers.has(key)) { + emitTimers.set(key, setTimeout(async () => { + emitTimers.delete(key) + const latestRecord = emitPending.get(key) + if (latestRecord) { + emitPending.delete(key) + await cslPut(targetDomain, latestRecord.name, latestRecord.data, latestRecord.createTime, latestRecord.updateTime).catch(() => {}) + } + }, EMIT_THROTTLE_MS)) + } + return + } + + // 默认模式:IndexedDB + // 先尝试获取 createTime(从暂存或内存缓存) + let createTime: string + const pending = emitPending.get(key) + if (pending) { + createTime = pending.createTime + } else { + // 只在首次查库获取 createTime,失败就用当前时间(不阻塞后续 emit) + let existing: DBRecord | null = null + try { + existing = await dbGet(targetDomain, name) + } catch {} + createTime = existing?.createTime || now + } + const updateTime = now + const record: DBRecord = { name, data, createTime, updateTime } + + // 暂存最新数据(不再 delete,保证后续 emit 总能命中缓存不走 dbGet) + emitPending.set(key, record) + + // BroadcastChannel 每次都广播(保证 watch 实时性) + const ch = getChannel(targetDomain) + const msg: ChannelMessage = { type: 'emit', name, data, createTime, updateTime } + ch.postMessage(msg) + + // 节流写库 + if (!emitTimers.has(key)) { + emitTimers.set(key, setTimeout(async () => { + emitTimers.delete(key) + const latestRecord = emitPending.get(key) + if (latestRecord) { + // 注意:不再 delete emitPending,让下次 emit 继续命中缓存 + await dbPut(targetDomain, latestRecord) + } + }, EMIT_THROTTLE_MS)) + } + } + + /** + * get - 主动获取一次数据(先 BroadcastChannel 请求,60ms 超时转 IndexedDB) + * @param targetDomain 目标域名频道 + * @param name 数据名称 + * @param useCsl 是否使用 chrome.storage.local 跨域模式(通过 Background 中转) + * @returns 数据本体(含 createTime/updateTime),无数据返回 null + */ + async function get(targetDomain: string, name: string, useCsl?: boolean): Promise | null> { + if (useCsl) { + // 跨域模式:先广播请求,超时读 chrome.storage.local + const ch = getChannel(targetDomain) + const requestId = uid() + const msg: ChannelMessage = { type: 'get-request', name, id: requestId } + ch.postMessage(msg) + + const result = await new Promise | null>((resolve) => { + getResolvers.set(requestId, resolve as any) + setTimeout(() => { + if (getResolvers.has(requestId)) { + getResolvers.delete(requestId) + resolve(null) + } + }, GET_TIMEOUT_MS) + }) + + if (result) return result + + // 超时降级读 chrome.storage.local + const record = await cslGet(targetDomain, name).catch(() => null) + if (record) { + return { ...record.data, createTime: record.createTime, updateTime: record.updateTime } as BridgeData + } + return null + } + + // 默认模式:IndexedDB + const ch = getChannel(targetDomain) + const requestId = uid() + + // 发起广播请求 + const msg: ChannelMessage = { type: 'get-request', name, id: requestId } + ch.postMessage(msg) + + // 等待响应或超时 + const result = await new Promise | null>((resolve) => { + getResolvers.set(requestId, resolve as any) + setTimeout(() => { + if (getResolvers.has(requestId)) { + getResolvers.delete(requestId) + resolve(null) // 超时,标记为未收到响应 + } + }, GET_TIMEOUT_MS) + }) + + // 如果广播拿到了数据直接返回 + if (result) return result + + // 超时降级读 IndexedDB + const record = await dbGet(targetDomain, name) + if (record) { + return { ...record.data, createTime: record.createTime, updateTime: record.updateTime } as BridgeData + } + return null + } + + /** + * watch - 持续监听数据变化(初始读一次缓存,后续实时接收 put/emit 推送) + * @param targetDomain 目标域名频道 + * @param name 数据名称 + * @param callback 数据变化回调,参数为最新数据或 null + * @param useCsl 是否使用 chrome.storage.local 跨域模式(通过 Background 中转) + * @returns 取消监听的函数 + */ + function watch(targetDomain: string, name: string, callback: WatchCallback, useCsl?: boolean): () => void { + const key = `${targetDomain}::${name}` + // 确保频道已初始化 + getChannel(targetDomain) + + // 注册回调(BroadcastChannel 广播也能触发,不管 useCsl 与否) + if (!watchers.has(key)) watchers.set(key, new Set()) + watchers.get(key)!.add(callback) + + if (useCsl) { + // 跨域模式:初始读 chrome.storage.local + cslGet(targetDomain, name).then((record) => { + if (record) { + callback({ ...record.data, createTime: record.createTime, updateTime: record.updateTime } as BridgeData) + } else { + callback(null) + } + }).catch(() => callback(null)) + + // 监听 chrome.storage.onChanged 事件 + const storageListener = (changes: { [key: string]: chrome.storage.StorageChange }, areaName: string) => { + if (areaName !== 'local' || !changes[CSL_STORAGE_KEY]) return + const newValue = changes[CSL_STORAGE_KEY].newValue as Array<{ domain: string; value: DBRecord }> | undefined + if (!newValue) return + const item = newValue.find(i => i.domain === targetDomain && i.value?.name === name) + if (item && item.value) { + callback({ ...item.value.data, createTime: item.value.createTime, updateTime: item.value.updateTime } as BridgeData) + } + } + + if (typeof chrome !== 'undefined' && chrome.storage && chrome.storage.onChanged) { + chrome.storage.onChanged.addListener(storageListener) + } + + // 返回取消监听函数 + return () => { + const set = watchers.get(key) + if (set) { + set.delete(callback) + if (set.size === 0) watchers.delete(key) + } + if (typeof chrome !== 'undefined' && chrome.storage && chrome.storage.onChanged) { + chrome.storage.onChanged.removeListener(storageListener) + } + } + } + + // 默认模式:初始读取 IndexedDB + dbGet(targetDomain, name).then((record) => { + if (record) { + callback({ ...record.data, createTime: record.createTime, updateTime: record.updateTime } as BridgeData) + } else { + callback(null) + } + }) + + // 返回取消监听函数 + return () => { + const set = watchers.get(key) + if (set) { + set.delete(callback) + if (set.size === 0) watchers.delete(key) + } + } + } + + /** + * destroy - 销毁实例,关闭所有频道,清除定时器 + */ + function destroy(): void { + channels.forEach(ch => ch.close()) + channels.clear() + watchers.clear() + emitTimers.forEach(timer => clearTimeout(timer)) + emitTimers.clear() + emitPending.clear() + getResolvers.clear() + } + + return { put, emit, get, watch, destroy } +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 4a20f2d..99fedb0 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -49,7 +49,7 @@ export const JOB_FORM_LABELS: FormLabelItem[] = [ { key: "eduPeriod", zh: ["就读时间", "就读期间", "起止时间"], en: ["Study Period", "Duration", "Period"], section: "education", resumeField: "startDate,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: "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: "" }, // ---- 求职意向(主表) ---- @@ -68,27 +68,27 @@ export const JOB_FORM_LABELS: FormLabelItem[] = [ { key: "workPeriod", zh: ["起止时间", "在职时间", "就职时间"], en: ["Period", "Duration", "Employment Period"], section: "work", resumeField: "startDate,endDate" }, { 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: "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: "internPeriod", zh: ["起止时间", "实习时间", "就职时间"], en: ["Period", "Duration", "Employment Period"], section: "internship", resumeField: "startDate,endDate" }, { key: "internStartDate", zh: ["实习开始时间", "开始时间"], en: ["Internship Start Date", "Start Date", "From"], section: "internship", resumeField: "startDate" }, { key: "internEndDate", zh: ["实习结束时间", "结束时间"], en: ["Internship End Date", "End Date", "To"], section: "internship", resumeField: "endDate" }, - { key: "internDescription", zh: ["实习描述", "实习内容","工作描述", "工作职责", "工作内容", "职责描述", "岗位职责", "职位描述"], en: ["Internship Description", "Intern Duties"], section: "internship", resumeField: "description" }, + { 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: "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: "projectPeriod", zh: ["起止时间", "项目时间"], en: ["Period", "Duration", "Project Period"], section: "project", resumeField: "startDate,endDate" }, { key: "projectStartDate", zh: ["项目开始时间", "开始时间"], en: ["Project Start Date", "Start Date", "From"], section: "project", resumeField: "startDate" }, { key: "projectEndDate", zh: ["项目结束时间", "结束时间"], en: ["Project End Date", "End Date", "To"], section: "project", resumeField: "endDate" }, - { key: "projectDescription", zh: ["项目描述", "项目内容", "项目职责"], en: ["Project Description", "Project Details", "Project Responsibilities"], section: "project", resumeField: "description" }, + { 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: "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: "" }, diff --git a/src/lib/experienceSection.ts b/src/lib/experienceSection.ts index 552ceec..c8714cb 100644 --- a/src/lib/experienceSection.ts +++ b/src/lib/experienceSection.ts @@ -19,6 +19,12 @@ import { delay } from "./autofill" import { snapshotElementsInRange, diffSnapshots, findTopLevelNewElements } from "./dom" import type { ExperienceSection, ExperienceSectionConfig, ResumeData } from "./types" +/** 大标题排除关键词:包含这些文字的标签不作为大标题(它们是子标题/说明文字) */ +const TITLE_EXCLUDE_KEYWORDS = ["高中教育经历", "本科教育经历", "本科及以上教育经历", "填写高中", "填写本科"] + +/** 基本信息大标题关键词(精确全称匹配,用于分步表单页面识别) */ +const BASIC_INFO_TITLE_KEYWORDS = ["个人信息", "基本信息", "基础信息", "个人基本信息"] + // ==================================================================== // 一、类型定义 // ==================================================================== @@ -105,6 +111,46 @@ export interface ExperienceSectionLocateResult { * 【注意】优先找正文区域的标题,跳过导航/锚点/菜单区域 * 如果正文区域没找到,再 fallback 到全页面搜索 */ +/** + * 查找页面中所有匹配指定经历类型关键词的标题元素(按 DOM 顺序) + * 用于处理同名标题(如两个"教育经历")时逐个验证哪个有添加按钮 + */ +function findAllSectionTitles( + rootEl: Element, + lang: "zh" | "en", + config: ExperienceSectionConfig +): { titleElement: Element; titleText: string }[] { + const keywords = lang === "zh" ? config.zh : config.en + const results: { titleElement: Element; titleText: string }[] = [] + + 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) { + // 排除子标题/说明文字 + if (TITLE_EXCLUDE_KEYWORDS.some((kw) => directText.includes(kw))) { + node = walker.nextNode() + continue + } + for (const keyword of keywords) { + if (directText.includes(keyword) && directText.length < keyword.length + 10) { + results.push({ titleElement: el, titleText: directText }) + break + } + } + } + node = walker.nextNode() + } + return results +} + function findSectionTitle( rootEl: Element, lang: "zh" | "en", @@ -147,6 +193,11 @@ function findSectionTitle( .join("") if (directText) { + // 排除子标题/说明文字 + if (TITLE_EXCLUDE_KEYWORDS.some((kw) => directText.includes(kw))) { + node = walker1.nextNode() + continue + } for (const keyword of keywords) { if (directText.includes(keyword) && directText.length < keyword.length + 10) { if (!isInNavArea(el)) { @@ -170,6 +221,11 @@ function findSectionTitle( .join("") if (directText) { + // 排除子标题/说明文字 + if (TITLE_EXCLUDE_KEYWORDS.some((kw) => directText.includes(kw))) { + node = walker2.nextNode() + continue + } for (const keyword of keywords) { if (directText.includes(keyword) && directText.length < keyword.length + 10) { return { titleElement: el, titleText: directText } @@ -264,6 +320,11 @@ function findAllTitlesWithSameSignature(rootEl: Element, refSignature: TitleSign if (directText && directText.length > 0 && directText.length < 20 && el.children.length === 0) { if (!isInNavArea(el)) { + // 排除子标题/说明文字(如"填写高中教育经历"、"填写本科及以上教育经历") + if (TITLE_EXCLUDE_KEYWORDS.some((kw) => directText.includes(kw))) { + node = walker.nextNode() + continue + } const sig = extractTitleSignature(el) if (isSameSignature(sig, refSignature)) { // 【注意】如果有参考元素的 className,额外验证候选元素自身 className 必须一致 @@ -735,16 +796,40 @@ export function locateExperienceSections( ): ExperienceSectionLocateResult[] { // console.log("===== OfferPie: 开始定位经历区块 =====") - // 定位5种经历的大标题 + // 定位5种经历的大标题(需要验证范围内有添加按钮,否则不算可扩展的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 { + if (!result) { notFound.push(config) + continue + } + + // 验证该标题范围内是否有添加按钮 + // 如果没有(如"高中教育经历"这种固定区块),跳过它继续找下一个同名标题 + let validResult = result + let validated = false + const allTitleCandidates = findAllSectionTitles(rootEl, lang, config) + + for (const candidate of allTitleCandidates) { + // 找到该标题在页面中的下一个同结构标题作为范围边界(简单用 DOM 后续第一个同 signature 标题) + const candidateIdx = allTitleCandidates.indexOf(candidate) + const nextCandidate = candidateIdx < allTitleCandidates.length - 1 ? allTitleCandidates[candidateIdx + 1] : null + const addBtn = findAddButton(candidate.titleElement, nextCandidate?.titleElement || null, config, lang) + if (addBtn) { + validResult = candidate + validated = true + break + } + } + + if (validated) { + located.push({ config, titleElement: validResult.titleElement, titleText: validResult.titleText }) + } else { + // 所有同名标题都没有添加按钮,取第一个作为定位结果(expandedCount=1 固定段) + located.push({ config, titleElement: result.titleElement, titleText: result.titleText }) } } @@ -822,6 +907,55 @@ export function locateExperienceSections( return results } +/** + * 【补充逻辑】检测分步表单页面的"个人基本信息"大标题 + * 触发条件:locateExperienceSections 没有找到任何5大经历大标题 + * 判断逻辑: + * 1. 页面中存在 BASIC_INFO_TITLE_KEYWORDS 中的精确全称文字标签 + * 2. 该标签后面有 ≥2 个输入框 + * 3. 页面中没有5大经历的大标题关键词 + * 满足则返回该标签作为唯一大标题(代表分步填写的个人基本信息页) + */ +function detectBasicInfoPageTitle(): { element: Element; text: string } | null { + 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])" + + // 先确认页面中没有5大经历大标题(教育经历/工作经历/实习经历/项目经历/竞赛经历) + const expKeywords = ["教育经历", "工作经历", "实习经历", "项目经历", "竞赛经历"] + const pageText = document.body.innerText || "" + for (const kw of expKeywords) { + if (pageText.includes(kw)) return null + } + + // 遍历 DOM 找精确匹配的基本信息标题 + const walker = document.createTreeWalker(document.body, 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 && BASIC_INFO_TITLE_KEYWORDS.includes(directText)) { + // 检查该标签后面是否有 ≥2 个输入框 + const allInputs = document.body.querySelectorAll(INPUT_SEL) + let inputCountAfter = 0 + for (const inp of Array.from(allInputs)) { + if (el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) { + inputCountAfter++ + if (inputCountAfter >= 2) break + } + } + if (inputCountAfter >= 2) { + return { element: el, text: directText } + } + } + node = walker.nextNode() + } + return null +} + /** * 【工具方法】获取页面所有大标题列表(复用 locateExperienceSections 内部的 TitleSignature 匹配逻辑) * 用于填写结束后按大标题分组统计字段处理结果 @@ -833,7 +967,12 @@ export function getAllPageTitles( locateResults: ExperienceSectionLocateResult[] ): { element: Element; text: string }[] { const firstLocated = locateResults.find((r) => r.titleElement) - if (!firstLocated || !firstLocated.titleElement) return [] + if (!firstLocated || !firstLocated.titleElement) { + // 【补充分支】没有任何5大经历大标题被定位时,检测是否为分步表单页面(只有个人基本信息) + const fallbackTitle = detectBasicInfoPageTitle() + if (fallbackTitle) return [fallbackTitle] + return [] + } const refSignature = extractTitleSignature(firstLocated.titleElement) let allPageTitles = findAllTitlesWithSameSignature(document.body, refSignature, firstLocated.titleElement) @@ -1047,6 +1186,23 @@ export async function expandExperienceSections( }) result.expandedCount = result.segmentRanges.length console.log(` [重建] 用段容器特征重建 segmentRanges: ${result.segmentRanges.length} 段`) + } else if (matchingChildren.length === 1) { + // 【修复】从0段展开到1段时,父级下只有1个匹配容器,也应作为唯一的 segmentRange + const el = matchingChildren[0] + const inputs = el.querySelectorAll(INPUT_SEL) + const firstInput = inputs.length > 0 ? inputs[0] : el + const startLabel = findLabelBeforeInput(firstInput, el) + const childIdx = Array.from(parentEl.children).indexOf(el) + result.segmentRanges = [{ + startElement: startLabel || firstInput, + endElement: inputs.length > 0 ? inputs[inputs.length - 1] : el, + isNewlyAdded: true, + containerElement: el, + nthChildIndex: childIdx + 1, + locator: buildLocator(el, parentEl, childIdx + 1, result.titleText), + }] + result.expandedCount = 1 + console.log(` [重建] 从0段展开到1段,用新增容器构建 segmentRanges: 1 段`) } } } diff --git a/src/lib/fillStats.ts b/src/lib/fillStats.ts index 7528264..cdb6082 100644 --- a/src/lib/fillStats.ts +++ b/src/lib/fillStats.ts @@ -13,6 +13,7 @@ import { getAllPageTitles, locateExperienceSections } from "./experienceSection" import type { ExperienceSectionLocateResult } from "./experienceSection" import type { MatchedFormField, UnmatchedFormField } from "./types" import { isRequiredField, setFieldHighlight } from "./formStyle" +import { findLabelForInput } from "./labelFinder" // ==================================================================== // 一、类型定义 @@ -303,67 +304,12 @@ function scanBeisenCascadePickers( // 三、通用辅助方法 // ==================================================================== -/** 排除的标签文字(不视为有效标签) */ -const STAT_EXCLUDE_LABEL_TEXTS = ["请输入", "输入", ":", ":", "请选择", "选择", "*", "必填"] - /** input 选择器(排除 hidden/submit/button/checkbox/radio/file/disabled) */ const INPUT_SEL_STAT = "input:not([type='hidden']):not([type='submit']):not([type='button']):not([type='checkbox']):not([type='radio']):not([type='file']):not([disabled]), textarea:not([disabled])" -/** 表单项容器选择器集合 */ -const FORM_ITEM_SELS = [ - ".form-item", ".form-group", ".form-field", - ".el-form-item", ".ant-form-item", ".ant-row", - ".arco-form-item", ".t-form-item", ".n-form-item", - "[class*='form-item']", "[class*='form-group']", "[class*='formItem']", -] - /** 5大经历 section 名集合 */ const FIVE_EXP_SECTIONS = new Set(["education", "work", "internship", "project", "competition"]) -/** - * 查找 input 元素对应的标签文字 - */ -function findLabelForInput(inp: Element): { labelText: string; labelElement: Element | null } { - let labelText = "" - let labelElement: Element | null = null - let container: Element | null = null - for (const sel of FORM_ITEM_SELS) { - container = inp.closest(sel) - if (container) break - } - if (container) { - const labelEls = container.querySelectorAll("label, span, div, td, th, p, legend, dt") - for (const el of Array.from(labelEls)) { - const directText = Array.from(el.childNodes) - .filter((n) => n.nodeType === Node.TEXT_NODE) - .map((n) => n.textContent?.trim()) - .filter(Boolean) - .join("") - if (!directText || directText.length > 30) continue - if (STAT_EXCLUDE_LABEL_TEXTS.some((ex) => directText === ex)) continue - if (el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) { - labelText = directText - labelElement = el - break - } - } - } - if (!labelText) { - let prev: Element | null = inp.previousElementSibling - for (let i = 0; i < 3 && prev; i++) { - const text = prev.textContent?.trim() || "" - if (text && text.length <= 20 && !STAT_EXCLUDE_LABEL_TEXTS.some((ex) => text === ex)) { - labelText = text - labelElement = prev - break - } - prev = prev.previousElementSibling - } - } - if (!labelText) labelText = (inp as HTMLInputElement).getAttribute("placeholder") || "(未知字段)" - return { labelText, labelElement } -} - /** * 对非5大经历的其他经历类型区块,通过标签重复次数检测段数 @@ -474,6 +420,9 @@ export function scanPageFields(params: ScanPageFieldsParams): TitleStat[] { const allTitles = getAllPageTitles(sectionResults) if (allTitles.length === 0) return [] + // 构建大标题元素集合(用于 findLabelForInput 第二遍策略的停止边界) + const titleElementSet = new Set(allTitles.map((t) => t.element)) + const processedInputs = new Set() // 收集各阶段字段到统一格式 @@ -579,7 +528,7 @@ export function scanPageFields(params: ScanPageFieldsParams): TitleStat[] { const inputEl = inp as HTMLInputElement | HTMLTextAreaElement const alreadyHasValue = !!(inputEl.value && inputEl.value.trim().length > 0) - const { labelText, labelElement } = findLabelForInput(inp) + const { labelText, labelElement } = findLabelForInput(inp, titleElementSet) // 根据背景色判断来源和颜色 let color: FieldStatColor diff --git a/src/lib/labelFinder.ts b/src/lib/labelFinder.ts new file mode 100644 index 0000000..9e7c08d --- /dev/null +++ b/src/lib/labelFinder.ts @@ -0,0 +1,221 @@ +/** + * 表单字段标签定位模块(统一封装) + * 负责:根据 input/textarea 元素在 DOM 中定位其对应的标签文字 + * + * 两遍策略: + * 第一遍:通过 form-item 容器选择器定位 → 逐级向上修复 → 在容器内找标签 + * 第二遍:如果第一遍失败,沿 DOM 文档流逆向遍历找到有效中文标签 + * + * 所有需要"识别输入框前面的标签名字"的地方统一调用此模块 + */ + +// ============ 常量 ============ + +/** 排除的标签文字(不视为有效标签) */ +const EXCLUDE_LABEL_TEXTS = ["请输入", "输入", ":", ":", "请选择", "选择", "*", "必填", "确定", "取消", "搜索", "无准确的毕业时间可填写预计毕业时间"] + +/** 表单项容器选择器集合(用于第一遍策略的 closest 查找) */ +const FORM_ITEM_SELS = [ + ".form-item", ".form-group", ".form-field", + ".el-form-item", ".ant-form-item", ".ant-row", + ".arco-form-item", ".t-form-item", ".n-form-item", + "[class*='form-item']", "[class*='form-group']", "[class*='formItem']", +] + +// ============ 工具方法 ============ + +/** + * 判断文本是否为有效的表单字段标签 + * 必须包含至少2个汉字,排除纯符号/纯英文/单字符 + */ +export function isValidLabelText(text: string): boolean { + const trimmed = text.trim() + if (!trimmed) return false + if (trimmed.length > 30) return false + // 必须包含至少2个汉字 + const chineseChars = trimmed.match(/[\u4e00-\u9fff]/g) + if (!chineseChars || chineseChars.length < 2) return false + // 排除已知无意义标签 + if (EXCLUDE_LABEL_TEXTS.some((ex) => trimmed === ex)) return false + return true +} + +// ============ 第二遍策略:DOM 逆向遍历 ============ + +/** + * 【第二遍策略】沿 DOM 文档流往前遍历,查找 input 对应的标签文字 + * 从 input 出发逆向走,逐个检查文本节点 + * + * 停止条件: + * - 碰到另一个 input/textarea/select 元素 + * - 碰到大标题元素(titleElements 集合中的元素) + * - 碰到包含 input 的非祖先元素(前一个字段区域) + * - 最多走 80 个节点 + * + * 过滤条件:跳过不含至少2个汉字的文本 + */ +function walkBackwardForLabel(inp: Element, titleElements?: Set): { text: string; element: Element } | null { + const INPUT_TAG_SET = new Set(["INPUT", "TEXTAREA", "SELECT"]) + let maxSteps = 80 + let current: Node | null = inp + + while (maxSteps-- > 0) { + let prev: Node | null = null + + if (current.previousSibling) { + prev = current.previousSibling + while (prev.lastChild) { + prev = prev.lastChild + } + } else { + prev = current.parentNode + } + + if (!prev || prev === document.body || prev === document.documentElement) break + current = prev + + if (current.nodeType === Node.ELEMENT_NODE) { + const el = current as Element + if (INPUT_TAG_SET.has(el.tagName)) break + if (titleElements && titleElements.has(el)) break + if (!el.contains(inp) && el.querySelector("input, textarea, select")) { + if (el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) break + } + } + + if (current.nodeType === Node.TEXT_NODE) { + const text = current.textContent?.trim() || "" + if (isValidLabelText(text)) { + const parentEl = current.parentElement + if (parentEl) { + return { text, element: parentEl } + } + } + } + } + + return null +} + +// ============ 主方法:查找 input 对应的标签文字 ============ + +/** + * 查找 input/textarea 元素对应的标签文字 + * + * @param inp - 输入框元素 + * @param titleElements - 大标题元素集合(用于第二遍策略的停止边界,可选) + * @returns { labelText, labelElement } 标签文字和标签元素 + * + * 策略流程: + * 1. closest(FORM_ITEM_SELS) 找容器 + * 2. 验证容器内是否同时有标签和输入框,不满足则逐级向上找(最多4层) + * 3. 在容器内 querySelectorAll 找 input 前面的第一个有效文字元素 + * 4. fallback: previousElementSibling 查找 + * 5. 第二遍:如果以上都未得到含≥2个汉字的标签,启动 walkBackwardForLabel 逆向遍历 + * 6. 都找不到则返回 placeholder 或 "(未知字段)" + */ +export function findLabelForInput(inp: Element, titleElements?: Set): { labelText: string; labelElement: Element | null } { + let labelText = "" + let labelElement: Element | null = null + let container: Element | null = null + for (const sel of FORM_ITEM_SELS) { + container = inp.closest(sel) + if (container) break + } + + // 【验证】container 应同时包含"标签文字"和"输入框",否则匹配层级太浅 + if (container) { + const hasInput = container.querySelector("input:not([type='hidden']):not([type='submit']):not([type='button']):not([type='checkbox']):not([type='radio']):not([type='file']), textarea") + let hasLabelText = false + if (hasInput) { + const candidates = container.querySelectorAll("label, span, div, td, th, p, legend, dt") + for (const el of Array.from(candidates)) { + if (el === inp || el.contains(inp)) continue + const dt = Array.from(el.childNodes) + .filter((n) => n.nodeType === Node.TEXT_NODE) + .map((n) => n.textContent?.trim()) + .filter(Boolean) + .join("") + if (dt && dt.length <= 30 && !EXCLUDE_LABEL_TEXTS.some((ex) => dt === ex)) { + if (el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) { + hasLabelText = true + break + } + } + } + } + // 逐级向上找,最多 4 层 + if (!hasLabelText) { + let parent = container.parentElement + for (let up = 0; up < 4 && parent; up++) { + const parentCandidates = parent.querySelectorAll("label, span, div, td, th, p, legend, dt") + let found = false + for (const el of Array.from(parentCandidates)) { + if (el === inp || el.contains(inp)) continue + const dt = Array.from(el.childNodes) + .filter((n) => n.nodeType === Node.TEXT_NODE) + .map((n) => n.textContent?.trim()) + .filter(Boolean) + .join("") + if (dt && dt.length <= 30 && !EXCLUDE_LABEL_TEXTS.some((ex) => dt === ex)) { + if (el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) { + found = true + break + } + } + } + if (found) { + container = parent + break + } + parent = parent.parentElement + } + } + } + + // 在容器内找标签 + if (container) { + const labelEls = container.querySelectorAll("label, span, div, td, th, p, legend, dt") + for (const el of Array.from(labelEls)) { + const directText = Array.from(el.childNodes) + .filter((n) => n.nodeType === Node.TEXT_NODE) + .map((n) => n.textContent?.trim()) + .filter(Boolean) + .join("") + if (!directText || directText.length > 30) continue + if (EXCLUDE_LABEL_TEXTS.some((ex) => directText === ex)) continue + if (el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) { + labelText = directText + labelElement = el + break + } + } + } + + // fallback: previousElementSibling + if (!labelText) { + let prev: Element | null = inp.previousElementSibling + for (let i = 0; i < 3 && prev; i++) { + const text = prev.textContent?.trim() || "" + if (text && text.length <= 20 && !EXCLUDE_LABEL_TEXTS.some((ex) => text === ex)) { + labelText = text + labelElement = prev + break + } + prev = prev.previousElementSibling + } + } + + // 【第二遍策略】如果第一遍未识别出有效标签(空或不含2个汉字),逆向遍历 DOM + if (!labelText || !isValidLabelText(labelText)) { + const foundLabel = walkBackwardForLabel(inp, titleElements) + if (foundLabel) { + labelText = foundLabel.text + labelElement = foundLabel.element + } + } + + // 最终 fallback:都找不到才用 placeholder 或返回"(未知字段)" + if (!labelText) labelText = (inp as HTMLInputElement).getAttribute("placeholder") || "(未知字段)" + return { labelText, labelElement } +}