/** * PageAnalysis Domain Entity * * Represents a complete analysis of a page with discovered elements. * Encapsulates element management, analysis versioning, and statistics. * * Domain invariants enforced: * - Version must be positive * - Element count must match elements array length * - URL must be valid * * @layer Domain */ import { AnalysisStatus, ElementType, SelectorType, } from './DiscoveredPage'; import { ReliabilityStatus, ListingTemplate, SearchMechanism, PaginationMechanism } from '@archer/domain'; /** * Selector Strategy Interface * * Single selector with reliability score */ export interface SelectorStrategy { type: SelectorType; value: string; score: number; // 0-1, higher is more reliable } /** * Page Element Interface * * Discovered interactive element with metadata */ export interface PageElement { id: string; type: ElementType; selectors: SelectorStrategy[]; semanticDescription: string; text?: string; isVisible: boolean; position?: { x: number; y: number; width: number; height: number; }; } interface PageAnalysisProps { id: string; tenantId: string; discoveredPageId?: string; url: string; version: number; status: AnalysisStatus; analyzedAt: Date; title?: string; elements: PageElement[]; elementCount: number; analysisDurationMs?: number; reliabilityStatus?: ReliabilityStatus; listingTemplates?: ListingTemplate[]; searchMechanism?: SearchMechanism; paginationMechanism?: PaginationMechanism; } /** * PageAnalysis Entity * * Encapsulates page analysis business logic and element management. * Use factory methods (create, fromPersistence) to construct instances. */ export class PageAnalysis { private constructor(private readonly props: PageAnalysisProps) { this.validate(); } /** * Creates a new PageAnalysis instance * * @param data - Page analysis creation data * @returns PageAnalysis domain entity * @throws Error if validation fails */ static create(data: { id: string; tenantId: string; discoveredPageId?: string; url: string; title?: string; elements: PageElement[]; analysisDurationMs?: number; }): PageAnalysis { const now = new Date(); return new PageAnalysis({ id: data.id, tenantId: data.tenantId, discoveredPageId: data.discoveredPageId, url: data.url, version: 1, status: AnalysisStatus.ANALYZED, analyzedAt: now, title: data.title, elements: data.elements, elementCount: data.elements.length, analysisDurationMs: data.analysisDurationMs, }); } /** * Reconstructs PageAnalysis from persistence layer * * @param data - Persisted page analysis data * @returns PageAnalysis domain entity */ static fromPersistence(data: { id: string; tenantId: string; discoveredPageId?: string; url: string; version: number; status: AnalysisStatus; analyzedAt: string | Date; title?: string; elements: PageElement[]; elementCount: number; analysisDurationMs?: number; reliabilityStatus?: ReliabilityStatus; listingTemplates?: ListingTemplate[]; searchMechanism?: SearchMechanism; paginationMechanism?: PaginationMechanism; }): PageAnalysis { return new PageAnalysis({ id: data.id, tenantId: data.tenantId, discoveredPageId: data.discoveredPageId, url: data.url, version: data.version, status: data.status, analyzedAt: typeof data.analyzedAt === 'string' ? new Date(data.analyzedAt) : data.analyzedAt, title: data.title, elements: data.elements, elementCount: data.elementCount, analysisDurationMs: data.analysisDurationMs, reliabilityStatus: data.reliabilityStatus, listingTemplates: data.listingTemplates, searchMechanism: data.searchMechanism, paginationMechanism: data.paginationMechanism, }); } /** * Validates domain invariants * * @throws Error if validation fails */ private validate(): void { if (!this.props.tenantId) { throw new Error('Page analysis must belong to a tenant'); } if (!this.props.url || this.props.url.trim().length === 0) { throw new Error('Analysis URL cannot be empty'); } if (this.props.version < 1) { throw new Error('Analysis version must be positive'); } if (this.props.elementCount !== this.props.elements.length) { throw new Error('Element count must match elements array length'); } } // Getters get id(): string { return this.props.id; } get tenantId(): string { return this.props.tenantId; } get discoveredPageId(): string | undefined { return this.props.discoveredPageId; } get url(): string { return this.props.url; } get version(): number { return this.props.version; } get status(): AnalysisStatus { return this.props.status; } get analyzedAt(): Date { return this.props.analyzedAt; } get title(): string | undefined { return this.props.title; } get elements(): PageElement[] { return [...this.props.elements]; // Return copy to preserve immutability } get elementCount(): number { return this.props.elementCount; } get analysisDurationMs(): number | undefined { return this.props.analysisDurationMs; } get reliabilityStatus(): ReliabilityStatus | undefined { return this.props.reliabilityStatus; } get listingTemplates(): ListingTemplate[] | undefined { return this.props.listingTemplates; } get searchMechanism(): SearchMechanism | undefined { return this.props.searchMechanism; } get paginationMechanism(): PaginationMechanism | undefined { return this.props.paginationMechanism; } /** * Checks if analysis is complete * * @returns true if status is ANALYZED */ isComplete(): boolean { return this.props.status === AnalysisStatus.ANALYZED; } /** * Checks if analysis is in progress * * @returns true if status is IN_PROGRESS */ isInProgress(): boolean { return this.props.status === AnalysisStatus.IN_PROGRESS; } /** * Checks if analysis failed * * @returns true if status is FAILED */ hasFailed(): boolean { return this.props.status === AnalysisStatus.FAILED; } /** * Gets element by ID * * @param elementId - Element ID to find * @returns Element if found, undefined otherwise */ getElementByld(elementId: string): PageElement | undefined { return this.props.elements.find((el) => el.id === elementId); } /** * Gets elements by type * * @param type - Element type to filter * @returns Array of elements matching the type */ getElementsByType(type: ElementType): PageElement[] { return this.props.elements.filter((el) => el.type === type); } /** * Gets visible elements * * @returns Array of visible elements */ getVisibleElements(): PageElement[] { return this.props.elements.filter((el) => el.isVisible); } /** * Gets element count by type * * @returns Record of element types to counts */ getElementCountByType(): Record { const counts: Partial> = {}; for (const element of this.props.elements) { counts[element.type] = (counts[element.type] || 0) + 1; } return counts as Record; } /** * Gets best selector for an element * * Best selector is the one with highest score. * Priority: ARIA > TEXT > CSS > XPATH > POSITION * * @param elementId - Element ID * @returns Best selector strategy or undefined */ getBestSelector(elementId: string): SelectorStrategy | undefined { const element = this.getElementByld(elementId); if (!element || element.selectors.length === 0) { return undefined; } return element.selectors.reduce((best, current) => { if (current.score > best.score) { return current; } // If scores are equal, prefer higher priority selector type if (current.score === best.score) { const priorityOrder = [ SelectorType.ARIA, SelectorType.TEXT, SelectorType.CSS, SelectorType.XPATH, SelectorType.POSITION, ]; const currentPriority = priorityOrder.indexOf(current.type); const bestPriority = priorityOrder.indexOf(best.type); return currentPriority < bestPriority ? current : best; } return best; }); } /** * Updates element in analysis * * @param elementId - Element ID to update * @param updates - Element properties to update * @returns New PageAnalysis instance with updated element */ updateElement( elementId: string, updates: Partial> ): PageAnalysis { const updatedElements = this.props.elements.map((el) => el.id === elementId ? { ...el, ...updates } : el ); return new PageAnalysis({ ...this.props, elements: updatedElements, }); } /** * Adds element to analysis * * @param element - Element to add * @returns New PageAnalysis instance with added element */ addElement(element: PageElement): PageAnalysis { return new PageAnalysis({ ...this.props, elements: [...this.props.elements, element], elementCount: this.props.elementCount + 1, }); } /** * Removes element from analysis * * @param elementId - Element ID to remove * @returns New PageAnalysis instance with removed element */ removeElement(elementId: string): PageAnalysis { const filteredElements = this.props.elements.filter((el) => el.id !== elementId); return new PageAnalysis({ ...this.props, elements: filteredElements, elementCount: filteredElements.length, }); } /** * Creates a new version of the analysis * * @param updates - Analysis properties to update * @returns New PageAnalysis instance with incremented version */ createNewVersion(updates: { elements?: PageElement[]; title?: string; analysisDurationMs?: number; }): PageAnalysis { const elements = updates.elements ?? this.props.elements; return new PageAnalysis({ ...this.props, ...updates, version: this.props.version + 1, analyzedAt: new Date(), elements, elementCount: elements.length, }); } /** * Updates analysis properties * * @param updates - Properties to update * @returns New PageAnalysis instance with updates */ update(updates: { status?: AnalysisStatus; title?: string; analysisDurationMs?: number; }): PageAnalysis { return new PageAnalysis({ ...this.props, ...updates, }); } /** * Checks if analysis has pattern detection data * * @returns true if analysis has listing templates, search mechanism, or pagination mechanism */ hasPatterns(): boolean { return !!( this.props.listingTemplates?.length || this.props.searchMechanism || this.props.paginationMechanism ); } /** * Checks if analysis is confirmed by AI * * @returns true if reliability status is CONFIRMED */ isConfirmed(): boolean { return this.props.reliabilityStatus === ReliabilityStatus.CONFIRMED; } /** * Gets count of detected listing templates * * @returns Number of listing templates detected (0 if none) */ getListingCount(): number { return this.props.listingTemplates?.length ?? 0; } /** * Converts entity to persistence format * * @returns Plain object for storage */ toPersistence(): { id: string; tenantId: string; discoveredPageId?: string; url: string; version: number; status: AnalysisStatus; analyzedAt: string; title?: string; elements: PageElement[]; elementCount: number; analysisDurationMs?: number; reliabilityStatus?: ReliabilityStatus; listingTemplates?: ListingTemplate[]; searchMechanism?: SearchMechanism; paginationMechanism?: PaginationMechanism; } { return { id: this.props.id, tenantId: this.props.tenantId, discoveredPageId: this.props.discoveredPageId, url: this.props.url, version: this.props.version, status: this.props.status, analyzedAt: this.props.analyzedAt.toISOString(), title: this.props.title, elements: this.props.elements, elementCount: this.props.elementCount, analysisDurationMs: this.props.analysisDurationMs, reliabilityStatus: this.props.reliabilityStatus, listingTemplates: this.props.listingTemplates, searchMechanism: this.props.searchMechanism, paginationMechanism: this.props.paginationMechanism, }; } }