Commit 65c3cb13 authored by 李文光's avatar 李文光

fix(plugin): 采集插件跨域改走后台代理 + 后端地址动态授权 + 后端 CORS 放开

内容脚本跨域 fetch 改为经 background Service Worker 转发, 解决同步时本地服务未启动(Chrome MV3 内容脚本跨源请求始终按跨源处理, 即使有 host_permissions 也被 CORS 拦截)

扩展新增 optional_host_permissions + 运行时 chrome.permissions.request 动态授权, 换后端地址只需在弹窗填新地址并点一次测试连接, 无需改 manifest

后端 CORS 默认放宽为 *, 同步更新 test_static_security 的 Access-Control-Allow-Origin 断言
parent 45ea60ee
...@@ -36,7 +36,7 @@ class Settings(BaseSettings): ...@@ -36,7 +36,7 @@ class Settings(BaseSettings):
ingest_token: str = Field(default="", alias="INGEST_TOKEN") ingest_token: str = Field(default="", alias="INGEST_TOKEN")
ingest_owner: str = Field(default="admin", alias="INGEST_OWNER") ingest_owner: str = Field(default="admin", alias="INGEST_OWNER")
cors_origins_raw: str = Field( cors_origins_raw: str = Field(
default="http://127.0.0.1:5173,http://localhost:5173", alias="CORS_ORIGINS" default="*", alias="CORS_ORIGINS"
) )
max_upload_mb: int = Field(default=20, alias="MAX_UPLOAD_MB") max_upload_mb: int = Field(default=20, alias="MAX_UPLOAD_MB")
...@@ -122,3 +122,4 @@ def get_settings() -> Settings: ...@@ -122,3 +122,4 @@ def get_settings() -> Settings:
return Settings() return Settings()
...@@ -30,4 +30,5 @@ def test_frontend_origin_is_allowed_by_cors(client): ...@@ -30,4 +30,5 @@ def test_frontend_origin_is_allowed_by_cors(client):
}, },
) )
assert response.status_code == 200 assert response.status_code == 200
assert response.headers["access-control-allow-origin"] == "http://127.0.0.1:5173" assert response.headers["access-control-allow-origin"] == "*"
...@@ -7,7 +7,9 @@ ...@@ -7,7 +7,9 @@
1. 启动后端:`python -m uvicorn backend.app.main:app --host 127.0.0.1 --port 4177` 1. 启动后端:`python -m uvicorn backend.app.main:app --host 127.0.0.1 --port 4177`
2. 打开 Chrome 或 Edge 的扩展程序页面并开启开发者模式。 2. 打开 Chrome 或 Edge 的扩展程序页面并开启开发者模式。
3. 选择“加载已解压的扩展程序”,选择本目录。 3. 选择“加载已解压的扩展程序”,选择本目录。
4. 在扩展弹窗中确认 API 地址为 `http://127.0.0.1:4177/api/ingest`;部署到其他地址时可直接修改并保存。 4. 在扩展弹窗中确认 API 地址为 `http://127.0.0.1:4177/api/ingest`
> 部署到其他地址时:直接在弹窗里修改 API 地址并保存,点「测试连接」时 Chrome 会弹出一次访问授权,允许后即可;**无需改动 `manifest.json`**(扩展通过 `optional_host_permissions` 按需申请任意 http/https 主机权限,已授权的地址会直接通过)。
本扩展从 BOSS 直聘和猎聘页面采集候选人简历并提交给后端。`reference/` 是外部方案参考副本,不属于运行时扩展。 本扩展从 BOSS 直聘和猎聘页面采集候选人简历并提交给后端。`reference/` 是外部方案参考副本,不属于运行时扩展。
......
/** /**
* 简历采集器 — Background Service Worker * 简历采集器 — Background Service Worker
* - 通过 chrome.cookies API 获取 HttpOnly cookie(BOSS 的鉴权 cookie 是 HttpOnly 的) * - 通过 chrome.cookies API 获取 HttpOnly cookie(BOSS 的鉴权 cookie 是 HttpOnly 的)
* - 代理内容脚本/弹窗的跨域 fetch 到后端(Chrome MV3 内容脚本跨源请求始终按跨源处理,
* 即使有 host_permissions 也会被 CORS 拦截,必须经后台 Service Worker 转发)
* - 不自动发送任何请求到服务器,所有发送由 content script 浮动按钮触发 * - 不自动发送任何请求到服务器,所有发送由 content script 浮动按钮触发
*/ */
...@@ -33,6 +35,31 @@ async function getAllCookies(domain) { ...@@ -33,6 +35,31 @@ async function getAllCookies(domain) {
}); });
} }
// 从地址推导主机的 match pattern,如 http://172.18.100.59:4177/api/ingest -> http://172.18.100.59:4177/*
function originPatternFromUrl(raw) {
try {
var u = new URL(raw);
if (u.protocol !== 'http:' && u.protocol !== 'https:') return '';
return u.protocol + '//' + u.host + '/*';
} catch (e) {
return '';
}
}
// 后台发 POST 到后端(扩展上下文拥有目标主机权限时可绕过 CORS)
async function doIngest(apiUrl, payload) {
try {
var r = await fetch(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
return r.ok ? await r.json() : { ok: false, message: '服务器返回 ' + r.status };
} catch (e) {
return { ok: false, message: '本地服务未启动' };
}
}
// ── 消息处理 ────────────────────────────────────────── // ── 消息处理 ──────────────────────────────────────────
chrome.runtime.onMessage.addListener(function (msg, sender, sendResponse) { chrome.runtime.onMessage.addListener(function (msg, sender, sendResponse) {
if (msg.action === 'getCookies') { if (msg.action === 'getCookies') {
...@@ -44,6 +71,24 @@ chrome.runtime.onMessage.addListener(function (msg, sender, sendResponse) { ...@@ -44,6 +71,24 @@ chrome.runtime.onMessage.addListener(function (msg, sender, sendResponse) {
}); });
return true; // 保持异步通道 return true; // 保持异步通道
} }
if (msg.action === 'ingest' && msg.apiUrl && msg.payload) {
var apiUrl = msg.apiUrl;
var payload = msg.payload;
var pattern = originPatternFromUrl(apiUrl);
if (pattern) {
chrome.permissions.contains({ origins: [pattern] }, function (has) {
if (!has) {
sendResponse({ ok: false, message: '未授权访问该后端,请先在弹窗点「测试连接」完成授权' });
return;
}
doIngest(apiUrl, payload).then(sendResponse);
});
} else {
doIngest(apiUrl, payload).then(sendResponse);
}
return true; // 保持异步通道
}
}); });
console.log('[简历采集器 bg] Service Worker 已启动'); console.log('[简历采集器 bg] Service Worker 已启动');
...@@ -49,7 +49,7 @@ ...@@ -49,7 +49,7 @@
function parseCookies(r) { var m = {}; if (!r) return m; r.split(';').forEach(function (p) { var e = p.indexOf('='); if (e > 0) m[p.slice(0, e).trim()] = p.slice(e + 1).trim(); }); return m; } function parseCookies(r) { var m = {}; if (!r) return m; r.split(';').forEach(function (p) { var e = p.indexOf('='); if (e > 0) m[p.slice(0, e).trim()] = p.slice(e + 1).trim(); }); return m; }
async function getCookies() { return new Promise(function (res) { try { var d = window.location.hostname.replace(/^www\./, ''); var ps = d.split('.'); if (ps.length > 2) d = ps.slice(-2).join('.'); chrome.runtime.sendMessage({ action: 'getCookies', domain: d }, function (c) { var e = chrome.runtime.lastError; if (e) { res(document.cookie || ''); return; } if (!Array.isArray(c) || !c.length) { res(document.cookie || ''); return; } try { res(c.filter(function (x) { return x && x.name; }).map(function (x) { return x.name + '=' + (x.value || ''); }).join('; ')); } catch (_) { res(document.cookie || ''); } }); } catch (_) { res(document.cookie || ''); } }); } async function getCookies() { return new Promise(function (res) { try { var d = window.location.hostname.replace(/^www\./, ''); var ps = d.split('.'); if (ps.length > 2) d = ps.slice(-2).join('.'); chrome.runtime.sendMessage({ action: 'getCookies', domain: d }, function (c) { var e = chrome.runtime.lastError; if (e) { res(document.cookie || ''); return; } if (!Array.isArray(c) || !c.length) { res(document.cookie || ''); return; } try { res(c.filter(function (x) { return x && x.name; }).map(function (x) { return x.name + '=' + (x.value || ''); }).join('; ')); } catch (_) { res(document.cookie || ''); } }); } catch (_) { res(document.cookie || ''); } }); }
function getApiUrl() { return new Promise(function (resolve) { chrome.storage.local.get('apiUrl', function (data) { resolve((data && data.apiUrl) || DEFAULT_API); }); }); } function getApiUrl() { return new Promise(function (resolve) { chrome.storage.local.get('apiUrl', function (data) { resolve((data && data.apiUrl) || DEFAULT_API); }); }); }
async function sendToLocal(d) { var apiUrl = await getApiUrl(); return fetch(apiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(d) }).then(function (r) { return r.ok ? r.json() : { ok: false, message: '服务器返回 ' + r.status }; }).catch(function () { return { ok: false, message: '本地服务未启动' }; }); } async function sendToLocal(d) { var apiUrl = await getApiUrl(); return new Promise(function (resolve) { try { chrome.runtime.sendMessage({ action: 'ingest', apiUrl: apiUrl, payload: d }, function (res) { if (chrome.runtime.lastError) { resolve({ ok: false, message: '本地服务未启动' }); return; } resolve(res || { ok: false, message: '本地服务未启动' }); }); } catch (e) { resolve({ ok: false, message: '本地服务未启动' }); } }); }
function resolvePdfUrl() { function resolvePdfUrl() {
if (capturedPdfUrl) return capturedPdfUrl; if (capturedPdfUrl) return capturedPdfUrl;
......
...@@ -6,6 +6,8 @@ ...@@ -6,6 +6,8 @@
"permissions": ["activeTab", "storage", "cookies", "scripting"], "permissions": ["activeTab", "storage", "cookies", "scripting"],
"optional_host_permissions": ["http://*:*/*", "https://*:*/*"],
"host_permissions": [ "host_permissions": [
"*://*.zhipin.com/*", "*://*.zhipin.com/*",
"*://zhipin.com/*", "*://zhipin.com/*",
......
...@@ -30,14 +30,24 @@ apiUrlInput.addEventListener("change", () => { ...@@ -30,14 +30,24 @@ apiUrlInput.addEventListener("change", () => {
chrome.storage.local.set({ apiUrl: apiUrlInput.value }); chrome.storage.local.set({ apiUrl: apiUrlInput.value });
}); });
// 测试连接 // 从地址推导主机的 match pattern,如 http://172.18.100.59:4177/api/ingest -> http://172.18.100.59:4177/*
testBtn.addEventListener("click", async () => { function originPatternFromUrl(raw) {
testBtn.disabled = true; try {
testBtn.textContent = "测试中..."; const u = new URL(raw);
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
return `${u.protocol}//${u.host}/*`;
} catch {
return null;
}
}
const apiUrl = apiUrlInput.value; function resetBtn() {
chrome.storage.local.set({ apiUrl }); testBtn.disabled = false;
testBtn.textContent = "测试连接";
}
// 实际连接测试逻辑
async function runTest(apiUrl) {
try { try {
const resp = await fetch(apiUrl.replace("/api/ingest", "/api/ping"), { const resp = await fetch(apiUrl.replace("/api/ingest", "/api/ping"), {
method: "GET", method: "GET",
...@@ -60,9 +70,31 @@ testBtn.addEventListener("click", async () => { ...@@ -60,9 +70,31 @@ testBtn.addEventListener("click", async () => {
addLog("err", "❌ 无法连接本地系统,请先启动招聘系统"); addLog("err", "❌ 无法连接本地系统,请先启动招聘系统");
} }
} }
}
testBtn.disabled = false; // 测试连接(点击按钮时同步申请该后端主机权限,换后端只需此处授权一次,无需改 manifest)
testBtn.textContent = "测试连接"; testBtn.addEventListener("click", () => {
testBtn.disabled = true;
testBtn.textContent = "测试中...";
const apiUrl = apiUrlInput.value;
chrome.storage.local.set({ apiUrl });
const pattern = originPatternFromUrl(apiUrl);
// 同步发起权限申请以保留用户手势;已授权过的会直接返回 true,不弹窗
if (pattern) {
chrome.permissions.request({ origins: [pattern] }, (res) => {
if (chrome.runtime.lastError || !res) {
addLog("err", "❌ 未授权访问该后端地址,请在弹窗中允许后重试");
return resetBtn();
}
runTest(apiUrl);
});
} else {
addLog("err", "❌ 后端地址格式不正确,应为 http(s)://主机:端口/api/ingest");
return resetBtn();
}
}); });
function addLog(type, msg) { function addLog(type, msg) {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment