Skip to content

Instantly share code, notes, and snippets.

@zachcp
Created June 6, 2026 15:40
Show Gist options
  • Select an option

  • Save zachcp/2a77fa9e0742edfae14c80ce9af7a803 to your computer and use it in GitHub Desktop.

Select an option

Save zachcp/2a77fa9e0742edfae14c80ce9af7a803 to your computer and use it in GitHub Desktop.
Substack --> OPML
// Substack → OPML exporter
// Usage: navigate to https://substack.com/@zachcp/reads, scroll to bottom to
// load all subscriptions, then paste into browser console.
// Downloads substack_subscriptions.opml to your Downloads folder.
(async () => {
// Collect all unique publication URLs from the page.
// Each subscription card links to the publication root (e.g. https://foo.substack.com
// or https://foo.com for custom domains).
const anchors = [...document.querySelectorAll("a[href]")];
const seen = new Set();
const feeds = [];
for (const a of anchors) {
const href = a.href;
// Keep only substack subdomain or plausible custom-domain publication links.
// Filter out nav, profile, settings, and archive links.
if (
!href ||
href.includes("/p/") || // individual posts
href.includes("/profile/") ||
href.includes("/settings") ||
href.includes("/about") ||
href.includes("substack.com/@") || // profile pages
href.includes("substack.com/s/") ||
href.endsWith("/reads") ||
href.endsWith("/notes") ||
seen.has(href)
) continue;
// Accept *.substack.com roots or any URL that is a direct child of its host
// (i.e. the card link to the publication homepage)
const url = new URL(href);
if (url.pathname !== "/" && url.pathname !== "") continue;
seen.add(href);
// Grab the nearest text as title — look for a heading inside the card
const card = a.closest("div, article, li") ?? a;
const heading = card.querySelector("h1, h2, h3, h4, strong, [class*='title'], [class*='name']");
const title = (heading?.textContent ?? a.textContent ?? url.hostname)
.trim()
.replace(/\s*by(?=\s|\p{Lu}|\d).*/iu, "") // strip "by AuthorName..." (with or without space)
.replace(/Subscribed?$/i, "") // strip trailing "Subscribe" / "Subscribed"
.trim()
.replace(/&/g, "&")
.replace(/"/g, """)
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
const base = href.replace(/\/$/, "");
feeds.push({ title: title || url.hostname, feedUrl: `${base}/feed`, htmlUrl: base });
}
if (!feeds.length) {
console.error("No feeds found. Make sure you're on https://substack.com/@zachcp/reads and have scrolled to load all subscriptions.");
return;
}
const outlines = feeds.map(
({ title, feedUrl, htmlUrl }) =>
` <outline type="rss" text="${title}" title="${title}" xmlUrl="${feedUrl}" htmlUrl="${htmlUrl}"/>`
);
const opml = `<?xml version="1.0" encoding="UTF-8"?>
<opml version="1.0">
<head>
<title>Substack Subscriptions</title>
<dateCreated>${new Date().toUTCString()}</dateCreated>
</head>
<body>
<outline text="Substack" title="Substack">
${outlines.join("\n")}
</outline>
</body>
</opml>`;
const blob = new Blob([opml], { type: "text/x-opml" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "substack_subscriptions.opml";
a.click();
URL.revokeObjectURL(url);
console.log(`Downloaded OPML with ${feeds.length} feeds.`);
console.table(feeds);
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment