FeedReaderService
This commit is contained in:
108
src/__tests__/FeedReaderService.test.ts
Normal file
108
src/__tests__/FeedReaderService.test.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import { FeedReaderService } from '../services/FeedReaderService';
|
||||
import { IFeedRepository } from '../repositories/FeedRepository';
|
||||
import { NewsSource } from '../types/Feed';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('../utils/logger');
|
||||
jest.mock('../services/ScrapingService');
|
||||
jest.mock('../utils/WebScraper');
|
||||
jest.mock('../extractors/ElPaisExtractor');
|
||||
jest.mock('../extractors/ElMundoExtractor');
|
||||
|
||||
// Mock fetch globally
|
||||
global.fetch = jest.fn();
|
||||
|
||||
const mockFeedRepository: jest.Mocked<IFeedRepository> = {
|
||||
create: jest.fn(),
|
||||
findAll: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
findByUrl: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
findBySource: jest.fn(),
|
||||
findTodaysFrontPage: jest.fn(),
|
||||
deleteMany: jest.fn(),
|
||||
count: jest.fn(),
|
||||
exists: jest.fn()
|
||||
};
|
||||
|
||||
// Mock ScrapingService
|
||||
const mockScrapingService = {
|
||||
processFeedBatch: jest.fn()
|
||||
};
|
||||
|
||||
jest.mock('../services/ScrapingService', () => {
|
||||
return {
|
||||
ScrapingService: jest.fn().mockImplementation(() => mockScrapingService)
|
||||
};
|
||||
});
|
||||
|
||||
// Mock WebScraper
|
||||
const mockWebScraper = {
|
||||
scrapeUrl: jest.fn(),
|
||||
convertToFeedData: jest.fn()
|
||||
};
|
||||
|
||||
jest.mock('../utils/WebScraper', () => {
|
||||
return {
|
||||
WebScraper: jest.fn().mockImplementation(() => mockWebScraper)
|
||||
};
|
||||
});
|
||||
|
||||
// Mock extractors
|
||||
const mockExtractor = {
|
||||
extractNews: jest.fn(),
|
||||
isEnabled: jest.fn().mockReturnValue(true),
|
||||
getName: jest.fn(),
|
||||
getSource: jest.fn()
|
||||
};
|
||||
|
||||
const mockElPaisExtractor = {
|
||||
...mockExtractor,
|
||||
getName: jest.fn().mockReturnValue('El País'),
|
||||
getSource: jest.fn().mockReturnValue(NewsSource.EL_PAIS)
|
||||
};
|
||||
|
||||
const mockElMundoExtractor = {
|
||||
...mockExtractor,
|
||||
getName: jest.fn().mockReturnValue('El Mundo'),
|
||||
getSource: jest.fn().mockReturnValue(NewsSource.EL_MUNDO)
|
||||
};
|
||||
|
||||
jest.mock('../extractors/NewspaperExtractorFactory', () => ({
|
||||
NewspaperExtractorFactory: {
|
||||
getAllAvailableExtractors: jest.fn(() => [mockElPaisExtractor, mockElMundoExtractor]),
|
||||
createExtractor: jest.fn((source) => {
|
||||
if (source === NewsSource.EL_PAIS) return mockElPaisExtractor;
|
||||
if (source === NewsSource.EL_MUNDO) return mockElMundoExtractor;
|
||||
return null;
|
||||
})
|
||||
}
|
||||
}));
|
||||
|
||||
describe('FeedReaderService', () => {
|
||||
let feedReaderService: FeedReaderService;
|
||||
const mockFetch = fetch as jest.MockedFunction<typeof fetch>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
feedReaderService = new FeedReaderService(mockFeedRepository);
|
||||
});
|
||||
|
||||
describe('Constructor and Initialization', () => {
|
||||
it('should initialize with available extractors', () => {
|
||||
const newspapers = feedReaderService.getAvailableNewspapers();
|
||||
expect(newspapers).toHaveLength(2);
|
||||
expect(newspapers.map(n => n.source)).toContain(NewsSource.EL_PAIS);
|
||||
expect(newspapers.map(n => n.source)).toContain(NewsSource.EL_MUNDO);
|
||||
});
|
||||
|
||||
it('should have all extractors enabled by default', () => {
|
||||
const newspapers = feedReaderService.getAvailableNewspapers();
|
||||
newspapers.forEach(newspaper => {
|
||||
expect(newspaper.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
193
src/services/FeedReaderService.ts
Normal file
193
src/services/FeedReaderService.ts
Normal file
@ -0,0 +1,193 @@
|
||||
import { ScrapingService } from './ScrapingService';
|
||||
import { IFeed, NewsSource } from '../types/Feed';
|
||||
import { IFeedRepository } from '../repositories/FeedRepository';
|
||||
import { Logger } from '../utils/logger';
|
||||
import { BaseNewspaperExtractor } from '../extractors/BaseNewspaperExtractor';
|
||||
import { NewspaperExtractorFactory } from '../extractors/NewspaperExtractorFactory';
|
||||
import { ScrapingResult } from '../types/NewspaperTypes';
|
||||
|
||||
/**
|
||||
* Servicio principal de lectura de feeds mediante web scraping
|
||||
*/
|
||||
export class FeedReaderService {
|
||||
private scrapingService: ScrapingService;
|
||||
private extractors: Map<NewsSource, BaseNewspaperExtractor>;
|
||||
|
||||
constructor(feedRepository: IFeedRepository) {
|
||||
this.scrapingService = new ScrapingService(feedRepository);
|
||||
this.extractors = new Map();
|
||||
this.initializeExtractors();
|
||||
}
|
||||
|
||||
/**
|
||||
* Inicializa todos los extractores disponibles
|
||||
*/
|
||||
private initializeExtractors(): void {
|
||||
const availableExtractors = NewspaperExtractorFactory.getAllAvailableExtractors();
|
||||
|
||||
for (const extractor of availableExtractors) {
|
||||
this.extractors.set(extractor.getSource(), extractor);
|
||||
Logger.info(`Initialized extractor for ${extractor.getName()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrae noticias de un periódico específico
|
||||
*/
|
||||
async extractFromNewspaper(source: NewsSource): Promise<ScrapingResult> {
|
||||
const extractor = this.extractors.get(source);
|
||||
|
||||
if (!extractor) {
|
||||
const error = `No extractor found for source: ${source}`;
|
||||
Logger.error(error);
|
||||
return {
|
||||
success: 0,
|
||||
failed: 1,
|
||||
duplicates: 0,
|
||||
items: [],
|
||||
errors: [error]
|
||||
};
|
||||
}
|
||||
|
||||
if (!extractor.isEnabled()) {
|
||||
Logger.info(`Skipping disabled extractor: ${extractor.getName()}`);
|
||||
return {
|
||||
success: 0,
|
||||
failed: 0,
|
||||
duplicates: 0,
|
||||
items: [],
|
||||
errors: []
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
Logger.info(`Starting extraction for ${extractor.getName()}`);
|
||||
const newsItems = await extractor.extractNews();
|
||||
|
||||
if (newsItems.length === 0) {
|
||||
Logger.warn(`No news items extracted for ${extractor.getName()}`);
|
||||
return {
|
||||
success: 0,
|
||||
failed: 0,
|
||||
duplicates: 0,
|
||||
items: [],
|
||||
errors: []
|
||||
};
|
||||
}
|
||||
|
||||
const results = await this.scrapingService.processFeedBatch(newsItems);
|
||||
const analyzed = this.analyzeResults(results);
|
||||
|
||||
Logger.info(`Completed extraction for ${extractor.getName()}: ${analyzed.success} success, ${analyzed.failed} failed, ${analyzed.duplicates} duplicates`);
|
||||
return analyzed;
|
||||
} catch (error) {
|
||||
const errorMsg = `Error extracting from ${extractor.getName()}: ${error}`;
|
||||
Logger.error(errorMsg);
|
||||
return {
|
||||
success: 0,
|
||||
failed: 1,
|
||||
duplicates: 0,
|
||||
items: [],
|
||||
errors: [errorMsg]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrae noticias de todos los periódicos disponibles
|
||||
*/
|
||||
async extractFromAllNewspapers(): Promise<Map<NewsSource, ScrapingResult>> {
|
||||
Logger.info(`Starting batch extraction from ${this.extractors.size} newspapers`);
|
||||
const results = new Map<NewsSource, ScrapingResult>();
|
||||
|
||||
for (const [source, extractor] of this.extractors) {
|
||||
if (extractor.isEnabled()) {
|
||||
const result = await this.extractFromNewspaper(source);
|
||||
results.set(source, result);
|
||||
} else {
|
||||
Logger.info(`Skipping disabled newspaper: ${extractor.getName()}`);
|
||||
}
|
||||
}
|
||||
|
||||
const totalStats = this.calculateTotalStats(results);
|
||||
Logger.info(`Batch extraction completed: ${totalStats.success} total success, ${totalStats.failed} total failed, ${totalStats.duplicates} total duplicates`);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene la lista de periódicos disponibles
|
||||
*/
|
||||
getAvailableNewspapers(): { source: NewsSource; name: string; enabled: boolean }[] {
|
||||
const newspapers: { source: NewsSource; name: string; enabled: boolean }[] = [];
|
||||
|
||||
for (const [source, extractor] of this.extractors) {
|
||||
newspapers.push({
|
||||
source,
|
||||
name: extractor.getName(),
|
||||
enabled: extractor.isEnabled()
|
||||
});
|
||||
}
|
||||
|
||||
return newspapers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Habilita o deshabilita un extractor específico
|
||||
*/
|
||||
setExtractorEnabled(source: NewsSource, enabled: boolean): boolean {
|
||||
const extractor = this.extractors.get(source);
|
||||
if (!extractor) {
|
||||
Logger.error(`Cannot set enabled state: No extractor found for source ${source}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Nota: En una implementación real, esto podría modificar la configuración
|
||||
// Por ahora, solo registramos el cambio
|
||||
Logger.info(`${enabled ? 'Enabled' : 'Disabled'} extractor for ${extractor.getName()}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analiza los resultados del procesamiento
|
||||
*/
|
||||
private analyzeResults(results: (IFeed | null)[]): ScrapingResult {
|
||||
const success = results.filter(item => item !== null).length;
|
||||
const failed = results.filter(item => item === null).length;
|
||||
|
||||
return {
|
||||
success,
|
||||
failed,
|
||||
duplicates: 0, // El ScrapingService maneja duplicados internamente
|
||||
items: results,
|
||||
errors: []
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula estadísticas totales de múltiples resultados
|
||||
*/
|
||||
private calculateTotalStats(results: Map<NewsSource, ScrapingResult>): ScrapingResult {
|
||||
let totalSuccess = 0;
|
||||
let totalFailed = 0;
|
||||
let totalDuplicates = 0;
|
||||
const allItems: (IFeed | null)[] = [];
|
||||
const allErrors: string[] = [];
|
||||
|
||||
for (const result of results.values()) {
|
||||
totalSuccess += result.success;
|
||||
totalFailed += result.failed;
|
||||
totalDuplicates += result.duplicates;
|
||||
allItems.push(...result.items);
|
||||
allErrors.push(...result.errors);
|
||||
}
|
||||
|
||||
return {
|
||||
success: totalSuccess,
|
||||
failed: totalFailed,
|
||||
duplicates: totalDuplicates,
|
||||
items: allItems,
|
||||
errors: allErrors
|
||||
};
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user