src / image_search / providers / BingImageProvider.ts
src / image_search / providers / BingImageProvider.ts
import { HTTPClient } from "../../http/HTTPClient";
import { ProviderError } from "../../models/errors";
import { SearchOptions } from "../../types/search";
import { ImageSearchResult } from "../models";
import { BaseImageProvider } from "./BaseImageProvider";
export const BING_BASE_URL = "https://www.bing.com";
export const BING_IMAGE_SEARCH_PATH = "/images/search";
export const BING_REFERER = "https://www.bing.com/";
interface BingTileJSON {
murl?: string;
purl?: string;
t?: string;
turl?: string;
}
export interface BingImageProviderOptions {
httpClient?: HTTPClient;
httpTimeoutSeconds?: number;
}
/**
* Decode common HTML entities present in Bing tile JSON attribute strings.
*/
export function decodeHTMLEntities(encoded: string): string {
return encoded
.replace(/"/g, '"')
.replace(/&/g, "&")
.replace(/'/g, "'")
.replace(/'/g, "'")
.replace(/</g, "<")
.replace(/>/g, ">");
}
/**
* Lightweight Bing Image Search provider implementation.
* Queries Bing HTML image search endpoints (`/images/search`) and extracts tile metadata
* embedded in `<a class="iusc" m="...">` attributes.
* Requires no API keys.
*/
export class BingImageProvider extends BaseImageProvider {
public static readonly PROVIDER_NAME = "bing";
constructor(options: BingImageProviderOptions = {}) {
super({
providerName: BingImageProvider.PROVIDER_NAME,
httpClient: options.httpClient,
httpTimeoutSeconds: options.httpTimeoutSeconds,
});
}
/**
* Execute image search using Bing HTML search endpoint.
*
* @param query Non-empty search query string.
* @param options Search options (numResults, extra kwargs).
* @returns Array of normalized ImageSearchResult objects.
* @throws ProviderError if HTTP request fails.
*/
public async searchImages(
query: string,
options: SearchOptions = {}
): Promise<ImageSearchResult[]> {
if (!query || !query.trim()) {
return [];
}
const cleanQuery = query.trim();
const numResults = options.numResults ?? 10;
const url = new URL(BING_IMAGE_SEARCH_PATH, BING_BASE_URL);
url.searchParams.append("q", cleanQuery);
url.searchParams.append("form", "HDRSC2");
url.searchParams.append("adlt", "off");
const headers: Record<string, string> = {
Referer: BING_REFERER,
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
};
let response;
try {
response = await this._httpClient.get<string>(url.toString(), { headers });
} catch (exc: unknown) {
const err = exc instanceof Error ? exc : new Error(String(exc));
throw new ProviderError(`Bing image search HTTP request failed: ${err.message}`);
}
if (response.statusCode !== 200 || !response.text) {
throw new ProviderError(
`Bing image search failed with status code ${response.statusCode}`
);
}
const html = response.text;
const tiles = this.parseBingHtml(html);
return tiles.slice(0, numResults);
}
/**
* Parse HTML response and extract deduplicated image tile metadata.
*/
public parseBingHtml(html: string): ImageSearchResult[] {
const results: ImageSearchResult[] = [];
const seen = new Set<string>();
// Flexible regex matching for <a ... class="iusc" ... m="..."> or <a ... m="..." ... class="iusc">
const iuscRegex = /<a\s+[^>]*?class=["']?[^"']*iusc[^"']*["']?[^>]*?m=(["'])(.*?)\1[^>]*>/gi;
const iuscRegexAlt = /<a\s+[^>]*?m=(["'])(.*?)\1[^>]*?class=["']?[^"']*iusc[^"']*["']?[^>]*>/gi;
const matches: string[] = [];
let match: RegExpExecArray | null;
while ((match = iuscRegex.exec(html)) !== null) {
matches.push(match[2]);
}
while ((match = iuscRegexAlt.exec(html)) !== null) {
matches.push(match[2]);
}
for (const rawM of matches) {
if (!rawM || !rawM.trim()) continue;
const decoded = decodeHTMLEntities(rawM);
let tileJson: BingTileJSON;
try {
tileJson = JSON.parse(decoded) as BingTileJSON;
} catch {
continue;
}
if (!tileJson || !tileJson.murl || typeof tileJson.murl !== "string") {
continue;
}
const imageUrl = tileJson.murl.trim();
// Ensure valid HTTP(S) URL
if (!imageUrl.startsWith("http://") && !imageUrl.startsWith("https://")) {
continue;
}
if (seen.has(imageUrl)) {
continue;
}
seen.add(imageUrl);
const sourceUrl = typeof tileJson.purl === "string" ? tileJson.purl.trim() : "";
let sourceDomain: string | undefined = undefined;
if (sourceUrl) {
try {
const parsed = new URL(sourceUrl);
sourceDomain = parsed.hostname;
} catch {
sourceDomain = undefined;
}
}
const title = typeof tileJson.t === "string" && tileJson.t.trim() ? tileJson.t.trim() : "";
const thumbnailUrl = typeof tileJson.turl === "string" && tileJson.turl.trim() ? tileJson.turl.trim() : undefined;
results.push({
title,
imageUrl,
sourceUrl,
sourceDomain,
thumbnailUrl,
raw: tileJson as unknown as Record<string, unknown>,
});
}
return results;
}
}
import { HTTPClient } from "../../http/HTTPClient";
import { ProviderError } from "../../models/errors";
import { SearchOptions } from "../../types/search";
import { ImageSearchResult } from "../models";
import { BaseImageProvider } from "./BaseImageProvider";
export const BING_BASE_URL = "https://www.bing.com";
export const BING_IMAGE_SEARCH_PATH = "/images/search";
export const BING_REFERER = "https://www.bing.com/";
interface BingTileJSON {
murl?: string;
purl?: string;
t?: string;
turl?: string;
}
export interface BingImageProviderOptions {
httpClient?: HTTPClient;
httpTimeoutSeconds?: number;
}
/**
* Decode common HTML entities present in Bing tile JSON attribute strings.
*/
export function decodeHTMLEntities(encoded: string): string {
return encoded
.replace(/"/g, '"')
.replace(/&/g, "&")
.replace(/'/g, "'")
.replace(/'/g, "'")
.replace(/</g, "<")
.replace(/>/g, ">");
}
/**
* Lightweight Bing Image Search provider implementation.
* Queries Bing HTML image search endpoints (`/images/search`) and extracts tile metadata
* embedded in `<a class="iusc" m="...">` attributes.
* Requires no API keys.
*/
export class BingImageProvider extends BaseImageProvider {
public static readonly PROVIDER_NAME = "bing";
constructor(options: BingImageProviderOptions = {}) {
super({
providerName: BingImageProvider.PROVIDER_NAME,
httpClient: options.httpClient,
httpTimeoutSeconds: options.httpTimeoutSeconds,
});
}
/**
* Execute image search using Bing HTML search endpoint.
*
* @param query Non-empty search query string.
* @param options Search options (numResults, extra kwargs).
* @returns Array of normalized ImageSearchResult objects.
* @throws ProviderError if HTTP request fails.
*/
public async searchImages(
query: string,
options: SearchOptions = {}
): Promise<ImageSearchResult[]> {
if (!query || !query.trim()) {
return [];
}
const cleanQuery = query.trim();
const numResults = options.numResults ?? 10;
const url = new URL(BING_IMAGE_SEARCH_PATH, BING_BASE_URL);
url.searchParams.append("q", cleanQuery);
url.searchParams.append("form", "HDRSC2");
url.searchParams.append("adlt", "off");
const headers: Record<string, string> = {
Referer: BING_REFERER,
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
};
let response;
try {
response = await this._httpClient.get<string>(url.toString(), { headers });
} catch (exc: unknown) {
const err = exc instanceof Error ? exc : new Error(String(exc));
throw new ProviderError(`Bing image search HTTP request failed: ${err.message}`);
}
if (response.statusCode !== 200 || !response.text) {
throw new ProviderError(
`Bing image search failed with status code ${response.statusCode}`
);
}
const html = response.text;
const tiles = this.parseBingHtml(html);
return tiles.slice(0, numResults);
}
/**
* Parse HTML response and extract deduplicated image tile metadata.
*/
public parseBingHtml(html: string): ImageSearchResult[] {
const results: ImageSearchResult[] = [];
const seen = new Set<string>();
// Flexible regex matching for <a ... class="iusc" ... m="..."> or <a ... m="..." ... class="iusc">
const iuscRegex = /<a\s+[^>]*?class=["']?[^"']*iusc[^"']*["']?[^>]*?m=(["'])(.*?)\1[^>]*>/gi;
const iuscRegexAlt = /<a\s+[^>]*?m=(["'])(.*?)\1[^>]*?class=["']?[^"']*iusc[^"']*["']?[^>]*>/gi;
const matches: string[] = [];
let match: RegExpExecArray | null;
while ((match = iuscRegex.exec(html)) !== null) {
matches.push(match[2]);
}
while ((match = iuscRegexAlt.exec(html)) !== null) {
matches.push(match[2]);
}
for (const rawM of matches) {
if (!rawM || !rawM.trim()) continue;
const decoded = decodeHTMLEntities(rawM);
let tileJson: BingTileJSON;
try {
tileJson = JSON.parse(decoded) as BingTileJSON;
} catch {
continue;
}
if (!tileJson || !tileJson.murl || typeof tileJson.murl !== "string") {
continue;
}
const imageUrl = tileJson.murl.trim();
// Ensure valid HTTP(S) URL
if (!imageUrl.startsWith("http://") && !imageUrl.startsWith("https://")) {
continue;
}
if (seen.has(imageUrl)) {
continue;
}
seen.add(imageUrl);
const sourceUrl = typeof tileJson.purl === "string" ? tileJson.purl.trim() : "";
let sourceDomain: string | undefined = undefined;
if (sourceUrl) {
try {
const parsed = new URL(sourceUrl);
sourceDomain = parsed.hostname;
} catch {
sourceDomain = undefined;
}
}
const title = typeof tileJson.t === "string" && tileJson.t.trim() ? tileJson.t.trim() : "";
const thumbnailUrl = typeof tileJson.turl === "string" && tileJson.turl.trim() ? tileJson.turl.trim() : undefined;
results.push({
title,
imageUrl,
sourceUrl,
sourceDomain,
thumbnailUrl,
raw: tileJson as unknown as Record<string, unknown>,
});
}
return results;
}
}