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);
}