src / engine / normalization / CanonicalSearchResultBuilder.ts
src / engine / normalization / CanonicalSearchResultBuilder.ts
import { SearchResult } from "../../models/SearchResult";
import { InternalProviderResult } from "../../providers/types/InternalProviderResult";
import { HtmlCleaner } from "./HtmlCleaner";
import { TextNormalizer } from "./TextNormalizer";
import { UrlValidator } from "./UrlValidator";
/**
* Engine-owned immutable builder for canonical SearchResult (Discovery Object) instances.
* Guarantees that there is EXACTLY ONE PLACE in the codebase where public SearchResult instances are constructed.
*
* Rules:
* - Immutably maps InternalProviderResult to brand new SearchResult.
* - Strips HTML/XML tags and decodes entities.
* - Normalizes whitespace.
* - Validates URL and title requirements.
* - Truncates snippet at maxSnippetLength without inventing synthetic text.
* - Omits internal raw payloads (rawItem) from the returned public SearchResult.
*/
export class CanonicalSearchResultBuilder {
/**
* Immutably constructs a canonical SearchResult from an InternalProviderResult candidate.
*
* @param candidate Internal candidate item returned by search provider extractors.
* @param maxSnippetLength Configured maximum snippet character limit (default: 400).
* @returns Clean, canonical SearchResult, or undefined if title or URL are invalid.
*/
public static build(
candidate: InternalProviderResult,
maxSnippetLength: number = 400
): SearchResult | undefined {
if (!candidate) return undefined;
// 1. Validate & Normalize URL
const cleanUrl = UrlValidator.validateUrl(candidate.url);
if (!cleanUrl) {
return undefined;
}
// 2. Clean & Normalize Title
const rawTitle = candidate.title ?? "";
const cleanTitle = TextNormalizer.truncate(
TextNormalizer.normalizeWhitespace(HtmlCleaner.stripHtml(rawTitle)),
200
);
if (!cleanTitle) {
return undefined;
}
// 3. Clean & Truncate Snippet (Non-inventive rule: empty candidate remains empty)
const rawSnippet = candidate.snippetCandidate ?? "";
const cleanSnippet = TextNormalizer.truncate(
TextNormalizer.normalizeWhitespace(HtmlCleaner.stripHtml(rawSnippet)),
maxSnippetLength
);
// 4. Construct Fresh Canonical SearchResult (Omit raw / internal data)
return {
title: cleanTitle,
url: cleanUrl,
snippet: cleanSnippet,
source: candidate.source ? candidate.source.toLowerCase().trim() : "unknown",
...(candidate.publishedDate ? { publishedDate: candidate.publishedDate } : {}),
...(typeof candidate.score === "number" ? { score: candidate.score } : {}),
};
}
}
import { SearchResult } from "../../models/SearchResult";
import { InternalProviderResult } from "../../providers/types/InternalProviderResult";
import { HtmlCleaner } from "./HtmlCleaner";
import { TextNormalizer } from "./TextNormalizer";
import { UrlValidator } from "./UrlValidator";
/**
* Engine-owned immutable builder for canonical SearchResult (Discovery Object) instances.
* Guarantees that there is EXACTLY ONE PLACE in the codebase where public SearchResult instances are constructed.
*
* Rules:
* - Immutably maps InternalProviderResult to brand new SearchResult.
* - Strips HTML/XML tags and decodes entities.
* - Normalizes whitespace.
* - Validates URL and title requirements.
* - Truncates snippet at maxSnippetLength without inventing synthetic text.
* - Omits internal raw payloads (rawItem) from the returned public SearchResult.
*/
export class CanonicalSearchResultBuilder {
/**
* Immutably constructs a canonical SearchResult from an InternalProviderResult candidate.
*
* @param candidate Internal candidate item returned by search provider extractors.
* @param maxSnippetLength Configured maximum snippet character limit (default: 400).
* @returns Clean, canonical SearchResult, or undefined if title or URL are invalid.
*/
public static build(
candidate: InternalProviderResult,
maxSnippetLength: number = 400
): SearchResult | undefined {
if (!candidate) return undefined;
// 1. Validate & Normalize URL
const cleanUrl = UrlValidator.validateUrl(candidate.url);
if (!cleanUrl) {
return undefined;
}
// 2. Clean & Normalize Title
const rawTitle = candidate.title ?? "";
const cleanTitle = TextNormalizer.truncate(
TextNormalizer.normalizeWhitespace(HtmlCleaner.stripHtml(rawTitle)),
200
);
if (!cleanTitle) {
return undefined;
}
// 3. Clean & Truncate Snippet (Non-inventive rule: empty candidate remains empty)
const rawSnippet = candidate.snippetCandidate ?? "";
const cleanSnippet = TextNormalizer.truncate(
TextNormalizer.normalizeWhitespace(HtmlCleaner.stripHtml(rawSnippet)),
maxSnippetLength
);
// 4. Construct Fresh Canonical SearchResult (Omit raw / internal data)
return {
title: cleanTitle,
url: cleanUrl,
snippet: cleanSnippet,
source: candidate.source ? candidate.source.toLowerCase().trim() : "unknown",
...(candidate.publishedDate ? { publishedDate: candidate.publishedDate } : {}),
...(typeof candidate.score === "number" ? { score: candidate.score } : {}),
};
}
}