← Files Willy SEOARCHIVED FILE

dist/src/extractor.js

6.69 KB · Oct 2, 2026 · 00:34 UTC

↓ Download file

import * as cheerio from 'cheerio';
const DEFAULT_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 (WillySEO-Bot/1.0)';
export class Extractor {
    /**
     * Descarga y parsea una URL real
     */
    static async extractFromUrl(urlStr, userAgent = DEFAULT_USER_AGENT) {
        let parsedUrl;
        try {
            parsedUrl = new URL(urlStr.startsWith('http') ? urlStr : `https://${urlStr}`);
        }
        catch {
            throw new Error(`URL no válida: ${urlStr}`);
        }
        const startTime = Date.now();
        const response = await fetch(parsedUrl.toString(), {
            headers: {
                'User-Agent': userAgent,
                Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
                'Accept-Language': 'es-ES,es;q=0.9,en;q=0.8',
            },
            redirect: 'follow',
        });
        const responseTimeMs = Date.now() - startTime;
        const html = await response.text();
        const headersRecord = {};
        response.headers.forEach((val, key) => {
            headersRecord[key.toLowerCase()] = val;
        });
        return this.parseHtml(html, response.url || parsedUrl.toString(), response.status, responseTimeMs, headersRecord);
    }
    /**
     * Parsea un string HTML (útil para borradores o tests)
     */
    static parseHtml(html, url = 'https://ejemplo.com', statusCode = 200, responseTimeMs = 100, headers = {}) {
        const $ = cheerio.load(html);
        // Title y Metas
        const title = $('title').first().text().trim() || undefined;
        const metaDescription = $('meta[name="description" i]').attr('content')?.trim() ||
            $('meta[property="og:description" i]').attr('content')?.trim() ||
            undefined;
        const metaRobots = $('meta[name="robots" i]').attr('content')?.toLowerCase() ||
            headers['x-robots-tag']?.toLowerCase() ||
            undefined;
        const canonical = $('link[rel="canonical" i]').attr('href')?.trim() || undefined;
        // Headings
        const h1 = [];
        $('h1').each((_, el) => {
            const txt = $(el).text().trim();
            if (txt)
                h1.push(txt);
        });
        const h2 = [];
        $('h2').each((_, el) => {
            const txt = $(el).text().trim();
            if (txt)
                h2.push(txt);
        });
        const h3 = [];
        $('h3').each((_, el) => {
            const txt = $(el).text().trim();
            if (txt)
                h3.push(txt);
        });
        const h4 = [];
        $('h4').each((_, el) => {
            const txt = $(el).text().trim();
            if (txt)
                h4.push(txt);
        });
        // Imágenes
        const images = [];
        $('img').each((_, el) => {
            const src = $(el).attr('src') || $(el).attr('data-src') || '';
            const alt = $(el).attr('alt');
            const isLazy = $(el).attr('loading') === 'lazy' || !!$(el).attr('data-src');
            images.push({
                src,
                alt: alt !== undefined ? alt.trim() : '',
                hasAlt: alt !== undefined && alt.trim().length > 0,
                isLazy,
            });
        });
        // Enlaces
        const links = [];
        let baseDomain = '';
        try {
            baseDomain = new URL(url).hostname;
        }
        catch {
            baseDomain = 'ejemplo.com';
        }
        $('a[href]').each((_, el) => {
            const href = $(el).attr('href')?.trim() || '';
            const text = $(el).text().trim();
            const rel = $(el).attr('rel')?.toLowerCase() || '';
            const isNofollow = rel.includes('nofollow');
            let isInternal = false;
            if (href.startsWith('/') || href.startsWith('#') || href.startsWith('./') || href.startsWith('../')) {
                isInternal = true;
            }
            else {
                try {
                    const linkDomain = new URL(href, url).hostname;
                    isInternal = linkDomain === baseDomain;
                }
                catch {
                    isInternal = false;
                }
            }
            links.push({
                href,
                text,
                isInternal,
                isNofollow,
            });
        });
        // Schemas JSON-LD
        const schemas = [];
        $('script[type="application/ld+json"]').each((_, el) => {
            try {
                const rawJson = $(el).html();
                if (rawJson) {
                    const parsed = JSON.parse(rawJson);
                    if (Array.isArray(parsed)) {
                        schemas.push(...parsed);
                    }
                    else {
                        schemas.push(parsed);
                    }
                }
            }
            catch {
                // Ignorar json-ld malformado pero registrar que intentó incluirlo
            }
        });
        // OpenGraph
        const openGraph = {};
        $('meta[property^="og:" i]').each((_, el) => {
            const prop = $(el).attr('property')?.toLowerCase() || '';
            const content = $(el).attr('content') || '';
            if (prop && content) {
                openGraph[prop] = content;
            }
        });
        // Twitter Card
        const twitterCard = {};
        $('meta[name^="twitter:" i]').each((_, el) => {
            const name = $(el).attr('name')?.toLowerCase() || '';
            const content = $(el).attr('content') || '';
            if (name && content) {
                twitterCard[name] = content;
            }
        });
        // Texto limpio y conteo de palabras
        $('script, style, noscript, svg, nav, footer, header').remove();
        const rawText = $('body').text().replace(/\s+/g, ' ').trim();
        const words = rawText.length > 0 ? rawText.split(/\s+/).filter(Boolean) : [];
        const wordCount = words.length;
        // Otros metas
        const language = $('html').attr('lang') || undefined;
        const charset = $('meta[charset]').attr('charset') || undefined;
        const viewport = $('meta[name="viewport" i]').attr('content') || undefined;
        return {
            url,
            statusCode,
            responseTimeMs,
            contentType: headers['content-type'] || 'text/html',
            headers,
            title,
            metaDescription,
            metaRobots,
            canonical,
            h1,
            h2,
            h3,
            h4,
            images,
            links,
            schemas,
            openGraph,
            twitterCard,
            wordCount,
            rawText,
            language,
            charset,
            viewport,
        };
    }
}

SHA-256: db9c9a6522a6a04cfa433bdbc950b7f6830cd13dc72f4524c74243867db859cc