您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Harvest following cards, export HTML with optional embedded avatar base64 or no avatar, live search
// ==UserScript== // @name Weibo Following Exporter (微博关注导出) // @namespace weibo-following-exporter // @author Roy // @version 1.2.4 // @description Harvest following cards, export HTML with optional embedded avatar base64 or no avatar, live search // @match https://weibo.com/u/page/follow/* // @match https://www.weibo.com/u/page/follow/* // @icon https://weibo.com/favicon.ico // @grant GM_xmlhttpRequest // @grant GM_addStyle // @connect sinaimg.cn // @connect *.sinaimg.cn // @license MIT // ==/UserScript== (function () { "use strict"; // ========= CONFIG ========= const TARGET_SELECTOR = "a.ALink_none_1w6rm.UserFeedCard_left_2XXOA"; const SCROLL_STEP_RATIO = 0.9; const TICK_MS = 400; const STALL_TICKS_TO_STOP = 6; const MAX_TICKS = 600; const MAX_CONCURRENCY = 6; // ========= UTILS ========= const esc = (s = "") => s.replace( /[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]) ); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const fmtDate = () => new Date().toISOString().slice(0, 10); const guessMimeFromUrl = (url) => { const u = (url || "").split("?")[0].toLowerCase(); if (u.endsWith(".jpg") || u.endsWith(".jpeg")) return "image/jpeg"; if (u.endsWith(".png")) return "image/png"; if (u.endsWith(".webp")) return "image/webp"; if (u.endsWith(".gif")) return "image/gif"; return "application/octet-stream"; }; const arrayBufferToBase64 = (buffer) => { let binary = ""; const bytes = new Uint8Array(buffer); for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]); return btoa(binary); }; function fetchAsBase64(url) { if (!url) return Promise.resolve({ dataUrl: "", mime: "", ok: false }); return new Promise((resolve) => { GM_xmlhttpRequest({ method: "GET", url, headers: { Referer: "https://weibo.com/", "User-Agent": "Mozilla/5.0", }, responseType: "arraybuffer", onload: (res) => { try { const buf = res.response; const ctHeader = (res.responseHeaders || "") .split(/\r?\n/) .find((h) => /^content-type:/i.test(h)); const mime = ctHeader ? ctHeader.split(":")[1].trim() : guessMimeFromUrl(url); const b64 = arrayBufferToBase64(buf); resolve({ dataUrl: `data:${mime};base64,${b64}`, mime, ok: true, }); } catch { resolve({ dataUrl: "", mime: "", ok: false }); } }, onerror: () => resolve({ dataUrl: "", mime: "", ok: false }), ontimeout: () => resolve({ dataUrl: "", mime: "", ok: false }), }); }); } function concurrencyPool(tasks, limit) { const results = new Array(tasks.length); let i = 0, active = 0; return new Promise((resolve) => { function next() { if (i >= tasks.length && active === 0) return resolve(results); while (active < limit && i < tasks.length) { const cur = i++; active++; tasks[cur]() .then((r) => { results[cur] = r; }) .catch(() => { results[cur] = null; }) .finally(() => { active--; next(); }); } } next(); }); } // ========= HUD ========= GM_addStyle(` .wfe-hud { position: fixed; right: 10px; bottom: 10px; z-index: 999999; background: rgba(0,0,0,.74); color: #fff; font: 12px/1.4 ui-sans-serif, system-ui; padding: 6px 8px; border-radius: 10px; box-shadow: 0 2px 8px rgba(0,0,0,.4); display: flex; gap: 8px; align-items: center; } .wfe-btn { cursor: pointer; background: #3b82f6; border: none; color: #fff; padding: 0 10px; border-radius: 6px; font-weight: 700; font-size: 12px; line-height: 1; min-width: 0; height: 26px; } .wfe-btn.alt { background: #10b981; } .wfe-btn[disabled] { opacity: .6; cursor: not-allowed; } .wfe-btn:hover:not([disabled]) { filter: brightness(.95); } .wfe-status { flex: 1 1 auto; min-width: 0; opacity: .9; white-space: normal; word-break: break-word; } .wfe-input { width: 96px; height: 26px; border-radius: 6px; border: 1px solid #9ca3af; padding: 0 8px; font-size: 12px; background: #111827; color: #fff; } .wfe-label { font-size: 12px; opacity: .9; } `); const hud = document.createElement("div"); hud.className = "wfe-hud"; const statusSpan = document.createElement("span"); statusSpan.className = "wfe-status"; const label = document.createElement("span"); label.className = "wfe-label"; label.textContent = "Limit:"; const limitInput = document.createElement("input"); limitInput.className = "wfe-input"; limitInput.type = "number"; limitInput.min = "1"; limitInput.placeholder = "unlimited"; const btnWithout = document.createElement("button"); // left btnWithout.className = "wfe-btn"; btnWithout.textContent = "Export (no avatars)"; const btnWith = document.createElement("button"); // right btnWith.className = "wfe-btn alt"; btnWith.textContent = "Export (with avatars)"; hud.append(statusSpan, label, limitInput, btnWithout, btnWith); document.body.appendChild(hud); const setHUD = (msg) => { statusSpan.textContent = msg; }; let running = false; function setProcessing(isProcessing, mode) { running = isProcessing; btnWithout.disabled = isProcessing; btnWith.disabled = isProcessing; if (isProcessing) { if (mode === "with") { btnWith.textContent = "Processing…"; btnWithout.textContent = "Export (no avatars)"; } else { btnWithout.textContent = "Processing…"; btnWith.textContent = "Export (with avatars)"; } } else { btnWithout.textContent = "Export (no avatars)"; btnWith.textContent = "Export (with avatars)"; } } // ========= HARVEST ========= const seen = new Map(); // key = userLink function harvestOnce(limit) { let reached = false; const nodes = document.querySelectorAll(TARGET_SELECTOR); for (const el of nodes) { if (limit && seen.size >= limit) { reached = true; break; } const href = el.getAttribute("href") || ""; const userId = href.startsWith("/u/") ? href.slice(3) : href.replace(/^\//, ""); const userLink = "https://weibo.com/" + (href.startsWith("/u/") ? "u/" + userId : userId); if (seen.has(userLink)) continue; const displayName = el.querySelector("span[usercard]")?.innerText.trim() || ""; const avatar = el.querySelector("img.woo-avatar-img")?.getAttribute("src") || ""; const descs = [...el.querySelectorAll(".UserFeedCard_clb_3cXsW")] .map((d) => d.innerText.trim()) .filter(Boolean); seen.set(userLink, { userLink, displayName, avatarUrl: avatar, avatarDataUrl: "", // used in "with avatars" mode des1: descs[0] || "", des2: descs[1] || "", }); if (limit && seen.size >= limit) { reached = true; break; } } return reached; } async function autoScrollAndHarvest(limit) { let ticks = 0, stall = 0, lastCount = 0; setHUD(`Scanning…${limit ? ` (limit ${limit})` : ""}`); while (ticks < MAX_TICKS) { ticks++; const hitLimit = harvestOnce(limit); const count = seen.size; if (count > lastCount) { stall = 0; lastCount = count; } else { stall++; } setHUD( `Collected: ${count}${ limit ? `/` + limit : "" } | tick: ${ticks} | no increase: ${stall}/${STALL_TICKS_TO_STOP}` ); if (hitLimit) break; const scroller = document.scrollingElement || document.documentElement || document.body; const atBottom = Math.ceil(scroller.scrollTop + window.innerHeight + 2) >= scroller.scrollHeight; if (stall >= STALL_TICKS_TO_STOP && atBottom) break; scroller.scrollTop = Math.min( scroller.scrollTop + Math.floor(window.innerHeight * SCROLL_STEP_RATIO), scroller.scrollHeight ); await sleep(TICK_MS); } setHUD( `Scan done. Total: ${seen.size}${limit ? ` (limit ${limit})` : ""}.` ); } async function processAvatarsBase64(items) { setHUD("Fetching avatars (base64)..."); const tasks = items.map((it, idx) => async () => { setHUD(`Fetching avatar ${idx + 1}/${items.length}`); if (!it.avatarUrl) return it; const res = await fetchAsBase64(it.avatarUrl); if (res.ok) it.avatarDataUrl = res.dataUrl; return it; }); await concurrencyPool(tasks, MAX_CONCURRENCY); } // ========= EXPORT RENDER ========= function renderHTML(items, withAvatars) { const now = fmtDate(); const cardsHTML = items .map((it) => { const searchable = [ it.displayName, it.des1, it.des2, it.userLink, ] .filter(Boolean) .join(" ") .toLowerCase(); const avatarBlock = withAvatars && it.avatarDataUrl ? ` <a href="${esc( it.avatarUrl )}" target="_blank" rel="noopener noreferrer" title="Open avatar"> <img class="avatar" src="${esc(it.avatarDataUrl)}" alt="${esc( it.displayName )}" loading="lazy"> </a> ` : `<!-- no avatar image -->`; const avatarUrlLine = withAvatars ? "" : `<div class="link">Avatar: <a href="${esc( it.avatarUrl )}" target="_blank" rel="noopener noreferrer">${esc( it.avatarUrl )}</a></div>`; return ` <div class="card" data-text="${esc(searchable)}"> <div class="row"> ${avatarBlock} <div class="meta"> <a class="name" href="${ it.userLink }" target="_blank" rel="noopener noreferrer"><strong>${esc( it.displayName )}</strong></a> <div class="link">${esc(it.userLink)}</div> ${avatarUrlLine} </div> </div> ${it.des1 ? `<p class="desc">${esc(it.des1)}</p>` : ""} ${it.des2 ? `<p class="desc">${esc(it.des2)}</p>` : ""} </div>`; }) .join("\n"); const inlineScript = ` <script> (function(){ const input = document.getElementById('wfe-search'); const countEl = document.getElementById('wfe-count'); const cards = Array.from(document.querySelectorAll('.card')); function update(){ const q = (input.value || '').toLowerCase(); let visible = 0; for (const el of cards){ const txt = el.getAttribute('data-text') || ''; const ok = !q || txt.includes(q); el.style.display = ok ? '' : 'none'; if (ok) visible++; } countEl.textContent = visible.toString(); } input.addEventListener('input', update); update(); const THEME_KEY = 'wfe-theme'; const btn = document.getElementById('wfe-theme-toggle'); const root = document.documentElement; function applyTheme(t){ root.setAttribute('data-theme', t); btn.textContent = (t==='dark' ? 'Light mode' : 'Dark mode'); } const saved = localStorage.getItem(THEME_KEY) || 'light'; applyTheme(saved); btn.addEventListener('click', function(){ const cur = root.getAttribute('data-theme') || 'light'; const next = cur === 'dark' ? 'light' : 'dark'; localStorage.setItem(THEME_KEY, next); applyTheme(next); }); })(); </script> `; return `<!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <title>Weibo Following Exporter (${items.length})</title> <meta name="viewport" content="width=device-width, initial-scale=1"> <style> :root { --radius: 14px; --border:#e5e7eb; --text:#111827; --ntext:#134f5c; --muted:#6b7280; --bg:#fafafa; --card-bg:#fff; --shadow: 0 1px 2px rgba(0,0,0,.04); --maxw: 900px; --headerH: 68px; } [data-theme="dark"] { --border:#374151; --text:#e5e7eb; --ntext:#26a69a; --muted:#9ca3af; --bg:#0f172a; --card-bg:#111827; --shadow: 0 1px 2px rgba(0,0,0,.4); } * { box-sizing: border-box; } body { font-family: ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Apple Color Emoji","Segoe UI Emoji"; margin: 0; color: var(--text); background: var(--bg); } .header { position: fixed; top: 0; left: 0; right: 0; height: var(--headerH); background: color-mix(in srgb, var(--bg) 85%, transparent); backdrop-filter: blur(6px); border-bottom: 1px solid var(--border); z-index: 10; } .header-inner { max-width: var(--maxw); height: 100%; margin: 0 auto; display: flex; align-items: center; gap: 12px; padding: 10px 16px; } .title { font-size: 16px; font-weight: 800; } .search { margin-left: auto; display: flex; align-items: center; gap: 8px; } .search label { color: var(--muted); font-size: 12px; } .search input { height: 36px; width: min(360px, 60vw); border: 1px solid var(--border); border-radius: 10px; padding: 0 10px; background: var(--card-bg); color: var(--text); font-size: 14px; } .container { max-width: var(--maxw); margin: 0 auto; padding: calc(var(--headerH) + 16px) 14px 24px; } .card { background: var(--card-bg); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px; box-shadow: var(--shadow); margin-bottom: 14px; } .row { display: flex; gap: 12px; align-items: center; margin-bottom: 8px; } .avatar { width: 56px; height: 56px; border-radius: 50%; object-fit: cover; border: 1px solid var(--border); flex: 0 0 auto; } .meta { min-width: 0; } .name { color: var(--ntext); text-decoration: none; font-size: 16px; } .name:hover { text-decoration: underline; } .link { color: var(--muted); font-size: 12px; word-break: break-all; } .desc { margin: 6px 0 0; line-height: 1.5; color: var(--text); } .footer { margin-top: 18px; color: var(--muted); font-size: 12px; text-align: right; } .theme-toggle { position: fixed; right: 14px; bottom: 14px; z-index: 20; background: var(--card-bg); color: var(--text); border: 1px solid var(--border); border-radius: 10px; padding: 8px 10px; cursor: pointer; box-shadow: var(--shadow); } </style> </head> <body> <div class="header"> <div class="header-inner"> <div class="title">Weibo Following Exporter (<span id="wfe-count">${ items.length }</span>)</div> <div class="search"> <label for="wfe-search">Search</label> <input id="wfe-search" type="search" placeholder="Type to filter…"> </div> </div> </div> <div class="container"> ${cardsHTML || "<p>No entries collected.</p>"} <div class="footer">Generated on ${now} — Mode: ${ withAvatars ? "with avatars (base64 embedded)" : "no avatars (URL only)" }</div> </div> <button id="wfe-theme-toggle" class="theme-toggle" type="button">Dark mode</button> ${inlineScript} </body> </html>`; } function downloadHtml(html, count, withAvatars, limitedFrom) { const now = fmtDate(); const suffix = withAvatars ? "with_avatars" : "no_avatars"; const note = limitedFrom ? `_limit${limitedFrom}` : "_all"; const blob = new Blob([html], { type: "text/html;charset=utf-8" }); const url = URL.createObjectURL(blob); const a = Object.assign(document.createElement("a"), { href: url, download: `weibo_following_list_${suffix}${note}_${count}_${now}.html`, }); document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); } async function runExport(withAvatars) { if (running) return; try { setProcessing(true, withAvatars ? "with" : "without"); seen.clear(); const raw = (limitInput.value || "").trim(); const limit = raw ? Math.max(1, parseInt(raw, 10)) : null; await autoScrollAndHarvest(limit); const items = [...seen.values()]; // already limited by harvest if (withAvatars) { await processAvatarsBase64(items); } else { setHUD("No-avatar mode (avatar URLs only)..."); } setHUD("Building HTML…"); const html = renderHTML(items, withAvatars); downloadHtml(html, items.length, withAvatars, limit || 0); setHUD( `Done. Exported ${items.length}${ limit ? ` (limit ${limit})` : "" }.` ); } catch (e) { console.error(e); setHUD("Error occurred. Check console."); } finally { setProcessing(false); } } // Buttons btnWith.onclick = () => runExport(true); btnWithout.onclick = () => runExport(false); setHUD( "Ready. Scroll a bit, set Limit if needed, then choose an export mode." ); })();