/ src / lib / api.ts
api.ts
  1  import type { SearchParams, SearchResponse, SearchType, SearchResult, Coordinates } from './types';
  2  import { yacyConfig, apiRoutes, defaults, external, timeouts } from './config';
  3  import { 
  4    determineContentType, 
  5    extractDomain, 
  6    formatUrl, 
  7    formatSize, 
  8    getThumbnailUrl, 
  9    truncateText, 
 10    decodeHtml
 11  } from './utils';
 12  
 13  /**
 14   * Clase que encapsula la funcionalidad de la API de búsqueda
 15   */
 16  class SearchService {
 17    /**
 18     * Cache simple para resultados de búsqueda (para minimizar peticiones duplicadas)
 19     * La clave es la URL de búsqueda y el valor es {data, timestamp}
 20     */
 21    private cache: Map<string, {data: SearchResponse, timestamp: number}> = new Map();
 22    
 23    /**
 24     * Tiempo de vida en milisegundos para entradas en cache
 25     */
 26    private readonly CACHE_TTL = 1000 * 60 * 5; // 5 minutos
 27    
 28    /**
 29     * Realiza una búsqueda a través de la API
 30     * @param params Parámetros de búsqueda
 31     * @param useCache Indica si se debe usar la cache
 32     * @returns Promesa con los resultados de búsqueda
 33     */
 34    async search(params: SearchParams, useCache = true): Promise<SearchResponse> {
 35      try {
 36        const searchParams = this.buildSearchParams(params);
 37        const apiUrl = `${apiRoutes.search}?${searchParams.toString()}`;
 38        
 39        // Si está activado el uso de cache y la búsqueda está en cache y no ha expirado
 40        if (useCache && this.cache.has(apiUrl)) {
 41          const cachedData = this.cache.get(apiUrl)!;
 42          const now = Date.now();
 43          
 44          // Si los datos en cache aún son válidos (no han expirado)
 45          if (now - cachedData.timestamp < this.CACHE_TTL) {
 46            console.log(`Usando resultados en cache para: ${params.query} (página ${params.page})`);
 47            return cachedData.data;
 48          } else {
 49            // Eliminar entrada expirada
 50            this.cache.delete(apiUrl);
 51          }
 52        }
 53        
 54        console.log(`Realizando búsqueda con parámetros:`, Object.fromEntries(searchParams.entries()));
 55        
 56        // Configurar timeout y realizar petición
 57        const controller = new AbortController();
 58        const timeoutId = setTimeout(() => controller.abort(), timeouts.search);
 59        
 60        const response = await fetch(apiUrl, {
 61          signal: controller.signal
 62        });
 63        
 64        clearTimeout(timeoutId);
 65        
 66        if (!response.ok) {
 67          throw new Error(`Error en la API de búsqueda: ${response.status} ${response.statusText}`);
 68        }
 69        
 70        const data = await response.json();
 71        
 72        // Almacenar en cache si no hay error
 73        if (!data.error && useCache) {
 74          this.cache.set(apiUrl, {
 75            data: data as SearchResponse,
 76            timestamp: Date.now()
 77          });
 78          
 79          // Limpiar cache si es demasiado grande
 80          if (this.cache.size > 100) {
 81            const oldestKey = this.findOldestCacheEntry();
 82            if (oldestKey) {
 83              this.cache.delete(oldestKey);
 84            }
 85          }
 86        }
 87        
 88        return data as SearchResponse;
 89      } catch (error) {
 90        console.error('Error en búsqueda:', error);
 91        throw error;
 92      }
 93    }
 94    
 95    /**
 96     * Encuentra la entrada más antigua en el cache para poder eliminarla
 97     * @returns Clave de la entrada más antigua o null si el cache está vacío
 98     */
 99    private findOldestCacheEntry(): string | null {
100      if (this.cache.size === 0) return null;
101      
102      let oldestKey: string | null = null;
103      let oldestTimestamp = Infinity;
104      
105      for (const [key, value] of this.cache.entries()) {
106        if (value.timestamp < oldestTimestamp) {
107          oldestTimestamp = value.timestamp;
108          oldestKey = key;
109        }
110      }
111      
112      return oldestKey;
113    }
114    
115    /**
116     * Verifica si el servidor YaCy está disponible
117     * @returns Promesa que se resuelve a true si está disponible, false en caso contrario
118     */
119    async checkYacyAvailability(): Promise<boolean> {
120      try {
121        const controller = new AbortController();
122        const timeoutId = setTimeout(() => controller.abort(), timeouts.statusCheck);
123        
124        const response = await fetch(apiRoutes.yacySearch, {
125          signal: controller.signal
126        });
127        
128        clearTimeout(timeoutId);
129        
130        return response.ok;
131      } catch (error) {
132        console.warn('Error al verificar disponibilidad de YaCy:', error);
133        return false;
134      }
135    }
136    
137    /**
138     * Construye los parámetros de búsqueda para la API
139     * @param params Parámetros de búsqueda proporcionados
140     * @returns URLSearchParams formateados
141     */
142    private buildSearchParams(params: SearchParams): URLSearchParams {
143      const searchParams = new URLSearchParams();
144      
145      // Parámetros básicos
146      if (params.query) searchParams.set('query', params.query);
147      if (params.type) searchParams.set('type', params.type);
148      if (params.language) searchParams.set('language', params.language);
149      if (params.page !== undefined) searchParams.set('page', params.page.toString());
150      
151      // Parámetros adicionales
152      if (params.resultadosPorPagina) {
153        searchParams.set('resultadosPorPagina', params.resultadosPorPagina.toString());
154      }
155      if (params.recurso) searchParams.set('recurso', params.recurso);
156      if (params.preferMask) searchParams.set('preferMask', params.preferMask);
157      if (params.constraints) searchParams.set('constraints', params.constraints);
158      if (params.mediaSearch) searchParams.set('mediaSearch', params.mediaSearch);
159      
160      return searchParams;
161    }
162    
163    /**
164     * Procesa un resultado de búsqueda para normalizar y enriquecer sus datos
165     * @param rawItem Item en formato raw de la API
166     * @param type Tipo de búsqueda
167     * @returns Item procesado
168     */
169    processSearchResult(rawItem: any, type: SearchType = 'web'): SearchResult {
170      // Determinar el tipo de contenido
171      const itemType = type === 'images' ? 'images' : determineContentType(rawItem.link, type as SearchType);
172      
173      // Obtener miniatura adecuada
174      const thumbnail = getThumbnailUrl(rawItem, itemType as SearchType);
175      
176      // Procesamiento para obtener imagen completa (para resultados tipo 'images')
177      let image: string | undefined;
178      if (itemType === 'images') {
179        image = rawItem.image || rawItem.iconlink || rawItem.imagelink || 
180               (rawItem.link && determineContentType(rawItem.link) === 'images' ? rawItem.link : null) || 
181               thumbnail;
182      }
183      
184      // Extraer y formatear información adicional
185      const domain = extractDomain(rawItem.link);
186      const formattedUrl = formatUrl(rawItem.link);
187      const description = decodeHtml(rawItem.snippet || rawItem.description || 'Sin descripción');
188      
189      // Extraer coordenadas para resultados tipo 'maps'
190      const coordinates = this.extractCoordinates(rawItem);
191      
192      // Crear objeto de resultado normalizado
193      return {
194        title: rawItem.title || 'Sin título',
195        link: rawItem.link,
196        description,
197        type: itemType as SearchType,
198        thumbnail,
199        image,
200        imageWidth: rawItem.imageWidth || (rawItem.attr?.width ? Number(rawItem.attr.width) : undefined),
201        imageHeight: rawItem.imageHeight || (rawItem.attr?.height ? Number(rawItem.attr.height) : undefined),
202        language: rawItem.language || this.detectLanguage(description),
203        tags: this.extractTags(rawItem),
204        date: rawItem.pubDate ? new Date(rawItem.pubDate) : 
205              rawItem.lastModified ? new Date(rawItem.lastModified) : undefined,
206        size: rawItem.size ? formatSize(Number(rawItem.size)) : undefined,
207        score: rawItem.score ? Number(rawItem.score) : undefined,
208        coordinates,
209        domain,
210        formattedUrl
211      };
212    }
213    
214    /**
215     * Extrae coordenadas geográficas de un resultado
216     * @param item Item del que extraer coordenadas
217     * @returns Coordenadas si se encuentran, undefined en caso contrario
218     */
219    private extractCoordinates(item: any): Coordinates | undefined {
220      // Si el ítem ya tiene coordenadas explícitas
221      if (item.coordinates) {
222        return item.coordinates;
223      }
224      
225      // Si el ítem tiene atributos con coordenadas
226      if (item.attr && (item.attr.lat || item.attr.latitude) && (item.attr.lng || item.attr.longitude)) {
227        const lat = parseFloat(item.attr.lat || item.attr.latitude);
228        const lng = parseFloat(item.attr.lng || item.attr.longitude);
229        
230        if (!isNaN(lat) && !isNaN(lng)) {
231          return { lat, lng };
232        }
233      }
234      
235      // Buscar en el campo GeoTag de YaCy
236      if (item.geotag) {
237        const parts = item.geotag.split(',');
238        if (parts.length >= 2) {
239          const lat = parseFloat(parts[0]);
240          const lng = parseFloat(parts[1]);
241          
242          if (!isNaN(lat) && !isNaN(lng)) {
243            return { lat, lng };
244          }
245        }
246      }
247      
248      // Buscar coordenadas en la descripción utilizando patrón común de coordenadas
249      // Ejemplo: "coordinates: 40.416775, -3.703790" o "lat: 40.416775, lng: -3.703790"
250      if (item.description) {
251        // Patrón para encontrar coordenadas en formatos comunes
252        const coordPatterns = [
253          /(?:coordinates|coord|position|lat|latitude)[:\s]+(-?\d+\.\d+)[,\s]+(?:lng|long|longitude)?[:\s]*(-?\d+\.\d+)/i,
254          /(-?\d+\.\d+)°\s*[NS]\s*,?\s*(-?\d+\.\d+)°\s*[EW]/i,
255          /ubicación[:\s]+(-?\d+\.\d+)[,\s]+(-?\d+\.\d+)/i
256        ];
257        
258        for (const pattern of coordPatterns) {
259          const match = item.description.match(pattern);
260          if (match && match.length >= 3) {
261            const lat = parseFloat(match[1]);
262            const lng = parseFloat(match[2]);
263            
264            if (!isNaN(lat) && !isNaN(lng) && Math.abs(lat) <= 90 && Math.abs(lng) <= 180) {
265              return { lat, lng };
266            }
267          }
268        }
269      }
270      
271      return undefined;
272    }
273    
274    /**
275     * Extrae tags/etiquetas relevantes del resultado
276     * @param item Item del que extraer tags
277     * @returns Array de tags
278     */
279    private extractTags(item: any): string[] {
280      const tags: string[] = [];
281      
282      // Si el ítem ya tiene tags
283      if (item.tags && Array.isArray(item.tags)) {
284        tags.push(...item.tags);
285      }
286      
287      // Detectar tipo de contenido por MIME type
288      if (item.mime) {
289        const mime = item.mime.split('/');
290        if (mime.length > 1) {
291          tags.push(mime[1].toUpperCase());
292        }
293      }
294      
295      // Añadir autor si está disponible
296      if (item.author) {
297        tags.push(`AUTOR: ${item.author}`);
298      }
299      
300      // Fecha de modificación
301      if (item.lastModified) {
302        const date = new Date(item.lastModified);
303        tags.push(date.getFullYear().toString());
304      }
305      
306      // Limitar a máximo 5 tags y eliminar duplicados
307      return [...new Set(tags)].slice(0, 5);
308    }
309    
310    /**
311     * Detección básica de idioma basada en palabras clave
312     * @param text Texto del que detectar idioma
313     * @returns Código de idioma detectado o 'unknown'
314     */
315    private detectLanguage(text: string): string {
316      if (!text) return 'unknown';
317      
318      // Convertir a minúsculas para mejorar detección
319      const lowerText = text.toLowerCase();
320      
321      // Frecuencia de palabras comunes por idioma
322      const es = (lowerText.match(/\b(el|la|los|las|en|que|por|con|para|como|este|esta)\b/g) || []).length;
323      const en = (lowerText.match(/\b(the|and|for|with|this|that|have|from|what|about)\b/g) || []).length;
324      const fr = (lowerText.match(/\b(le|la|les|des|en|que|pour|avec|comme|cette)\b/g) || []).length;
325      
326      // Determinar idioma más probable (con un umbral mínimo)
327      const minThreshold = 3; // Mínimo de coincidencias para considerar un idioma
328      
329      if (es > en && es > fr && es >= minThreshold) return 'es';
330      if (en > es && en > fr && en >= minThreshold) return 'en';
331      if (fr > es && fr > en && fr >= minThreshold) return 'fr';
332      
333      return 'unknown';
334    }
335  }
336  
337  // Exportar una instancia única del servicio API
338  export const searchService = new SearchService();
339