mirror of
https://github.com/ckelsoe/obsidian-rss-importer.git
synced 2026-07-22 07:48:56 +00:00
Import window: - Endless scroll: an IntersectionObserver auto-loads older Substack archive pages as a bottom sentinel scrolls into view. The "Load older" button stays as a fallback and end-of-archive indicator. - Select-all toggle that checks every available (not imported, not dismissed) loaded item and flips to "Deselect all" once all are selected. - Toolbar counts: a running loaded count that reads "All N items" once the archive is exhausted, and an "x of y selected" count beside the toggle. Resolver: - Detect a custom-domain Substack by its <generator>Substack</generator> feed marker, so it classifies as substack (and gains archive backfill) instead of falling back to generic. Backward compatible: feeds without the marker stay generic. Settings: - A "Re-detect" button on each feed editor page re-resolves the feed and updates its source type in place, upgrading a feed saved as generic without removing and re-adding it. feedId is left untouched so dedup and dismissals stay valid. Adds unit coverage for selectableItemIds, selectAllControlState, and the generator-marker detection on both the probe and explicit-feed-url paths.
448 lines
16 KiB
TypeScript
448 lines
16 KiB
TypeScript
/**
|
|
* Classifies a user-supplied feed input and resolves it to a canonical,
|
|
* fetchable feed.
|
|
*
|
|
* The user can type a Substack handle (`@kevin` or `substack.com/@kevin`), a
|
|
* Substack subdomain (`kevin.substack.com`), a Substack post URL
|
|
* (`.../p/some-slug`), a custom domain that fronts a Substack, or a plain feed
|
|
* URL (`.../feed`, `.../rss`). This resolver figures out which of those it is
|
|
* and produces the one canonical feed URL the source should enumerate from.
|
|
*
|
|
* It does NOT parse feed items. Its only job is classification plus
|
|
* canonicalization: pick the source type, find the canonical host, and return
|
|
* the feed URL. The `FeedSource` reads items from that URL afterwards.
|
|
*
|
|
* Two host-canonicalization wrinkles it handles:
|
|
*
|
|
* 1. Substack handles. A handle does not name a host, so the resolver calls
|
|
* the public profile API to learn the publication's custom domain or
|
|
* `<subdomain>.substack.com`.
|
|
* 2. Redirects. A `kevin.substack.com` publication that later moved to a
|
|
* custom domain answers feed requests with a 3xx to the custom domain. The
|
|
* resolver walks those redirects manually (hop cap 5) so the canonical host
|
|
* reflects where the feed actually lives. It also tolerates the
|
|
* `requestUrl` auto-follow case, where a request returns a direct 200 with
|
|
* no redirect to walk; there the requested host is treated as canonical.
|
|
*
|
|
* The resolver depends only on the injected `HttpFetcher`, never on Obsidian,
|
|
* so it is unit-testable with a stub fetcher.
|
|
*/
|
|
|
|
import type { HttpFetcher, HttpResponse, SourceType } from "./feed-source";
|
|
|
|
/** Maximum number of 3xx redirects to follow before giving up. */
|
|
const MAX_REDIRECT_HOPS = 5;
|
|
|
|
/** Lowest status code that counts as a redirect. */
|
|
const HTTP_REDIRECT_MIN = 300;
|
|
|
|
/** One past the highest status code that counts as a redirect. */
|
|
const HTTP_REDIRECT_MAX = 400;
|
|
|
|
/** The host all Substack handle and profile-API requests go through. */
|
|
const SUBSTACK_HOST = "substack.com";
|
|
|
|
/** Suffix that marks a Substack-hosted subdomain. */
|
|
const SUBSTACK_SUFFIX = ".substack.com";
|
|
|
|
/** Path suffixes that unambiguously name a feed without probing. */
|
|
const FEED_PATH_SUFFIXES = ["/feed", "/rss", "/feed/podcast"];
|
|
|
|
/** The result of resolving an input to a canonical, fetchable feed. */
|
|
export interface ResolverResult {
|
|
/** Which source implementation should handle this feed. */
|
|
sourceType: SourceType;
|
|
/** Canonical host the feed (and any API calls) resolve to. */
|
|
canonicalHost: string;
|
|
/** Canonical feed URL items are enumerated from. */
|
|
feedUrl: string;
|
|
/** Substack handle when the input named one, else null. */
|
|
handle: string | null;
|
|
}
|
|
|
|
/**
|
|
* Raised when an input cannot be resolved to a feed: malformed input, a profile
|
|
* lookup that returned nothing usable, or a redirect chain that exceeded the
|
|
* hop cap. The message states the specific reason; the original cause (when
|
|
* there is one) is attached so callers can surface it.
|
|
*/
|
|
export class ResolveError extends Error {
|
|
/** The underlying error, when this wraps one. */
|
|
readonly cause?: unknown;
|
|
|
|
constructor(message: string, cause?: unknown) {
|
|
super(message);
|
|
this.name = "ResolveError";
|
|
this.cause = cause;
|
|
}
|
|
}
|
|
|
|
/** Shape of the Substack public profile fields the resolver reads. */
|
|
interface ProfilePublication {
|
|
subdomain?: unknown;
|
|
custom_domain?: unknown;
|
|
}
|
|
|
|
/** Looks up a header case-insensitively; headers may use any casing. */
|
|
function getHeader(headers: Record<string, string>, name: string): string | undefined {
|
|
const target = name.toLowerCase();
|
|
for (const key of Object.keys(headers)) {
|
|
if (key.toLowerCase() === target) {
|
|
return headers[key];
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/** True when a status code is in the 3xx redirect range. */
|
|
function isRedirect(status: number): boolean {
|
|
return status >= HTTP_REDIRECT_MIN && status < HTTP_REDIRECT_MAX;
|
|
}
|
|
|
|
/**
|
|
* Parses a URL, throwing a ResolveError (rather than a bare TypeError) when the
|
|
* input is not a usable absolute URL.
|
|
*/
|
|
function parseUrl(raw: string): URL {
|
|
try {
|
|
return new URL(raw);
|
|
} catch (err) {
|
|
throw new ResolveError(`Not a valid URL: ${raw}`, err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Adds a scheme to a bare host or host/path so it parses as an absolute URL.
|
|
* Leaves inputs that already carry a scheme untouched.
|
|
*/
|
|
function ensureScheme(raw: string): string {
|
|
return /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
|
|
}
|
|
|
|
/** True when a host is `substack.com` itself (not a subdomain of it). */
|
|
function isSubstackApex(host: string): boolean {
|
|
return host.toLowerCase() === SUBSTACK_HOST;
|
|
}
|
|
|
|
/** True when a host is `<something>.substack.com`. */
|
|
function isSubstackSubdomain(host: string): boolean {
|
|
return host.toLowerCase().endsWith(SUBSTACK_SUFFIX);
|
|
}
|
|
|
|
/** True when a path ends in a recognized feed suffix. */
|
|
function hasFeedPathSuffix(pathname: string): boolean {
|
|
const lower = pathname.replace(/\/$/, "").toLowerCase();
|
|
return FEED_PATH_SUFFIXES.some((suffix) => lower.endsWith(suffix));
|
|
}
|
|
|
|
/** True when a content-type names an XML/RSS/Atom payload. */
|
|
function looksLikeXmlContentType(contentType: string | undefined): boolean {
|
|
if (contentType === undefined) {
|
|
return false;
|
|
}
|
|
const lower = contentType.toLowerCase();
|
|
return lower.includes("xml") || lower.includes("rss") || lower.includes("atom");
|
|
}
|
|
|
|
/**
|
|
* True when a response body begins like a feed: an XML declaration or a
|
|
* recognized feed root element. An HTML document (`<!DOCTYPE html>`, `<html>`)
|
|
* must not match, so a plain `<` prefix is not enough.
|
|
*/
|
|
function bodyLooksLikeFeed(text: string): boolean {
|
|
const head = text.trimStart().slice(0, 200).toLowerCase();
|
|
return (
|
|
head.startsWith("<?xml") ||
|
|
head.startsWith("<rss") ||
|
|
head.startsWith("<feed") ||
|
|
head.startsWith("<rdf")
|
|
);
|
|
}
|
|
|
|
/**
|
|
* True when a feed body is a Substack feed, identified by its
|
|
* `<generator>Substack</generator>` marker. This is the hard signal that lets a
|
|
* custom-domain Substack (one added by its domain rather than an `@handle`) be
|
|
* recognized as Substack and gain archive backfill, instead of falling back to
|
|
* generic and being stuck at the recent RSS window. The marker lives in the
|
|
* channel header that every Substack feed emits, so a substring test over the
|
|
* body is reliable; a generic site that merely links to Substack will not carry
|
|
* a `<generator>Substack` tag.
|
|
*/
|
|
function bodyIsSubstackFeed(text: string): boolean {
|
|
return /<generator>\s*substack/i.test(text);
|
|
}
|
|
|
|
/**
|
|
* Classifies an already-fetched feed response. A Substack body (generator
|
|
* marker) resolves as Substack with the feed normalized to `host/feed` (the
|
|
* path the Substack source and its archive API expect); anything else resolves
|
|
* as a generic feed at the URL the redirect chain landed on. Shared by the
|
|
* explicit-feed-URL and probe paths so both detect custom-domain Substacks.
|
|
*/
|
|
function classifyFeedResponse(finalUrl: URL, response: HttpResponse): ResolverResult {
|
|
const host = finalUrl.hostname.toLowerCase();
|
|
if (bodyIsSubstackFeed(response.text)) {
|
|
return {
|
|
sourceType: "substack",
|
|
canonicalHost: host,
|
|
feedUrl: `https://${host}/feed`,
|
|
handle: null,
|
|
};
|
|
}
|
|
return {
|
|
sourceType: "generic",
|
|
canonicalHost: host,
|
|
feedUrl: finalUrl.toString(),
|
|
handle: null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Extracts a bare Substack handle from an input, or null when the input does
|
|
* not name one. Accepts `@handle`, `substack.com/@handle`, and
|
|
* `https://substack.com/@handle` (with optional trailing path or slash).
|
|
*/
|
|
function extractHandle(raw: string): string | null {
|
|
const trimmed = raw.trim();
|
|
const bare = /^@([A-Za-z0-9_-]+)$/.exec(trimmed);
|
|
if (bare) {
|
|
return bare[1] ?? null;
|
|
}
|
|
const withScheme = /^(?:https?:\/\/)?(?:www\.)?substack\.com\/@([A-Za-z0-9_-]+)/i.exec(trimmed);
|
|
if (withScheme) {
|
|
return withScheme[1] ?? null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Reads the canonical host from a Substack publication object: the custom
|
|
* domain when present, otherwise `<subdomain>.substack.com`. Returns null when
|
|
* neither field is a usable string.
|
|
*/
|
|
function hostFromPublication(pub: ProfilePublication | undefined): string | null {
|
|
if (pub === undefined) {
|
|
return null;
|
|
}
|
|
if (typeof pub.custom_domain === "string" && pub.custom_domain.trim().length > 0) {
|
|
return pub.custom_domain.trim().toLowerCase();
|
|
}
|
|
if (typeof pub.subdomain === "string" && pub.subdomain.trim().length > 0) {
|
|
return `${pub.subdomain.trim().toLowerCase()}${SUBSTACK_SUFFIX}`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Reads the publication object out of a public_profile JSON response. Prefers
|
|
* `primaryPublication`, then the primary entry of `publicationUsers`, then the
|
|
* first `publicationUsers` entry. Returns undefined when the JSON has none.
|
|
*/
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null;
|
|
}
|
|
|
|
function publicationFromProfile(json: unknown): ProfilePublication | undefined {
|
|
if (!isRecord(json)) {
|
|
return undefined;
|
|
}
|
|
|
|
const primary = json["primaryPublication"];
|
|
if (isRecord(primary)) {
|
|
return primary;
|
|
}
|
|
|
|
const users = json["publicationUsers"];
|
|
if (Array.isArray(users)) {
|
|
const list: unknown[] = users;
|
|
const primaryUser: unknown =
|
|
list.find((u): u is Record<string, unknown> => isRecord(u) && u["is_primary"] === true) ??
|
|
list[0];
|
|
if (isRecord(primaryUser)) {
|
|
const pub = primaryUser["publication"];
|
|
if (isRecord(pub)) {
|
|
return pub;
|
|
}
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
export class FeedResolver {
|
|
private readonly fetcher: HttpFetcher;
|
|
|
|
constructor(fetcher: HttpFetcher) {
|
|
this.fetcher = fetcher;
|
|
}
|
|
|
|
/**
|
|
* Classifies `input` and resolves it to a canonical feed.
|
|
*
|
|
* Dispatch order:
|
|
* 1. A Substack handle (`@x`, `substack.com/@x`) -> profile API lookup.
|
|
* 2. A URL whose host is `*.substack.com` -> Substack, feed at host/feed.
|
|
* A `/p/<slug>` post URL on a substack.com host falls in here; the same
|
|
* path on a custom domain does not (those are added via @handle).
|
|
* 3. A URL whose path already names a feed (`/feed`, `/rss`, ...) -> fetch it
|
|
* and classify by body: Substack (generator marker) or generic.
|
|
* 4. Any other URL -> probe `host/feed`; XML classifies the same way.
|
|
*
|
|
* In cases 2 to 4 the canonical host is derived by walking redirects from
|
|
* the candidate feed URL, so a publication that moved hosts resolves to
|
|
* where its feed actually lives. In cases 3 and 4 the fetched feed body is
|
|
* inspected for the Substack generator marker, so a custom-domain Substack
|
|
* added by its domain is recognized as Substack (and gains archive backfill)
|
|
* rather than silently falling back to generic.
|
|
*/
|
|
async resolve(input: string): Promise<ResolverResult> {
|
|
const trimmed = input.trim();
|
|
if (trimmed.length === 0) {
|
|
throw new ResolveError("Empty input");
|
|
}
|
|
|
|
const handle = extractHandle(trimmed);
|
|
if (handle !== null) {
|
|
return this.resolveHandle(handle);
|
|
}
|
|
|
|
const url = parseUrl(ensureScheme(trimmed));
|
|
|
|
if (isSubstackApex(url.hostname)) {
|
|
// `substack.com/...` that is not an `@handle` is not a feed we can
|
|
// canonicalize without more information.
|
|
throw new ResolveError(`Not a resolvable Substack input: ${trimmed}`);
|
|
}
|
|
|
|
// A Substack post URL is recognized as Substack only on a substack.com
|
|
// host. A `/p/<slug>` path on a custom domain is NOT enough to claim
|
|
// Substack: a non-Substack site can use the same path shape. Custom-domain
|
|
// Substacks are added via the @handle path instead, which the profile API
|
|
// canonicalizes correctly.
|
|
if (isSubstackSubdomain(url.hostname)) {
|
|
return this.resolveSubstackHost(url.hostname, null);
|
|
}
|
|
|
|
if (hasFeedPathSuffix(url.pathname)) {
|
|
return this.resolveExplicitFeedUrl(url.toString());
|
|
}
|
|
|
|
return this.probeForFeed(url.hostname);
|
|
}
|
|
|
|
/**
|
|
* Resolves a Substack handle: GET the public profile, read the publication's
|
|
* custom domain or subdomain, then canonicalize that host (walking any
|
|
* redirect to a moved custom domain).
|
|
*/
|
|
private async resolveHandle(handle: string): Promise<ResolverResult> {
|
|
const profileUrl = `https://${SUBSTACK_HOST}/api/v1/user/${encodeURIComponent(handle)}/public_profile`;
|
|
let response: HttpResponse;
|
|
try {
|
|
response = await this.fetcher({ url: profileUrl });
|
|
} catch (err) {
|
|
throw new ResolveError(`Profile lookup failed for @${handle}`, err);
|
|
}
|
|
if (response.status < 200 || response.status >= 300) {
|
|
throw new ResolveError(`Profile lookup for @${handle} returned status ${response.status}`);
|
|
}
|
|
|
|
const publication = publicationFromProfile(response.json);
|
|
const host = hostFromPublication(publication);
|
|
if (host === null) {
|
|
throw new ResolveError(`No publication found for @${handle}`);
|
|
}
|
|
return this.resolveSubstackHost(host, handle);
|
|
}
|
|
|
|
/**
|
|
* Canonicalizes a Substack host by walking redirects from `host/feed`, then
|
|
* returns the canonical feed URL. The host the chain lands on is canonical.
|
|
*/
|
|
private async resolveSubstackHost(host: string, handle: string | null): Promise<ResolverResult> {
|
|
const candidate = `https://${host.toLowerCase()}/feed`;
|
|
const canonicalHost = await this.walkToCanonicalHost(candidate);
|
|
return {
|
|
sourceType: "substack",
|
|
canonicalHost,
|
|
feedUrl: `https://${canonicalHost}/feed`,
|
|
handle,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Resolves a plain feed URL (one whose path already names a feed), walking
|
|
* redirects to find the canonical host and inspecting the body so a
|
|
* custom-domain Substack served at `/feed` is recognized as Substack rather
|
|
* than generic.
|
|
*/
|
|
private async resolveExplicitFeedUrl(feedUrl: string): Promise<ResolverResult> {
|
|
const { finalUrl, response } = await this.walkRedirects(feedUrl);
|
|
return classifyFeedResponse(finalUrl, response);
|
|
}
|
|
|
|
/**
|
|
* Probes `host/feed` for a feed when the input gave no feed path. A response
|
|
* that reads as XML resolves at the (post-redirect) canonical host, as
|
|
* Substack when the body carries the Substack generator marker and generic
|
|
* otherwise. Anything that does not read as a feed is not one we can use.
|
|
*/
|
|
private async probeForFeed(host: string): Promise<ResolverResult> {
|
|
const candidate = `https://${host.toLowerCase()}/feed`;
|
|
const { finalUrl, response } = await this.walkRedirects(candidate);
|
|
const contentType = getHeader(response.headers, "Content-Type");
|
|
const isXml = looksLikeXmlContentType(contentType) || bodyLooksLikeFeed(response.text);
|
|
if (response.status >= 200 && response.status < 300 && isXml) {
|
|
return classifyFeedResponse(finalUrl, response);
|
|
}
|
|
throw new ResolveError(`No feed found at ${candidate}`);
|
|
}
|
|
|
|
/** Walks redirects from a URL and returns just the canonical host. */
|
|
private async walkToCanonicalHost(startUrl: string): Promise<string> {
|
|
const { finalUrl } = await this.walkRedirects(startUrl);
|
|
return finalUrl.hostname.toLowerCase();
|
|
}
|
|
|
|
/**
|
|
* Follows 3xx redirects manually starting from `startUrl`, resolving relative
|
|
* Location headers against the current URL, up to MAX_REDIRECT_HOPS hops.
|
|
*
|
|
* Returns the final URL and the final non-redirect response. A direct 200
|
|
* (the `requestUrl` auto-follow case, where the redirect was already
|
|
* followed and no Location is present to walk) ends the walk at the
|
|
* requested URL. A 3xx that exceeds the hop cap, or one missing a Location
|
|
* header, raises a ResolveError.
|
|
*/
|
|
private async walkRedirects(startUrl: string): Promise<{ finalUrl: URL; response: HttpResponse }> {
|
|
let current = parseUrl(startUrl);
|
|
for (let hop = 0; hop < MAX_REDIRECT_HOPS; hop += 1) {
|
|
let response: HttpResponse;
|
|
try {
|
|
response = await this.fetcher({ url: current.toString() });
|
|
} catch (err) {
|
|
throw new ResolveError(`Request failed for ${current.toString()}`, err);
|
|
}
|
|
|
|
if (!isRedirect(response.status)) {
|
|
// Either a real 200/4xx/5xx, or the auto-followed 200 case: the
|
|
// requested URL is canonical.
|
|
return { finalUrl: current, response };
|
|
}
|
|
|
|
const location = getHeader(response.headers, "Location");
|
|
if (location === undefined || location.trim().length === 0) {
|
|
throw new ResolveError(
|
|
`Redirect from ${current.toString()} had no Location header`,
|
|
);
|
|
}
|
|
// Resolve relative Locations against the current URL.
|
|
current = new URL(location.trim(), current);
|
|
}
|
|
|
|
throw new ResolveError(
|
|
`Too many redirects resolving ${startUrl} (cap ${MAX_REDIRECT_HOPS})`,
|
|
);
|
|
}
|
|
}
|