${esc(b.title)}
diff --git a/src/capture/url-readable.test.ts b/src/capture/url-readable.test.ts
index 565553d..b6e1f8e 100644
--- a/src/capture/url-readable.test.ts
+++ b/src/capture/url-readable.test.ts
@@ -54,3 +54,75 @@ describe('url-readable adapter (Story 6.3)', () => {
await assert.rejects(() => adapter.fetch({ buffer: Buffer.from('x') }, { itemId: 'x', boardId: 'b' }), /URL/i);
});
});
+
+// --- og:image hero-image download (screenshot-less captures get a picture) ---
+
+import { mkdtempSync, rmSync, existsSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+
+const HTML_WITH_OG = `
Edifier R1280T
+
+
Edifier R1280T
${LONG}
${LONG}
`;
+
+// Fetch that serves the page HTML for any non-image URL and an injected response for the image.
+function urlAwareFetch(pageHtml: string, imageResp: unknown): typeof fetch {
+ return (async (u: unknown) => {
+ if (String(u).includes('cdn.example')) return imageResp;
+ return { text: async () => pageHtml };
+ }) as unknown as typeof fetch;
+}
+const imageResponse = (contentType: string, bytes = [1, 2, 3, 4]) => ({
+ ok: true,
+ headers: { get: (h: string) => (h.toLowerCase() === 'content-type' ? contentType : null) },
+ arrayBuffer: async () => new Uint8Array(bytes).buffer,
+});
+
+describe('url-readable adapter — og:image hero image', () => {
+ it('downloads og:image as an image asset and writes the file', async () => {
+ const dir = mkdtempSync(join(tmpdir(), 'og-'));
+ try {
+ const adapter = createUrlReadableAdapter({
+ fetchImpl: urlAwareFetch(HTML_WITH_OG, imageResponse('image/jpeg')),
+ assertUrl: async () => {}, // skip real DNS for cdn.example
+ });
+ const out = await adapter.fetch('https://shop.example/r1280t', { itemId: 'w1', boardId: 'b', screenshotsDir: dir });
+ assert.equal(out.assets.length, 1, 'one image asset emitted');
+ assert.equal(out.assets[0].kind, 'image');
+ assert.equal(out.assets[0].path, 'screenshots/w1-og.jpg');
+ assert.ok(existsSync(join(dir, 'w1-og.jpg')), 'image file written to screenshotsDir');
+ assert.match(String(out.fields.text), /Edifier R1280T/, 'text capture still succeeds');
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+ it('skips a blocked image URL (SSRF) but the capture still succeeds', async () => {
+ const dir = mkdtempSync(join(tmpdir(), 'og-'));
+ try {
+ const adapter = createUrlReadableAdapter({
+ fetchImpl: urlAwareFetch(HTML_WITH_OG, imageResponse('image/jpeg')),
+ assertUrl: async () => { throw new Error('blocked'); },
+ });
+ const out = await adapter.fetch('https://shop.example/r1280t', { itemId: 'w2', boardId: 'b', screenshotsDir: dir });
+ assert.deepEqual(out.assets, [], 'no asset when the image URL is blocked');
+ assert.match(String(out.fields.text), /Edifier R1280T/, 'text capture unaffected');
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+ it('skips a non-image content-type (honest fallback, no asset)', async () => {
+ const dir = mkdtempSync(join(tmpdir(), 'og-'));
+ try {
+ const adapter = createUrlReadableAdapter({
+ fetchImpl: urlAwareFetch(HTML_WITH_OG, imageResponse('text/html')),
+ assertUrl: async () => {},
+ });
+ const out = await adapter.fetch('https://shop.example/r1280t', { itemId: 'w3', boardId: 'b', screenshotsDir: dir });
+ assert.deepEqual(out.assets, [], 'no asset when the response is not an image');
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/src/capture/url-readable.ts b/src/capture/url-readable.ts
index 5997368..e2564a3 100644
--- a/src/capture/url-readable.ts
+++ b/src/capture/url-readable.ts
@@ -1,12 +1,24 @@
+import { createHash } from 'node:crypto';
+import { mkdirSync, writeFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+
+import { config } from '../config.js';
import { captureLibrary } from '../processor-library.js';
-import type { CaptureAdapter, CaptureCtx, CaptureResult, CaptureSource } from './adapter.js';
+import type { AssetSpec, CaptureAdapter, CaptureCtx, CaptureResult, CaptureSource } from './adapter.js';
+import { assertCapturableUrl } from './net-guard.js';
// Story 6.3 — url-readable adapter (Library). A thin wrapper over the prototype's
// proven Library capture (`captureLibrary`: plain fetch → Readability + turndown →
// markdown, with a headless-render SPA fallback when the text is too thin, and a
// clear "no readable text" error otherwise). REUSED from processor-library.ts (not
// forked) — the logic is sound and already injectable/tested. Decoupled from
-// analysis (Library enrichment is Epic 7). Library captures no screenshot.
+// analysis (Library enrichment is Epic 7).
+//
+// Readable captures take no screenshot, so a card had no picture. We now also pull the
+// page's og:image (extracted by captureLibrary) and download it as the item's `image`
+// asset — so product/wish-list cards get a hero image. Best-effort: any failure (no
+// image, blocked URL, non-image type, oversize, network) leaves the capture successful
+// with no asset, and the UI shows its honest "no image" placeholder instead of failing.
/** Pull the title from the markdown's leading `# ` line (extractReadableMarkdown). */
function titleFromMarkdown(markdown: string): string | undefined {
@@ -14,9 +26,68 @@ function titleFromMarkdown(markdown: string): string | undefined {
return m ? m[1].trim() : undefined;
}
+// Only types the screenshot route serves with an image content-type (server.ts).
+const IMAGE_EXT: Record
= {
+ 'image/jpeg': 'jpg',
+ 'image/png': 'png',
+ 'image/webp': 'webp',
+};
+const MAX_IMAGE_BYTES = 12 * 1024 * 1024; // a hero image over ~12MB is almost certainly wrong
+const IMAGE_TIMEOUT_MS = 8000;
+
interface Deps {
fetchImpl?: typeof fetch;
renderImpl?: (url: string) => Promise;
+ /** Injectable SSRF guard (tests avoid real DNS); defaults to assertCapturableUrl. */
+ assertUrl?: (url: string) => Promise;
+}
+
+/**
+ * Download the hero image as the item's `image` asset. Best-effort: returns undefined
+ * (NOT throw) on a blocked URL, non-image content-type, oversize body, or any network
+ * error — capture must still succeed with its text. SSRF-guarded; size/type/time-capped.
+ */
+async function downloadHeroImage(
+ imageUrl: string,
+ ctx: CaptureCtx,
+ deps: Deps,
+): Promise {
+ const fetchFn = deps.fetchImpl ?? globalThis.fetch;
+ const assertUrl = deps.assertUrl ?? assertCapturableUrl;
+ try {
+ await assertUrl(imageUrl); // SSRF: the og:image URL is also user-influenced data
+ } catch {
+ return undefined; // blocked (private/loopback/bad scheme) → honest fallback
+ }
+ const ctrl = new AbortController();
+ const timer = setTimeout(() => ctrl.abort(), IMAGE_TIMEOUT_MS);
+ const onAbort = () => ctrl.abort();
+ ctx.signal?.addEventListener('abort', onAbort, { once: true });
+ try {
+ const res = await fetchFn(imageUrl, { signal: ctrl.signal, redirect: 'follow' });
+ if (!res.ok) return undefined;
+ const ctype = (res.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase();
+ const ext = IMAGE_EXT[ctype];
+ if (!ext) return undefined; // not a renderable image type
+ const buf = Buffer.from(await res.arrayBuffer());
+ if (buf.length === 0 || buf.length > MAX_IMAGE_BYTES) return undefined;
+
+ const screenshotsDir = ctx.screenshotsDir ?? config.screenshotsDir;
+ const filename = `${ctx.itemId}-og.${ext}`;
+ const abs = join(screenshotsDir, filename);
+ mkdirSync(dirname(abs), { recursive: true });
+ writeFileSync(abs, buf);
+ return {
+ kind: 'image',
+ path: `screenshots/${filename}`, // relative form (Story 2.2), served at /screenshots/
+ hash: createHash('sha256').update(buf).digest('hex'),
+ };
+ } catch {
+ return undefined; // timeout / network / decode → honest fallback
+ } finally {
+ clearTimeout(timer);
+ ctx.signal?.removeEventListener('abort', onAbort);
+ }
}
export function createUrlReadableAdapter(deps: Deps = {}): CaptureAdapter {
@@ -40,7 +111,14 @@ export function createUrlReadableAdapter(deps: Deps = {}): CaptureAdapter {
// existing item.title on re-capture (runCaptureForItem lifts title → column).
const fields: Record = { text, url };
if (title) fields.title = title;
- return { fields, assets: [] };
+
+ // Best-effort hero image (og:image). Never fails the capture.
+ const assets: AssetSpec[] = [];
+ if (captured.imageUrl) {
+ const asset = await downloadHeroImage(captured.imageUrl, ctx, deps);
+ if (asset) assets.push(asset);
+ }
+ return { fields, assets };
},
};
}
diff --git a/src/db/hydrate.ts b/src/db/hydrate.ts
index b827a08..c4ece5a 100644
--- a/src/db/hydrate.ts
+++ b/src/db/hydrate.ts
@@ -20,7 +20,10 @@ export function hydrateItemForUi(item: Item, itemAssets: Asset[] = []): Record a.kind === 'screenshot');
+ // The card/modal image: a real screenshot (url-screenshot boards) or, failing that,
+ // the page's hero image (og:image, captured for readable boards). Either one fills the
+ // single `screenshot` field the renderers read.
+ const shot = itemAssets.find((a) => a.kind === 'screenshot') ?? itemAssets.find((a) => a.kind === 'image');
if (shot?.path) out.screenshot = shot.path;
const fields = (item.fields as Record) ?? {};
diff --git a/src/processor-library.test.ts b/src/processor-library.test.ts
index f3b379a..610dfb8 100644
--- a/src/processor-library.test.ts
+++ b/src/processor-library.test.ts
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import "./processor-library.js"; // registers the library processor as a module side-effect
-import { captureLibrary, extractReadableMarkdown, validateLibraryAnalysis } from "./processor-library.js";
+import { captureLibrary, extractOgImage, extractReadableMarkdown, validateLibraryAnalysis } from "./processor-library.js";
import { getProcessor } from "./processors.js";
// HTML fixture: clear article plus nav/footer noise
@@ -271,3 +271,33 @@ test("libraryProcessor.summarize returns type, topics, and summary lines", () =>
assert.ok(!lines.some((l) => l.includes("steal_this")), "should not reference inspiration design fields");
assert.ok(!lines.some((l) => l.includes("audience")), "should not reference inspiration meta fields");
});
+
+// --- extractOgImage (hero image for screenshot-less captures) ---
+
+test("extractOgImage prefers og:image and returns an absolute URL", () => {
+ const html = `x`;
+ assert.equal(extractOgImage(html, "https://shop.example/r1280t"), "https://cdn.example/p/r1280t.jpg");
+});
+
+test("extractOgImage resolves a relative og:image against the page URL", () => {
+ const html = ``;
+ assert.equal(extractOgImage(html, "https://shop.example/products/r1280t"), "https://shop.example/img/hero.png");
+});
+
+test("extractOgImage falls back to twitter:image when og:image is absent", () => {
+ const html = ``;
+ assert.equal(extractOgImage(html, "https://shop.example/x"), "https://cdn.example/t.webp");
+});
+
+test("extractOgImage reads a JSON-LD product image (string, array, or {url})", () => {
+ const ld = (img: string) => ``;
+ const base = "https://shop.example/x";
+ assert.equal(extractOgImage(ld(JSON.stringify({ "@type": "Product", image: "https://cdn.example/a.jpg" })), base), "https://cdn.example/a.jpg");
+ assert.equal(extractOgImage(ld(JSON.stringify({ "@type": "Product", image: ["https://cdn.example/b.jpg"] })), base), "https://cdn.example/b.jpg");
+ assert.equal(extractOgImage(ld(JSON.stringify({ "@type": "Product", image: { url: "https://cdn.example/c.jpg" } })), base), "https://cdn.example/c.jpg");
+});
+
+test("extractOgImage returns undefined when the page has no image and ignores malformed JSON-LD", () => {
+ assert.equal(extractOgImage(`No image here`, "https://x.example"), undefined);
+ assert.equal(extractOgImage(``, "https://x.example"), undefined);
+});
diff --git a/src/processor-library.ts b/src/processor-library.ts
index 8ab9d6d..9b2932c 100644
--- a/src/processor-library.ts
+++ b/src/processor-library.ts
@@ -129,6 +129,74 @@ export function extractReadableMarkdown(html: string, url: string): string {
return (dom.window.document.body?.textContent ?? "").slice(0, 10000);
}
+/** Find an image URL inside a JSON-LD node (string | {url} | array of either). */
+function jsonLdImage(data: unknown): string | undefined {
+ const nodes = Array.isArray(data)
+ ? data
+ : data && typeof data === "object" && Array.isArray((data as Record)["@graph"])
+ ? ((data as Record)["@graph"] as unknown[])
+ : [data];
+ for (const n of nodes) {
+ if (!n || typeof n !== "object") continue;
+ const img = (n as Record).image;
+ if (typeof img === "string") return img;
+ if (Array.isArray(img) && img.length) {
+ const first = img[0];
+ if (typeof first === "string") return first;
+ if (first && typeof first === "object" && typeof (first as Record).url === "string")
+ return (first as Record).url;
+ }
+ if (img && typeof img === "object" && typeof (img as Record).url === "string")
+ return (img as Record).url;
+ }
+ return undefined;
+}
+
+/**
+ * Extract the page's social/hero image — the picture a readable (screenshot-less)
+ * capture can still show. Order: og:image (secure first), twitter:image, JSON-LD
+ * product image, link[rel=image_src]. Relative URLs resolve against the page URL.
+ * Pure + network-free (parses the already-fetched HTML); returns undefined if none.
+ */
+export function extractOgImage(html: string, url: string): string | undefined {
+ let doc: Document;
+ try {
+ doc = new JSDOM(html, { url }).window.document;
+ } catch {
+ return undefined;
+ }
+ const attr = (sel: string, name: string): string | undefined => {
+ const v = doc.querySelector(sel)?.getAttribute(name);
+ return v && v.trim() ? v.trim() : undefined;
+ };
+ let raw =
+ attr('meta[property="og:image:secure_url"]', "content") ??
+ attr('meta[property="og:image"]', "content") ??
+ attr('meta[name="og:image"]', "content") ??
+ attr('meta[name="twitter:image"]', "content") ??
+ attr('meta[property="twitter:image"]', "content") ??
+ attr('link[rel="image_src"]', "href");
+ if (!raw) {
+ for (const s of doc.querySelectorAll('script[type="application/ld+json"]')) {
+ try {
+ const found = jsonLdImage(JSON.parse(s.textContent ?? ""));
+ if (found) {
+ raw = found;
+ break;
+ }
+ } catch {
+ /* malformed JSON-LD — skip */
+ }
+ }
+ }
+ if (!raw) return undefined;
+ try {
+ return new URL(raw, url).href; // resolve relative ("/img/x.jpg") against the page
+ } catch {
+ return undefined;
+ }
+}
+
export async function captureLibrary(
url: string,
opts?: { fetchImpl?: typeof fetch; renderImpl?: (url: string) => Promise }
@@ -138,6 +206,7 @@ export async function captureLibrary(
const response = await fetchFn(url);
const html = await response.text();
+ const imageUrl = extractOgImage(html, url); // hero image from the static HTML (head meta)
let text = extractReadableMarkdown(html, url);
// JS-rendered pages (SPAs) return a near-empty server shell, so fetch+readability
@@ -163,7 +232,7 @@ export async function captureLibrary(
);
}
- return { text, screenshotPath: null };
+ return { text, screenshotPath: null, imageUrl };
}
const libraryProcessor: Processor = {
diff --git a/src/processors.ts b/src/processors.ts
index 51beb75..89d1dc6 100644
--- a/src/processors.ts
+++ b/src/processors.ts
@@ -1,4 +1,6 @@
-export type Captured = { text: string; screenshotPath?: string | null };
+// `imageUrl` — the page's social/hero image (og:image etc.), when found. Readable
+// captures have no screenshot, so this is how a Library/wish-list card gets a picture.
+export type Captured = { text: string; screenshotPath?: string | null; imageUrl?: string };
export interface Processor {
type: string;