commit ec826c55ff9e304192495b8eeb52f12785018350 Author: kyugao Date: Mon Jun 15 10:06:03 2026 +0800 first commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..b747be1 --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +# xueqiu_sync — 雪球 Cookie 复制 Chrome 扩展 + +把本地 Chrome 里 xueqiu.com 的 HttpOnly Cookie 读出来,一键复制成你需要的格式。 +**不依赖任何后端 / 数据库 / Python**。 + +## 为什么不做成自动化 + +浏览器扩展**不能**直接连 PostgreSQL / MySQL(沙箱限制:无 DB 驱动、禁裸 TCP、暴露凭据)。 +所以这版只做"读 cookie + 复制到剪贴板",你粘到哪里由你决定。 + +## 准备 + +- Chrome / Edge / 其他 Chromium 内核浏览器 +- 一次:在浏览器里登录过 [xueqiu.com](https://xueqiu.com/)(让 cookie 落到本地) + +## 安装(开发模式,30 秒) + +1. 打开 `chrome://extensions/` +2. 右上角打开「**开发者模式**」 +3. 点「**加载已解压的扩展程序**」 +4. 选这个目录的 `chrome-extension/` 子目录: + ``` + /home/gao/Development/quant_home/xueqiu_sync/chrome-extension/ + ``` +5. 工具栏会出现一个 ❄ 雪花图标 + +## 使用 + +1. 浏览器登录过 xueqiu.com 后,点工具栏的 ❄ 图标 +2. 弹窗会**自动**读出 `xq_a_token` 和 `u`(含 HttpOnly) +3. 选一个复制按钮: + +| 按钮 | 复制内容 | 用在 | +|---|---|---| +| **复制组合字符串** | `xq_a_token=xxx;u=yyy` | dashboard 的 `POST /api/data/datasource/xueqiu-cookie`、`market_sync` 的 `XUEQIU_TOKEN` 字段值 | +| **复制 .env 行** | `XUEQIU_TOKEN=xq_a_token=xxx;u=yyy` | 直接粘到 `market_sync/.env` | +| **仅 xq_a_token** | `xxx` | 单独使用场景 | + +## 改代码 + +`chrome://extensions/` → 找到本扩展 → 点 ↻ 重新加载。 + +## 目录结构 + +``` +xueqiu_sync/ +├── README.md +├── .gitignore +└── chrome-extension/ + ├── manifest.json + ├── popup.html + ├── popup.js + └── icons/ + ├── icon16.png + ├── icon48.png + └── icon128.png +``` + +## License + +私有 diff --git a/chrome-extension/content.js b/chrome-extension/content.js new file mode 100644 index 0000000..9d23283 --- /dev/null +++ b/chrome-extension/content.js @@ -0,0 +1,48 @@ +// content.js — 在 xueqiu.com 页面里跑,document.cookie 读非 HttpOnly 的 cookie, +// 然后缓存起来等 popup 来取。 +// 注意:document.cookie 读不到 HttpOnly(xq_a_token 是 HttpOnly 拿不到) +// 但能拿到 u、device_id 等普通 cookie,作为辅助数据。 + +(function () { + const PAGE_CACHE_KEY = "__xueqiu_sync_page_cache__"; + const TTL_MS = 30_000; + + function read() { + const out = { url: location.href, cookies: {}, ts: Date.now() }; + try { + const pairs = document.cookie.split(";"); + for (const p of pairs) { + const idx = p.indexOf("="); + if (idx > 0) { + const k = p.slice(0, idx).trim(); + const v = decodeURIComponent(p.slice(idx + 1).trim()); + if (k) out.cookies[k] = v; + } + } + } catch (_) {} + return out; + } + + function update() { + try { + window[PAGE_CACHE_KEY] = read(); + } catch (_) {} + } + + update(); + // 兜底:每 5s 刷一次,捕获导航/SPA 切换 + setInterval(update, 5000); + + // 响应 popup 的查询 + chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + if (msg && msg.type === "xueqiu_sync/get_page_cookies") { + let cache = window[PAGE_CACHE_KEY]; + if (!cache || Date.now() - cache.ts > TTL_MS) { + cache = read(); + window[PAGE_CACHE_KEY] = cache; + } + sendResponse(cache); + return true; + } + }); +})(); diff --git a/chrome-extension/icons/icon128.png b/chrome-extension/icons/icon128.png new file mode 100644 index 0000000..a811ba6 Binary files /dev/null and b/chrome-extension/icons/icon128.png differ diff --git a/chrome-extension/icons/icon16.png b/chrome-extension/icons/icon16.png new file mode 100644 index 0000000..23b4692 Binary files /dev/null and b/chrome-extension/icons/icon16.png differ diff --git a/chrome-extension/icons/icon48.png b/chrome-extension/icons/icon48.png new file mode 100644 index 0000000..c288dd9 Binary files /dev/null and b/chrome-extension/icons/icon48.png differ diff --git a/chrome-extension/manifest.json b/chrome-extension/manifest.json new file mode 100644 index 0000000..2dfc76e --- /dev/null +++ b/chrome-extension/manifest.json @@ -0,0 +1,32 @@ +{ + "manifest_version": 3, + "name": "雪球 Cookie 复制", + "version": "0.1.0", + "description": "读取本地 xueqiu.com 的 HttpOnly Cookie,一键复制到剪贴板(多种格式)。", + "permissions": ["cookies", "clipboardWrite", "tabs"], + "host_permissions": [ + "https://xueqiu.com/*", + "https://*.xueqiu.com/*" + ], + "content_scripts": [ + { + "matches": ["https://xueqiu.com/*", "https://*.xueqiu.com/*"], + "js": ["content.js"], + "run_at": "document_idle" + } + ], + "action": { + "default_popup": "popup.html", + "default_title": "复制雪球 Cookie", + "default_icon": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } + }, + "icons": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } +} diff --git a/chrome-extension/popup.html b/chrome-extension/popup.html new file mode 100644 index 0000000..1460ec8 --- /dev/null +++ b/chrome-extension/popup.html @@ -0,0 +1,209 @@ + + + + + 雪球 Cookie 复制 + + + +

雪球 Cookie 复制

+ +
+ +
未读取
+
+
+ +
未读取
+
+ +
+ + +
+ +
+ +
+
+ + +
+
+ +
+ +
+ +
+ 调试信息(点开看) +
加载中…
+
+ +
+ 手动粘贴(兜底) +

+ 如果自动读不到:打开 xueqiu.com 页面 → F12 → Network → 任意请求 → Request Headers → 复制 Cookie 字段整行 → 粘到下面 → 点「提取」。 +

+ + +
+ +
+ +
+ 用法:
+ 1) 在 Chrome 登录过 xueqiu.com(一次即可)
+ 2) 打开此弹窗 → 选格式 → 一键复制
+ 3) 粘到 .env / dashboard / 任何你需要的地方 +
+ + + + diff --git a/chrome-extension/popup.js b/chrome-extension/popup.js new file mode 100644 index 0000000..fff9851 --- /dev/null +++ b/chrome-extension/popup.js @@ -0,0 +1,317 @@ +// popup.js — 读 xueqiu.com 的 HttpOnly Cookie,弹窗展示 + 一键复制。 +// +// 浏览器扩展能直接读 HttpOnly cookie(chrome.cookies API), +// 但不能直连 PG/MySQL(沙箱限制)。所以这里只读 + 展示 + 复制。 +// 复制完手动粘到 .env / dashboard / 任何需要的地方。 +// +// v0.2 改动: +// 1) 尝试多种 chrome.cookies 查询模式(domain / url / 带 partitionKey) +// 2) 同时从 content script 拉页面 document.cookie +// 3) 手动粘贴兜底(万一所有自动读都失败) + +const $xq = document.getElementById("xq-val"); +const $u = document.getElementById("u-val"); +const $combined = document.getElementById("combined-val"); +const $msg = document.getElementById("msg"); +const $debug = document.getElementById("debug"); +const $manual = document.getElementById("manual-textarea"); +const $useManual = document.getElementById("use-manual"); + +let COOKIES = { xq_a_token: "", u: "" }; +let ALL_COOKIES = []; +let PAGE_COOKIES = {}; + +function setMsg(text, level = "info") { + $msg.className = level; + $msg.textContent = text; +} + +function clearMsg() { + $msg.className = ""; + $msg.textContent = ""; +} + +function setVal(el, v, isEmpty) { + el.textContent = v || "未读取"; + el.classList.toggle("empty", !!isEmpty); +} + +function combinedString() { + const { xq_a_token, u } = COOKIES; + if (!xq_a_token || !u) return ""; + return `xq_a_token=${xq_a_token};u=${u}`; +} + +function mergeCookies(...lists) { + const seen = new Set(); + const out = []; + for (const list of lists) { + for (const c of list || []) { + const key = `${c.domain}|${c.path}|${c.name}`; + if (!seen.has(key)) { + seen.add(key); + out.push(c); + } + } + } + return out; +} + +function tryParseCookieString(s) { + // 接受 "a=1; b=2" 格式 + const out = {}; + if (!s) return out; + const norm = s.replace(/\n/g, ";").replace(/\r/g, ";"); + for (const part of norm.split(";")) { + const idx = part.indexOf("="); + if (idx > 0) { + const k = part.slice(0, idx).trim(); + const v = part.slice(idx + 1).trim(); + if (k) out[k] = v; + } + } + return out; +} + +async function readCookies() { + clearMsg(); + ALL_COOKIES = []; + PAGE_COOKIES = {}; + + if (typeof chrome === "undefined" || !chrome.cookies) { + setMsg("❌ chrome.cookies API 不可用 — 扩展可能没正确加载", "err"); + return; + } + + const errors = []; + const queries = []; + + // 模式 A:domain 模式 + try { + const a = await chrome.cookies.getAll({ domain: ".xueqiu.com" }); + queries.push({ mode: "domain=.xueqiu.com", n: a.length, list: a }); + } catch (e) { + errors.push(`domain=.xueqiu.com: ${e.message || e}`); + } + try { + const a = await chrome.cookies.getAll({ domain: "xueqiu.com" }); + queries.push({ mode: "domain=xueqiu.com (exact)", n: a.length, list: a }); + } catch (e) { + errors.push(`domain=xueqiu.com: ${e.message || e}`); + } + + // 模式 B:url 模式(多个变体) + const urls = [ + "https://xueqiu.com/", + "https://www.xueqiu.com/", + "https://xueqiu.com", + "https://www.xueqiu.com", + ]; + for (const u of urls) { + try { + const r = await chrome.cookies.getAll({ url: u }); + queries.push({ mode: `url=${u}`, n: r.length, list: r }); + } catch (e) { + errors.push(`url=${u}: ${e.message || e}`); + } + } + + // 模式 C:按 cookie 名精确查(不走 list,直接 single get) + for (const u of urls) { + for (const name of ["xq_a_token", "u"]) { + try { + const c = await chrome.cookies.get({ url: u, name }); + if (c) queries.push({ mode: `get ${name} @ ${u}`, n: 1, list: [c] }); + } catch (e) { + // 静默:找不到是正常的 + } + } + } + + // 模式 D:分桶 cookie(Chrome 115+ 默认 partition 行为) + for (const u of urls) { + try { + const r = await chrome.cookies.getAll({ + url: u, + partitionKey: { topLevelSite: "https://xueqiu.com" }, + }); + queries.push({ mode: `url=${u} (partitioned TLS=xueqiu.com)`, n: r.length, list: r }); + } catch (e) { + // 忽略 + } + } + + // 模式 E:从 content script 读(page document.cookie) + try { + const tabs = await chrome.tabs.query({ + url: ["https://xueqiu.com/*", "https://*.xueqiu.com/*"], + }); + for (const t of tabs) { + try { + const resp = await chrome.tabs.sendMessage(t.id, { + type: "xueqiu_sync/get_page_cookies", + }); + if (resp && resp.cookies) { + PAGE_COOKIES = { ...PAGE_COOKIES, ...resp.cookies }; + queries.push({ mode: `page @ ${t.url}`, n: Object.keys(resp.cookies).length, list: [] }); + // 把 PAGE_COOKIES 转成伪 cookie 对象参与合并 + for (const [k, v] of Object.entries(resp.cookies)) { + queries[queries.length - 1].list.push({ + domain: "(page)", + path: "/", + name: k, + value: v, + }); + } + } + } catch (_) { + // content script 没注入 + } + } + } catch (e) { + errors.push(`tabs query: ${e.message || e}`); + } + + // 合并所有模式的 cookie + const allLists = queries.map((q) => q.list); + ALL_COOKIES = mergeCookies(...allLists); + + console.log("[xueqiu_sync] queries:", queries); + console.log("[xueqiu_sync] merged:", ALL_COOKIES); + + const xq = ALL_COOKIES.find((c) => c.name === "xq_a_token" && c.value); + const u = ALL_COOKIES.find((c) => c.name === "u" && c.value); + COOKIES = { + xq_a_token: xq ? xq.value : "", + u: u ? u.value : "", + }; + setVal($xq, COOKIES.xq_a_token, !COOKIES.xq_a_token); + setVal($u, COOKIES.u, !COOKIES.u); + $combined.value = combinedString() || "未读取"; + + // 扩展自身信息 + let extInfo = null; + try { + extInfo = { + id: chrome.runtime.id, + manifest: chrome.runtime.getManifest(), + }; + } catch (e) { + extInfo = { id: "(no chrome.runtime)", err: e.message }; + } + + renderDebug(queries, errors, extInfo); + + // 状态判断 + if (ALL_COOKIES.length === 0) { + setMsg( + "❌ 一个 xueqiu.com cookie 都没拿到。\n" + + "请展开下方「调试信息」看具体哪些查询返回 0 个。" + + "如果只有 page @ 拿到、其它都 0:可能是分桶 cookie,需要等 Chrome 改进。\n" + + "如果全部都 0:可能是 Chrome profile 不一致 / 扩展权限没生效。", + "err", + ); + } else if (!COOKIES.xq_a_token) { + const names = ALL_COOKIES.map((c) => c.name).join(", "); + setMsg( + `❌ 拿到了 ${ALL_COOKIES.length} 个 cookie 但没有 xq_a_token。\n字段:${names}\n` + + "xq_a_token 是 HttpOnly。如果只能从 page 读到 u(无 xq_a_token):\n" + + "请展开调试区看「get xq_a_token @ ...」的查询结果。", + "err", + ); + } else if (!COOKIES.u) { + setMsg("❌ 缺 u 字段 — Cookie 不完整", "err"); + } else { + setMsg(`✅ 已读取 xq_a_token + u(共 ${ALL_COOKIES.length} 个 xueqiu.com cookie)`, "ok"); + } +} + +function renderDebug(queries, errors, extInfo) { + const lines = []; + if (extInfo) { + lines.push(`[扩展] id=${extInfo.id}`); + if (extInfo.manifest) { + const perms = (extInfo.manifest.permissions || []).join(", "); + const hosts = (extInfo.manifest.host_permissions || []).join(", "); + lines.push(`[扩展] permissions=${perms}`); + lines.push(`[扩展] host_permissions=${hosts}`); + lines.push(`[扩展] manifest_version=${extInfo.manifest.manifest_version}`); + } + } + for (const q of queries) { + lines.push(`[查询] ${q.mode} → ${q.n} 个`); + } + if (errors && errors.length) { + lines.push("[错误](仅 show failure)"); + for (const e of errors) lines.push(" - " + e); + } + if (ALL_COOKIES.length) { + lines.push("[合并后] 列表:"); + for (const c of ALL_COOKIES) { + const v = (c.value || "").slice(0, 40); + lines.push(` ${c.domain} ${c.path} ${c.name}=${v}${c.value && c.value.length > 40 ? "…" : ""}`); + } + } + $debug.textContent = lines.join("\n"); +} + +async function copyText(text, label) { + if (!text) { + setMsg("❌ 没有可复制的内容(Cookie 缺失)", "err"); + return; + } + try { + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(text); + } else { + const ta = document.createElement("textarea"); + ta.value = text; + ta.style.position = "fixed"; + ta.style.opacity = "0"; + document.body.appendChild(ta); + ta.select(); + document.execCommand("copy"); + document.body.removeChild(ta); + } + setMsg(`✅ 已复制:${label}`, "ok"); + } catch (e) { + setMsg(`❌ 复制失败:${e.message || e}`, "err"); + } +} + +document.getElementById("copy-combined").addEventListener("click", () => { + copyText(combinedString(), "xq_a_token=...;u=..."); +}); +document.getElementById("copy-env").addEventListener("click", () => { + copyText(`XUEQIU_TOKEN=${combinedString()}`, "XUEQIU_TOKEN=..."); +}); +document.getElementById("copy-xq").addEventListener("click", () => { + copyText(COOKIES.xq_a_token, "xq_a_token (单独值)"); +}); +document.getElementById("reload-btn").addEventListener("click", () => { + readCookies(); +}); + +// 手动粘贴兜底:用户从 DevTools Network tab 复制 Cookie header 粘到这里 +$useManual.addEventListener("click", () => { + const text = $manual.value.trim(); + if (!text) { + setMsg("❌ 粘贴框为空", "err"); + return; + } + const pairs = tryParseCookieString(text); + const xq = pairs["xq_a_token"] || ""; + const u = pairs["u"] || ""; + if (!xq || !u) { + setMsg(`❌ 粘贴内容里找不到 xq_a_token 和/或 u(找到字段:${Object.keys(pairs).join(", ")})`, "err"); + return; + } + COOKIES = { xq_a_token: xq, u }; + setVal($xq, xq, false); + setVal($u, u, false); + $combined.value = combinedString(); + setMsg("✅ 已从手动粘贴提取(仅当前会话有效)", "ok"); +}); + +// 弹窗打开时立刻读一次 +readCookies();