mirror of
https://github.com/andwati/andwati.com.git
synced 2026-10-18 01:12:53 +00:00
Fix: a manually-set cover_image URL was never cached locally
The enrichment guard was `if (entry.cover_image) return entry;` — meaning once *any* cover_image was set, including a raw external URL you pasted into Strapi by hand, it was treated as "already done" and never downloaded. The site's coverExists() check only looks for local /covers/... files, so a remote URL silently never rendered. - content-sync.ts: enrichBookshelfEntry now branches on whether cover_image is an http(s) URL (cache it via the new cacheRemoteImage) vs. unset (existing ISBN/DOI lookup) vs. already a local path (left alone). - bookshelf-covers.ts: cacheRemoteImage() downloads and caches any image URL. Also fixes a real bug caught while testing this against the actual O'Reilly cover URL from the reported entry: their CDN labels WebP responses as Content-Type: image/jpeg, so trusting the header would've cached it with the wrong extension. Now sniffs the real file signature (RIFF/WEBP, PNG, JPEG, GIF magic bytes) instead of trusting either the header or the URL. Verified the download+sniff logic directly against the real cover URL from the reported entry (Designing Data-Intensive Applications) — correctly detects and saves as .webp. Didn't touch the running dev Strapi/DB while testing.
This commit is contained in:
@@ -32,6 +32,71 @@ export async function fetchBookCover(
|
||||
return `/covers/${slug}.jpg`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sniffs the actual file signature rather than trusting the Content-Type
|
||||
* header — some CDNs (O'Reilly's included, observed while building this)
|
||||
* mislabel WebP responses as image/jpeg, which would otherwise get a wrong
|
||||
* extension and a mismatched Content-Type when we later serve it ourselves.
|
||||
*/
|
||||
function extensionFromBytes(buf: Buffer): string {
|
||||
if (
|
||||
buf.length >= 12 &&
|
||||
buf.subarray(0, 4).toString("ascii") === "RIFF" &&
|
||||
buf.subarray(8, 12).toString("ascii") === "WEBP"
|
||||
) {
|
||||
return "webp";
|
||||
}
|
||||
if (
|
||||
buf.length >= 8 &&
|
||||
buf
|
||||
.subarray(0, 8)
|
||||
.equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
|
||||
) {
|
||||
return "png";
|
||||
}
|
||||
if (
|
||||
buf.length >= 3 &&
|
||||
buf[0] === 0xff &&
|
||||
buf[1] === 0xd8 &&
|
||||
buf[2] === 0xff
|
||||
) {
|
||||
return "jpg";
|
||||
}
|
||||
if (
|
||||
buf.length >= 6 &&
|
||||
(buf.subarray(0, 6).toString("ascii") === "GIF87a" ||
|
||||
buf.subarray(0, 6).toString("ascii") === "GIF89a")
|
||||
) {
|
||||
return "gif";
|
||||
}
|
||||
return "jpg";
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads whatever image URL is at `url` (e.g. a cover_image the user
|
||||
* pasted in by hand) and caches it locally, so the site never depends on
|
||||
* that URL staying up. Used for any http(s) cover_image, not just ISBN
|
||||
* lookups.
|
||||
*/
|
||||
export async function cacheRemoteImage(
|
||||
url: string,
|
||||
slug: string,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return undefined;
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
if (buf.byteLength < 200) return undefined;
|
||||
const ext = extensionFromBytes(buf);
|
||||
const file = join(COVERS_DIR, `${slug}.${ext}`);
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
writeFileSync(file, buf);
|
||||
return `/covers/${slug}.${ext}`;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
interface PaperMetadata {
|
||||
title?: string;
|
||||
authors?: string[];
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { join } from "node:path";
|
||||
import { fetchBookCover, fetchPaperMetadata } from "./bookshelf-covers";
|
||||
import {
|
||||
cacheRemoteImage,
|
||||
fetchBookCover,
|
||||
fetchPaperMetadata,
|
||||
} from "./bookshelf-covers";
|
||||
import {
|
||||
deleteEntry,
|
||||
readAllEntries,
|
||||
@@ -277,35 +281,42 @@ interface DocumentsClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfills a bookshelf entry's cover (books, via Open Library by ISBN) or
|
||||
* missing title/authors (papers, via Semantic Scholar by DOI/arXiv id) the
|
||||
* first time it's created/updated with an identifier but no cached cover.
|
||||
* Runs once per entry: `cover_image` already set is treated as "done".
|
||||
* Backfills a bookshelf entry's cover — either by caching whatever URL is
|
||||
* already in `cover_image` (e.g. one pasted in by hand, or an editor's own
|
||||
* hosted cover) locally, or by looking one up (books via Open Library by
|
||||
* ISBN) when there's no cover_image at all yet — and, for papers, missing
|
||||
* title/authors via Semantic Scholar by DOI/arXiv id. Runs once per entry:
|
||||
* a `cover_image` already pointing at a local /covers/... path is treated
|
||||
* as "done" and left alone.
|
||||
*/
|
||||
async function enrichBookshelfEntry(
|
||||
documents: (uid: string) => DocumentsClient,
|
||||
entry: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (entry.cover_image) return entry;
|
||||
|
||||
const slug = entry.slug as string;
|
||||
const coverImage = entry.cover_image as string | undefined;
|
||||
const patch: Record<string, unknown> = {};
|
||||
|
||||
try {
|
||||
if (entry.kind === "book" && entry.isbn) {
|
||||
const cover = await fetchBookCover(entry.isbn as string, slug);
|
||||
if (cover) patch.cover_image = cover;
|
||||
} else if (entry.kind === "paper" && (entry.doi || entry.arxiv_id)) {
|
||||
const meta = await fetchPaperMetadata({
|
||||
doi: entry.doi as string | undefined,
|
||||
arxivId: entry.arxiv_id as string | undefined,
|
||||
});
|
||||
if (meta?.title && !entry.title) patch.title = meta.title;
|
||||
if (
|
||||
meta?.authors?.length &&
|
||||
!(entry.authors as string[] | undefined)?.length
|
||||
) {
|
||||
patch.authors = meta.authors;
|
||||
if (coverImage && /^https?:\/\//i.test(coverImage)) {
|
||||
const cached = await cacheRemoteImage(coverImage, slug);
|
||||
if (cached) patch.cover_image = cached;
|
||||
} else if (!coverImage) {
|
||||
if (entry.kind === "book" && entry.isbn) {
|
||||
const cover = await fetchBookCover(entry.isbn as string, slug);
|
||||
if (cover) patch.cover_image = cover;
|
||||
} else if (entry.kind === "paper" && (entry.doi || entry.arxiv_id)) {
|
||||
const meta = await fetchPaperMetadata({
|
||||
doi: entry.doi as string | undefined,
|
||||
arxivId: entry.arxiv_id as string | undefined,
|
||||
});
|
||||
if (meta?.title && !entry.title) patch.title = meta.title;
|
||||
if (
|
||||
meta?.authors?.length &&
|
||||
!(entry.authors as string[] | undefined)?.length
|
||||
) {
|
||||
patch.authors = meta.authors;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
||||
Reference in New Issue
Block a user