src / extractors / imageExtractor.ts
src / extractors / imageExtractor.ts
import * as path from "path";
import { ImageMarker } from "../cache/WebpageCache";
import { ImageDownloader } from "../image_search/utils/ImageDownloader";
import { Logger } from "../utils/Logger";
export const MAX_MAIN_CONTENT_IMAGES = 8;
export interface ImageExtractionResult {
markers: ImageMarker[];
foundCount: number;
filteredCount: number;
}
export interface ImageProcessOutcome {
updatedMarkdown: string;
downloadedCount: number;
downloadedFilenames: string[];
}
const REJECT_KEYWORDS = [
"logo",
"icon",
"favicon",
"avatar",
"emoji",
"button",
"badge",
"banner",
"social",
"share",
"ad",
"advertisement",
"pixel",
"spacer",
"tracker",
"widget",
"header",
"footer",
"nav",
"profile",
"thumb-small",
"spinner",
"loading",
];
/**
* Derives a stable, clean human-readable slug from alt text or URL basename.
* Handles filename collision deduplication using an active set.
*/
export function generateStableFilename(
srcUrl: string,
altOrCaption: string | undefined,
existingFilenames: Set<string>
): string {
let ext = ".webp";
try {
const parsedUrl = new URL(srcUrl);
const urlExt = path.extname(parsedUrl.pathname).toLowerCase();
if ([".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif", ".bmp"].includes(urlExt)) {
ext = urlExt === ".jpeg" ? ".jpg" : urlExt;
}
} catch {
// Ignore URL parse error
}
let slug = "";
if (altOrCaption && altOrCaption.trim()) {
slug = altOrCaption
.trim()
.toLowerCase()
.replace(/[^a-z0-9]/g, "_")
.replace(/_+/g, "_");
if (slug.startsWith("_")) slug = slug.substring(1);
if (slug.endsWith("_")) slug = slug.substring(0, slug.length - 1);
if (slug.length > 30) slug = slug.substring(0, 30);
}
if (!slug) {
try {
const parsedUrl = new URL(srcUrl);
const base = path.basename(parsedUrl.pathname, path.extname(parsedUrl.pathname));
slug = base.toLowerCase().replace(/[^a-z0-9]/g, "_").replace(/_+/g, "_");
} catch {
// Ignore
}
}
if (!slug || slug.length < 3 || ["image", "photo", "picture", "file", "img"].includes(slug)) {
slug = "content_image";
}
let candidate = `${slug}${ext}`;
let counter = 2;
while (existingFilenames.has(candidate)) {
candidate = `${slug}_${counter}${ext}`;
counter++;
}
existingFilenames.add(candidate);
return candidate;
}
/**
* Scans DOM document for main content images strictly within <article>, <main>, or <figure>.
* Filters out decorative icons, logos, trackers, and ads.
* Assigns insertionIndex based on preceding content block elements.
*/
export function extractMainContentImageMarkers(
doc: Document,
baseUrl: string
): ImageExtractionResult {
const DOCUMENT_POSITION_PRECEDING =
doc.defaultView?.Node.DOCUMENT_POSITION_PRECEDING ?? 2;
const mainContainers = Array.from(doc.querySelectorAll("article, main, .post-content, .article-content, #content"));
const container = mainContainers[0] || doc.body;
if (!container) {
return { markers: [], foundCount: 0, filteredCount: 0 };
}
const allImgElements = Array.from(container.querySelectorAll("img"));
const foundCount = allImgElements.length;
// Gather all structural content blocks in container for insertionIndex calculation
const contentBlocks = Array.from(
container.querySelectorAll("h1, h2, h3, h4, h5, h6, p, ul, ol, table, blockquote, pre")
);
const markers: ImageMarker[] = [];
let filteredCount = 0;
for (const img of allImgElements) {
const src = img.getAttribute("src") || img.getAttribute("data-src");
if (!src || src.startsWith("data:") || src.startsWith("blob:")) {
filteredCount++;
continue;
}
let absoluteUrl: string;
try {
absoluteUrl = new URL(src, baseUrl).href;
} catch {
filteredCount++;
continue;
}
// Check URL keywords for rejection
const lowerUrl = absoluteUrl.toLowerCase();
if (REJECT_KEYWORDS.some((kw) => lowerUrl.includes(kw))) {
filteredCount++;
continue;
}
// Check class names / IDs for rejection
const classAndId = `${img.className} ${img.id}`.toLowerCase();
if (REJECT_KEYWORDS.some((kw) => classAndId.includes(kw))) {
filteredCount++;
continue;
}
// Dimension check (ignore tiny icons < 50px)
const widthAttr = parseInt(img.getAttribute("width") || "0", 10);
const heightAttr = parseInt(img.getAttribute("height") || "0", 10);
if ((widthAttr > 0 && widthAttr < 50) || (heightAttr > 0 && heightAttr < 50)) {
filteredCount++;
continue;
}
const alt = img.getAttribute("alt")?.trim();
const title = img.getAttribute("title")?.trim();
// Check figure caption
const figure = img.closest("figure");
const captionNode = figure?.querySelector("figcaption");
const caption = captionNode?.textContent?.trim();
const cleanAlt = alt && !["image", "photo", "picture", "icon"].includes(alt.toLowerCase()) ? alt : undefined;
const cleanCaption = caption || title || undefined;
// Reject if no alt, caption, or title description available
if (!cleanAlt && !cleanCaption) {
filteredCount++;
continue;
}
// Calculate insertionIndex: count content blocks preceding this image in DOM
let insertionIndex = 0;
for (const block of contentBlocks) {
if (img.compareDocumentPosition(block) & DOCUMENT_POSITION_PRECEDING) {
insertionIndex++;
} else {
break;
}
}
markers.push({
src: absoluteUrl,
alt: cleanAlt,
caption: cleanCaption,
insertionIndex,
});
if (markers.length >= MAX_MAIN_CONTENT_IMAGES) {
break;
}
}
return {
markers,
foundCount,
filteredCount: filteredCount + Math.max(0, allImgElements.length - filteredCount - markers.length),
};
}
/**
* Downloads main content images and inserts inline semantic image blocks into clean Markdown body.
* Formats image blocks using the required user format:
*
* <img file="college_sorted.webp">
* Description of image...
*/
export async function downloadAndInsertImages(
markdown: string,
markers: ImageMarker[],
workingDir: string,
downloader?: ImageDownloader,
maxImages: number = MAX_MAIN_CONTENT_IMAGES
): Promise<ImageProcessOutcome> {
if (!markers || markers.length === 0 || !markdown) {
return {
updatedMarkdown: markdown,
downloadedCount: 0,
downloadedFilenames: [],
};
}
const activeDownloader = downloader ?? new ImageDownloader();
const existingFilenames = new Set<string>();
const downloadedFilenames: string[] = [];
const selectedMarkers = markers.slice(0, maxImages);
// Split markdown into structural content blocks (by double newlines)
const blocks = markdown.split(/\n\s*\n/);
const blockImageMap = new Map<number, string[]>();
for (let i = 0; i < selectedMarkers.length; i++) {
const marker = selectedMarkers[i];
const targetFilename = generateStableFilename(marker.src, marker.alt || marker.caption, existingFilenames);
try {
const downloadRes = await activeDownloader.downloadSingleImage(
marker.src,
path.basename(targetFilename, path.extname(targetFilename)),
workingDir
);
if (downloadRes) {
downloadedFilenames.push(downloadRes.filename);
const rawDesc = marker.alt || marker.caption || "Main content image";
const safeDesc = rawDesc
.replace(/\\/g, "\\\\")
.replace(/\[/g, "\\[")
.replace(/\]/g, "\\]")
.replace(/\s+/g, " ")
.trim();
const imageBlock = ``;
// Map image block to target insertionIndex
const targetIndex = Math.min(marker.insertionIndex, blocks.length);
const list = blockImageMap.get(targetIndex) || [];
list.push(imageBlock);
blockImageMap.set(targetIndex, list);
}
} catch (err) {
Logger.debug("[WebpageReader]", `Failed to download image ${marker.src}: ${err}`);
}
}
if (downloadedFilenames.length === 0) {
return {
updatedMarkdown: markdown,
downloadedCount: 0,
downloadedFilenames: [],
};
}
// Re-assemble markdown blocks with inline image blocks inserted at exact positions
const resultBlocks: string[] = [];
for (let idx = 0; idx <= blocks.length; idx++) {
if (blockImageMap.has(idx)) {
resultBlocks.push(...blockImageMap.get(idx)!);
}
if (idx < blocks.length) {
resultBlocks.push(blocks[idx]);
}
}
return {
updatedMarkdown: resultBlocks.join("\n\n"),
downloadedCount: downloadedFilenames.length,
downloadedFilenames,
};
}
import * as path from "path";
import { ImageMarker } from "../cache/WebpageCache";
import { ImageDownloader } from "../image_search/utils/ImageDownloader";
import { Logger } from "../utils/Logger";
export const MAX_MAIN_CONTENT_IMAGES = 8;
export interface ImageExtractionResult {
markers: ImageMarker[];
foundCount: number;
filteredCount: number;
}
export interface ImageProcessOutcome {
updatedMarkdown: string;
downloadedCount: number;
downloadedFilenames: string[];
}
const REJECT_KEYWORDS = [
"logo",
"icon",
"favicon",
"avatar",
"emoji",
"button",
"badge",
"banner",
"social",
"share",
"ad",
"advertisement",
"pixel",
"spacer",
"tracker",
"widget",
"header",
"footer",
"nav",
"profile",
"thumb-small",
"spinner",
"loading",
];
/**
* Derives a stable, clean human-readable slug from alt text or URL basename.
* Handles filename collision deduplication using an active set.
*/
export function generateStableFilename(
srcUrl: string,
altOrCaption: string | undefined,
existingFilenames: Set<string>
): string {
let ext = ".webp";
try {
const parsedUrl = new URL(srcUrl);
const urlExt = path.extname(parsedUrl.pathname).toLowerCase();
if ([".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif", ".bmp"].includes(urlExt)) {
ext = urlExt === ".jpeg" ? ".jpg" : urlExt;
}
} catch {
// Ignore URL parse error
}
let slug = "";
if (altOrCaption && altOrCaption.trim()) {
slug = altOrCaption
.trim()
.toLowerCase()
.replace(/[^a-z0-9]/g, "_")
.replace(/_+/g, "_");
if (slug.startsWith("_")) slug = slug.substring(1);
if (slug.endsWith("_")) slug = slug.substring(0, slug.length - 1);
if (slug.length > 30) slug = slug.substring(0, 30);
}
if (!slug) {
try {
const parsedUrl = new URL(srcUrl);
const base = path.basename(parsedUrl.pathname, path.extname(parsedUrl.pathname));
slug = base.toLowerCase().replace(/[^a-z0-9]/g, "_").replace(/_+/g, "_");
} catch {
// Ignore
}
}
if (!slug || slug.length < 3 || ["image", "photo", "picture", "file", "img"].includes(slug)) {
slug = "content_image";
}
let candidate = `${slug}${ext}`;
let counter = 2;
while (existingFilenames.has(candidate)) {
candidate = `${slug}_${counter}${ext}`;
counter++;
}
existingFilenames.add(candidate);
return candidate;
}
/**
* Scans DOM document for main content images strictly within <article>, <main>, or <figure>.
* Filters out decorative icons, logos, trackers, and ads.
* Assigns insertionIndex based on preceding content block elements.
*/
export function extractMainContentImageMarkers(
doc: Document,
baseUrl: string
): ImageExtractionResult {
const DOCUMENT_POSITION_PRECEDING =
doc.defaultView?.Node.DOCUMENT_POSITION_PRECEDING ?? 2;
const mainContainers = Array.from(doc.querySelectorAll("article, main, .post-content, .article-content, #content"));
const container = mainContainers[0] || doc.body;
if (!container) {
return { markers: [], foundCount: 0, filteredCount: 0 };
}
const allImgElements = Array.from(container.querySelectorAll("img"));
const foundCount = allImgElements.length;
// Gather all structural content blocks in container for insertionIndex calculation
const contentBlocks = Array.from(
container.querySelectorAll("h1, h2, h3, h4, h5, h6, p, ul, ol, table, blockquote, pre")
);
const markers: ImageMarker[] = [];
let filteredCount = 0;
for (const img of allImgElements) {
const src = img.getAttribute("src") || img.getAttribute("data-src");
if (!src || src.startsWith("data:") || src.startsWith("blob:")) {
filteredCount++;
continue;
}
let absoluteUrl: string;
try {
absoluteUrl = new URL(src, baseUrl).href;
} catch {
filteredCount++;
continue;
}
// Check URL keywords for rejection
const lowerUrl = absoluteUrl.toLowerCase();
if (REJECT_KEYWORDS.some((kw) => lowerUrl.includes(kw))) {
filteredCount++;
continue;
}
// Check class names / IDs for rejection
const classAndId = `${img.className} ${img.id}`.toLowerCase();
if (REJECT_KEYWORDS.some((kw) => classAndId.includes(kw))) {
filteredCount++;
continue;
}
// Dimension check (ignore tiny icons < 50px)
const widthAttr = parseInt(img.getAttribute("width") || "0", 10);
const heightAttr = parseInt(img.getAttribute("height") || "0", 10);
if ((widthAttr > 0 && widthAttr < 50) || (heightAttr > 0 && heightAttr < 50)) {
filteredCount++;
continue;
}
const alt = img.getAttribute("alt")?.trim();
const title = img.getAttribute("title")?.trim();
// Check figure caption
const figure = img.closest("figure");
const captionNode = figure?.querySelector("figcaption");
const caption = captionNode?.textContent?.trim();
const cleanAlt = alt && !["image", "photo", "picture", "icon"].includes(alt.toLowerCase()) ? alt : undefined;
const cleanCaption = caption || title || undefined;
// Reject if no alt, caption, or title description available
if (!cleanAlt && !cleanCaption) {
filteredCount++;
continue;
}
// Calculate insertionIndex: count content blocks preceding this image in DOM
let insertionIndex = 0;
for (const block of contentBlocks) {
if (img.compareDocumentPosition(block) & DOCUMENT_POSITION_PRECEDING) {
insertionIndex++;
} else {
break;
}
}
markers.push({
src: absoluteUrl,
alt: cleanAlt,
caption: cleanCaption,
insertionIndex,
});
if (markers.length >= MAX_MAIN_CONTENT_IMAGES) {
break;
}
}
return {
markers,
foundCount,
filteredCount: filteredCount + Math.max(0, allImgElements.length - filteredCount - markers.length),
};
}
/**
* Downloads main content images and inserts inline semantic image blocks into clean Markdown body.
* Formats image blocks using the required user format:
*
* <img file="college_sorted.webp">
* Description of image...
*/
export async function downloadAndInsertImages(
markdown: string,
markers: ImageMarker[],
workingDir: string,
downloader?: ImageDownloader,
maxImages: number = MAX_MAIN_CONTENT_IMAGES
): Promise<ImageProcessOutcome> {
if (!markers || markers.length === 0 || !markdown) {
return {
updatedMarkdown: markdown,
downloadedCount: 0,
downloadedFilenames: [],
};
}
const activeDownloader = downloader ?? new ImageDownloader();
const existingFilenames = new Set<string>();
const downloadedFilenames: string[] = [];
const selectedMarkers = markers.slice(0, maxImages);
// Split markdown into structural content blocks (by double newlines)
const blocks = markdown.split(/\n\s*\n/);
const blockImageMap = new Map<number, string[]>();
for (let i = 0; i < selectedMarkers.length; i++) {
const marker = selectedMarkers[i];
const targetFilename = generateStableFilename(marker.src, marker.alt || marker.caption, existingFilenames);
try {
const downloadRes = await activeDownloader.downloadSingleImage(
marker.src,
path.basename(targetFilename, path.extname(targetFilename)),
workingDir
);
if (downloadRes) {
downloadedFilenames.push(downloadRes.filename);
const rawDesc = marker.alt || marker.caption || "Main content image";
const safeDesc = rawDesc
.replace(/\\/g, "\\\\")
.replace(/\[/g, "\\[")
.replace(/\]/g, "\\]")
.replace(/\s+/g, " ")
.trim();
const imageBlock = ``;
// Map image block to target insertionIndex
const targetIndex = Math.min(marker.insertionIndex, blocks.length);
const list = blockImageMap.get(targetIndex) || [];
list.push(imageBlock);
blockImageMap.set(targetIndex, list);
}
} catch (err) {
Logger.debug("[WebpageReader]", `Failed to download image ${marker.src}: ${err}`);
}
}
if (downloadedFilenames.length === 0) {
return {
updatedMarkdown: markdown,
downloadedCount: 0,
downloadedFilenames: [],
};
}
// Re-assemble markdown blocks with inline image blocks inserted at exact positions
const resultBlocks: string[] = [];
for (let idx = 0; idx <= blocks.length; idx++) {
if (blockImageMap.has(idx)) {
resultBlocks.push(...blockImageMap.get(idx)!);
}
if (idx < blocks.length) {
resultBlocks.push(blocks[idx]);
}
}
return {
updatedMarkdown: resultBlocks.join("\n\n"),
downloadedCount: downloadedFilenames.length,
downloadedFilenames,
};
}