// ==UserScript== // @name 小红书增强 // @name:zh 小红书增强 // @name:zh-CN 小红书增强 // @name:en Xiaohongshu Awesome // @namespace https://github.com/LeonYew-Ley/xiaohongshu-awesome // @version 0.2.4 // @description 模块化改造小红书网页体验,功能可单独开关 // @description:zh 模块化改造小红书网页体验,功能可单独开关 // @description:zh-CN 模块化改造小红书网页体验,功能可单独开关 // @description:en Enhance the Xiaohongshu website with modular, toggleable features // @author LeonYew-Ley // @match *://www.xiaohongshu.com/* // @match *://xiaohongshu.com/* // @icon https://www.xiaohongshu.com/favicon.ico // @license MIT // @homepageURL https://github.com/LeonYew-Ley/xiaohongshu-awesome // @supportURL https://github.com/LeonYew-Ley/xiaohongshu-awesome/issues // @run-at document-idle // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // ==/UserScript== (() => { "use strict"; const STORAGE_KEY = "xhs-awesome:plugins"; const LAST_USER_KEY = "xhs-awesome:last-user-id"; function unwrap(value, depth = 0) { if (depth > 6 || value == null || typeof value !== "object") return value; if (Object.prototype.hasOwnProperty.call(value, "value")) { const inner = value.value; if (inner == null || typeof inner !== "object" || !("deps" in inner)) { return unwrap(inner, depth + 1); } } return value; } const storage = { read(key, fallback) { try { if (typeof GM_getValue === "function") return GM_getValue(key, fallback); } catch { /* fall through */ } try { const raw = localStorage.getItem(key); return raw == null ? fallback : JSON.parse(raw); } catch { return fallback; } }, write(key, value) { try { if (typeof GM_setValue === "function") { GM_setValue(key, value); return; } } catch { /* fall through */ } try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* ignore */ } }, }; const settings = { map() { const value = storage.read(STORAGE_KEY, {}); return value && typeof value === "object" ? value : {}; }, enabled(id, fallback) { const map = this.map(); return id in map ? Boolean(map[id]) : fallback; }, set(id, on) { const map = this.map(); map[id] = on; storage.write(STORAGE_KEY, map); }, }; const plugins = []; function definePlugin(plugin) { plugins.push(plugin); } function getLoggedInUserId() { const user = unwrap(window.__INITIAL_STATE__?.user); const info = unwrap(user?.userInfo); const userId = unwrap(info?.userId); if (unwrap(user?.loggedIn) && userId && !unwrap(info?.guest)) { const id = String(userId); storage.write(LAST_USER_KEY, id); return id; } return ""; } function getUserIdFromSidebar() { const links = document.querySelectorAll( '.channel-list-content a[href*="/user/profile/"]' ); for (const link of links) { const match = String(link.getAttribute("href") || "").match( /\/user\/profile\/([0-9a-f]+)/i ); if (match?.[1]) return match[1]; } return ""; } function getFavoriteUserId() { const id = getLoggedInUserId() || getUserIdFromSidebar(); if (id) storage.write(LAST_USER_KEY, id); return id || String(storage.read(LAST_USER_KEY, "") || ""); } function favoriteUrl(userId) { return `/user/profile/${userId}?tab=fav&subTab=note`; } function isFavoriteLocation(userId) { if (!userId) return false; const path = location.pathname; if (path !== `/user/profile/${userId}`) return false; const tab = new URLSearchParams(location.search).get("tab"); return tab === "fav"; } function findVueRouter() { const seeds = [ document.querySelector("#app"), document.querySelector("#global"), document.querySelector(".side-bar"), document.querySelector(".channel-list-content"), ]; for (const el of seeds) { const fromApp = el?.__vue_app__?.config?.globalProperties?.$router; if (fromApp && typeof fromApp.push === "function") return fromApp; let comp = el?.__vueParentComponent; for (let i = 0; i < 24 && comp; i++) { const router = comp.appContext?.config?.globalProperties?.$router || comp.proxy?.$router; if (router && typeof router.push === "function") return router; comp = comp.parent; } } return null; } function goFavorite(userId) { const href = favoriteUrl(userId); if (location.pathname + location.search === href) return; const router = findVueRouter(); if (router) { return router.push({ path: `/user/profile/${userId}`, query: { tab: "fav", subTab: "note" }, }); } location.assign(href); } definePlugin({ id: "sidebar-favorites", name: "侧边栏收藏", defaultEnabled: true, mount() { const ITEM_ID = "xhs-awesome-sidebar-fav"; let timer = 0; let meObserver = null; let silencingMe = false; function findList() { return document.querySelector(".channel-list-content"); } function findTemplate(list) { return [...list.querySelectorAll("li")].find((li) => { if (li.id === ITEM_ID) return false; if (li.classList.contains("user")) return false; const link = li.querySelector("a.link-wrapper"); const label = li.querySelector("span.channel"); const text = label?.textContent.trim(); if (!link || !label || li.classList.contains("bottom-channel")) return false; return text !== "我" && text !== "收藏"; }); } function asListChild(list, node) { let current = node; while (current && current.parentElement !== list) { current = current.parentElement; } return current?.parentElement === list ? current : null; } function findInsertBefore(list) { const markers = [ ...list.querySelectorAll(".login-btn"), ...list.querySelectorAll(".user.side-bar-component"), ...list.querySelectorAll(".bottom-menu-component"), ...[...list.querySelectorAll("span.channel, span.text")].filter( (el) => el.textContent.trim() === "我" ), ]; for (const marker of markers) { const child = asListChild(list, marker); if (child && !child.querySelector(`#${ITEM_ID}`)) return child; } return null; } function wrapLikeSiblings(list, item) { const sample = list.querySelector(":scope > .popover-container"); if (!sample) return item; const wrap = sample.cloneNode(false); wrap.removeAttribute("id"); wrap.style.marginBottom = "8px"; wrap.style.display = "block"; const trigger = document.createElement("div"); const sampleTrigger = sample.querySelector(":scope > .popover-trigger"); trigger.className = sampleTrigger?.className || "popover-trigger"; if (sampleTrigger) { for (const attr of sampleTrigger.attributes) { if (attr.name.startsWith("data-v-")) { trigger.setAttribute(attr.name, attr.value); } } } wrap.appendChild(trigger); trigger.appendChild(item); return wrap; } function insertNode(list, item) { const node = wrapLikeSiblings(list, item); const before = findInsertBefore(list); if (before && before.parentNode === list) { list.insertBefore(node, before); return; } list.appendChild(node); } function setIcon(svg) { if (!svg) return; svg.innerHTML = ''; svg.setAttribute("viewBox", "0 0 24 24"); svg.removeAttribute("data-v-4aaf826d"); } function setActive(el, link, on) { el?.classList.toggle("active-channel", on); if (!link) return; link.classList.toggle("active", on); link.classList.toggle("router-link-exact-active", on); if (on) link.setAttribute("aria-current", "page"); else link.removeAttribute("aria-current"); } function syncMeInactive(list, userId) { if (!list || !isFavoriteLocation(userId) || silencingMe) return; silencingMe = true; try { for (const me of list.querySelectorAll("li.user.side-bar-component")) { setActive(me, me.querySelector("a"), false); } for (const extra of list.querySelectorAll(".bottom-menu-component a")) { extra.classList.remove("active", "router-link-exact-active", "active-channel"); extra.removeAttribute("aria-current"); } } finally { silencingMe = false; } } function watchMeActive(list, userId) { meObserver?.disconnect(); meObserver = null; if (!list || !isFavoriteLocation(userId)) return; const me = list.querySelector("li.user.side-bar-component"); if (!me) return; meObserver = new MutationObserver(() => { syncMeInactive(list, getFavoriteUserId()); }); meObserver.observe(me, { attributes: true, subtree: true, attributeFilter: ["class", "aria-current"], }); } function syncItem(item, userId) { const link = item.querySelector("a"); const label = item.querySelector("span.channel"); if (label) label.textContent = "收藏"; if (!link) return; if (userId) { const href = favoriteUrl(userId); link.setAttribute("href", href); link.setAttribute("title", "收藏"); link.addEventListener("click", onKnownUserClick); } else { link.setAttribute("href", "#"); link.setAttribute("title", "登录后查看收藏"); link.addEventListener("click", onGuestClick); } setActive(item, link, isFavoriteLocation(userId)); } function onKnownUserClick(event) { const userId = getFavoriteUserId(); if (!userId) return; if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; event.preventDefault(); Promise.resolve(goFavorite(userId)).finally(() => { const list = findList(); const item = document.getElementById(ITEM_ID); if (list && item) { syncMeInactive(list, userId); syncItem(item, userId); watchMeActive(list, userId); } schedule(); }); } function onGuestClick(event) { event.preventDefault(); const login = document.querySelector("#login-btn") || document.querySelector(".login-btn button"); if (login) login.click(); } function removeItem() { const item = document.getElementById(ITEM_ID); if (!item) return; const wrap = item.closest(".popover-container"); if (wrap?.parentElement) wrap.remove(); else item.remove(); } function ensureItem() { const list = findList(); if (!list) return; let item = document.getElementById(ITEM_ID); if (item && !list.contains(item)) { removeItem(); item = null; } if (!item) { const template = findTemplate(list); if (!template) return; item = template.cloneNode(true); item.id = ITEM_ID; item.classList.remove("active-channel"); item.removeAttribute("style"); const link = item.querySelector("a"); if (link) { link.classList.remove("active", "router-link-exact-active"); link.removeAttribute("aria-current"); link.setAttribute("target", "_self"); } setIcon(item.querySelector("svg")); const badge = item.querySelector(".badge-container"); if (badge) { const svg = badge.querySelector("svg"); badge.replaceWith(svg || badge); } insertNode(list, item); } const nextLink = item.querySelector("a"); if (nextLink) { nextLink.removeEventListener("click", onKnownUserClick); nextLink.removeEventListener("click", onGuestClick); } const userId = getFavoriteUserId(); syncItem(item, userId); syncMeInactive(list, userId); watchMeActive(list, userId); } function schedule() { if (timer) return; timer = window.setTimeout(() => { timer = 0; try { ensureItem(); } catch { /* Vue 可能正在拆掉侧栏,下一轮再挂 */ } }, 80); } const observer = new MutationObserver(schedule); observer.observe(document.documentElement, { childList: true, subtree: true }); window.addEventListener("popstate", schedule); ensureItem(); return () => { observer.disconnect(); meObserver?.disconnect(); window.removeEventListener("popstate", schedule); window.clearTimeout(timer); removeItem(); }; }, }); const running = new Map(); function start(plugin) { stop(plugin.id); if (!settings.enabled(plugin.id, plugin.defaultEnabled !== false)) return; const teardown = plugin.mount(); if (typeof teardown === "function") running.set(plugin.id, teardown); } function stop(id) { const teardown = running.get(id); if (!teardown) return; try { teardown(); } catch { /* ignore */ } running.delete(id); } function boot() { for (const plugin of plugins) { start(plugin); const on = settings.enabled(plugin.id, plugin.defaultEnabled !== false); try { GM_registerMenuCommand(`${on ? "关闭" : "开启"}:${plugin.name}`, () => { const next = !settings.enabled(plugin.id, plugin.defaultEnabled !== false); settings.set(plugin.id, next); if (next) start(plugin); else stop(plugin.id); }); } catch { /* menu is optional outside Tampermonkey */ } } } boot(); })();