// ==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 = `