// ==UserScript== // @name 岗位雷达 JobRadar (Minimalist Light UI - 知识库纯净版) // @namespace job-radar // @version 4.0.0 // @description 高保真的专精特新小巨人名单 // @author JobRadar // @match *://*/* // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // @connect * // @run-at document-idle // ==/UserScript== (function () { "use strict"; /* ============================================================ * 1. 配置管理 & 历史记录存储 * ============================================================ */ const DEFAULT_CONFIG = { provider: "zhipu", zhipuKey: "", zhipuModel: "glm-4-plus", tavilyKey: "", llmBaseUrl: "https://api.siliconflow.cn/v1/chat/completions", llmKey: "", llmModel: "Qwen/Qwen2.5-7B-Instruct", panelPos: null, }; const config = Object.assign({}, DEFAULT_CONFIG, GM_getValue("jobRadarConfig", {})); function saveConfig() { GM_setValue("jobRadarConfig", config); } function saveGiantsHistory(city, industry, data) { let hist = GM_getValue("jrGiantsHistory", []); hist = hist.filter(h => !(h.city === city && h.industry === industry)); hist.unshift({ city, industry, data, time: new Date().getTime() }); if (hist.length > 15) hist = hist.slice(0, 15); GM_setValue("jrGiantsHistory", hist); } function getGiantsHistory() { return GM_getValue("jrGiantsHistory", []); } function clearGiantsHistory() { GM_setValue("jrGiantsHistory", []); } /* ============================================================ * 2. 网络请求核心 * ============================================================ */ function httpPost(url, headers, body) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "POST", url, headers: headers || { "Content-Type": "application/json" }, data: typeof body === "string" ? body : JSON.stringify(body), timeout: 90000, onload: (res) => { if (res.status >= 200 && res.status < 300) { try { resolve(JSON.parse(res.responseText)); } catch (e) { reject(new Error("响应解析失败,非合法 JSON 格式")); } } else { let msg = res.responseText || "未知错误"; try { const j = JSON.parse(res.responseText); msg = j.error ? (j.error.message || JSON.stringify(j.error)) : msg; } catch (_) {} reject(new Error(`API 异常 (${res.status}):${msg.slice(0, 150)}`)); } }, onerror: () => reject(new Error("网络请求失败,请检查网络或 API 跨域配置")), ontimeout: () => reject(new Error("请求超时(AI 思考时间过长)。建议在搜索时加上「行业赛道」以减少计算量。")), }); }); } /* ============================================================ * 3. AI 接入层 * ============================================================ */ async function askZhipu(prompt) { const key = config.zhipuKey.trim(); if (!key) throw new Error("未配置智谱 API Key,请前往设置面板填写"); const body = { model: config.zhipuModel || "glm-4-plus", messages: [{ role: "user", content: prompt }], // 允许使用 web_search,但在提示词里要求以自身记忆为主 tools: [{ type: "web_search", web_search: { enable: true, search_result: true } }], temperature: 0.15, max_tokens: 4096, }; const data = await httpPost( "https://open.bigmodel.cn/api/paas/v4/chat/completions", { "Content-Type": "application/json", Authorization: `Bearer ${key}` }, body ); return data.choices?.[0]?.message?.content || ""; } async function tavilySearch(query) { const key = config.tavilyKey.trim(); if (!key) throw new Error("未配置 Tavily API Key"); const body = { api_key: key, query, search_depth: "advanced", max_results: 15, include_answer: false }; const data = await httpPost("https://api.tavily.com/search", null, body); if (!data.results?.length) throw new Error("未能检索到相关网页,请尝试调整关键词"); return data.results.map(r => ({ title: r.title || "", url: r.url || "", content: (r.content || "").slice(0, 500) })); } async function askLlm(prompt) { const key = (config.llmKey || "").trim(); const url = (config.llmBaseUrl || "").trim(); if (!key || !url) throw new Error("未配置对话模型 API 或 Key"); const body = { model: config.llmModel || "Qwen/Qwen2.5-7B-Instruct", messages: [{ role: "user", content: prompt }], temperature: 0.15, max_tokens: 4096, }; const data = await httpPost(url, { "Content-Type": "application/json", Authorization: `Bearer ${key}` }, body); return data.choices?.[0]?.message?.content || ""; } async function askAI(userPrompt) { if (config.provider === "tavily") { const results = await tavilySearch(userPrompt.queryText); const material = results.map((r, i) => `[${i + 1}] 标题:${r.title}\n链接:${r.url}\n摘要:${r.content}`).join("\n\n"); const prompt = `${userPrompt.instruction}\n\n以下是部分辅助参考资料,请结合这些资料与你自身的庞大知识库提取信息:\n\n${material}`; return await askLlm(prompt); } return await askZhipu(userPrompt.instruction); } /* ============================================================ * 4. Prompt 与 JSON 解析器 (全面强化知识库调用) * ============================================================ */ function getToday() { const d = new Date(); return { full: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`, yearMonth: `${d.getFullYear()}年${d.getMonth() + 1}月` }; } function buildGiantsPrompt(city, industry) { const timeInfo = getToday(); const hasInd = Boolean(industry && industry.trim()); const targetScope = hasInd ? `【${city}】地区属于【${industry}】赛道` : `【${city}】地区`; return { queryText: hasInd ? `${city} ${industry} 专精特新 小巨人 企业 名单` : `${city} 专精特新 小巨人 企业 名单`, instruction: `今天是${timeInfo.full}。我们需要整理${targetScope}的国家级专精特新"小巨人"企业名单。 ⚠️ 核心挖掘指令(以内部知识库为主,防改名干扰): 1. 【激活内部记忆】:很多企业可能在近年改过名(如某某科技有限公司更名为股份有限公司),单纯依赖搜索摘要极易产生错误或遗漏。请**主要调取你自身庞大的内部知识库记忆**,回忆并提取属于该地的小巨人名单,遇到曾用名请自动输出最新的正确公司名称! 2. 【数量优先】:请努力调用知识库,目标提取 15~30 家真实的专精特新企业。如果只记得公司名字,不知道详细业务介绍或批次,也必须把名字列出来(其余字段填"暂无")。 3. 【防幻觉兜底】:对于 hiring 字段,如果没有明确的近期公开招人信息,【必须填】"未捕捉到近期发布",严禁自行猜测! 4. 严格输出纯 JSON,格式如下: {"companies":[{"name":"企业全称","industry":"细分行业(若无填暂无)","batch":"批次(若无填暂无)","intro":"主营业务(若无填暂无)","hiring":"招聘动态,若无填'未捕捉到'"}],"summary":"产业概括"}` }; } function extractJSON(text) { try { const start = text.indexOf('{'); const end = text.lastIndexOf('}'); if (start === -1) throw new Error("未找到 JSON 结构"); let jsonStr = text.substring(start, end !== -1 ? end + 1 : text.length); jsonStr = jsonStr.replace(/,\s*([}\]])/g, "$1"); return JSON.parse(jsonStr); } catch (err) { console.warn("标准 JSON 解析失败,触发底层残片强行抢救机制...", err); const fallbackData = { companies: [], summary: "(AI 生成已触达字数极限,为您抢救并展示已获取的部分名单)" }; const companyMatches = text.match(/\{\s*"name"[^{}]+\}/g); if (companyMatches) { companyMatches.forEach(m => { try { const fixedObj = JSON.parse(m.replace(/,\s*\}/g, "}")); fallbackData.companies.push(fixedObj); } catch (e) {} }); } if (fallbackData.companies.length > 0) return fallbackData; throw new Error("AI 输出超时或数据被严重截断,请尝试添加「行业赛道」缩小搜索范围。"); } } /* ============================================================ * 5. 极简白底设计 (Minimalist Light Theme) * ============================================================ */ const SVGS = { radar: ``, corp: ``, cog: ``, search: `` }; const CSS = ` :root { --jr-base: #f9fafb; --jr-surface: #ffffff; --jr-border: #e5e7eb; --jr-border-focus: #d1d5db; --jr-text-main: #111827; --jr-text-mut: #4b5563; --jr-text-dim: #9ca3af; --jr-accent: #18181b; --jr-accent-text: #ffffff; --jr-accent-hover: #3f3f46; --jr-radius: 12px; --jr-font: -apple-system, BlinkMacSystemFont, "Inter", "SF Pro Text", "PingFang SC", sans-serif; } .jr-fab { position: fixed; z-index: 2147483647; right: 30px; bottom: 30px; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 0 16px; height: 44px; border-radius: 24px; background: var(--jr-surface); color: var(--jr-text-main); font-family: var(--jr-font); font-weight: 500; font-size: 14px; cursor: pointer; border: 1px solid var(--jr-border); box-shadow: 0 4px 16px rgba(0,0,0,0.06); transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); } .jr-fab:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba(0,0,0,0.1); border-color: var(--jr-border-focus);} .jr-panel { position: fixed; z-index: 2147483646; width: 440px; max-height: 85vh; display: flex; flex-direction: column; background: var(--jr-surface); border: 1px solid var(--jr-border); border-radius: var(--jr-radius); box-shadow: 0 12px 40px rgba(0,0,0,0.08); color: var(--jr-text-main); font-family: var(--jr-font); font-size: 13px; opacity: 0; transform: translateY(10px) scale(0.98); transition: opacity 0.25s ease, transform 0.3s cubic-bezier(0.16, 1, 0.3, 1); pointer-events: none; } .jr-panel.jr-show { opacity: 1; transform: translateY(0) scale(1); pointer-events: auto; } .jr-head { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px 12px; cursor: grab; user-select: none; border-bottom: 1px solid transparent; } .jr-head:active { cursor: grabbing; } .jr-head-left { display: flex; align-items: center; gap: 8px; } .jr-logo { color: var(--jr-text-main); display: flex; align-items: center; justify-content: center;} .jr-title-box { display: flex; flex-direction: column; } .jr-title { font-weight: 600; font-size: 15px; letter-spacing: -0.2px; color: var(--jr-text-main); } .jr-close { width: 26px; height: 26px; display: flex; align-items: center; justify-content: center; background: transparent; border: none; color: var(--jr-text-dim); border-radius: 6px; cursor: pointer; transition: all 0.2s; font-size: 14px; } .jr-close:hover { background: var(--jr-base); color: var(--jr-text-main); } .jr-tabs { display: flex; margin: 0 20px 16px; background: var(--jr-base); border-radius: 8px; padding: 4px; border: 1px solid var(--jr-border); } .jr-tab { flex: 1; display: flex; align-items: center; justify-content: center; gap: 6px; padding: 6px 0; background: transparent; border: none; border-radius: 6px; color: var(--jr-text-mut); font-size: 13px; font-weight: 500; cursor: pointer; transition: all 0.2s; } .jr-tab:hover { color: var(--jr-text-main); } .jr-tab.on { background: var(--jr-surface); color: var(--jr-text-main); box-shadow: 0 1px 3px rgba(0,0,0,0.06); } .jr-body { padding: 0 20px 20px; overflow-y: auto; flex: 1; display: flex; flex-direction: column; gap: 16px;} .jr-body::-webkit-scrollbar { width: 4px; } .jr-body::-webkit-scrollbar-thumb { background: var(--jr-border-focus); border-radius: 4px; } .jr-row { display: flex; gap: 8px; } .jr-input { flex: 1; padding: 10px 12px; border-radius: 8px; border: 1px solid var(--jr-border); background: var(--jr-base); color: var(--jr-text-main); font-size: 13px; font-family: inherit; outline: none; transition: all 0.2s; min-width: 0; } .jr-input::placeholder { color: var(--jr-text-dim); } .jr-input:focus { border-color: var(--jr-text-mut); background: var(--jr-surface); box-shadow: 0 0 0 3px rgba(0,0,0,0.03); } .jr-btn { padding: 0 16px; border: none; border-radius: 8px; background: var(--jr-accent); color: var(--jr-accent-text); font-weight: 500; cursor: pointer; font-size: 13px; transition: all 0.2s; white-space: nowrap; font-family: inherit; } .jr-btn:hover:not(:disabled) { background: var(--jr-accent-hover); } .jr-btn:active:not(:disabled) { transform: scale(0.97); } .jr-btn:disabled { opacity: 0.5; cursor: not-allowed; } .jr-hist-bar { display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--jr-border); padding-bottom: 8px; } .jr-hist-title { font-size: 12px; color: var(--jr-text-main); font-weight: 500; } .jr-hist-clear { font-size: 12px; color: #ef4444; cursor: pointer; font-weight: 500;} .jr-hist-clear:hover { opacity: 0.8; } .jr-hist-list { display: flex; flex-wrap: wrap; gap: 6px; } .jr-tag { font-size: 12px; padding: 4px 10px; border-radius: 6px; background: var(--jr-base); color: var(--jr-text-mut); font-weight: 400; border: 1px solid var(--jr-border); } .jr-tag.hist { cursor: pointer; transition: all 0.2s; } .jr-tag.hist:hover { background: var(--jr-border); color: var(--jr-text-main); } .jr-card { background: var(--jr-surface); border: 1px solid var(--jr-border); border-radius: 10px; padding: 16px; transition: all 0.2s ease; display: flex; flex-direction: column; gap: 10px; box-shadow: 0 1px 2px rgba(0,0,0,0.02);} .jr-card:hover { border-color: var(--jr-border-focus); box-shadow: 0 4px 12px rgba(0,0,0,0.04); } .jr-card-head { display: flex; flex-direction: column; gap: 8px; } .jr-card-title { font-weight: 600; font-size: 14.5px; color: var(--jr-text-main); line-height: 1.4; } .jr-card-meta { display: flex; flex-wrap: wrap; gap: 6px; } .jr-card-sum { color: var(--jr-text-mut); font-size: 13px; line-height: 1.6; } .jr-card-sum.highlight { color: var(--jr-text-main); font-weight: 500; } .jr-action-bar { display: flex; gap: 8px; margin-top: 4px; } .jr-link { display: inline-flex; align-items: center; gap: 4px; padding: 6px 12px; border-radius: 6px; background: var(--jr-base); color: var(--jr-text-mut); text-decoration: none; font-size: 12px; font-weight: 500; border: 1px solid var(--jr-border); transition: all 0.2s; } .jr-link:hover { background: var(--jr-border); color: var(--jr-text-main); } .jr-link.primary { background: var(--jr-text-main); color: var(--jr-surface); border: none; } .jr-link.primary:hover { background: var(--jr-text-mut); } .jr-summary { border-left: 3px solid var(--jr-text-main); padding: 8px 12px; background: var(--jr-base); color: var(--jr-text-main); font-size: 13px; line-height: 1.6; border-radius: 0 8px 8px 0;} .jr-err { color: #b91c1c; font-size: 13px; background: #fef2f2; padding: 12px; border-radius: 8px; border: 1px solid #fecaca; } .jr-empty { text-align: center; color: var(--jr-text-dim); padding: 32px 0; font-size: 13px; } .jr-loading { display: flex; flex-direction: column; align-items: center; gap: 16px; padding: 40px 0; color: var(--jr-text-mut); } .jr-spinner { width: 24px; height: 24px; border: 2px solid var(--jr-border); border-top-color: var(--jr-text-main); border-radius: 50%; animation: jr-spin 0.6s linear infinite; } @keyframes jr-spin { to { transform: rotate(360deg); } } .jr-cfg-group { display: flex; flex-direction: column; gap: 16px; } .jr-cfg-row { display: flex; flex-direction: column; gap: 8px; } .jr-cfg-label { font-size: 13px; color: var(--jr-text-main); font-weight: 500; display: flex; justify-content: space-between; align-items: center;} .jr-cfg-label small { color: var(--jr-text-dim); font-weight: normal; } .jr-select { padding: 10px 12px; border-radius: 8px; border: 1px solid var(--jr-border); background: var(--jr-base); color: var(--jr-text-main); font-size: 13px; outline: none; cursor:pointer; font-family: inherit;} `; let panelEl, fabEl; function initUI() { if (document.getElementById("jr-style")) return; const st = document.createElement("style"); st.id = "jr-style"; st.textContent = CSS; document.head.appendChild(st); fabEl = document.createElement("div"); fabEl.className = "jr-fab"; fabEl.innerHTML = `${SVGS.radar} 雷达`; fabEl.addEventListener("click", togglePanel); document.body.appendChild(fabEl); panelEl = document.createElement("div"); panelEl.className = "jr-panel"; panelEl.innerHTML = `
JobRadar
`; document.body.appendChild(panelEl); panelEl.querySelector("#jr-close").addEventListener("click", togglePanel); panelEl.querySelectorAll(".jr-tab").forEach(tab => tab.addEventListener("click", () => switchTab(tab.dataset.tab))); makeDraggable(panelEl.querySelector(".jr-head"), panelEl); restorePanelPos(); switchTab("giants"); } function togglePanel() { if (panelEl.classList.contains("jr-show")) panelEl.classList.remove("jr-show"); else { panelEl.classList.add("jr-show"); restorePanelPos(); } } function makeDraggable(handle, target) { let sx, sy, ox, oy, dragging = false; handle.addEventListener("mousedown", (e) => { if (e.target.tagName === "BUTTON") return; dragging = true; sx = e.clientX; sy = e.clientY; const r = target.getBoundingClientRect(); ox = r.left; oy = r.top; target.style.right = "auto"; target.style.bottom = "auto"; target.style.left = ox + "px"; target.style.top = oy + "px"; e.preventDefault(); }); window.addEventListener("mousemove", (e) => { if (dragging) { target.style.left = ox + e.clientX - sx + "px"; target.style.top = oy + e.clientY - sy + "px"; }}); window.addEventListener("mouseup", () => { if (dragging) { dragging = false; config.panelPos = { x: target.style.left, y: target.style.top }; saveConfig(); }}); } function restorePanelPos() { if (config.panelPos) { panelEl.style.right = "auto"; panelEl.style.bottom = "auto"; panelEl.style.left = config.panelPos.x; panelEl.style.top = config.panelPos.y; } else { panelEl.style.right = "24px"; panelEl.style.bottom = "90px"; } } /* ============================================================ * 6. 核心交互 * ============================================================ */ function switchTab(name) { panelEl.querySelectorAll(".jr-tab").forEach(t => t.classList.toggle("on", t.dataset.tab === name)); const body = panelEl.querySelector("#jr-body"); if (name === "giants") { body.innerHTML = `
AI 将利用内部庞大知识库,精准梳理该地专精特新名录。
`; const btn = body.querySelector("#jr-ggo"); const histBox = body.querySelector("#jr-ghist"); const resBox = body.querySelector("#jr-gres"); renderGiantsHistoryUI(histBox, resBox); const executeSearch = async () => { const city = body.querySelector("#jr-gc").value.trim(); const ind = body.querySelector("#jr-gi").value.trim(); if (!city) return; GM_setValue("jrLastGCity", city); GM_setValue("jrLastGIndustry", ind); btn.disabled = true; resBox.innerHTML = `
正在调用知识库,梳理目标小巨人名录...
`; try { const text = await askAI(buildGiantsPrompt(city, ind)); const data = extractJSON(text); saveGiantsHistory(city, ind, data); renderGiantsHistoryUI(histBox, resBox); renderGiantCards(resBox, data, city, ind); } catch (err) { resBox.innerHTML = `
错误: ${esc(err.message)}
`; } finally { btn.disabled = false; } }; btn.addEventListener("click", executeSearch); body.addEventListener("keydown", e => { if (e.key === "Enter" && (e.target.id === "jr-gc" || e.target.id === "jr-gi")) executeSearch(); }); } else if (name === "cfg") { renderCfgTab(body); } } function renderGiantsHistoryUI(histBox, resBox) { const hist = getGiantsHistory(); if (!hist.length) { histBox.innerHTML = ""; return; } let html = `
历史挖掘 清空记录
`; hist.forEach((h, i) => { html += `${esc(h.city + (h.industry ? ` / ${h.industry}` : ""))}`; }); html += `
`; histBox.innerHTML = html; histBox.querySelector("#jr-gclear").addEventListener("click", () => { clearGiantsHistory(); renderGiantsHistoryUI(histBox, resBox); }); histBox.querySelectorAll(".jr-tag.hist").forEach(tag => { tag.addEventListener("click", (e) => { const h = hist[e.target.getAttribute("data-idx")]; document.querySelector("#jr-gc").value = h.city; document.querySelector("#jr-gi").value = h.industry; renderGiantCards(resBox, h.data, h.city, h.industry); }); }); } function renderGiantCards(box, data, city, industry) { const list = (data.companies || []).filter(c => c && c.name); if (!list.length) return box.innerHTML = `
未能提取到企业数据,请尝试放宽行业条件。
`; let html = data.summary ? `
${esc(data.summary)}
` : ""; list.forEach(c => { const isHiring = c.hiring && !/未捕捉|暂无/.test(c.hiring); const searchUrl = `https://www.bing.com/search?q=${encodeURIComponent(c.name + " 招聘")}`; html += `
${esc(c.name)}
${c.industry && !/暂无/.test(c.industry) ? `${esc(c.industry)}` : ""} ${c.batch && !/暂无/.test(c.batch) ? `${esc(c.batch)}` : ""}
${c.intro && !/暂无/.test(c.intro) ? `
${esc(c.intro)}
` : ""} ${isHiring ? `
动态: ${esc(c.hiring)}
` : ""}
${SVGS.search} 全网搜该企业岗位
`; }); html += `
已提取 ${list.length} 家企业信息。
`; box.innerHTML = html; } function renderCfgTab(body) { const isZhipu = config.provider === "zhipu"; // 预设邀请链接 & 作者主页链接 const zhipuInviteUrl = "https://www.bigmodel.cn/invite?icode=jd1kl9fAoiiWBCYkwaVmrf2gad6AKpjZefIo3dVEQyA%3D"; const douyinAuthorUrl = "https://www.douyin.com/user/MS4wLjABAAAAqWl-s3OZLtVuMFZkL1HPy3yxI6XtUdq1SUzA0RaVbjaRy_JFx-cP55oW4IPFLDFX"; body.innerHTML = `
交流与反馈 获取最新脚本动态
👋 找到作者 (抖音)
`; body.querySelector("#jr-provider").addEventListener("change", (e) => { body.querySelector("#jr-zhipu-cfg").style.display = e.target.value === "zhipu" ? "flex" : "none"; body.querySelector("#jr-tavily-cfg").style.display = e.target.value === "tavily" ? "flex" : "none"; }); body.querySelector("#jr-save").addEventListener("click", () => { config.provider = body.querySelector("#jr-provider").value; config.zhipuKey = body.querySelector("#jr-zkey").value.trim(); config.zhipuModel = body.querySelector("#jr-zmodel").value.trim() || "glm-4-plus"; config.tavilyKey = body.querySelector("#jr-tkey").value.trim(); config.llmBaseUrl = body.querySelector("#jr-lurl").value.trim(); config.llmKey = body.querySelector("#jr-lkey").value.trim(); config.llmModel = body.querySelector("#jr-lmodel").value.trim(); saveConfig(); body.querySelector("#jr-cfg-msg").innerHTML = `
设置已保存,立即生效。
`; }); } function esc(s) { return String(s == null ? "" : s).replace(/[&<>]/g, c => ({'&':'&','<':'<','>':'>'}[c])); } function escAttr(s) { return esc(s).replace(/["']/g, c => ({'"':'"',"'":'''}[c])); } if (window.top === window.self) { if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", initUI); else initUI(); } })();