src / net / archiveWaterfall.ts
src / net / archiveWaterfall.ts
export interface ArchiveResolution {
htmlOrText: string;
archiveUrl: string;
provider: "wikipedia-rest" | "wayback-cdx-raw" | "wayback-avail" | "jina-reader";
isPlainText?: boolean;
}
const UNARCHIVABLE_PATHS =
/\b(wp-content\/uploads\/.*\/\/|\/search\?|\/login|\/signin|merriam-webster\.com\/dictionary)\b/i;
/**
* Unwraps URLs that are already Wayback / Archive links so we never double-wrap them.
*/
export function unwrapArchiveUrl(rawUrl: string): string {
const match = rawUrl.match(
/^https?:\/\/web\.archive\.org\/web\/[0-9a-z_*]+\/(https?:\/\/.+)$/i
);
if (match && match[1]) {
return match[1];
}
return rawUrl;
}
export async function fetchFromArchiveWaterfall(
inputUrl: string,
log?: (msg: string) => void
): Promise<ArchiveResolution | null> {
const targetUrl = unwrapArchiveUrl(inputUrl.trim());
if (UNARCHIVABLE_PATHS.test(targetUrl)) {
return null;
}
// 1. Fast-Path Wikipedia REST API
const wikiMatch = targetUrl.match(
/^https?:\/\/([a-z]{2,3})\.wikipedia\.org\/wiki\/([^#?]+)/i
);
if (wikiMatch) {
const lang = wikiMatch[1].toLowerCase();
const rawTitle = decodeURIComponent(wikiMatch[2]);
const wikiContent = await fetchWikipediaRestArticle(lang, rawTitle);
if (wikiContent) {
log?.(`[ARCHIVE] Resolved Wikipedia article via REST API: ${rawTitle}`);
return {
htmlOrText: wikiContent,
archiveUrl: `https://${lang}.wikipedia.org/wiki/${encodeURIComponent(rawTitle)}`,
provider: "wikipedia-rest",
isPlainText: true,
};
}
}
// 2. Wayback CDX + raw id_
const cdxSnap = await fetchWaybackCdxRaw(targetUrl);
if (cdxSnap) {
log?.(`[ARCHIVE] Recovered via Wayback CDX raw snapshot: ${cdxSnap.archiveUrl}`);
return cdxSnap;
}
// 3. Wayback Availability API
const availSnap = await fetchWaybackAvailability(targetUrl);
if (availSnap) {
log?.(`[ARCHIVE] Recovered via Wayback Availability API: ${availSnap.archiveUrl}`);
return availSnap;
}
// 4. Clean Reader Mirror
const readerSnap = await fetchCleanReaderMirror(targetUrl);
if (readerSnap) {
log?.(`[ARCHIVE] Recovered via Clean Reader Mirror for: ${targetUrl}`);
return readerSnap;
}
return null;
}
async function fetchWikipediaRestArticle(
lang: string,
slug: string
): Promise<string | null> {
const candidates = [
slug,
slug
.split("_")
.map((w) => (w.length > 0 ? w[0].toUpperCase() + w.slice(1) : w))
.join("_"),
];
for (const title of new Set(candidates)) {
try {
const apiUrl = `https://${lang}.wikipedia.org/w/api.php?action=query&prop=extracts&explaintext=1&titles=${encodeURIComponent(
title
)}&redirects=1&format=json`;
const res = await fetch(apiUrl, {
headers: {
"User-Agent": "DeepSwarmResearch/5.9.2 (https://saverapps.uk; local@saverapps.uk)",
Accept: "application/json",
},
signal: AbortSignal.timeout(4000),
});
if (!res.ok) continue;
const data: any = await res.json();
const pages = data?.query?.pages;
if (!pages) continue;
const pageObj: any = Object.values(pages)[0];
if (
pageObj &&
!("missing" in pageObj) &&
typeof pageObj.extract === "string" &&
pageObj.extract.length > 150
) {
return `# ${pageObj.title}\n\n${pageObj.extract}`;
}
} catch {
// Try next candidate
}
}
return null;
}
async function fetchWaybackCdxRaw(targetUrl: string): Promise<ArchiveResolution | null> {
try {
const cdxUrl = `https://web.archive.org/cdx/search/cdx?url=${encodeURIComponent(
targetUrl
)}&output=json&fl=timestamp,original&filter=statuscode:200&collapse=digest&limit=-1`;
const cdxRes = await fetch(cdxUrl, {
headers: { "User-Agent": "DeepSwarmResearch/5.9.2" },
signal: AbortSignal.timeout(4500),
});
if (!cdxRes.ok) return null;
const rows: any = await cdxRes.json();
if (!Array.isArray(rows) || rows.length < 2 || !Array.isArray(rows[1])) {
return null;
}
const [timestamp, original] = rows[1];
const rawSnapshotUrl = `https://web.archive.org/web/${timestamp}id_/${original}`;
const snapRes = await fetch(rawSnapshotUrl, {
headers: { "User-Agent": "Mozilla/5.0 (compatible; DeepSwarmResearch/5.9.2)" },
signal: AbortSignal.timeout(6500),
});
if (!snapRes.ok) return null;
const html = await snapRes.text();
if (html.length < 250) return null;
return {
htmlOrText: html,
archiveUrl: `https://web.archive.org/web/${timestamp}/${original}`,
provider: "wayback-cdx-raw",
};
} catch {
return null;
}
}
async function fetchWaybackAvailability(targetUrl: string): Promise<ArchiveResolution | null> {
try {
const availUrl = `https://archive.org/wayback/available?url=${encodeURIComponent(targetUrl)}`;
const res = await fetch(availUrl, { signal: AbortSignal.timeout(4000) });
if (!res.ok) return null;
const data: any = await res.json();
const closest = data?.archived_snapshots?.closest;
if (!closest?.available || !closest?.url) return null;
const rawUrl = String(closest.url)
.replace(/^http:\/\//i, "https://")
.replace(/\/web\/(\d+)\//, "/web/$1id_/");
const pageRes = await fetch(rawUrl, { signal: AbortSignal.timeout(6500) });
if (!pageRes.ok) return null;
const html = await pageRes.text();
if (html.length < 250) return null;
return {
htmlOrText: html,
archiveUrl: String(closest.url).replace(/^http:\/\//i, "https://"),
provider: "wayback-avail",
};
} catch {
return null;
}
}
async function fetchCleanReaderMirror(targetUrl: string): Promise<ArchiveResolution | null> {
if (/\b(academia\.edu|researchgate\.net|sciencedirect\.com)\b/i.test(targetUrl)) {
return null;
}
try {
const mirrorUrl = `https://r.jina.ai/${targetUrl}`;
const res = await fetch(mirrorUrl, {
headers: {
Accept: "text/plain",
"X-Return-Format": "markdown",
},
signal: AbortSignal.timeout(6000),
});
if (!res.ok) return null;
const text = (await res.text()).trim();
if (
text.length < 300 ||
/Access Denied|Just a moment\.\.\.|Enable JavaScript and cookies/i.test(text.slice(0, 400))
) {
return null;
}
return {
htmlOrText: text,
archiveUrl: targetUrl,
provider: "jina-reader",
isPlainText: true,
};
} catch {
return null;
}
}
export interface ArchiveResolution {
htmlOrText: string;
archiveUrl: string;
provider: "wikipedia-rest" | "wayback-cdx-raw" | "wayback-avail" | "jina-reader";
isPlainText?: boolean;
}
const UNARCHIVABLE_PATHS =
/\b(wp-content\/uploads\/.*\/\/|\/search\?|\/login|\/signin|merriam-webster\.com\/dictionary)\b/i;
/**
* Unwraps URLs that are already Wayback / Archive links so we never double-wrap them.
*/
export function unwrapArchiveUrl(rawUrl: string): string {
const match = rawUrl.match(
/^https?:\/\/web\.archive\.org\/web\/[0-9a-z_*]+\/(https?:\/\/.+)$/i
);
if (match && match[1]) {
return match[1];
}
return rawUrl;
}
export async function fetchFromArchiveWaterfall(
inputUrl: string,
log?: (msg: string) => void
): Promise<ArchiveResolution | null> {
const targetUrl = unwrapArchiveUrl(inputUrl.trim());
if (UNARCHIVABLE_PATHS.test(targetUrl)) {
return null;
}
// 1. Fast-Path Wikipedia REST API
const wikiMatch = targetUrl.match(
/^https?:\/\/([a-z]{2,3})\.wikipedia\.org\/wiki\/([^#?]+)/i
);
if (wikiMatch) {
const lang = wikiMatch[1].toLowerCase();
const rawTitle = decodeURIComponent(wikiMatch[2]);
const wikiContent = await fetchWikipediaRestArticle(lang, rawTitle);
if (wikiContent) {
log?.(`[ARCHIVE] Resolved Wikipedia article via REST API: ${rawTitle}`);
return {
htmlOrText: wikiContent,
archiveUrl: `https://${lang}.wikipedia.org/wiki/${encodeURIComponent(rawTitle)}`,
provider: "wikipedia-rest",
isPlainText: true,
};
}
}
// 2. Wayback CDX + raw id_
const cdxSnap = await fetchWaybackCdxRaw(targetUrl);
if (cdxSnap) {
log?.(`[ARCHIVE] Recovered via Wayback CDX raw snapshot: ${cdxSnap.archiveUrl}`);
return cdxSnap;
}
// 3. Wayback Availability API
const availSnap = await fetchWaybackAvailability(targetUrl);
if (availSnap) {
log?.(`[ARCHIVE] Recovered via Wayback Availability API: ${availSnap.archiveUrl}`);
return availSnap;
}
// 4. Clean Reader Mirror
const readerSnap = await fetchCleanReaderMirror(targetUrl);
if (readerSnap) {
log?.(`[ARCHIVE] Recovered via Clean Reader Mirror for: ${targetUrl}`);
return readerSnap;
}
return null;
}
async function fetchWikipediaRestArticle(
lang: string,
slug: string
): Promise<string | null> {
const candidates = [
slug,
slug
.split("_")
.map((w) => (w.length > 0 ? w[0].toUpperCase() + w.slice(1) : w))
.join("_"),
];
for (const title of new Set(candidates)) {
try {
const apiUrl = `https://${lang}.wikipedia.org/w/api.php?action=query&prop=extracts&explaintext=1&titles=${encodeURIComponent(
title
)}&redirects=1&format=json`;
const res = await fetch(apiUrl, {
headers: {
"User-Agent": "DeepSwarmResearch/5.9.2 (https://saverapps.uk; local@saverapps.uk)",
Accept: "application/json",
},
signal: AbortSignal.timeout(4000),
});
if (!res.ok) continue;
const data: any = await res.json();
const pages = data?.query?.pages;
if (!pages) continue;
const pageObj: any = Object.values(pages)[0];
if (
pageObj &&
!("missing" in pageObj) &&
typeof pageObj.extract === "string" &&
pageObj.extract.length > 150
) {
return `# ${pageObj.title}\n\n${pageObj.extract}`;
}
} catch {
// Try next candidate
}
}
return null;
}
async function fetchWaybackCdxRaw(targetUrl: string): Promise<ArchiveResolution | null> {
try {
const cdxUrl = `https://web.archive.org/cdx/search/cdx?url=${encodeURIComponent(
targetUrl
)}&output=json&fl=timestamp,original&filter=statuscode:200&collapse=digest&limit=-1`;
const cdxRes = await fetch(cdxUrl, {
headers: { "User-Agent": "DeepSwarmResearch/5.9.2" },
signal: AbortSignal.timeout(4500),
});
if (!cdxRes.ok) return null;
const rows: any = await cdxRes.json();
if (!Array.isArray(rows) || rows.length < 2 || !Array.isArray(rows[1])) {
return null;
}
const [timestamp, original] = rows[1];
const rawSnapshotUrl = `https://web.archive.org/web/${timestamp}id_/${original}`;
const snapRes = await fetch(rawSnapshotUrl, {
headers: { "User-Agent": "Mozilla/5.0 (compatible; DeepSwarmResearch/5.9.2)" },
signal: AbortSignal.timeout(6500),
});
if (!snapRes.ok) return null;
const html = await snapRes.text();
if (html.length < 250) return null;
return {
htmlOrText: html,
archiveUrl: `https://web.archive.org/web/${timestamp}/${original}`,
provider: "wayback-cdx-raw",
};
} catch {
return null;
}
}
async function fetchWaybackAvailability(targetUrl: string): Promise<ArchiveResolution | null> {
try {
const availUrl = `https://archive.org/wayback/available?url=${encodeURIComponent(targetUrl)}`;
const res = await fetch(availUrl, { signal: AbortSignal.timeout(4000) });
if (!res.ok) return null;
const data: any = await res.json();
const closest = data?.archived_snapshots?.closest;
if (!closest?.available || !closest?.url) return null;
const rawUrl = String(closest.url)
.replace(/^http:\/\//i, "https://")
.replace(/\/web\/(\d+)\//, "/web/$1id_/");
const pageRes = await fetch(rawUrl, { signal: AbortSignal.timeout(6500) });
if (!pageRes.ok) return null;
const html = await pageRes.text();
if (html.length < 250) return null;
return {
htmlOrText: html,
archiveUrl: String(closest.url).replace(/^http:\/\//i, "https://"),
provider: "wayback-avail",
};
} catch {
return null;
}
}
async function fetchCleanReaderMirror(targetUrl: string): Promise<ArchiveResolution | null> {
if (/\b(academia\.edu|researchgate\.net|sciencedirect\.com)\b/i.test(targetUrl)) {
return null;
}
try {
const mirrorUrl = `https://r.jina.ai/${targetUrl}`;
const res = await fetch(mirrorUrl, {
headers: {
Accept: "text/plain",
"X-Return-Format": "markdown",
},
signal: AbortSignal.timeout(6000),
});
if (!res.ok) return null;
const text = (await res.text()).trim();
if (
text.length < 300 ||
/Access Denied|Just a moment\.\.\.|Enable JavaScript and cookies/i.test(text.slice(0, 400))
) {
return null;
}
return {
htmlOrText: text,
archiveUrl: targetUrl,
provider: "jina-reader",
isPlainText: true,
};
} catch {
return null;
}
}