Forked from vadimfedenko/analyze-images
Forked from vadimfedenko/analyze-images
src / pageImages.ts
src / pageImages.ts
import { load } from "cheerio";
export interface PageImage { url: string; source: string; }
export function httpUrl(value: string, base?: string): string {
const url = new URL(value, base);
if (!["http:", "https:"].includes(url.protocol)) throw new Error("Only HTTP(S) URLs are supported.");
url.hash = "";
return url.href;
}
// Preserve Product.image array order; never align unrelated DOM and JSON-LD arrays by index.
export function selectPageImages(html: string, pageUrl: string): PageImage[] {
const $ = load(html);
const nodes: Record<string, unknown>[] = [];
function visit(value: unknown): void {
if (Array.isArray(value)) { value.forEach(visit); return; }
if (!value || typeof value !== "object") return;
const node = value as Record<string, unknown>;
nodes.push(node);
Object.values(node).forEach(visit);
}
$('script[type="application/ld+json"]').each((_, element) => {
try { visit(JSON.parse($(element).text())); } catch { /* Skip malformed JSON-LD. */ }
});
const products = nodes.filter(node => [node["@type"]].flat().includes("Product"));
const canonical = (value: unknown): string | undefined => {
try { return typeof value === "string" ? httpUrl(value, pageUrl).replace(/\/$/, "") : undefined; }
catch { return undefined; }
};
const currentPage = canonical(pageUrl);
const product = products.find(node => canonical(node.url) === currentPage || canonical(node["@id"]) === currentPage)
?? products[0];
const result: PageImage[] = [];
const seen = new Set<string>();
function add(value: unknown, source: string): void {
if (result.length >= 64) return;
if (value && typeof value === "object") {
const obj = value as Record<string, unknown>;
value = obj.contentUrl ?? obj.url;
}
if (typeof value !== "string" || !value.trim()) return;
try {
const url = httpUrl(value, pageUrl);
if (!seen.has(url)) { seen.add(url); result.push({ url, source }); }
} catch { /* Skip data URLs and unsupported schemes. */ }
}
if (product) {
const images = Array.isArray(product.image) ? product.image : [product.image];
images.forEach((image, index) => add(image, `Product.image[${index + 1}]`));
}
if (result.length) return result;
$('meta[property="og:image"], meta[name="twitter:image"]').each((_, element) => {
add($(element).attr("content"), "page metadata image");
});
if (result.length) return result;
$('main img, article img').each((_, element) => {
add($(element).attr("data-src") ?? $(element).attr("src"), "main/article img");
});
return result;
}
export async function readLimited(response: Response, limit: number): Promise<Buffer> {
if (!response.ok || !response.body) {
await response.body?.cancel();
throw new Error(`HTTP ${response.status}: ${response.url}`);
}
const reader = response.body.getReader();
const chunks: Buffer[] = [];
let size = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
size += value.length;
if (size > limit) throw new Error(`Response exceeds ${limit} bytes.`);
chunks.push(Buffer.from(value));
}
} finally {
await reader.cancel();
reader.releaseLock();
}
if (!size) throw new Error("Empty response body.");
return Buffer.concat(chunks);
}
import { load } from "cheerio";
export interface PageImage { url: string; source: string; }
export function httpUrl(value: string, base?: string): string {
const url = new URL(value, base);
if (!["http:", "https:"].includes(url.protocol)) throw new Error("Only HTTP(S) URLs are supported.");
url.hash = "";
return url.href;
}
// Preserve Product.image array order; never align unrelated DOM and JSON-LD arrays by index.
export function selectPageImages(html: string, pageUrl: string): PageImage[] {
const $ = load(html);
const nodes: Record<string, unknown>[] = [];
function visit(value: unknown): void {
if (Array.isArray(value)) { value.forEach(visit); return; }
if (!value || typeof value !== "object") return;
const node = value as Record<string, unknown>;
nodes.push(node);
Object.values(node).forEach(visit);
}
$('script[type="application/ld+json"]').each((_, element) => {
try { visit(JSON.parse($(element).text())); } catch { /* Skip malformed JSON-LD. */ }
});
const products = nodes.filter(node => [node["@type"]].flat().includes("Product"));
const canonical = (value: unknown): string | undefined => {
try { return typeof value === "string" ? httpUrl(value, pageUrl).replace(/\/$/, "") : undefined; }
catch { return undefined; }
};
const currentPage = canonical(pageUrl);
const product = products.find(node => canonical(node.url) === currentPage || canonical(node["@id"]) === currentPage)
?? products[0];
const result: PageImage[] = [];
const seen = new Set<string>();
function add(value: unknown, source: string): void {
if (result.length >= 64) return;
if (value && typeof value === "object") {
const obj = value as Record<string, unknown>;
value = obj.contentUrl ?? obj.url;
}
if (typeof value !== "string" || !value.trim()) return;
try {
const url = httpUrl(value, pageUrl);
if (!seen.has(url)) { seen.add(url); result.push({ url, source }); }
} catch { /* Skip data URLs and unsupported schemes. */ }
}
if (product) {
const images = Array.isArray(product.image) ? product.image : [product.image];
images.forEach((image, index) => add(image, `Product.image[${index + 1}]`));
}
if (result.length) return result;
$('meta[property="og:image"], meta[name="twitter:image"]').each((_, element) => {
add($(element).attr("content"), "page metadata image");
});
if (result.length) return result;
$('main img, article img').each((_, element) => {
add($(element).attr("data-src") ?? $(element).attr("src"), "main/article img");
});
return result;
}
export async function readLimited(response: Response, limit: number): Promise<Buffer> {
if (!response.ok || !response.body) {
await response.body?.cancel();
throw new Error(`HTTP ${response.status}: ${response.url}`);
}
const reader = response.body.getReader();
const chunks: Buffer[] = [];
let size = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
size += value.length;
if (size > limit) throw new Error(`Response exceeds ${limit} bytes.`);
chunks.push(Buffer.from(value));
}
} finally {
await reader.cancel();
reader.releaseLock();
}
if (!size) throw new Error("Empty response body.");
return Buffer.concat(chunks);
}