☰

Reddit Saved - Session Links Collector + Export

Load your actual Reddit Saved posts directly from Reddit's Saved listing, then review, copy or export them as TXT/CSV. Does not scrape page DOM or collect ordinary Reddit activity.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램을 먼저 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Reddit Saved - Session Links Collector + Export
// @namespace    https://greasyfork.org/userscripts/thochaos
// @version      2026.10.04.4
// @description  Load your actual Reddit Saved posts directly from Reddit's Saved listing, then review, copy or export them as TXT/CSV. Does not scrape page DOM or collect ordinary Reddit activity.
// @author       thochaos
// @license      MIT
// @match        https://reddit.com/*
// @match        https://*.reddit.com/*
// @noframes
// @run-at       document-idle
// @grant        none
// ==/UserScript==

(function () {
    'use strict';

    const VERSION =
        '2026.10.04.4';

    /*
     * New key deliberately prevents contaminated DOM-collected
     * data from v2026.10.04.1-.3 being restored.
     */
    const STORAGE_KEY =
        'redditSavedLinksCollector_session_v3';

    const ROUTE_POLL_MS =
        500;

    const PAGE_LIMIT =
        100;

    let linksOnlyActive =
        false;

    let collectorActive =
        false;

    let loading =
        false;

    let currentAbortController =
        null;

    let lastKnownUrl =
        location.href;

    let routeSyncTimer =
        null;

    let toastTimer =
        null;

    const collectedPosts =
        new Map();

    // ============================================================
    // Helpers
    // ============================================================

    function normalizeWhitespace(value) {
        return String(
            value || ''
        )
            .replace(/\s+/g, ' ')
            .trim();
    }

    function escapeHtml(value) {
        return String(value)
            .replaceAll(
                '&',
                '&'
            )
            .replaceAll(
                '<',
                '&lt;'
            )
            .replaceAll(
                '>',
                '&gt;'
            )
            .replaceAll(
                '"',
                '&quot;'
            )
            .replaceAll(
                "'",
                '&#039;'
            );
    }

    function escapeCSV(value) {
        const str =
            String(
                value ?? ''
            );

        if (
            str.includes(',') ||
            str.includes('"') ||
            str.includes('\n') ||
            str.includes('\r')
        ) {
            return (
                '"' +
                str.replaceAll(
                    '"',
                    '""'
                ) +
                '"'
            );
        }

        return str;
    }

    function getDateString() {
        return new Date()
            .toISOString()
            .slice(
                0,
                10
            );
    }

    function sleep(ms) {
        return new Promise(
            resolve =>
                setTimeout(
                    resolve,
                    ms
                )
        );
    }

    // ============================================================
    // Saved route
    // ============================================================

    function getSavedRoute() {
        const path =
            location.pathname
                .replace(
                    /\/+$/,
                    ''
                );

        const match =
            path.match(
                /^\/user\/([^/]+)\/saved$/i
            );

        if (!match) {
            return null;
        }

        let username;

        try {
            username =
                decodeURIComponent(
                    match[1]
                );
        } catch {
            username =
                match[1];
        }

        return {
            username,
            path
        };
    }

    function isSavedPage() {
        return Boolean(
            getSavedRoute()
        );
    }

    // ============================================================
    // Reddit post URLs
    // ============================================================

    function normalizePermalink(
        permalink
    ) {
        if (!permalink) {
            return '';
        }

        try {
            const parsed =
                new URL(
                    permalink,
                    'https://www.reddit.com'
                );

            if (
                !/\/comments\/[a-z0-9]+/i.test(
                    parsed.pathname
                )
            ) {
                return '';
            }

            parsed.protocol =
                'https:';

            parsed.hostname =
                'www.reddit.com';

            parsed.search =
                '';

            parsed.hash =
                '';

            let path =
                parsed.pathname;

            if (
                !path.endsWith('/')
            ) {
                path += '/';
            }

            return (
                'https://www.reddit.com' +
                path
            );

        } catch {
            return '';
        }
    }

    // ============================================================
    // Session persistence
    // ============================================================

    function loadStoredPosts() {
        try {
            const raw =
                JSON.parse(
                    sessionStorage.getItem(
                        STORAGE_KEY
                    ) ||
                    '[]'
                );

            if (
                !Array.isArray(
                    raw
                )
            ) {
                return;
            }

            collectedPosts.clear();

            for (
                const post
                of raw
            ) {
                if (
                    !post?.url
                ) {
                    continue;
                }

                collectedPosts.set(
                    post.url,
                    post
                );
            }

        } catch (error) {
            console.warn(
                '[Reddit Saved Collector] Could not restore session.',
                error
            );
        }
    }

    function saveStoredPosts() {
        try {
            sessionStorage.setItem(
                STORAGE_KEY,
                JSON.stringify(
                    [
                        ...collectedPosts.values()
                    ]
                )
            );

        } catch (error) {
            console.warn(
                '[Reddit Saved Collector] Could not save session.',
                error
            );
        }
    }

    // ============================================================
    // Reddit Saved JSON API
    // ============================================================

    function buildSavedListingUrl(
        username,
        after,
        count
    ) {
        const encodedUser =
            encodeURIComponent(
                username
            );

        const url =
            new URL(
                `/user/${encodedUser}/saved.json`,
                location.origin
            );

        url.searchParams.set(
            'limit',
            String(
                PAGE_LIMIT
            )
        );

        /*
         * Critical:
         *
         * Only saved Link objects (posts).
         * Saved comments are deliberately excluded.
         */
        url.searchParams.set(
            'type',
            'links'
        );

        url.searchParams.set(
            'raw_json',
            '1'
        );

        url.searchParams.set(
            'count',
            String(
                count
            )
        );

        if (after) {
            url.searchParams.set(
                'after',
                after
            );
        }

        return url.href;
    }

    function listingChildToPost(
        child,
        existingPosts
    ) {
        /*
         * Reddit fullname kinds:
         *
         * t3 = Link/post
         * t1 = Comment
         *
         * type=links should already limit the response to t3,
         * but enforce it again here.
         */
        if (
            child?.kind !==
            't3'
        ) {
            return null;
        }

        const data =
            child.data;

        if (!data) {
            return null;
        }

        const url =
            normalizePermalink(
                data.permalink
            );

        if (!url) {
            return null;
        }

        const previous =
            existingPosts.get(
                url
            );

        return {
            id:
                data.name ||
                (
                    data.id
                        ? `t3_${data.id}`
                        : ''
                ),

            url,

            title:
                normalizeWhitespace(
                    data.title
                ) ||
                url,

            subreddit:
                normalizeWhitespace(
                    data.subreddit
                ),

            author:
                normalizeWhitespace(
                    data.author
                ),

            sourceTitle:
                'Reddit Saved',

            sourceUrl:
                location.href,

            firstSeen:
                previous?.firstSeen ||
                new Date()
                    .toISOString()
        };
    }

    async function requestSavedPage(
        username,
        after,
        count,
        signal
    ) {
        const url =
            buildSavedListingUrl(
                username,
                after,
                count
            );

        console.debug(
            '[Reddit Saved Collector] Requesting Saved listing:',
            {
                username,
                after,
                count,
                url
            }
        );

        const response =
            await fetch(
                url,
                {
                    method:
                        'GET',

                    credentials:
                        'include',

                    headers: {
                        Accept:
                            'application/json'
                    },

                    signal
                }
            );

        if (
            response.status ===
            401 ||
            response.status ===
            403
        ) {
            throw new Error(
                'Reddit rejected access to the Saved listing. Make sure you are logged in.'
            );
        }

        if (!response.ok) {
            throw new Error(
                `Reddit Saved listing returned HTTP ${response.status}.`
            );
        }

        const contentType =
            response.headers
                .get(
                    'content-type'
                ) ||
            '';

        if (
            !contentType
                .toLowerCase()
                .includes(
                    'json'
                )
        ) {
            throw new Error(
                'Reddit returned a non-JSON response for the Saved listing.'
            );
        }

        let payload;

        try {
            payload =
                await response.json();

        } catch {
            throw new Error(
                'Reddit returned invalid JSON for the Saved listing.'
            );
        }

        const listing =
            payload?.data;

        if (
            !listing ||
            !Array.isArray(
                listing.children
            )
        ) {
            throw new Error(
                'Reddit returned an unexpected Saved listing format.'
            );
        }

        return listing;
    }

    async function loadSavedPosts() {
        const route =
            getSavedRoute();

        if (!route) {
            return;
        }

        if (loading) {
            return;
        }

        loading =
            true;

        updateButtons();

        if (
            currentAbortController
        ) {
            currentAbortController.abort();
        }

        const controller =
            new AbortController();

        currentAbortController =
            controller;

        const existingPosts =
            new Map(
                collectedPosts
            );

        /*
         * Build the refreshed result separately.
         *
         * We only replace the current collection after Reddit has
         * completed pagination successfully. A failed request therefore
         * cannot destroy the last good collection.
         */
        const refreshed =
            new Map();

        const seenCursors =
            new Set();

        let after =
            null;

        let count =
            0;

        let page =
            0;

        try {
            setStatus(
                'Loading saved posts from Reddit...'
            );

            while (true) {
                /*
                 * If we navigated away while a request was in flight,
                 * stop immediately.
                 */
                if (!isSavedPage()) {
                    throw new DOMException(
                        'Navigation changed',
                        'AbortError'
                    );
                }

                page++;

                if (
                    page >
                    100
                ) {
                    throw new Error(
                        'Pagination safety limit reached.'
                    );
                }

                setStatus(
                    `Loading Saved page ${page} — ${refreshed.size} posts found...`
                );

                const listing =
                    await requestSavedPage(
                        route.username,
                        after,
                        count,
                        controller.signal
                    );

                const children =
                    listing.children;

                let postsThisPage =
                    0;

                for (
                    const child
                    of children
                ) {
                    const post =
                        listingChildToPost(
                            child,
                            existingPosts
                        );

                    if (!post) {
                        continue;
                    }

                    refreshed.set(
                        post.url,
                        post
                    );

                    postsThisPage++;
                }

                count +=
                    children.length;

                console.debug(
                    '[Reddit Saved Collector] Saved listing page complete:',
                    {
                        page,
                        listingItems:
                            children.length,

                        postsAccepted:
                            postsThisPage,

                        totalSavedPosts:
                            refreshed.size,

                        nextAfter:
                            listing.after
                    }
                );

                /*
                 * Do NOT use:
                 *
                 * children.length < PAGE_LIMIT
                 *
                 * as the stopping rule.
                 *
                 * Reddit listings can return short intermediate pages.
                 * The authoritative end condition is after === null.
                 */
                const nextAfter =
                    listing.after ||
                    null;

                if (!nextAfter) {
                    break;
                }

                if (
                    seenCursors.has(
                        nextAfter
                    )
                ) {
                    throw new Error(
                        'Reddit repeated a pagination cursor.'
                    );
                }

                seenCursors.add(
                    nextAfter
                );

                after =
                    nextAfter;

                /*
                 * Keep requests sequential and avoid hammering Reddit.
                 */
                await sleep(
                    100
                );
            }

            /*
             * Ensure the route did not change between the last request
             * finishing and committing the result.
             */
            if (!isSavedPage()) {
                return;
            }

            collectedPosts.clear();

            for (
                const [
                    url,
                    post
                ]
                of refreshed
            ) {
                collectedPosts.set(
                    url,
                    post
                );
            }

            saveStoredPosts();

            updateCounter();

            setStatus(
                `Loaded ${collectedPosts.size} saved post${collectedPosts.size === 1 ? '' : 's'} from Reddit.`
            );

            console.log(
                `[Reddit Saved Collector] Loaded ${collectedPosts.size} saved posts across ${page} listing page${page === 1 ? '' : 's'}.`
            );

        } catch (error) {
            if (
                error?.name ===
                'AbortError'
            ) {
                console.debug(
                    '[Reddit Saved Collector] Saved listing request cancelled.'
                );

                return;
            }

            console.error(
                '[Reddit Saved Collector]',
                error
            );

            setStatus(
                'Could not load Saved posts: ' +
                error.message
            );

        } finally {
            if (
                currentAbortController ===
                controller
            ) {
                currentAbortController =
                    null;
            }

            loading =
                false;

            updateButtons();
        }
    }

    // ============================================================
    // Export formatting
    // ============================================================

    function formatTitlesAndUrls(
        posts
    ) {
        return posts
            .map(
                post =>
                    `${post.title}\n${post.url}`
            )
            .join(
                '\n\n'
            );
    }

    function formatUrlsOnly(
        posts
    ) {
        return posts
            .map(
                post =>
                    post.url
            )
            .join(
                '\n'
            );
    }

    function formatTXT(
        posts
    ) {
        return posts
            .map(
                post => {
                    const lines = [
                        post.title,
                        post.url
                    ];

                    if (
                        post.subreddit
                    ) {
                        lines.push(
                            `Subreddit: r/${post.subreddit}`
                        );
                    }

                    if (
                        post.author
                    ) {
                        lines.push(
                            `Author: u/${post.author}`
                        );
                    }

                    if (
                        post.firstSeen
                    ) {
                        lines.push(
                            `Collected: ${post.firstSeen}`
                        );
                    }

                    return lines.join(
                        '\n'
                    );
                }
            )
            .join(
                '\n\n'
            );
    }

    function formatCSV(
        posts
    ) {
        const rows = [
            [
                'Title',
                'URL',
                'Subreddit',
                'Author',
                'Collected At'
            ]
        ];

        for (
            const post
            of posts
        ) {
            rows.push(
                [
                    post.title ||
                        '',

                    post.url ||
                        '',

                    post.subreddit ||
                        '',

                    post.author ||
                        '',

                    post.firstSeen ||
                        ''
                ]
            );
        }

        return rows
            .map(
                row =>
                    row
                        .map(
                            escapeCSV
                        )
                        .join(',')
            )
            .join(
                '\r\n'
            );
    }

    // ============================================================
    // Clipboard
    // ============================================================

    async function copyText(
        text,
        successMessage
    ) {
        try {
            await navigator
                .clipboard
                .writeText(
                    text
                );

            showToast(
                successMessage
            );

        } catch (error) {
            console.error(
                '[Reddit Saved Collector] Clipboard failed.',
                error
            );

            showToast(
                'Clipboard copy failed.'
            );
        }
    }

    // ============================================================
    // Downloads
    // ============================================================

    function downloadBlob(
        contents,
        filename,
        type
    ) {
        const blob =
            new Blob(
                [
                    contents
                ],
                {
                    type
                }
            );

        const url =
            URL.createObjectURL(
                blob
            );

        const anchor =
            document.createElement(
                'a'
            );

        anchor.href =
            url;

        anchor.download =
            filename;

        document.body
            ?.appendChild(
                anchor
            );

        anchor.click();

        anchor.remove();

        setTimeout(
            () =>
                URL.revokeObjectURL(
                    url
                ),
            1000
        );
    }

    function downloadCSV(
        posts
    ) {
        const csv =
            '\uFEFF' +
            formatCSV(
                posts
            );

        downloadBlob(
            csv,
            `reddit-saved-posts-${getDateString()}.csv`,
            'text/csv;charset=utf-8'
        );

        showToast(
            `CSV exported: ${posts.length} posts`
        );
    }

    function downloadTXT(
        posts
    ) {
        downloadBlob(
            formatTXT(
                posts
            ),
            `reddit-saved-posts-${getDateString()}.txt`,
            'text/plain;charset=utf-8'
        );

        showToast(
            `TXT exported: ${posts.length} posts`
        );
    }

    // ============================================================
    // Toast
    // ============================================================

    function createToast() {
        if (
            document.getElementById(
                'reddit-saved-collector-toast'
            )
        ) {
            return true;
        }

        if (
            !document.documentElement
        ) {
            return false;
        }

        const toast =
            document.createElement(
                'div'
            );

        toast.id =
            'reddit-saved-collector-toast';

        toast.style = `
            position:fixed;
            left:50%;
            bottom:20px;
            transform:translateX(-50%);
            z-index:2147483647;
            padding:8px 14px;
            background:#111;
            color:#fff;
            border:1px solid #00d8ff;
            border-radius:6px;
            font-family:system-ui,sans-serif;
            font-size:12px;
            pointer-events:none;
            opacity:0;
            transition:opacity .2s;
        `;

        document.documentElement
            .appendChild(
                toast
            );

        return true;
    }

    function showToast(
        message
    ) {
        if (!createToast()) {
            return;
        }

        const toast =
            document.getElementById(
                'reddit-saved-collector-toast'
            );

        if (!toast) {
            return;
        }

        toast.textContent =
            message;

        toast.style.opacity =
            '1';

        clearTimeout(
            toastTimer
        );

        toastTimer =
            setTimeout(
                () => {
                    toast.style.opacity =
                        '0';
                },
                1800
            );
    }

    // ============================================================
    // Floating panel
    // ============================================================

    function createPanel() {
        if (
            !collectorActive ||
            !isSavedPage()
        ) {
            return;
        }

        const existing =
            document.getElementById(
                'reddit-saved-collector-counter'
            );

        if (existing) {
            updateCounter();
            updateButtons();

            return;
        }

        if (
            !document.documentElement
        ) {
            return;
        }

        const box =
            document.createElement(
                'div'
            );

        box.id =
            'reddit-saved-collector-counter';

        box.style = `
            position:fixed;
            right:18px;
            bottom:18px;
            z-index:2147483645;
            width:210px;
            padding:10px 12px;
            background:#111;
            color:#fff;
            border:1px solid #00d8ff;
            border-radius:7px;
            box-shadow:0 4px 18px rgba(0,0,0,.5);
            font-family:system-ui,sans-serif;
            font-size:12px;
            text-align:center;
        `;

        box.innerHTML = `
            <div
                style="
                    color:#00d8ff;
                    font-size:10px;
                    margin-bottom:4px;
                "
            >
                REDDIT SAVED POSTS
            </div>

            <div
                id="reddit-saved-collector-count"
                style="
                    font-weight:700;
                    margin-bottom:5px;
                "
            >
                0 saved posts
            </div>

            <div
                id="reddit-saved-collector-status"
                style="
                    margin-bottom:7px;
                    color:#aaa;
                    font-size:10px;
                    line-height:1.3;
                "
            >
                Ready.
            </div>

            <button
                id="reddit-saved-refresh"
                type="button"
                style="
                    width:100%;
                    padding:6px;
                    background:#174f67;
                    color:#fff;
                    border:1px solid #277a9c;
                    border-radius:5px;
                    cursor:pointer;
                    font-weight:600;
                    font-size:11px;
                "
            >
                REFRESH SAVED POSTS
            </button>

            <button
                id="reddit-saved-links-only"
                type="button"
                style="
                    width:100%;
                    margin-top:5px;
                    padding:6px;
                    background:#222;
                    color:#fff;
                    border:1px solid #444;
                    border-radius:5px;
                    cursor:pointer;
                    font-weight:600;
                    font-size:11px;
                "
            >
                LINKS ONLY
            </button>

            <button
                id="reddit-saved-reset"
                type="button"
                style="
                    width:100%;
                    margin-top:5px;
                    padding:4px;
                    background:#311;
                    color:#bbb;
                    border:1px solid #522;
                    border-radius:5px;
                    cursor:pointer;
                    font-size:10px;
                "
            >
                RESET SESSION LIST
            </button>

            <div
                style="
                    margin-top:6px;
                    color:#666;
                    font-size:9px;
                "
            >
                v${VERSION} · Alt+R
            </div>
        `;

        document.documentElement
            .appendChild(
                box
            );

        box.querySelector(
            '#reddit-saved-refresh'
        )?.addEventListener(
            'click',
            loadSavedPosts
        );

        box.querySelector(
            '#reddit-saved-links-only'
        )?.addEventListener(
            'click',
            toggleLinksOnly
        );

        box.querySelector(
            '#reddit-saved-reset'
        )?.addEventListener(
            'click',
            resetCollection
        );

        updateCounter();
        updateButtons();
    }

    function removePanel() {
        document
            .getElementById(
                'reddit-saved-collector-counter'
            )
            ?.remove();
    }

    function updateCounter() {
        const element =
            document.getElementById(
                'reddit-saved-collector-count'
            );

        if (!element) {
            return;
        }

        element.textContent =
            `${collectedPosts.size} saved post${collectedPosts.size === 1 ? '' : 's'}`;
    }

    function setStatus(
        message
    ) {
        const element =
            document.getElementById(
                'reddit-saved-collector-status'
            );

        if (!element) {
            return;
        }

        element.textContent =
            message;
    }

    function updateButtons() {
        const refresh =
            document.getElementById(
                'reddit-saved-refresh'
            );

        const linksOnly =
            document.getElementById(
                'reddit-saved-links-only'
            );

        const reset =
            document.getElementById(
                'reddit-saved-reset'
            );

        for (
            const button
            of [
                refresh,
                linksOnly,
                reset
            ]
        ) {
            if (!button) {
                continue;
            }

            button.disabled =
                loading;

            button.style.opacity =
                loading
                    ? '0.55'
                    : '1';

            button.style.cursor =
                loading
                    ? 'default'
                    : 'pointer';
        }

        if (refresh) {
            refresh.textContent =
                loading
                    ? 'LOADING...'
                    : 'REFRESH SAVED POSTS';
        }
    }

    // ============================================================
    // Reset
    // ============================================================

    function resetCollection() {
        if (!isSavedPage()) {
            return;
        }

        if (
            !confirm(
                'Clear the locally collected Saved-post list for this browser session?\n\nThis does NOT unsave anything on Reddit.'
            )
        ) {
            return;
        }

        collectedPosts.clear();

        sessionStorage.removeItem(
            STORAGE_KEY
        );

        updateCounter();

        setStatus(
            'Session list cleared. Use Refresh Saved Posts to load it again.'
        );

        showToast(
            'Local session list cleared.'
        );
    }

    // ============================================================
    // Links-only overlay
    // ============================================================

    function renderLinksOnly() {
        if (
            linksOnlyActive ||
            !isSavedPage()
        ) {
            return;
        }

        const posts =
            [
                ...collectedPosts.values()
            ];

        if (!posts.length) {
            alert(
                'No saved Reddit posts have been loaded yet.'
            );

            return;
        }

        const overlay =
            document.createElement(
                'div'
            );

        overlay.id =
            'reddit-links-only-overlay';

        overlay.style = `
            position:fixed;
            inset:0;
            z-index:2147483646;
            overflow:auto;
            background:#0f0f0f;
            color:#eee;
            font-family:
                system-ui,
                -apple-system,
                BlinkMacSystemFont,
                "Segoe UI",
                sans-serif;
        `;

        const rows =
            posts
                .map(
                    (
                        post,
                        index
                    ) => {

                        const subreddit =
                            post.subreddit
                                ? `
                                    <span class="rlv-subreddit">
                                        r/${escapeHtml(post.subreddit)}
                                    </span>
                                `
                                : '';

                        const author =
                            post.author
                                ? `
                                    <span class="rlv-author">
                                        u/${escapeHtml(post.author)}
                                    </span>
                                `
                                : '';

                        return `
                            <div class="rlv-row">

                                <div class="rlv-number">
                                    ${index + 1}
                                </div>

                                <div class="rlv-content">

                                    <a
                                        class="rlv-title"
                                        href="${escapeHtml(post.url)}"
                                        target="_blank"
                                        rel="noopener noreferrer"
                                    >
                                        ${escapeHtml(post.title)}
                                    </a>

                                    <div class="rlv-meta">

                                        ${subreddit}
                                        ${author}

                                        <a
                                            class="rlv-url"
                                            href="${escapeHtml(post.url)}"
                                            target="_blank"
                                            rel="noopener noreferrer"
                                        >
                                            ${escapeHtml(post.url)}
                                        </a>

                                    </div>

                                </div>

                            </div>
                        `;
                    }
                )
                .join('');

        overlay.innerHTML = `
            <style>

                #reddit-links-only-overlay * {
                    box-sizing:border-box;
                }

                #rlv-root {
                    max-width:1200px;
                    margin:0 auto;
                    padding:28px;
                }

                .rlv-header {
                    position:sticky;
                    top:0;
                    z-index:10;
                    display:flex;
                    justify-content:space-between;
                    align-items:center;
                    gap:20px;
                    margin:-28px -28px 20px -28px;
                    padding:16px 28px;
                    background:#111;
                    border-bottom:1px solid #333;
                }

                .rlv-heading {
                    color:#fff;
                    font-size:20px;
                    font-weight:700;
                }

                .rlv-count {
                    margin-top:3px;
                    color:#999;
                    font-size:12px;
                }

                .rlv-buttons {
                    display:flex;
                    gap:8px;
                    flex-wrap:wrap;
                    justify-content:flex-end;
                }

                .rlv-button {
                    padding:8px 12px;
                    border:1px solid #444;
                    border-radius:6px;
                    background:#222;
                    color:#fff;
                    cursor:pointer;
                    font-size:12px;
                    font-weight:600;
                }

                .rlv-button:hover {
                    background:#333;
                }

                .rlv-row {
                    display:grid;
                    grid-template-columns:42px 1fr;
                    gap:10px;
                    padding:13px 8px;
                    border-bottom:1px solid #292929;
                }

                .rlv-row:hover {
                    background:#161616;
                }

                .rlv-number {
                    padding-top:2px;
                    color:#666;
                    text-align:right;
                    font-size:12px;
                }

                .rlv-content {
                    min-width:0;
                }

                .rlv-title {
                    display:inline-block;
                    color:#e8e8e8 !important;
                    font-size:15px;
                    font-weight:600;
                    line-height:1.35;
                    text-decoration:none !important;
                }

                .rlv-title:hover {
                    color:#00d8ff !important;
                    text-decoration:underline !important;
                }

                .rlv-meta {
                    display:flex;
                    align-items:center;
                    gap:8px;
                    min-width:0;
                    margin-top:5px;
                    font-size:11px;
                }

                .rlv-subreddit,
                .rlv-author {
                    flex:0 0 auto;
                    padding:2px 5px;
                    border-radius:3px;
                    background:#242424;
                    color:#aaa;
                }

                .rlv-url {
                    display:block;
                    min-width:0;
                    overflow:hidden;
                    text-overflow:ellipsis;
                    white-space:nowrap;
                    color:#666 !important;
                    text-decoration:none !important;
                }

                .rlv-url:hover {
                    color:#aaa !important;
                    text-decoration:underline !important;
                }

                @media (max-width:800px) {

                    #rlv-root {
                        padding:16px;
                    }

                    .rlv-header {
                        align-items:flex-start;
                        flex-direction:column;
                        margin:-16px -16px 16px -16px;
                        padding:12px 16px;
                    }

                    .rlv-buttons {
                        justify-content:flex-start;
                    }

                    .rlv-row {
                        grid-template-columns:30px 1fr;
                    }
                }

            </style>

            <div id="rlv-root">

                <div class="rlv-header">

                    <div>

                        <div class="rlv-heading">
                            Reddit Saved — Saved Posts
                        </div>

                        <div class="rlv-count">
                            ${posts.length}
                            saved post${posts.length === 1 ? '' : 's'}
                            returned by Reddit
                        </div>

                    </div>

                    <div class="rlv-buttons">

                        <button
                            id="rlv-copy-title-url"
                            class="rlv-button"
                            type="button"
                        >
                            COPY TITLES + URLS
                        </button>

                        <button
                            id="rlv-copy-urls"
                            class="rlv-button"
                            type="button"
                        >
                            COPY URLS
                        </button>

                        <button
                            id="rlv-download-csv"
                            class="rlv-button"
                            type="button"
                        >
                            DOWNLOAD CSV
                        </button>

                        <button
                            id="rlv-download-txt"
                            class="rlv-button"
                            type="button"
                        >
                            DOWNLOAD TXT
                        </button>

                        <button
                            id="rlv-close"
                            class="rlv-button"
                            type="button"
                        >
                            RETURN TO REDDIT
                        </button>

                    </div>

                </div>

                <div id="rlv-list">
                    ${rows}
                </div>

            </div>
        `;

        document.documentElement
            .appendChild(
                overlay
            );

        linksOnlyActive =
            true;

        overlay
            .querySelector(
                '#rlv-close'
            )
            ?.addEventListener(
                'click',
                closeLinksOnly
            );

        overlay
            .querySelector(
                '#rlv-copy-title-url'
            )
            ?.addEventListener(
                'click',
                () =>
                    copyText(
                        formatTitlesAndUrls(
                            posts
                        ),
                        `Copied ${posts.length} titles + URLs`
                    )
            );

        overlay
            .querySelector(
                '#rlv-copy-urls'
            )
            ?.addEventListener(
                'click',
                () =>
                    copyText(
                        formatUrlsOnly(
                            posts
                        ),
                        `Copied ${posts.length} URLs`
                    )
            );

        overlay
            .querySelector(
                '#rlv-download-csv'
            )
            ?.addEventListener(
                'click',
                () =>
                    downloadCSV(
                        posts
                    )
            );

        overlay
            .querySelector(
                '#rlv-download-txt'
            )
            ?.addEventListener(
                'click',
                () =>
                    downloadTXT(
                        posts
                    )
            );
    }

    function closeLinksOnly() {
        document
            .getElementById(
                'reddit-links-only-overlay'
            )
            ?.remove();

        linksOnlyActive =
            false;
    }

    function toggleLinksOnly() {
        if (
            linksOnlyActive
        ) {
            closeLinksOnly();
            return;
        }

        if (
            isSavedPage()
        ) {
            renderLinksOnly();
        }
    }

    // ============================================================
    // Collector lifecycle
    // ============================================================

    function startCollector() {
        if (!isSavedPage()) {
            stopCollector();
            return;
        }

        if (
            collectorActive
        ) {
            createPanel();
            return;
        }

        collectorActive =
            true;

        createPanel();

        updateCounter();

        /*
         * Automatically refresh when entering Saved.
         *
         * Existing session data may appear immediately while Reddit's
         * current Saved listing is fetched.
         */
        loadSavedPosts();

        console.log(
            '[Reddit Saved Collector] Saved API collector activated.'
        );
    }

    function stopCollector() {
        if (
            currentAbortController
        ) {
            currentAbortController.abort();

            currentAbortController =
                null;
        }

        loading =
            false;

        collectorActive =
            false;

        removePanel();

        if (
            linksOnlyActive
        ) {
            closeLinksOnly();
        }
    }

    function syncCollectorToRoute() {
        lastKnownUrl =
            location.href;

        if (
            isSavedPage()
        ) {
            startCollector();

        } else {
            stopCollector();
        }
    }

    function scheduleRouteSync(
        delay = 0
    ) {
        clearTimeout(
            routeSyncTimer
        );

        routeSyncTimer =
            setTimeout(
                syncCollectorToRoute,
                delay
            );
    }

    // ============================================================
    // Reddit SPA navigation
    // ============================================================

    function hookHistoryMethod(
        methodName
    ) {
        const original =
            history[
                methodName
            ];

        if (
            typeof original !==
            'function'
        ) {
            return;
        }

        history[
            methodName
        ] =
            function (...args) {
                const result =
                    original.apply(
                        this,
                        args
                    );

                scheduleRouteSync(
                    0
                );

                return result;
            };
    }

    hookHistoryMethod(
        'pushState'
    );

    hookHistoryMethod(
        'replaceState'
    );

    window.addEventListener(
        'popstate',
        () =>
            scheduleRouteSync(
                0
            )
    );

    window.addEventListener(
        'hashchange',
        () =>
            scheduleRouteSync(
                0
            )
    );

    window.addEventListener(
        'pageshow',
        () =>
            scheduleRouteSync(
                0
            )
    );

    document.addEventListener(
        'visibilitychange',
        () => {
            if (
                document.visibilityState ===
                'visible'
            ) {
                scheduleRouteSync(
                    0
                );
            }
        }
    );

    /*
     * Lightweight route fallback.
     *
     * No DOM content is scanned here.
     */
    setInterval(
        () => {
            if (
                location.href !==
                lastKnownUrl
            ) {
                console.debug(
                    '[Reddit Saved Collector] Route changed:',
                    lastKnownUrl,
                    '→',
                    location.href
                );

                syncCollectorToRoute();
            }
        },
        ROUTE_POLL_MS
    );

    // ============================================================
    // Alt + R
    // ============================================================

    document.addEventListener(
        'keydown',
        event => {

            const target =
                event.target;

            const typing =
                target instanceof
                    HTMLInputElement ||
                target instanceof
                    HTMLTextAreaElement ||
                target?.isContentEditable;

            if (typing) {
                return;
            }

            if (
                event.altKey &&
                !event.ctrlKey &&
                !event.shiftKey &&
                event.code ===
                    'KeyR'
            ) {
                if (
                    !isSavedPage()
                ) {
                    return;
                }

                event.preventDefault();

                toggleLinksOnly();
            }
        }
    );

    // ============================================================
    // Start
    // ============================================================

    loadStoredPosts();

    syncCollectorToRoute();

    console.log(
        `[Reddit Saved Collector] v${VERSION} API collector installed.`
    );

})();