Zhihu Blacklist Helper (Fixed API)

Efficiently hide blocked content on Zhihu. Fixed "Block" button stuck on loading.

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Zhihu Blacklist Helper (Fixed API)
// @namespace    
// @version      3.3
// @description  Efficiently hide blocked content on Zhihu. Fixed "Block" button stuck on loading.
// @match        *://www.zhihu.com/*
// @license MIT
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @run-at       document-end
// ==/UserScript==

(function () {
    'use strict';

    const Store = {
        get config() {
            return GM_getValue("Config", {
                hideBlocked: false,
                tempShow: false
            });
        },
        set config(val) { GM_setValue("Config", val); },
        get blockedList() { return GM_getValue("blockedIdList", []); },
        set blockedList(val) { GM_setValue("blockedIdList", [...new Set(val)]); }
    };

    class ZhihuBlocker {
        constructor() {
            this.observer = null;
            this.isFetching = false;
            this.init();
        }

        async init() {
            if (Store.blockedList.length === 0) await this.syncBlockList();
            this.scanPage();
            this.startObserver();
            this.injectFloatingButton();
            this.fixCopy();
        }

        // Helper to get XSRF token from cookies (Required for blocking)
        getXsrfToken() {
            // Try common cookie name variations
            const patterns = [
                /_xsrf=([^;]+)/,
                /xsrf-token=([^;]+)/,
                /XSRF-TOKEN=([^;]+)/,
                /xsrf_token=([^;]+)/
            ];

            for (const pattern of patterns) {
                const match = document.cookie.match(pattern);
                if (match) return decodeURIComponent(match[1]);
            }

            return '';
        }

        async syncBlockList() {
            if (this.isFetching) return;
            this.isFetching = true;
            let offset = 0;
            let isEnd = false;
            let newBlockedList = [];

            const btn = document.getElementById('zbh-float-btn');
            if(btn) btn.textContent = "⏳";

            try {
                while (!isEnd) {
                    const response = await fetch(`https://www.zhihu.com/api/v3/settings/blocked_users?limit=20&offset=${offset}`);
                    if (!response.ok) {
                        throw new Error(`HTTP ${response.status}`);
                    }
                    const json = await response.json();
                    if (!json.data) break;
                    json.data.forEach(user => {
                        if (user.id) newBlockedList.push(String(user.id));
                    });
                    if (json.paging && json.paging.is_end === false) offset += 20;
                    else isEnd = true;
                }
                Store.blockedList = newBlockedList;
                console.log(`[ZBH] Synced ${newBlockedList.length} users.`);
                if(btn) btn.textContent = "⚙️";
            } catch (err) {
                console.error("[ZBH] Sync failed", err);
                if(btn) {
                    btn.textContent = "❌";
                    btn.title = "Sync failed. Click to retry.";
                    setTimeout(() => {
                        btn.textContent = "⚙️";
                        btn.title = "";
                    }, 3000);
                }
            } finally {
                this.isFetching = false;
            }
        }

        startObserver() {
            const config = { childList: true, subtree: true };
            let timeout;
            this.observer = new MutationObserver((mutations) => {
                if (timeout) clearTimeout(timeout);
                timeout = setTimeout(() => this.scanPage(), 100);
            });
            this.observer.observe(document.body, config);
        }

        scanPage() {
            const listItems = document.querySelectorAll('.List-item, .Card.TopstoryItem');
            const blockedIds = Store.blockedList;

            listItems.forEach(item => {
                if (item.dataset.zbhProcessed === "true") {
                    this.applyVisibility(item, item.dataset.zbhIsBlocked === "true");
                    return;
                }

                const contentItem = item.querySelector('.ContentItem');
                if (!contentItem) return;

                // 1. Get Hash ID (Stable ID for checking if blocked)
                let authorId = "";
                let type = "";
                try {
                    const extra = JSON.parse(contentItem.getAttribute("data-za-extra-module") || "{}");
                    authorId = String(extra.card?.content?.author_member_hash_id || "");
                    type = extra.card?.content?.type || "";
                } catch (e) {}

                if (!authorId) return;

                // 2. Get User Slug (Required for API Calls) - CRITICAL FIX
                // We look for the user link inside the item
                const userLink = item.querySelector('.UserLink-link');
                let userSlug = "";
                let userName = "User";

                if (userLink) {
                    // Extract slug from href like "/people/example-user"
                    const href = userLink.getAttribute('href') || "";
                    const parts = href.split('/');
                    // usually the last part, but sometimes there are query params
                    userSlug = parts[parts.length - 1] || parts[parts.length - 2];
                    userName = userLink.innerText;
                } else {
                    // Fallback for some layouts
                    const zop = JSON.parse(contentItem.getAttribute("data-zop") || "{}");
                    userName = zop.authorName || "User";
                }

                // If we can't find a slug, we can't provide a block button
                if (!userSlug && type === "Answer") return;

                const isBlocked = blockedIds.includes(authorId);

                if (type === "Answer") {
                    this.injectBlockButton(item, userSlug, authorId, isBlocked);
                }

                item.dataset.zbhProcessed = "true";
                item.dataset.zbhIsBlocked = isBlocked ? "true" : "false";
                item.dataset.zbhAuthorName = userName;
                this.applyVisibility(item, isBlocked);
            });
        }

        applyVisibility(item, isBlocked) {
            const cfg = Store.config;
            const content = item.querySelector('.ContentItem') || item.firstElementChild;
            let placeholder = item.querySelector('.zbh-placeholder');

            if (!isBlocked) {
                if (content) content.hidden = false;
                if (placeholder) placeholder.hidden = true;
                item.style.display = '';
                return;
            }

            if (cfg.hideBlocked) {
                if (cfg.tempShow) {
                    if (content) content.hidden = true;
                    if (!placeholder) {
                        placeholder = document.createElement('div');
                        placeholder.className = 'zbh-placeholder';
                        placeholder.style.cssText = "background:#f6f6f6; color:#999; text-align:center; padding:10px; cursor:pointer; margin-bottom:10px; border-radius:4px;";
                        placeholder.textContent = `🚫 Blocked content from ${item.dataset.zbhAuthorName} (Click to view)`;
                        placeholder.onclick = () => { content.hidden = false; placeholder.hidden = true; };
                        item.appendChild(placeholder);
                    } else {
                        placeholder.hidden = false;
                    }
                } else {
                    if (content) content.hidden = true;
                    item.style.display = 'none';
                }
            } else {
                if (content) content.hidden = false;
                item.style.display = '';
            }
        }

        injectBlockButton(item, slug, id, isBlocked) {
            const authorInfo = item.querySelector('.AuthorInfo');
            if (!authorInfo || authorInfo.querySelector('.zbh-btn')) return;

            const btn = document.createElement('button');
            btn.className = `Button zbh-btn ${isBlocked ? 'Button--red' : 'Button--blue'}`;
            btn.textContent = isBlocked ? "Blocked" : "Block";
            btn.style.cssText = "margin-left: 10px; padding: 0 8px; height: 24px; line-height: 22px; font-size: 12px;";

            btn.onclick = async (e) => {
                e.stopPropagation();
                await this.toggleBlockAction(slug, id, btn);
            };
            authorInfo.appendChild(btn);
        }

        async toggleBlockAction(slug, id, btnElement) {
            const isBlocked = btnElement.classList.contains('Button--red');
            const method = isBlocked ? "DELETE" : "POST";
            const url = `https://www.zhihu.com/api/v4/members/${slug}/actions/block`;

            // Check for XSRF token before making request
            const xsrfToken = this.getXsrfToken();
            if (!xsrfToken) {
                btnElement.textContent = "Login required";
                btnElement.title = "Please log in to Zhihu";
                setTimeout(() => {
                    btnElement.textContent = isBlocked ? "Blocked" : "Block";
                    btnElement.title = "";
                }, 2000);
                return;
            }

            btnElement.disabled = true;
            btnElement.textContent = "...";

            try {
                const res = await fetch(url, {
                    method: method,
                    headers: {
                        'x-xsrf-token': xsrfToken, // Security Header
                        'Content-Type': 'application/json'
                    }
                });

                if (res.ok) {
                    let list = Store.blockedList;
                    if (isBlocked) list = list.filter(x => x !== id);
                    else list.push(id);
                    Store.blockedList = list;

                    const newStatus = !isBlocked;
                    btnElement.classList.toggle('Button--red', newStatus);
                    btnElement.classList.toggle('Button--blue', !newStatus);
                    btnElement.textContent = newStatus ? "Blocked" : "Block";
                    btnElement.title = "";

                    // Force a re-scan to update UI visibility immediately
                    document.querySelectorAll(`[data-zbh-processed]`).forEach(el => {
                         // Only re-process the item we just clicked to save performance
                         if(el.contains(btnElement)) {
                             el.removeAttribute('data-zbh-processed');
                         }
                    });
                    this.scanPage();
                } else {
                    // Better error messages based on status code
                    let errorMsg;
                    if (res.status === 401 || res.status === 403) {
                        errorMsg = "Auth failed";
                    } else if (res.status === 429) {
                        errorMsg = "Rate limited";
                    } else {
                        errorMsg = `Error ${res.status}`;
                    }
                    console.error(`[ZBH] Block action failed: ${res.status}`);
                    btnElement.textContent = errorMsg;
                    btnElement.title = `Failed to ${isBlocked ? 'unblock' : 'block'}. Click to retry.`;
                    setTimeout(() => {
                        btnElement.textContent = isBlocked ? "Blocked" : "Block";
                        btnElement.title = "";
                    }, 2500);
                }
            } catch (err) {
                console.error("[ZBH] Network error:", err);
                btnElement.textContent = "Network error";
                btnElement.title = "Check your connection";
                setTimeout(() => {
                    btnElement.textContent = isBlocked ? "Blocked" : "Block";
                    btnElement.title = "";
                }, 2500);
            } finally {
                btnElement.disabled = false;
            }
        }

        injectFloatingButton() {
            if(document.getElementById('zbh-float-btn')) return;
            const btn = document.createElement('div');
            btn.id = 'zbh-float-btn';
            btn.textContent = "⚙️";
            btn.style.cssText = `position: fixed; bottom: 80px; right: 20px; width: 40px; height: 40px; background: white; border-radius: 50%; box-shadow: 0 4px 10px rgba(0,0,0,0.2); text-align: center; line-height: 40px; font-size: 20px; cursor: pointer; z-index: 9999; user-select: none; transition: transform 0.2s;`;
            btn.onmouseenter = () => btn.style.transform = "scale(1.1)";
            btn.onmouseleave = () => btn.style.transform = "scale(1)";
            btn.onclick = () => this.toggleSettingsPanel();
            document.body.appendChild(btn);
        }

        toggleSettingsPanel() {
            let panel = document.getElementById('zbh-panel');
            let overlay = document.getElementById('zbh-overlay');
            if (panel) {
                panel.remove();
                if (overlay) overlay.remove();
                return;
            }

            const cfg = Store.config;

            // Create overlay backdrop
            overlay = document.createElement('div');
            overlay.id = 'zbh-overlay';
            overlay.style.cssText = `position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.3); z-index: 9998; cursor: pointer;`;

            // Create panel
            panel = document.createElement('div');
            panel.id = 'zbh-panel';
            panel.style.cssText = `position: fixed; bottom: 130px; right: 20px; z-index: 9999; background: white; border: 1px solid #ebebeb; padding: 15px; border-radius: 8px; box-shadow: 0 5px 20px rgba(0,0,0,0.1); font-family: -apple-system, BlinkMacSystemFont, sans-serif; font-size: 14px; min-width: 250px;`;

            const createCheck = (label, key) => `<div style="margin-bottom: 8px;"><label style="cursor:pointer; display:flex; align-items:center;"><input type="checkbox" id="zbh-${key}" ${cfg[key] ? 'checked' : ''} style="margin-right:8px;">${label}</label></div>`;

            panel.innerHTML = `<div style="font-weight:bold; margin-bottom:10px; border-bottom:1px solid #eee; padding-bottom:5px;">Blacklist Helper</div>${createCheck("Hide items completely", "hideBlocked")}${createCheck("Show 'Blocked' placeholder", "tempShow")}<div style="margin-top:15px; display:flex; justify-content:space-between;"><button id="zbh-refresh" style="cursor:pointer; background:none; border:none; color:#175199;">🔄 Sync</button><button id="zbh-save" style="cursor:pointer; background:#0084ff; color:white; border:none; padding:5px 10px; border-radius:4px;">Save</button></div>`;

            // Append overlay and panel
            document.body.appendChild(overlay);
            document.body.appendChild(panel);

            // Close panel when clicking outside (on overlay)
            overlay.onclick = () => {
                panel.remove();
                overlay.remove();
            };

            // Prevent panel clicks from closing the overlay
            panel.onclick = (e) => {
                e.stopPropagation();
            };

            document.getElementById('zbh-save').onclick = () => {
                Store.config = {
                    hideBlocked: document.getElementById('zbh-hideBlocked').checked,
                    tempShow: document.getElementById('zbh-tempShow').checked
                };
                panel.remove();
                overlay.remove();
                document.querySelectorAll('[data-zbh-processed]').forEach(el => el.removeAttribute('data-zbh-processed'));
                this.scanPage();
            };
            document.getElementById('zbh-refresh').onclick = async () => {
                 document.getElementById('zbh-refresh').textContent = "Syncing...";
                 await this.syncBlockList();
                 document.getElementById('zbh-refresh').textContent = "Done!";
            };
        }

        fixCopy() { document.addEventListener('copy', (e) => e.stopPropagation(), true); }
    }

    new ZhihuBlocker();
})();