Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1265,6 +1265,11 @@
gap: 12px;
margin-bottom: 4px;
}
/* No screenshot → the title row sits at the very top, so reserve space on the right
for the absolute close button (top:14 right:14, 30px) the favorite star would
otherwise collide with. With a screenshot, the close button floats over the image
and the row clears it naturally. */
.modal-title-row.no-shot { padding-right: 36px; }

.modal-title { font-size: 20px; font-weight: 700; }

Expand Down Expand Up @@ -2287,7 +2292,7 @@
<div class="grid-card" data-id="${b.id}" style="position:relative">
${b.screenshot
? `<img class="card-screenshot" src="${shotSrc(b)}" alt="${esc(b.title)}" loading="lazy" />`
: `<div class="card-no-screenshot">No screenshot</div>`}
: `<div class="card-no-screenshot">No image</div>`}
<button class="fav-btn${b.favorite ? ' active' : ''}" data-id="${b.id}" title="${b.favorite ? 'Unfavorite' : 'Favorite'}">${HEART_SVG}</button>
<button class="more-btn" data-id="${b.id}" title="More">···</button>
<div class="card-body">
Expand Down Expand Up @@ -2521,7 +2526,7 @@
content.innerHTML = `
${item.screenshot ? `<img class="modal-screenshot" src="${shotSrc(item)}" alt="${esc(item.title || '')}">` : ''}
<div class="modal-body">
<div class="modal-title-row" style="display:flex;align-items:center;gap:10px">
<div class="modal-title-row${item.screenshot ? '' : ' no-shot'}" style="display:flex;align-items:center;gap:10px">
<div class="modal-title" style="flex:1">${esc(item.title || item.url || '')}</div>
${tierBadge}${favStar}
</div>
Expand Down Expand Up @@ -2788,7 +2793,7 @@
content.innerHTML = `
${b.screenshot
? `<img class="modal-screenshot" src="${shotSrc(b)}" alt="${esc(b.title)}" />`
: `<div class="modal-screenshot-empty">No screenshot captured</div>`}
: `<div class="modal-screenshot-empty">No image</div>`}
<div class="modal-body">
<div class="modal-title-row">
<div class="modal-title">${esc(b.title)}</div>
Expand Down
72 changes: 72 additions & 0 deletions src/capture/url-readable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<!doctype html><html><head><title>Edifier R1280T</title>
<meta property="og:image" content="https://cdn.example/r1280t.jpg"></head>
<body><article><h1>Edifier R1280T</h1><p>${LONG}</p><p>${LONG}</p></article></body></html>`;

// 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 });
}
});
});
84 changes: 81 additions & 3 deletions src/capture/url-readable.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,93 @@
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 {
const m = /^#\s+(.+)$/m.exec(markdown);
return m ? m[1].trim() : undefined;
}

// Only types the screenshot route serves with an image content-type (server.ts).
const IMAGE_EXT: Record<string, string> = {
'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<string>;
/** Injectable SSRF guard (tests avoid real DNS); defaults to assertCapturableUrl. */
assertUrl?: (url: string) => Promise<void>;
}

/**
* 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<AssetSpec | undefined> {
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 {
Expand All @@ -40,7 +111,14 @@ export function createUrlReadableAdapter(deps: Deps = {}): CaptureAdapter {
// existing item.title on re-capture (runCaptureForItem lifts title → column).
const fields: Record<string, unknown> = { 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 };
},
};
}
5 changes: 4 additions & 1 deletion src/db/hydrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ export function hydrateItemForUi(item: Item, itemAssets: Asset[] = []): Record<s
};
if (item.errorReason) out.error_reason = item.errorReason;

const shot = itemAssets.find((a) => 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<string, unknown>) ?? {};
Expand Down
32 changes: 31 additions & 1 deletion src/processor-library.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 = `<html><head><meta property="og:image" content="https://cdn.example/p/r1280t.jpg"></head><body>x</body></html>`;
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 = `<html><head><meta property="og:image" content="/img/hero.png"></head></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 = `<html><head><meta name="twitter:image" content="https://cdn.example/t.webp"></head></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) => `<html><head><script type="application/ld+json">${img}</script></head></html>`;
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(`<html><head><title>No image here</title></head></html>`, "https://x.example"), undefined);
assert.equal(extractOgImage(`<html><head><script type="application/ld+json">{ not json </script></head></html>`, "https://x.example"), undefined);
});
71 changes: 70 additions & 1 deletion src/processor-library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>)["@graph"])
? ((data as Record<string, unknown>)["@graph"] as unknown[])
: [data];
for (const n of nodes) {
if (!n || typeof n !== "object") continue;
const img = (n as Record<string, unknown>).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<string, unknown>).url === "string")
return (first as Record<string, string>).url;
}
if (img && typeof img === "object" && typeof (img as Record<string, unknown>).url === "string")
return (img as Record<string, string>).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<string> }
Expand All @@ -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
Expand All @@ -163,7 +232,7 @@ export async function captureLibrary(
);
}

return { text, screenshotPath: null };
return { text, screenshotPath: null, imageUrl };
}

const libraryProcessor: Processor = {
Expand Down
4 changes: 3 additions & 1 deletion src/processors.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Loading