Bitcointalk Saved Posts

Save Bitcointalk posts locally and automatically show your remaining sMerit.

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

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

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

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

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

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

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

Advertisement:

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

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

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

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

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

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

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

Advertisement:

// ==UserScript==
// @name         Bitcointalk Saved Posts
// @namespace    https://bitcointalk.org/
// @version      1.1.0
// @description  Save Bitcointalk posts locally and automatically show your remaining sMerit.
// @author       Misfoxie
// @match        https://bitcointalk.org/*
// @match        http://bitcointalk.org/*
// @match        https://www.bitcointalk.org/*
// @match        http://www.bitcointalk.org/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_xmlhttpRequest
// @connect      bitcointalk.org
// @connect      www.bitcointalk.org
// @run-at       document-end
// ==/UserScript==

(function () {
    'use strict';

    const STORAGE_KEY = 'btt_saved_posts_v1';
    const POST_SELECTOR = '.subject[id^="subject_"]';
    const SMERIT_CACHE_KEY = 'btt_remaining_smerit_cache';
    const SMERIT_CACHE_TIME_KEY = 'btt_remaining_smerit_cache_time';
    const SMERIT_CACHE_DURATION = 5 * 60 * 1000;

    const css = `
        #bttsp-launcher { position:fixed; right:18px; bottom:18px; z-index:99998; border:0; border-radius:999px; background:#263b55; color:#fff; padding:11px 15px; box-shadow:0 4px 18px #0005; cursor:pointer; font:600 13px Arial,sans-serif; }
        #bttsp-launcher:hover { background:#345273; }
        #bttsp-panel { position:fixed; right:18px; bottom:68px; z-index:99999; width:min(390px,calc(100vw - 28px)); max-height:min(650px,calc(100vh - 90px)); display:none; flex-direction:column; overflow:hidden; border:1px solid #8da0b4; border-radius:10px; background:#f5f7fa; color:#1d2936; box-shadow:0 10px 36px #0007; font:13px Arial,sans-serif; text-align:left; }
        #bttsp-panel.bttsp-open { display:flex; }
        .bttsp-head { display:flex; align-items:center; justify-content:space-between; padding:13px 14px; background:#263b55; color:#fff; }
        .bttsp-head strong { font-size:15px; }
        .bttsp-close { border:0; background:transparent; color:#fff; font-size:22px; line-height:18px; cursor:pointer; }
        #bttsp-list { overflow:auto; padding:8px; }
        .bttsp-empty { padding:28px 15px; color:#627181; text-align:center; line-height:1.5; }
        .bttsp-thread { margin-bottom:8px; overflow:hidden; border:1px solid #c8d1db; border-radius:7px; background:#fff; }
        .bttsp-thread summary { position:relative; display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; padding:10px 11px; cursor:pointer; list-style:none; }
        .bttsp-thread summary::-webkit-details-marker { display:none; }
        .bttsp-summary-text { min-width:0; }
        .bttsp-title { display:block; overflow:hidden; color:#203f64; font-weight:700; text-overflow:ellipsis; white-space:nowrap; }
        .bttsp-preview { display:block; margin-top:5px; overflow:hidden; color:#596978; font-size:12px; text-overflow:ellipsis; white-space:nowrap; }
        .bttsp-meta { display:block; margin-top:5px; color:#8995a1; font-size:11px; }
        .bttsp-open-latest { display:inline-block; align-self:center; border:1px solid #315a83; border-radius:5px; background:#345f89; color:#fff !important; padding:7px 10px; font-weight:700; text-decoration:none !important; white-space:nowrap; }
        .bttsp-open-latest:hover { background:#264b70; }
        .bttsp-posts { border-top:1px solid #dce2e8; }
        .bttsp-item { display:grid; grid-template-columns:1fr auto; gap:8px; padding:9px 11px; border-bottom:1px solid #edf0f3; }
        .bttsp-item:last-child { border-bottom:0; }
        .bttsp-link { min-width:0; color:#294f79; text-decoration:none; }
        .bttsp-link:hover { text-decoration:underline; }
        .bttsp-link span { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
        .bttsp-link small { display:block; margin-top:3px; color:#7a8793; }
        .bttsp-delete { align-self:center; border:0; border-radius:4px; background:#eceff2; color:#8b3030; padding:4px 7px; cursor:pointer; }
        .bttsp-save { position:relative !important; top:2px !important; margin-left:7px !important; border:1px solid #526b85 !important; border-radius:4px !important; background:#edf2f6 !important; color:#27435f !important; padding:3px 7px !important; cursor:pointer !important; font:bold 11px Arial,sans-serif !important; vertical-align:middle !important; }
        .bttsp-save:hover { background:#dce7ef !important; }
        .bttsp-save.bttsp-is-saved { border-color:#a56d08 !important; background:#ffe7a8 !important; color:#634200 !important; }
        .bttsp-smerit { display:inline-block; margin-left:7px; border:1px solid #9abe92; border-radius:11px; background:#dcebd8; color:#17601d; padding:3px 9px; font:bold 12px Arial,sans-serif; line-height:1.25; vertical-align:middle; white-space:nowrap; }
        .bttsp-smerit.bttsp-smerit-error { background:#eee; color:#777; }
        .bttsp-saved-row { box-shadow:inset 5px 0 #e0a11d !important; }
        .bttsp-saved-cell { background-image:linear-gradient(90deg,rgba(255,224,132,.30),rgba(255,255,255,0) 55%) !important; }
        .bttsp-saved-cell, .bttsp-saved-cell * { color:#71869a !important; }
        .bttsp-saved-cell a, .bttsp-saved-cell a * { color:#4f789f !important; }
        .bttsp-saved-row .bttsp-poster-text { color:#71869a !important; }
        .bttsp-saved-row .poster_info a .bttsp-poster-text { color:#4f789f !important; }
        .bttsp-saved-cell .bttsp-save { border-color:#a56d08 !important; background:#ffe7a8 !important; color:#634200 !important; opacity:1 !important; }
        .bttsp-saved-cell .bttsp-smerit { border-color:#9abe92 !important; background:#dcebd8 !important; color:#17601d !important; opacity:1 !important; }
        .bttsp-saved-cell .bttsp-saved-label { background:#e0a11d !important; color:#302000 !important; }
        .bttsp-saved-label { display:inline-block; margin-left:7px; border-radius:10px; background:#e0a11d; color:#302000; padding:2px 7px; font:bold 10px Arial,sans-serif; vertical-align:middle; }
        #bttsp-toast { position:fixed; left:50%; bottom:24px; z-index:100000; transform:translateX(-50%); border-radius:6px; background:#182536; color:#fff; padding:9px 14px; box-shadow:0 3px 12px #0006; font:13px Arial,sans-serif; opacity:0; pointer-events:none; transition:opacity .2s; }
        #bttsp-toast.bttsp-show { opacity:1; }
        @media (max-width:600px) { #bttsp-launcher { right:10px; bottom:10px; } #bttsp-panel { right:7px; bottom:58px; } }
    `;

    let saved = loadSaved();
    let toastTimer;

    function loadSaved() {
        const value = GM_getValue(STORAGE_KEY, []);
        if (!Array.isArray(value)) return [];
        return value.filter(item => item && item.postId && item.topicId && item.url);
    }

    function persist() {
        GM_setValue(STORAGE_KEY, saved);
    }

    function getCachedSmerit() {
        const value = localStorage.getItem(SMERIT_CACHE_KEY);
        const time = Number(localStorage.getItem(SMERIT_CACHE_TIME_KEY));
        if (value === null || !time || Date.now() - time > SMERIT_CACHE_DURATION) return null;
        return value;
    }

    function cacheSmerit(value) {
        localStorage.setItem(SMERIT_CACHE_KEY, String(value));
        localStorage.setItem(SMERIT_CACHE_TIME_KEY, String(Date.now()));
    }

    function findMeritLinks() {
        return [...document.querySelectorAll('a[href*="action=merit"]')];
    }

    function extractSmerit(html) {
        const doc = new DOMParser().parseFromString(html, 'text/html');
        const text = (doc.body ? doc.body.textContent : html).replace(/\s+/g, ' ').trim();
        const patterns = [
            /You have\s+(\d+)\s+sendable merits?/i,
            /You have\s+(\d+)\s+sMerits?/i,
            /You have\s+(\d+)\s+available merits?/i,
            /You can send\s+(\d+)\s+merits?/i,
            /sendable merits?\s*[:\-]?\s*(\d+)/i,
            /sMerits?\s*[:\-]?\s*(\d+)/i
        ];
        for (const pattern of patterns) {
            const match = text.match(pattern);
            if (match) return match[1];
        }
        return null;
    }

    function showSmerit(value, failed = false) {
        findMeritLinks().forEach(link => {
            let badge = link.parentElement && link.parentElement.querySelector(`.bttsp-smerit[data-for="${link.href}"]`);
            if (!badge) {
                badge = el('span', 'bttsp-smerit');
                badge.dataset.for = link.href;
                link.insertAdjacentElement('afterend', badge);
            }
            badge.textContent = `sMerit: ${value}`;
            badge.classList.toggle('bttsp-smerit-error', failed);
            badge.title = failed ? 'Could not read the current sMerit balance' : 'Remaining sendable merit (cached for five minutes)';
        });
    }

    function initSmerit() {
        const meritLinks = findMeritLinks();
        if (!meritLinks.length) return;

        const cached = getCachedSmerit();
        if (cached !== null) {
            showSmerit(cached);
            return;
        }

        GM_xmlhttpRequest({
            method: 'GET',
            url: meritLinks[0].href,
            headers: { Accept: 'text/html' },
            onload(response) {
                const value = extractSmerit(response.responseText || '');
                if (value !== null) {
                    cacheSmerit(value);
                    showSmerit(value);
                } else {
                    showSmerit('?', true);
                    console.warn('[Bitcointalk Saved Posts] Could not detect remaining sMerit.');
                }
            },
            onerror() {
                showSmerit('?', true);
                console.warn('[Bitcointalk Saved Posts] Failed to load the Merit page.');
            }
        });
    }

    function normalizeText(value, maxLength) {
        const text = String(value || '').replace(/\s+/g, ' ').trim();
        return text.length > maxLength ? `${text.slice(0, maxLength - 1).trimEnd()}\u2026` : text;
    }

    function parseTopicId(url) {
        const match = String(url).match(/[?;&]topic=(\d+)/i);
        return match ? match[1] : '';
    }

    function getPostData(subject) {
        const postId = subject.id.replace('subject_', '');
        const contentCell = subject.closest('.td_headerandpost') || subject.closest('td');
        const postTable = contentCell && contentCell.closest('table');
        const permalink = (contentCell && contentCell.querySelector(`a[href*="#msg${postId}"]`)) || subject.querySelector('a');
        const postBody = contentCell && contentCell.querySelector('.post');
        const authorLink = postTable && postTable.querySelector('.poster_info a[href*="action=profile"]');
        const numberLink = contentCell && contentCell.querySelector('.message_number');
        const topicId = parseTopicId(permalink ? permalink.href : location.href);
        if (!postId || !topicId || !permalink) return null;

        return {
            postId,
            topicId,
            threadTitle: normalizeText((subject.querySelector('a') || subject).textContent.replace(/^Re:\s*/i, ''), 180) || `Topic ${topicId}`,
            postTitle: normalizeText((subject.querySelector('a') || subject).textContent, 180),
            author: normalizeText(authorLink && authorLink.textContent, 80) || 'Unknown member',
            postNumber: normalizeText(numberLink && numberLink.textContent, 20) || `#${postId}`,
            snippet: normalizeText(postBody && postBody.textContent, 240) || 'Saved Bitcointalk post',
            url: new URL(permalink.href, location.href).href,
            savedAt: Date.now()
        };
    }

    function findSaved(postId) {
        return saved.find(item => item.postId === String(postId));
    }

    function savePost(data) {
        if (findSaved(data.postId)) {
            saved = saved.filter(item => item.postId !== data.postId);
            showToast('Post removed from saved posts');
        } else {
            saved.push(data);
            showToast('Post saved on this device');
        }
        persist();
        decoratePosts();
        renderPanel();
    }

    function preparePosterText(postTable) {
        const poster = postTable && postTable.querySelector('.poster_info');
        if (!poster || poster.dataset.bttspTextPrepared) return;
        poster.dataset.bttspTextPrepared = '1';

        const walker = document.createTreeWalker(poster, NodeFilter.SHOW_TEXT);
        const textNodes = [];
        while (walker.nextNode()) {
            if (walker.currentNode.nodeValue.trim()) textNodes.push(walker.currentNode);
        }
        textNodes.forEach(textNode => {
            const wrapper = el('span', 'bttsp-poster-text');
            textNode.parentNode.insertBefore(wrapper, textNode);
            wrapper.appendChild(textNode);
        });
    }

    function removePost(postId) {
        saved = saved.filter(item => item.postId !== String(postId));
        persist();
        decoratePosts();
        renderPanel();
        showToast('Saved post removed');
    }

    function decoratePosts() {
        document.querySelectorAll(POST_SELECTOR).forEach(subject => {
            const data = getPostData(subject);
            if (!data) return;
            const contentCell = subject.closest('.td_headerandpost') || subject.closest('td');
            const postTable = contentCell && contentCell.closest('table');
            const buttonArea = contentCell && contentCell.querySelector('.td_buttons > div, .td_buttons');
            if (!buttonArea) return;
            preparePosterText(postTable);

            let button = buttonArea.querySelector(`.bttsp-save[data-post-id="${data.postId}"]`);
            if (!button) {
                button = document.createElement('button');
                button.type = 'button';
                button.className = 'bttsp-save';
                button.dataset.postId = data.postId;
                button.addEventListener('click', event => {
                    event.preventDefault();
                    event.stopPropagation();
                    savePost(getPostData(subject));
                });
                buttonArea.appendChild(button);
            }

            const isSaved = Boolean(findSaved(data.postId));
            button.textContent = isSaved ? '\u2605 Saved' : '\u2606 Save';
            button.title = isSaved ? 'Remove this saved post' : 'Save this post locally';
            button.classList.toggle('bttsp-is-saved', isSaved);
            if (postTable) postTable.classList.toggle('bttsp-saved-row', isSaved);
            if (contentCell) contentCell.classList.toggle('bttsp-saved-cell', isSaved);

            let label = subject.querySelector('.bttsp-saved-label');
            if (isSaved && !label) {
                label = document.createElement('span');
                label.className = 'bttsp-saved-label';
                label.textContent = 'SAVED';
                subject.appendChild(label);
            } else if (!isSaved && label) {
                label.remove();
            }
        });
    }

    function groupByThread() {
        const groups = new Map();
        saved.forEach(post => {
            if (!groups.has(post.topicId)) groups.set(post.topicId, []);
            groups.get(post.topicId).push(post);
        });
        return [...groups.values()]
            .map(posts => posts.sort((a, b) => b.savedAt - a.savedAt))
            .sort((a, b) => b[0].savedAt - a[0].savedAt);
    }

    function formatDate(timestamp) {
        try { return new Date(timestamp).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }); }
        catch (_) { return new Date(timestamp).toLocaleString(); }
    }

    function el(tag, className, text) {
        const node = document.createElement(tag);
        if (className) node.className = className;
        if (text !== undefined) node.textContent = text;
        return node;
    }

    function renderPanel() {
        const list = document.getElementById('bttsp-list');
        if (!list) return;
        list.replaceChildren();

        const groups = groupByThread();
        if (!groups.length) {
            list.appendChild(el('div', 'bttsp-empty', 'No saved posts yet. Open a thread and use the \u2606 Save button beside a post.'));
            return;
        }

        groups.forEach(posts => {
            const latest = posts[0];
            const details = el('details', 'bttsp-thread');
            const summary = document.createElement('summary');
            const summaryText = el('span', 'bttsp-summary-text');
            summaryText.appendChild(el('span', 'bttsp-title', latest.threadTitle));
            summaryText.appendChild(el('span', 'bttsp-preview', latest.snippet));
            summaryText.appendChild(el('span', 'bttsp-meta', `${posts.length} saved post${posts.length === 1 ? '' : 's'} \u00b7 latest ${formatDate(latest.savedAt)}`));
            const openLatest = el('a', 'bttsp-open-latest', 'Open');
            openLatest.href = latest.url;
            openLatest.title = `Open the most recently saved post (${latest.postNumber})`;
            openLatest.addEventListener('click', event => event.stopPropagation());
            summary.append(summaryText, openLatest);
            details.appendChild(summary);

            const postList = el('div', 'bttsp-posts');
            posts.forEach(post => {
                const item = el('div', 'bttsp-item');
                const link = el('a', 'bttsp-link');
                link.href = post.url;
                link.title = post.snippet;
                link.appendChild(el('span', '', `${post.postNumber} \u2014 ${post.snippet}`));
                link.appendChild(el('small', '', `${post.author} \u00b7 ${formatDate(post.savedAt)}`));
                const remove = el('button', 'bttsp-delete', '\u2715');
                remove.type = 'button';
                remove.title = 'Remove saved post';
                remove.addEventListener('click', event => {
                    event.preventDefault();
                    event.stopPropagation();
                    removePost(post.postId);
                });
                item.append(link, remove);
                postList.appendChild(item);
            });
            details.appendChild(postList);
            list.appendChild(details);
        });
    }

    function showToast(message) {
        const toast = document.getElementById('bttsp-toast');
        if (!toast) return;
        toast.textContent = message;
        toast.classList.add('bttsp-show');
        clearTimeout(toastTimer);
        toastTimer = setTimeout(() => toast.classList.remove('bttsp-show'), 1800);
    }

    function buildUi() {
        const style = document.createElement('style');
        style.textContent = css;

        const launcher = el('button', '', 'Saved posts');
        launcher.id = 'bttsp-launcher';
        launcher.type = 'button';

        const panel = el('aside');
        panel.id = 'bttsp-panel';
        panel.setAttribute('aria-label', 'Saved Bitcointalk posts');
        const head = el('div', 'bttsp-head');
        head.appendChild(el('strong', '', 'Saved threads'));
        const close = el('button', 'bttsp-close', '\u00d7');
        close.type = 'button';
        close.title = 'Close';
        head.appendChild(close);
        const list = el('div');
        list.id = 'bttsp-list';
        panel.append(head, list);

        const toast = el('div');
        toast.id = 'bttsp-toast';
        document.head.appendChild(style);
        document.body.append(launcher, panel, toast);

        launcher.addEventListener('click', () => panel.classList.toggle('bttsp-open'));
        close.addEventListener('click', () => panel.classList.remove('bttsp-open'));
        document.addEventListener('keydown', event => {
            if (event.key === 'Escape') panel.classList.remove('bttsp-open');
        });
    }

    buildUi();
    decoratePosts();
    renderPanel();
    initSmerit();
})();