Weibo Following Exporter (微博关注导出)

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) =>
				({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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."
	);
})();