From b06dd6911d8c83a119d31933265e29b6e093f985 Mon Sep 17 00:00:00 2001 From: Charles Kelsoe Date: Sun, 14 Jun 2026 16:27:53 -0400 Subject: [PATCH] Converter: strip Substack app-install / subscribe / CTA clutter Remove email-truncation and 'read in the app' promos, subscribe forms, and CTA buttons by their Substack data-component-name (InstallSubstackAppToDOM, SubscribeWidgetToDOM, ButtonCreateButton) plus the matching class families (install-substack-app, subscription-widget, preamble, digest-post-embed, fake-input/button, cta-caption). Real images (Image2ToDOM) and prose are kept. Structural match only, never body text. Test added. --- __tests__/html-converter.test.ts | 17 +++++++++++++++++ html-converter.ts | 25 +++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/__tests__/html-converter.test.ts b/__tests__/html-converter.test.ts index 4d87099..930120c 100644 --- a/__tests__/html-converter.test.ts +++ b/__tests__/html-converter.test.ts @@ -138,6 +138,23 @@ describe("convertHtmlToMarkdown: widget stripping and degradation", () => { const md = convertHtmlToMarkdown("

You can subscribe at the library for free.

"); expect(md).toContain("You can subscribe at the library for free."); }); + + it("strips Substack app-install and subscribe embeds by data-component-name, keeping images and prose", () => { + const html = + '
' + + '

If you are reading this in email, the text may cut off. Get the app.

' + + 'Get the app
' + + "

The real article body that must be kept.

" + + '
A figure
' + + '
' + + 'Become a supporter
'; + const md = convertHtmlToMarkdown(html); + expect(md).toContain("The real article body that must be kept."); + expect(md).toContain("https://substackcdn.com/image/x.png"); + expect(md).not.toContain("Get the app"); + expect(md).not.toContain("reading this in email"); + expect(md).not.toContain("Become a supporter"); + }); }); describe("convertHtmlToMarkdown: footnotes", () => { diff --git a/html-converter.ts b/html-converter.ts index 57311af..4815970 100644 --- a/html-converter.ts +++ b/html-converter.ts @@ -99,6 +99,27 @@ const WIDGET_STRIP_KEYS: readonly string[] = [ "poll-embed", "comments-button", "footer-buttons", + // Substack email-truncation / "read in the app" promo and CTA form blocks. + "install-substack-app", + "subscription-widget", + "preamble", + "digest-post-embed", + "fake-input", + "fake-button", + "email-input", + "cta-caption", +]; + +/** + * `data-component-name` values Substack stamps on its embedded widgets. This is + * the most reliable marker for the subscribe form, the "read in the app" promo, + * and CTA buttons. `Image2ToDOM` (real captioned images) is deliberately NOT + * listed so images are preserved. Matched exactly (not substring). + */ +const WIDGET_STRIP_COMPONENTS: readonly string[] = [ + "SubscribeWidgetToDOM", + "InstallSubstackAppToDOM", + "ButtonCreateButton", ]; /** @@ -153,6 +174,10 @@ function classMatchesAny(node: ClassedElement, keys: readonly string[]): boolean /** Known subscribe/share/button widget: removed outright. */ function isStripWidget(node: ClassedElement): boolean { + const component = node.getAttribute("data-component-name"); + if (component !== null && WIDGET_STRIP_COMPONENTS.includes(component)) { + return true; + } return classMatchesAny(node, WIDGET_STRIP_KEYS); }