Skip to content

Instantly share code, notes, and snippets.

@Yukaii
Last active February 21, 2026 21:00
Show Gist options
  • Select an option

  • Save Yukaii/00c6c22b34e2451c8e7884b20de88276 to your computer and use it in GitHub Desktop.

Select an option

Save Yukaii/00c6c22b34e2451c8e7884b20de88276 to your computer and use it in GitHub Desktop.
Pinterest Following Boards userscript

Pinterest Following Boards Userscript

A userscript that adds a lightweight right-side toggle handle to open a custom "Following Boards" panel on Pinterest.

Features

  • Opens followed boards in a custom panel
  • Multi-image board previews (modal-like collage)
  • Owner info with avatar/initial
  • 1-day local cache with manual refresh
  • Avoids fragile DOM injection into Pinterest's React sidebar tree

Install

Install from:

Source

// ==UserScript==
// @name Pinterest Sidebar: Following Boards Tab
// @namespace https://github.com/yukai
// @version 0.1.1
// @description Adds a "Following boards" tab to Pinterest home sidebar.
// @match https://www.pinterest.com/*
// @downloadURL https://gistcdn.githack.com/Yukaii/00c6c22b34e2451c8e7884b20de88276/raw/b43da434b35645e859846273e507844e5fed7910/pinterest-following-boards.user.js
// @updateURL https://gist.githack.com/Yukaii/00c6c22b34e2451c8e7884b20de88276/raw/pinterest-following-boards.user.js
// @grant none
// @run-at document-start
// ==/UserScript==
(function () {
'use strict';
const CUSTOM_FEED_TAB_ATTR = 'data-following-boards-feed-tab';
const CUSTOM_NAV_TAB_ATTR = 'data-following-boards-nav-tab';
const CUSTOM_BOUND_ATTR = 'data-following-boards-bound';
const CUSTOM_TAB_ID = 'codex-following-boards-tab';
const CUSTOM_FLOAT_TAB_ID = 'codex-following-boards-float-tab';
const CUSTOM_LABEL = 'Following boards';
const TOGGLE_ID = 'codex-following-boards-toggle';
const PENDING_PANEL_KEY = 'codex_following_boards_panel_pending';
const PANEL_ID = 'codex-following-boards-panel';
const PANEL_STYLE_ID = 'codex-following-boards-panel-style';
const CACHE_KEY_PREFIX = 'codex_following_boards_cache_v1';
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const AVATAR_CACHE_KEY = 'codex_following_boards_avatar_v1';
let panelRequestSeq = 0;
function parseUsernameFromPath(pathname) {
const match = pathname.match(/^\/([^/?#]+)\/$/);
if (!match) return null;
const username = match[1];
const blocked = new Set(['', 'homefeed', 'pin', 'search', 'ideas', 'settings', '_saved']);
return blocked.has(username) ? null : username;
}
function getUsername() {
const byBoardsTabHref = document
.querySelector('a[data-test-id="boards-tab"][href]')
?.getAttribute('href');
const fromBoardsTab = byBoardsTabHref ? parseUsernameFromPath(byBoardsTabHref) : null;
if (fromBoardsTab) return fromBoardsTab;
const byProfileHref = document
.querySelector('a[aria-label][href^="/"][href$="/"]:not([href="/"])')
?.getAttribute('href');
const fromProfile = byProfileHref ? parseUsernameFromPath(byProfileHref) : null;
if (fromProfile) return fromProfile;
const pathMatch = location.pathname.match(/^\/([^/?#]+)\/following\/(?:boards|people|creators)\/?$/);
if (pathMatch) return pathMatch[1];
return null;
}
function getProfileHref() {
const username = getUsername();
if (!username) return null;
return `/${username}/`;
}
function usernameFromHref(href) {
if (!href) return null;
try {
const path = new URL(href, location.origin).pathname;
const m = path.match(/^\/([^/?#]+)\/$/);
if (!m) return null;
const candidate = m[1];
const blocked = new Set(['', 'homefeed', 'pin', 'search', 'ideas', 'settings', '_saved']);
return blocked.has(candidate) ? null : candidate;
} catch {
return null;
}
}
function normalizeText(text) {
return (text || '').replace(/\s+/g, ' ').trim().toLowerCase();
}
function compactText(text) {
return (text || '').replace(/\s+/g, ' ').trim();
}
function getCacheKey() {
const username = getUsername();
return username ? `${CACHE_KEY_PREFIX}:${username}` : CACHE_KEY_PREFIX;
}
function readAvatarCache() {
try {
const raw = localStorage.getItem(AVATAR_CACHE_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw);
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
}
function writeAvatarCache(cache) {
try {
localStorage.setItem(AVATAR_CACHE_KEY, JSON.stringify(cache));
} catch {
// Ignore storage failures.
}
}
function applyAvatarCacheToBoards(boards) {
const avatarCache = readAvatarCache();
return boards.map((board) => ({
...board,
ownerAvatar: board.ownerUsername ? (avatarCache[board.ownerUsername] || '') : '',
}));
}
function readBoardCache() {
try {
const raw = localStorage.getItem(getCacheKey());
if (!raw) return null;
const parsed = JSON.parse(raw);
if (!parsed || !Array.isArray(parsed.boards) || typeof parsed.ts !== 'number') return null;
if (parsed.boards.length === 0) return null;
return parsed;
} catch {
return null;
}
}
function writeBoardCache(boards) {
if (!Array.isArray(boards) || boards.length === 0) return;
try {
localStorage.setItem(getCacheKey(), JSON.stringify({ ts: Date.now(), boards }));
} catch {
// Ignore storage failures.
}
}
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitFor(predicate, timeoutMs) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const value = predicate();
if (value) return value;
await wait(80);
}
return null;
}
function findFollowingButton() {
const candidates = Array.from(document.querySelectorAll('[role="button"], button, a'));
for (const el of candidates) {
if (el.matches(`#${CUSTOM_FLOAT_TAB_ID}, #${CUSTOM_TAB_ID}, [${CUSTOM_NAV_TAB_ATTR}="1"], [${CUSTOM_FEED_TAB_ATTR}="1"]`)) {
continue;
}
if (el.closest('[role="dialog"]')) continue;
const text = normalizeText(el.textContent);
const aria = normalizeText(el.getAttribute('aria-label'));
if (el.getAttribute('role') === 'button' || el.tagName === 'BUTTON' || el.tagName === 'DIV') {
if (/追蹤中|following/.test(`${text} ${aria}`)) return el;
}
}
return null;
}
function findBoardsTabInDialog(dialog) {
if (!dialog) return null;
const candidates = Array.from(dialog.querySelectorAll('[role="tab"], button, a'));
for (const el of candidates) {
const text = normalizeText(el.textContent);
const aria = normalizeText(el.getAttribute('aria-label'));
if (/圖版|boards?/.test(`${text} ${aria}`)) return el;
}
return null;
}
function ensurePanelStyles() {
if (document.getElementById(PANEL_STYLE_ID)) return;
const style = document.createElement('style');
style.id = PANEL_STYLE_ID;
style.textContent = `
#${PANEL_ID}{position:fixed;top:16px;right:16px;z-index:2147483646;width:min(420px,calc(100vw - 32px));max-height:calc(100vh - 32px);background:#fff;border:1px solid #ddd;border-radius:14px;box-shadow:0 10px 35px rgba(0,0,0,.2);display:flex;flex-direction:column;overflow:hidden}
#${PANEL_ID}[data-hidden="1"]{display:none}
#${PANEL_ID} [data-role="head"]{display:flex;align-items:center;justify-content:space-between;padding:12px 14px;border-bottom:1px solid #eee}
#${PANEL_ID} [data-role="title"]{font:700 14px/1.2 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;color:#111}
#${PANEL_ID} [data-role="close"],#${PANEL_ID} [data-role="refresh"]{border:1px solid #d9d9d9;background:#fff;border-radius:8px;padding:6px 8px;font:600 12px/1 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;cursor:pointer}
#${PANEL_ID} [data-role="tools"]{display:flex;gap:6px}
#${PANEL_ID} [data-role="body"]{overflow:auto;padding:10px 12px}
#${PANEL_ID} [data-role="status"]{font:500 12px/1.5 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;color:#555;padding:6px 2px}
#${PANEL_ID} [data-role="list"]{display:flex;flex-direction:column;gap:8px}
#${PANEL_ID} [data-role="item"]{display:grid;grid-template-columns:72px 1fr;gap:10px;align-items:center;padding:8px;border:1px solid #e8e8e8;border-radius:12px;color:#111;text-decoration:none;background:#fff}
#${PANEL_ID} [data-role="item"]:hover{background:#f8f8f8}
#${PANEL_ID} [data-role="name"]{font:600 13px/1.3 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}
#${PANEL_ID} [data-role="meta"]{font:500 12px/1.35 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;color:#666;margin-top:4px}
#${PANEL_ID} [data-role="thumb"]{width:72px;height:72px;border-radius:10px;overflow:hidden;background:#f2f2f2}
#${PANEL_ID} [data-role="thumb"] img{width:100%;height:100%;object-fit:cover;display:block}
#${PANEL_ID} [data-role="thumb-grid"]{width:72px;height:72px;display:grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;gap:2px}
#${PANEL_ID} [data-role="owner-row"]{display:flex;align-items:center;gap:6px;margin-top:4px}
#${PANEL_ID} [data-role="avatar"]{width:18px;height:18px;border-radius:9px;background:#e8e8e8;color:#666;display:flex;align-items:center;justify-content:center;font:700 10px/1 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;overflow:hidden}
#${PANEL_ID} [data-role="avatar"] img{width:100%;height:100%;object-fit:cover;display:block}
#${PANEL_ID} [data-role="owner"]{font:600 11px/1.2 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;color:#444}
#${TOGGLE_ID}{position:fixed;right:0;top:50%;transform:translateY(-50%);z-index:2147483644;width:28px;height:94px;border:1px solid #d7d7d7;border-right:none;border-radius:10px 0 0 10px;background:#fff;box-shadow:0 4px 14px rgba(0,0,0,.12);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;cursor:pointer;padding:0}
#${TOGGLE_ID}:hover{background:#f7f7f7}
#${TOGGLE_ID} [data-role="chevron"]{width:12px;height:12px;display:block;color:#666;transform:rotate(180deg)}
#${TOGGLE_ID}[aria-expanded="true"] [data-role="chevron"]{transform:rotate(0deg)}
#${TOGGLE_ID} [data-role="label"]{font:600 9px/1 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;color:#333;writing-mode:vertical-rl;text-orientation:mixed;letter-spacing:.04em}
`;
document.head.appendChild(style);
}
function getOrCreatePanel() {
let panel = document.getElementById(PANEL_ID);
if (panel) return panel;
ensurePanelStyles();
panel = document.createElement('aside');
panel.id = PANEL_ID;
panel.setAttribute('data-hidden', '1');
panel.innerHTML = `
<div data-role="head">
<div data-role="title">${CUSTOM_LABEL}</div>
<div data-role="tools">
<button type="button" data-role="refresh">Refresh</button>
<button type="button" data-role="close">Close</button>
</div>
</div>
<div data-role="body">
<div data-role="status">Loading…</div>
<div data-role="list"></div>
</div>
`;
document.body.appendChild(panel);
panel.querySelector('[data-role="close"]')?.addEventListener('click', () => {
panelRequestSeq += 1; // cancel any in-flight load so it won't reopen panel
panel?.setAttribute('data-hidden', '1');
setToggleExpanded(false);
});
panel.querySelector('[data-role="refresh"]')?.addEventListener('click', () => {
void loadBoardsIntoPanel({ forceRefresh: true });
});
return panel;
}
function setToggleExpanded(expanded) {
const button = document.getElementById(TOGGLE_ID);
if (!(button instanceof HTMLButtonElement)) return;
button.setAttribute('aria-expanded', expanded ? 'true' : 'false');
}
function setPanelStatus(message) {
const panel = getOrCreatePanel();
const status = panel.querySelector('[data-role="status"]');
if (status) status.textContent = message;
}
function renderBoardsInPanel(boards) {
const panel = getOrCreatePanel();
panel.setAttribute('data-hidden', '0');
const list = panel.querySelector('[data-role="list"]');
if (!(list instanceof HTMLDivElement)) return;
list.innerHTML = '';
for (const board of boards) {
const item = document.createElement('a');
item.setAttribute('data-role', 'item');
item.href = board.href;
item.target = '_blank';
item.rel = 'noopener noreferrer';
item.innerHTML = `<div data-role="thumb"></div><div><div data-role="name"></div><div data-role="meta"></div><div data-role="owner-row"><div data-role="avatar"></div><div data-role="owner"></div></div></div>`;
const thumb = item.querySelector('[data-role="thumb"]');
const name = item.querySelector('[data-role="name"]');
const meta = item.querySelector('[data-role="meta"]');
const owner = item.querySelector('[data-role="owner"]');
const avatar = item.querySelector('[data-role="avatar"]');
const previewImages = Array.isArray(board.images) ? board.images.filter(Boolean).slice(0, 4) : [];
if (thumb && previewImages.length > 1) {
const grid = document.createElement('div');
grid.setAttribute('data-role', 'thumb-grid');
for (const src of previewImages) {
const img = document.createElement('img');
img.src = src;
img.loading = 'lazy';
img.alt = '';
grid.appendChild(img);
}
thumb.appendChild(grid);
} else if (thumb && (board.image || previewImages[0])) {
const img = document.createElement('img');
img.src = board.image || previewImages[0];
img.loading = 'lazy';
img.alt = '';
thumb.appendChild(img);
}
if (name) name.textContent = board.name || board.href;
if (meta) meta.textContent = board.meta || board.href;
const ownerName = board.ownerName || board.ownerUsername || '';
if (owner) owner.textContent = ownerName ? `@${ownerName}` : '';
if (avatar) {
if (board.ownerAvatar) {
const img = document.createElement('img');
img.src = board.ownerAvatar;
img.alt = '';
avatar.appendChild(img);
} else {
const initial = (ownerName || board.name || '?').trim().charAt(0).toUpperCase() || '?';
avatar.textContent = initial;
}
}
list.appendChild(item);
}
setPanelStatus(boards.length ? `${boards.length} boards` : 'No boards found');
}
function extractBoardsFromDialog(dialog) {
function imageUrlFrom(img) {
if (!img) return '';
const direct = img.getAttribute('src');
if (direct) return direct;
const srcset = img.getAttribute('srcset') || '';
const first = srcset.split(',')[0]?.trim()?.split(' ')[0] || '';
return first;
}
const map = new Map();
const links = Array.from(dialog.querySelectorAll('a[href]'));
for (const link of links) {
const href = link.getAttribute('href') || '';
if (!/^\/[^/]+\/[^/]+\/?$/.test(href)) continue;
const raw = compactText(link.textContent);
if (!raw) continue;
const parts = raw.split(',').map((x) => x.trim()).filter(Boolean);
const name = parts[0] || href;
const meta = parts.slice(1).join(' · ');
const ownerUsername = href.replace(/^\/|\/$/g, '').split('/')[0] || '';
const ownerName = ownerUsername;
const img = imageUrlFrom(link.querySelector('img')) ||
imageUrlFrom(link.parentElement?.querySelector('img')) ||
imageUrlFrom(link.closest('div')?.querySelector('img')) ||
'';
const imageCandidates = Array.from(
(link.closest('li,article,div') || link.parentElement || dialog).querySelectorAll('img')
)
.map(imageUrlFrom)
.filter(Boolean);
const uniqueImages = [...new Set(imageCandidates)].slice(0, 4);
if (!map.has(href)) map.set(href, {
href,
name,
meta,
ownerUsername,
ownerName,
image: img || uniqueImages[0] || '',
images: uniqueImages.length ? uniqueImages : (img ? [img] : []),
});
}
return Array.from(map.values());
}
function closeDialog(dialog) {
if (!dialog) return;
const closeButton = Array.from(dialog.querySelectorAll('button,[role="button"]')).find((el) => {
const aria = normalizeText(el.getAttribute('aria-label'));
const text = normalizeText(el.textContent);
return /close|關閉|关闭|닫기|fermer|cerrar|chiudi/.test(`${aria} ${text}`);
});
if (closeButton) closeButton.click();
else {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
}
}
async function fetchBoardsFromModal() {
const followingBtn = await waitFor(findFollowingButton, 4000);
if (!followingBtn) return [];
followingBtn.click();
const dialog = await waitFor(
() => document.querySelector('[role="dialog"][aria-label], [role="dialog"], div[aria-modal="true"]'),
4000
);
if (!dialog) return [];
const boardsTab = await waitFor(() => findBoardsTabInDialog(dialog), 3000);
if (!boardsTab) return [];
if (boardsTab.getAttribute('aria-selected') !== 'true') {
boardsTab.click();
await wait(300);
}
const boards = await waitFor(() => {
const list = extractBoardsFromDialog(dialog);
return list.length > 0 ? list : null;
}, 4000) || [];
closeDialog(dialog);
return boards;
}
async function enrichBoardsWithAvatarsProgressive(boards, options = {}) {
const onUpdate = options.onUpdate || (() => {});
const isCancelled = options.isCancelled || (() => false);
const concurrency = Math.max(1, Math.min(6, options.concurrency || 4));
const avatarCache = readAvatarCache();
const owners = [...new Set(boards.map((b) => b.ownerUsername).filter(Boolean))];
const toFetch = owners.filter((owner) => !avatarCache[owner]);
if (toFetch.length === 0) return applyAvatarCacheToBoards(boards);
let index = 0;
const workers = Array.from({ length: Math.min(concurrency, toFetch.length) }, async () => {
while (index < toFetch.length && !isCancelled()) {
const owner = toFetch[index++];
try {
const resp = await fetch(`/${owner}/`);
const html = await resp.text();
const metaMatch = html.match(/property=\"og:image\" content=\"([^\"]+)\"/i);
const fallbackMatch = html.match(/\"image_(?:small|medium|large)_url\":\"(https:[^\"]+)\"/);
const candidate = metaMatch?.[1] || fallbackMatch?.[1] || '';
const avatar = /default_(30|60|75|120|280)\.png/.test(candidate) ? '' : candidate;
if (avatar) {
avatarCache[owner] = avatar;
if (!isCancelled()) onUpdate(applyAvatarCacheToBoards(boards), false);
}
} catch {
// Best effort.
}
}
});
await Promise.all(workers);
writeAvatarCache(avatarCache);
const finalBoards = applyAvatarCacheToBoards(boards);
if (!isCancelled()) onUpdate(finalBoards, true);
return finalBoards;
}
async function loadBoardsIntoPanel(options = {}) {
const requestSeq = ++panelRequestSeq;
const panel = getOrCreatePanel();
panel.setAttribute('data-hidden', '0');
setToggleExpanded(true);
const isCancelled = () => requestSeq !== panelRequestSeq;
const now = Date.now();
const cache = readBoardCache();
const hasFreshCache = !!(cache && now - cache.ts < CACHE_TTL_MS);
if (cache && cache.boards?.length) {
if (isCancelled()) return;
renderBoardsInPanel(cache.boards);
if (!options.forceRefresh && hasFreshCache) return;
setPanelStatus(`${cache.boards.length} boards · refreshing…`);
} else {
setPanelStatus('Loading followed boards…');
}
const boards = await fetchBoardsFromModal();
if (isCancelled()) return;
const baseBoards = applyAvatarCacheToBoards(boards);
writeBoardCache(baseBoards);
renderBoardsInPanel(baseBoards);
if (baseBoards.length === 0) return;
setPanelStatus(`${baseBoards.length} boards · updating avatars…`);
void enrichBoardsWithAvatarsProgressive(baseBoards, {
concurrency: 4,
isCancelled,
onUpdate(nextBoards, done) {
if (isCancelled()) return;
renderBoardsInPanel(nextBoards);
writeBoardCache(nextBoards);
setPanelStatus(done ? `${nextBoards.length} boards` : `${nextBoards.length} boards · updating avatars…`);
},
});
}
async function handleFollowingBoardsClick(event, clickedAnchor = null) {
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
event.preventDefault();
event.stopPropagation();
if (typeof event.stopImmediatePropagation === 'function') event.stopImmediatePropagation();
const username = getUsername() || usernameFromHref(clickedAnchor?.getAttribute('href') || '');
if (!username) return;
const profileHref = `/${username}/`;
const onProfile = location.pathname === profileHref || location.pathname === `/${username}/_profile/`;
if (!onProfile) {
sessionStorage.setItem(PENDING_PANEL_KEY, username);
location.assign(profileHref);
return;
}
const panel = document.getElementById(PANEL_ID);
if (panel && panel.getAttribute('data-hidden') !== '1') {
// Keep behavior single-purpose: tab click always opens/shows panel.
panel.setAttribute('data-hidden', '0');
setToggleExpanded(true);
return;
}
await loadBoardsIntoPanel({ forceRefresh: false });
}
function ensureSidebarToggleButton() {
ensurePanelStyles();
let button = document.getElementById(TOGGLE_ID);
if (!(button instanceof HTMLButtonElement)) {
button = document.createElement('button');
button.type = 'button';
button.id = TOGGLE_ID;
button.setAttribute('aria-label', CUSTOM_LABEL);
button.setAttribute('aria-expanded', 'false');
button.innerHTML = `
<svg data-role="chevron" viewBox="0 0 24 24" aria-hidden="true">
<path fill="currentColor" d="M8.7 5.3a1 1 0 0 1 1.4 0l5.6 5.6a1.5 1.5 0 0 1 0 2.2l-5.6 5.6a1 1 0 1 1-1.4-1.4l5-5-5-5a1 1 0 0 1 0-1.4z"></path>
</svg>
<span data-role="label">Boards</span>
`;
button.addEventListener('click', (event) => {
void handleFollowingBoardsClick(event);
});
document.body.appendChild(button);
}
}
async function maybeResumePendingModalFlow() {
const pendingUsername = sessionStorage.getItem(PENDING_PANEL_KEY);
if (!pendingUsername) return;
const currentUsername = getUsername();
const onProfile = currentUsername && (
location.pathname === `/${currentUsername}/` ||
location.pathname === `/${currentUsername}/_profile/`
);
if (!onProfile) return;
sessionStorage.removeItem(PENDING_PANEL_KEY);
await loadBoardsIntoPanel({ forceRefresh: true });
}
function ensureFollowingBoardsTab() {
ensureSidebarToggleButton();
}
let rafQueued = false;
function scheduleEnsureTab() {
if (rafQueued) return;
rafQueued = true;
requestAnimationFrame(() => {
rafQueued = false;
ensureFollowingBoardsTab();
});
}
const observer = new MutationObserver(scheduleEnsureTab);
function setup() {
observer.observe(document.documentElement, {
childList: true,
subtree: true,
});
const origPushState = history.pushState;
const origReplaceState = history.replaceState;
history.pushState = function (...args) {
const result = origPushState.apply(this, args);
scheduleEnsureTab();
return result;
};
history.replaceState = function (...args) {
const result = origReplaceState.apply(this, args);
scheduleEnsureTab();
return result;
};
window.addEventListener('popstate', scheduleEnsureTab, true);
window.addEventListener('hashchange', scheduleEnsureTab, true);
window.addEventListener('scroll', scheduleEnsureTab, true);
window.addEventListener('resize', scheduleEnsureTab, true);
document.addEventListener('readystatechange', scheduleEnsureTab, true);
scheduleEnsureTab();
maybeResumePendingModalFlow();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', setup, { once: true });
} else {
setup();
}
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment