Something went wrong. Try again.
a tool for shared writing and social publishing
Something went wrong. Try again.
4.0 kB · 116 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117import { strFromU8, unzipSync } from "fflate";import { parseCsv } from "src/email-subscribers/import";
// Substack's export (Settings → Import/Export → Export your data) is a zip// of:// posts.csv one row per post or draft, metadata only// posts/{id}.{slug}.html that post's body as an HTML fragment// posts/{id}.{slug}.*.csv per-post email delivery/open/click logs// email_list.{subdomain}.csv the subscriber list// Only the first two are read here. The export has no images (bodies link to// Substack's CDN), cover images, or tags; see substackSite.ts for those.
export type SubstackPost = { id: string; slug: string; title: string; subtitle: string; publishedAt: string | null; isPublished: boolean; // "everyone" for a free post; anything else is some paid tier. audience: string; type: string; html: string; coverImage: string | null; tags: string[];};
export type SubstackExport = { // From the subscriber list's file name; null if the export has no list. subdomain: string | null; posts: SubstackPost[]; // Drafts with nothing in them, left out of `posts`. emptyDrafts: number;};
const hasContent = (html: string) => /<(img|iframe)\b|data-attrs=/.test(html) || html.replace(/<[^>]*>/g, "").trim() !== "";
// Drafts that were never published have no title in posts.csv, only the slug// Substack derived from one.const titleFromSlug = (slug: string) => { const words = slug.replace(/-/g, " ").trim(); return words ? words[0].toUpperCase() + words.slice(1) : "(Untitled)";};
export function parseSubstackExport(zip: Uint8Array): SubstackExport { let subdomain: string | null = null; const files = unzipSync(zip, { filter: (f) => { const name = f.name.split("/").pop()!; const list = /^email_list\.(.+)\.csv$/.exec(name); if (list) subdomain = list[1]; return name === "posts.csv" || /(^|\/)posts\/[^/]+\.html$/.test(f.name); }, }); const csvPath = Object.keys(files).find((f) => /(^|\/)posts\.csv$/.test(f)); if (!csvPath) throw new Error("Not a Substack export: no posts.csv"); const root = csvPath.slice(0, -"posts.csv".length);
const [header, ...rows] = parseCsv( strFromU8(files[csvPath]).replace(/^\uFEFF/, ""), ); const column = (name: string) => { const i = header.indexOf(name); if (i < 0) throw new Error(`posts.csv has no "${name}" column`); return i; }; const cols = { id: column("post_id"), date: column("post_date"), published: column("is_published"), type: column("type"), audience: column("audience"), title: column("title"), subtitle: column("subtitle"), };
const posts: SubstackPost[] = []; let emptyDrafts = 0; for (const row of rows) { // post_id is "{numeric id}.{slug}", which is also the body's file name. const postId = row[cols.id]?.trim(); if (!postId) continue; const dot = postId.indexOf("."); if (dot < 0) throw new Error(`Unexpected post_id "${postId}"`); const slug = postId.slice(dot + 1); const body = files[`${root}posts/${postId}.html`]; const html = body ? strFromU8(body) : ""; const isPublished = row[cols.published] === "true"; if (!hasContent(html)) { if (isPublished) throw new Error(`Published post ${postId} has no body`); emptyDrafts++; continue; } posts.push({ id: postId.slice(0, dot), slug, title: row[cols.title]?.trim() || titleFromSlug(slug), subtitle: row[cols.subtitle]?.trim() ?? "", publishedAt: row[cols.date] || null, isPublished, audience: row[cols.audience] || "everyone", type: row[cols.type] || "newsletter", html, coverImage: null, tags: [], }); } // Oldest first, so an import that publishes in list order produces a // sensibly-ordered feed; undated drafts go last. posts.sort((a, b) => (a.publishedAt ?? "9").localeCompare(b.publishedAt ?? "9"), ); return { subdomain, posts, emptyDrafts };}