Zhihu Blacklist Helper (Fixed API)

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

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 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();
})();