mirror of
https://github.com/ckelsoe/obsidian-rss-importer.git
synced 2026-07-22 07:48:56 +00:00
A 'Load older' control in the import window pages back through a Substack publication's archive (/api/v1/archive?offset=) beyond the ~20-item RSS window, fetching each older post's body on demand (/api/v1/posts/<slug>). ListItemsOptions gains an offset; offset 0 keeps the inline-body RSS path. Generic RSS feeds expose only their recent window, so the control is Substack-only. Defensive JSON parsing; selection preserved across paged loads. +20 tests.
416 lines
13 KiB
TypeScript
416 lines
13 KiB
TypeScript
/**
|
||
* Shared building blocks for the concrete feed sources.
|
||
*
|
||
* Both `GenericRssFeedSource` and `SubstackFeedSource` fetch the same way, map
|
||
* raw parsed items onto the normalized `FeedItem` the same way, and build the
|
||
* same `ResolvedFeed` preview. That shared work lives here so the two sources do
|
||
* not drift apart by copy-and-tweak. Anything source-specific (Substack's
|
||
* paywall pass) is layered on top by the source after calling these helpers.
|
||
*
|
||
* Nothing in this module touches Obsidian or the DOM at module scope: the only
|
||
* DOM use is inside `parseFeed`, which constructs its DOMParser lazily. That
|
||
* keeps the module importable in a plain Node context.
|
||
*/
|
||
|
||
import type {
|
||
FeedAudience,
|
||
FeedItem,
|
||
FeedItemKind,
|
||
HttpFetcher,
|
||
ResolvedFeed,
|
||
} from "./feed-source";
|
||
import { parseFeed } from "./feed-xml";
|
||
import type { ParsedFeed, RawFeedItem } from "./feed-xml";
|
||
import type { ResolverResult } from "./feed-resolver";
|
||
|
||
/** How many recent item titles to surface in the add-feed preview card. */
|
||
const SAMPLE_TITLE_LIMIT = 5;
|
||
|
||
/** MIME-type prefixes that mark an enclosure as a playable podcast/media item. */
|
||
const PODCAST_MEDIA_PREFIXES = ["audio/", "video/"] as const;
|
||
|
||
/**
|
||
* Raised when a feed URL cannot be fetched as a usable feed: a transport error
|
||
* from the fetcher, or a non-2xx HTTP status. The originating status (or the
|
||
* underlying error) is surfaced so callers can show a real reason instead of a
|
||
* bare "fetch failed". A `FeedParseError` thrown by `parseFeed` propagates
|
||
* unchanged; this error covers only the fetch/status layer above it.
|
||
*/
|
||
export class FeedFetchError extends Error {
|
||
/** The HTTP status when the failure was a non-2xx response, else null. */
|
||
readonly status: number | null;
|
||
/** The underlying transport error, when this wraps one. */
|
||
readonly cause?: unknown;
|
||
|
||
constructor(message: string, status: number | null, cause?: unknown) {
|
||
super(message);
|
||
this.name = "FeedFetchError";
|
||
this.status = status;
|
||
this.cause = cause;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* GET `feedUrl` through the injected fetcher and parse the response body as a
|
||
* feed. Throws `FeedFetchError` on a transport failure or non-2xx status (the
|
||
* status is included in the message and on the error). Parse failures surface as
|
||
* the `FeedParseError` thrown by `parseFeed`.
|
||
*/
|
||
export async function fetchAndParseFeed(
|
||
fetcher: HttpFetcher,
|
||
feedUrl: string,
|
||
): Promise<ParsedFeed> {
|
||
let status: number;
|
||
let text: string;
|
||
try {
|
||
const response = await fetcher({ url: feedUrl, method: "GET" });
|
||
status = response.status;
|
||
text = response.text;
|
||
} catch (err) {
|
||
throw new FeedFetchError(`Failed to fetch feed at ${feedUrl}`, null, err);
|
||
}
|
||
|
||
if (status < 200 || status >= 300) {
|
||
throw new FeedFetchError(
|
||
`Feed request to ${feedUrl} returned status ${status}`,
|
||
status,
|
||
);
|
||
}
|
||
|
||
return parseFeed(text);
|
||
}
|
||
|
||
/** True when an enclosure MIME type names playable audio or video media. */
|
||
function isMediaType(type: string | null): boolean {
|
||
if (type === null) {
|
||
return false;
|
||
}
|
||
const lower = type.trim().toLowerCase();
|
||
return PODCAST_MEDIA_PREFIXES.some((prefix) => lower.startsWith(prefix));
|
||
}
|
||
|
||
/**
|
||
* Derive a stable, non-empty id for an item that has neither a guid nor a link.
|
||
* Built from the title and pubDate so the same item produces the same id across
|
||
* runs (dedup depends on this). Never returns an empty string: a feed item with
|
||
* no title and no date still gets a deterministic placeholder id.
|
||
*/
|
||
function deriveFallbackId(raw: RawFeedItem): string {
|
||
const titlePart = raw.title.trim();
|
||
const datePart = raw.pubDateIso ?? "";
|
||
const combined = `${titlePart} |