17

I have almost 1700 questions listed in my "saves" on Stack Overflow (mostly to give me a curated set to search within, when looking for targets to close duplicates), partially organized into a whole bunch of lists (representing back-burner projects that I might never get around to).

Is there any provided way to download this information? I couldn't find anything relevant in the SEDE database schema (not surprising, since this per-user information isn't public).

1
  • 3
    I don't think such an option exists right now. I'd support this as a feature request though. Adding an option to export the lists as JSON/XML/CSV would be nice.
    – 41686d6564
    Commented Aug 6, 2023 at 4:59

3 Answers 3

7

Here’s a somewhat hastily-built user script adding an "export" user script command. It can also be invoked directly by copy-pasting the entire source code into the developer console, in which case it will perform the export immediately.

It scrapes the data available from the /users/saves/... pages, stuffs them into an XBEL file and presents it for download. Private notes, post and list IDs are preserved in custom-namespaced attributes.

Limitations: the titles will be extracted as they are displayed on the page, so in particular they may include the "[duplicate]" or "[closed]" marker. Metadata other than titles, IDs and private notes are not extracted.

// ==UserScript==
// @name     Stack Exchange: save export
// @grant    GM.registerMenuCommand
// @match    https://*.stackexchange.com/*
// @match    https://*.superuser.com/*
// @match    https://*.stackoverflow.com/*
// @match    https://*.mathoverflow.net/*
// @match    https://*.serverfault.com/*
// @match    https://*.askubuntu.com/*
// @match    https://stackapps.com/*
// @exclude  https://api.stackexchange.com/*
// @exclude  https://data.stackexchange.com/*
// @exclude  https://openid.stackexchange.com/*
// @exclude  https://area51.stackexchange.com/*
// @exclude  https://stackexchange.com/*
// @exclude  https://contests.stackoverflow.com/*
// @exclude  /^https?:\/\/winterbash\d{4,}\.stackexchange\.com\//
// @run-at   document-start
// ==/UserScript==

const fetchHTML = (url) => new Promise((ok, ko) => {
    const xhr = new XMLHttpRequest();
    xhr.responseType = 'document';
    xhr.onload = (ev) => {
        ok(xhr.response);
    };
    xhr.onabort = ev => {
        ko(ev);
    };
    xhr.onerror = ev => {
        ko(ev);
    };
    xhr.ontimeout = ev => {
        ko(ev);
    };
    xhr.onloadend = ev => {
        ko(new TypeError('internal error'));
    };
    xhr.open('GET', `${url}`);
    xhr.send(null);
});

const enumerateLists = async function* () {
    const doc = await fetchHTML(new URL(`/posts/1/open-save-modal?isMoveTo=true`, location));

    for (const nodeList of doc.querySelectorAll(
        '.js-save-manage-select option[data-list-id]'))
    {
        const { listId, listName } = nodeList.dataset;
        yield { id: listId, name: listName };
    }
};

const enumerateSaves = async function* (userId) {
    let url = new URL(`/users/saves/${userId}/all`, location);
    for (;;) {
        const doc = await fetchHTML(url);
        const nodeSaves = doc.querySelector('.js-saves-page');

        const nodeList = nodeSaves.querySelector('.js-saves-post-list');
        for (const nodePost of nodeList.querySelectorAll('.js-post-summary[data-post-id]')) {
            const { postId, postTypeId } = nodePost.dataset;
            const nodeSavedIn = nodePost.querySelector('.js-saved-in');
            const nodeLink = nodePost.querySelector(
                '.s-post-summary--content-title :any-link');
            const nodeNote = nodePost.querySelector(
                ':not(.d-none) > .js-saved-post-note');
            const listId = /^\/users\/saves\/\d+\/(\d+)$/.exec(new URL(nodeSavedIn.href).pathname)?.[1] ?? null;

            const url = new URL(nodeLink.href);
            url.search = '';

            yield {
                postId,
                postTypeId,
                listId,
                url: `${url}`,
                title: nodeLink.textContent,
                note: nodeNote?.textContent ?? null,
            };
        }

        const nodeNext = nodeSaves.querySelector(':any-link[rel~="next"]');
        if (!nodeNext)
            break;
        url = new URL(nodeNext.href);
    }
};

const exportSavesToXBEL = async (userId) => {
    const MY_NS = 'https://meta.stackexchange.com/q/391982';

    const doc = document.implementation.createDocument('', 'xbel');
    doc.documentElement.setAttribute('version', '1.0');
    doc.documentElement.setAttributeNS(MY_NS, 'sese:version', '0.1');

    const root = doc.createElement('folder');
    {
        const nodeTitle = doc.createElement('title');
        nodeTitle.textContent = `Saves from ${location.host}`;
        root.appendChild(nodeTitle);

        root.setAttributeNS(MY_NS, 'sese:site-href', location.origin);
        doc.documentElement.appendChild(root);
    }

    const lists = new Map();

    {
        const nodeList = doc.createElement('folder');

        const nodeTitle = doc.createElement('title');
        nodeTitle.textContent = `For later`;
        nodeList.appendChild(nodeTitle);

        nodeList.setAttributeNS(MY_NS, 'sese:list-href',
            new URL(`/users/saves/${userId}`, location));

        root.appendChild(nodeList);
        lists.set(null, nodeList);
    }

    for await (const item of enumerateLists()) {
        const nodeList = doc.createElement('folder');

        const nodeTitle = doc.createElement('title');
        nodeTitle.textContent = item.name;
        nodeList.appendChild(nodeTitle);

        nodeList.setAttributeNS(MY_NS, 'sese:list-id', item.id);
        nodeList.setAttributeNS(MY_NS, 'sese:list-href',
            new URL(`/users/saves/${userId}/${item.id}`, location));

        root.appendChild(nodeList);
        lists.set(item.id, nodeList);
    }

    for await (const item of enumerateSaves(userId)) {
        const nodeItem = doc.createElement('bookmark');

        const nodeTitle = doc.createElement('title');
        nodeTitle.textContent = item.title;
        nodeItem.appendChild(nodeTitle);

        nodeItem.setAttribute('href', item.url);
        nodeItem.setAttributeNS(MY_NS, 'sese:post-id', item.postId);
        nodeItem.setAttributeNS(MY_NS, 'sese:post-type-id', item.postTypeId);
        if (item.note != null) {
            nodeItem.setAttributeNS(MY_NS, 'sese:post-note', item.note);
        }

        lists.get(item.listId).appendChild(nodeItem);
    }

    const blob = new Blob(
        [new XMLSerializer().serializeToString(doc, 'text/xml')],
        { type: 'application/xbel+xml' }
    );

    const blobURL = URL.createObjectURL(blob);
    {
        const link = document.createElement('a');
        link.href = blobURL;
        link.download = `${location.hostname}.saves.xbel`;
        link.click();
    }
    setTimeout(() => URL.revokeObjectURL(blobURL), 60_000);
};

if (typeof GM !== 'undefined') {
    GM.registerMenuCommand('Export saves as XBEL', () => {
        exportSavesToXBEL(unsafeWindow.StackExchange.options.user.userId);
    });
} else {
    exportSavesToXBEL(StackExchange.options.user.userId);
}
3

This is currently not possible.

The most viable option would be adding the option to view own Saves via the API, something that was possible when it was still Bookmarks and then Favorites, but then disabled due to Saves being private.

There is already a pending feature request asking for this, which is under review and even got response from staff, however don't build your hopes too high, or to any height at all. The company resources are focused on other things these days.

2

Semi-automatically using the browser console (tested in Firefox 115):

  1. Navigate to your Saves page.

  2. Open the browser console, clear it, and disable all message types except "Logs".

  3. Paste in and run a simple IIFE (or a few statements) using JQuery to scrape the relevant information from each entry. In my case, I only wanted the post ID and the name of the save category, and I wanted these paired up on lines with a comma separating them, so I came up with:

    (() => {
        const posts = $(".s-post-summary");
        function i() { return $(this).attr('data-post-id'); }
        function c() { return $(this).text(); }
        const m = posts.map(i);
        const n = posts.find('.js-saved-in:eq(0)').map(c);
        return m.map((i, e) => e + ', ' + n[i]).get().join('\n');
    })();
    
  4. Go down to the bottom of the page, click "Next" to go to the next page, and paste in and run the code again. Repeat for all pages (30 saves at a time).

  5. Right-click in the console -> "Save all messages to file"; edit the file to remove the code blocks and anything else that managed to sneak in (in my case, logging from Noscript).

  6. Process the rest as desired.

Not exactly pleasant, but simple and effective. Although maybe I should have grabbed the page titles too.

2
  • Probably your script can also press the "next" button?
    – markalex
    Commented Aug 7, 2023 at 5:27
  • 1
    Probably I could parse out the URL for the next page, or just have them go in a loop and console.log each page's results, yeah. Commented Aug 7, 2023 at 5:46

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .