From 78938467ccfa1a89f0db82ee19f2bf9ae0439240 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Feb 2026 13:00:10 +0000 Subject: [PATCH 1/2] Add comprehensive SEO automation toolkit Includes full-featured CLI and library with: - SEO audit engine (meta, content, technical, links analysis with scoring) - Keyword density & n-gram analysis with target keyword placement tracking - XML sitemap generator via website crawling - Robots.txt generator with AI bot blocking option - Meta tag generator (Open Graph, Twitter Cards, canonical, hreflang) - Schema.org structured data generator (website, org, article, FAQ, local biz, product) - Beautiful HTML report generation with color-coded scoring - Side-by-side URL comparison tool https://claude.ai/code/session_01TLg5Fub8uKYrgeYmJUtiLS --- seo-toolkit/.gitignore | 6 + seo-toolkit/README.md | 238 +++++++++ seo-toolkit/package.json | 27 + seo-toolkit/src/analyzers/content-analyzer.js | 153 ++++++ seo-toolkit/src/analyzers/keyword-analyzer.js | 152 ++++++ seo-toolkit/src/analyzers/link-analyzer.js | 117 +++++ seo-toolkit/src/analyzers/meta-analyzer.js | 112 ++++ .../src/analyzers/technical-analyzer.js | 154 ++++++ seo-toolkit/src/cli.js | 483 ++++++++++++++++++ .../src/generators/meta-tags-generator.js | 85 +++ .../src/generators/report-generator.js | 150 ++++++ .../src/generators/robots-generator.js | 127 +++++ .../src/generators/sitemap-generator.js | 120 +++++ .../generators/structured-data-generator.js | 151 ++++++ seo-toolkit/src/index.js | 126 +++++ seo-toolkit/src/utils/fetcher.js | 61 +++ seo-toolkit/src/utils/scoring.js | 52 ++ 17 files changed, 2314 insertions(+) create mode 100644 seo-toolkit/.gitignore create mode 100644 seo-toolkit/README.md create mode 100644 seo-toolkit/package.json create mode 100644 seo-toolkit/src/analyzers/content-analyzer.js create mode 100644 seo-toolkit/src/analyzers/keyword-analyzer.js create mode 100644 seo-toolkit/src/analyzers/link-analyzer.js create mode 100644 seo-toolkit/src/analyzers/meta-analyzer.js create mode 100644 seo-toolkit/src/analyzers/technical-analyzer.js create mode 100644 seo-toolkit/src/cli.js create mode 100644 seo-toolkit/src/generators/meta-tags-generator.js create mode 100644 seo-toolkit/src/generators/report-generator.js create mode 100644 seo-toolkit/src/generators/robots-generator.js create mode 100644 seo-toolkit/src/generators/sitemap-generator.js create mode 100644 seo-toolkit/src/generators/structured-data-generator.js create mode 100644 seo-toolkit/src/index.js create mode 100644 seo-toolkit/src/utils/fetcher.js create mode 100644 seo-toolkit/src/utils/scoring.js diff --git a/seo-toolkit/.gitignore b/seo-toolkit/.gitignore new file mode 100644 index 0000000..7ce7706 --- /dev/null +++ b/seo-toolkit/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +reports/ +*.log +.DS_Store +sitemap.xml +robots.txt diff --git a/seo-toolkit/README.md b/seo-toolkit/README.md new file mode 100644 index 0000000..4eb6716 --- /dev/null +++ b/seo-toolkit/README.md @@ -0,0 +1,238 @@ +# SEO Toolkit + +A powerful, all-in-one SEO automation toolkit for auditing, analyzing, and optimizing websites from the command line. + +## Features + +| Feature | Description | +|---------|-------------| +| **Full SEO Audit** | Comprehensive analysis of meta tags, content, technical SEO, and links with scoring | +| **Keyword Analysis** | Keyword density, n-gram extraction, and target keyword placement tracking | +| **Sitemap Generator** | Crawls your site and generates a valid XML sitemap | +| **Robots.txt Generator** | Creates optimized robots.txt with AI bot blocking option | +| **Meta Tag Generator** | Produces complete meta tags including Open Graph and Twitter Cards | +| **Schema.org Generator** | Creates JSON-LD structured data for rich search results | +| **SEO Comparison** | Side-by-side comparison of two URLs | +| **HTML Reports** | Beautiful, detailed audit reports you can share | + +## Installation + +```bash +cd seo-toolkit +npm install + +# Optional: link globally for CLI access +npm link +``` + +## Usage + +### Full SEO Audit + +Run a comprehensive audit on any URL: + +```bash +# Basic audit +node src/cli.js audit https://example.com + +# Audit with target keyword tracking +node src/cli.js audit https://example.com -k "seo tools,website optimization" + +# Audit with broken link checking (slower but thorough) +node src/cli.js audit https://example.com --check-links + +# Save HTML report +node src/cli.js audit https://example.com -o reports/audit.html + +# Save JSON report +node src/cli.js audit https://example.com -o reports/audit.json + +# Output raw JSON to stdout +node src/cli.js audit https://example.com --json +``` + +### Keyword Analysis + +Analyze keyword density and placement: + +```bash +# Auto-detect top keywords +node src/cli.js keywords https://example.com + +# Check specific target keywords +node src/cli.js keywords https://example.com -t "seo,optimization,ranking" + +# Show top 30 keywords +node src/cli.js keywords https://example.com -n 30 +``` + +### Generate XML Sitemap + +Crawl your site and produce a sitemap: + +```bash +# Generate sitemap (crawls up to 50 pages) +node src/cli.js sitemap https://example.com + +# Crawl up to 200 pages +node src/cli.js sitemap https://example.com -m 200 + +# Custom output path +node src/cli.js sitemap https://example.com -o public/sitemap.xml +``` + +### Generate Robots.txt + +```bash +# Basic robots.txt +node src/cli.js robots + +# With sitemap URL +node src/cli.js robots -s https://example.com/sitemap.xml + +# Block AI crawlers +node src/cli.js robots --block-ai + +# Custom disallow paths +node src/cli.js robots -d "/admin,/api,/private" + +# Set crawl delay +node src/cli.js robots --delay 10 +``` + +### Generate Meta Tags + +```bash +# Generate full meta tag set +node src/cli.js meta --title "My Page Title" \ + --description "A compelling description of my page" \ + --url "https://example.com/page" \ + --image "https://example.com/image.jpg" \ + --site-name "My Website" \ + --twitter "@myhandle" + +# Save to file +node src/cli.js meta --title "My Page" -o meta-tags.html +``` + +### Generate Schema.org Structured Data + +```bash +# Website schema +node src/cli.js schema website --name "My Site" --url "https://example.com" + +# Organization schema +node src/cli.js schema organization --name "My Company" --url "https://example.com" + +# Article schema +node src/cli.js schema article --name "Article Title" --author "John Doe" + +# FAQ schema (generates template to customize) +node src/cli.js schema faq + +# Local business schema +node src/cli.js schema local-business --name "My Store" --phone "+1-555-0123" +``` + +### Compare Two URLs + +```bash +node src/cli.js compare https://example.com https://competitor.com +``` + +## Programmatic Usage + +Use the toolkit as a library in your own Node.js projects: + +```javascript +const { + runAudit, + generateSitemap, + generateRobots, + generateMetaTags, + generateWebsiteSchema, + wrapInScriptTag, +} = require('./src/index'); + +// Run a full audit +const results = await runAudit('https://example.com', { + targetKeywords: ['seo', 'optimization'], + checkBrokenLinks: true, +}); + +console.log(`Score: ${results.overall}/100 (${results.grade.grade})`); + +// Generate a sitemap +const sitemap = await generateSitemap('https://example.com', { maxPages: 100 }); +console.log(sitemap.xml); + +// Generate meta tags +const tags = generateMetaTags({ + title: 'My Page', + description: 'Description here', + url: 'https://example.com', +}); + +// Generate structured data +const schema = generateWebsiteSchema({ name: 'My Site', url: 'https://example.com' }); +const scriptTag = wrapInScriptTag(schema); +``` + +## What Gets Checked + +### Meta Analysis +- Title tag (length, presence) +- Meta description (length, presence) +- Canonical URL +- Viewport tag +- Charset declaration +- Open Graph tags +- Twitter Card tags +- Language attribute +- Robots directives + +### Content Analysis +- Heading hierarchy (H1-H6) +- Image alt attributes +- Image dimensions and lazy loading +- Internal and external links +- Anchor text quality +- Word count +- Paragraph readability + +### Technical Analysis +- Page load time +- Page size +- HTTPS +- HTTP status code +- Structured data (JSON-LD) +- Mobile-friendliness +- Favicon +- CSS/JS resource count +- Render-blocking resources +- Compression + +### Link Analysis +- Internal vs external link ratio +- Generic anchor text detection +- Nofollow usage +- Broken link detection (optional) + +## Scoring + +Each category is scored 0-100 and weighted to produce an overall score: + +| Category | Weight | +|----------|--------| +| Meta | 25% | +| Content | 20% | +| Technical | 20% | +| Performance | 15% | +| Mobile | 10% | +| Links | 10% | + +**Grades:** A (90+), B (80-89), C (70-79), D (50-69), F (<50) + +## License + +MIT diff --git a/seo-toolkit/package.json b/seo-toolkit/package.json new file mode 100644 index 0000000..749b1cc --- /dev/null +++ b/seo-toolkit/package.json @@ -0,0 +1,27 @@ +{ + "name": "seo-toolkit", + "version": "1.0.0", + "description": "Powerful SEO automation toolkit for auditing, analyzing, and optimizing websites", + "main": "src/index.js", + "bin": { + "seo-toolkit": "./src/cli.js" + }, + "scripts": { + "start": "node src/cli.js", + "analyze": "node src/cli.js analyze", + "report": "node src/cli.js report", + "sitemap": "node src/cli.js sitemap", + "keywords": "node src/cli.js keywords" + }, + "keywords": ["seo", "audit", "optimization", "sitemap", "keywords"], + "license": "MIT", + "dependencies": { + "axios": "^1.6.0", + "cheerio": "^1.0.0-rc.12", + "chalk": "^4.1.2", + "commander": "^11.1.0", + "cli-table3": "^0.6.3", + "ora": "^5.4.1", + "xml2js": "^0.6.2" + } +} diff --git a/seo-toolkit/src/analyzers/content-analyzer.js b/seo-toolkit/src/analyzers/content-analyzer.js new file mode 100644 index 0000000..396d08b --- /dev/null +++ b/seo-toolkit/src/analyzers/content-analyzer.js @@ -0,0 +1,153 @@ +const { formatIssue } = require('../utils/scoring'); + +/** + * Analyzes page content: headings, images, links, text quality. + */ +function analyzeContent($, url) { + const issues = []; + const data = {}; + + // --- Heading Structure --- + const headings = { h1: [], h2: [], h3: [], h4: [], h5: [], h6: [] }; + for (let i = 1; i <= 6; i++) { + $(`h${i}`).each((_, el) => { + headings[`h${i}`].push($(el).text().trim()); + }); + } + data.headings = headings; + + if (headings.h1.length === 0) { + issues.push(formatIssue('critical', 'Missing H1 tag', 'Add exactly one H1 tag containing your primary keyword.')); + } else if (headings.h1.length > 1) { + issues.push(formatIssue('warning', `Multiple H1 tags found (${headings.h1.length})`, 'Use only one H1 per page for clear topic hierarchy.')); + } else { + issues.push(formatIssue('pass', `H1 tag present: "${headings.h1[0]}"`, '')); + } + + if (headings.h2.length === 0) { + issues.push(formatIssue('warning', 'No H2 tags found', 'Add H2 subheadings to structure your content and include secondary keywords.')); + } else { + issues.push(formatIssue('pass', `${headings.h2.length} H2 tag(s) found`, '')); + } + + // Check heading hierarchy (no skipping levels) + const usedLevels = []; + for (let i = 1; i <= 6; i++) { + if (headings[`h${i}`].length > 0) usedLevels.push(i); + } + for (let i = 1; i < usedLevels.length; i++) { + if (usedLevels[i] - usedLevels[i - 1] > 1) { + issues.push(formatIssue('warning', + `Heading hierarchy skips from H${usedLevels[i - 1]} to H${usedLevels[i]}`, + 'Maintain sequential heading hierarchy (H1 > H2 > H3) for accessibility and SEO.' + )); + } + } + + // --- Images --- + const images = []; + $('img').each((_, el) => { + images.push({ + src: $(el).attr('src') || '', + alt: $(el).attr('alt'), + width: $(el).attr('width'), + height: $(el).attr('height'), + loading: $(el).attr('loading'), + }); + }); + data.images = images; + + const imagesWithoutAlt = images.filter((img) => !img.alt && img.alt !== ''); + const imagesWithEmptyAlt = images.filter((img) => img.alt === ''); + const imagesWithoutDimensions = images.filter((img) => !img.width || !img.height); + const imagesWithoutLazy = images.filter((img) => img.loading !== 'lazy'); + + if (imagesWithoutAlt.length > 0) { + issues.push(formatIssue('critical', + `${imagesWithoutAlt.length} image(s) missing alt attribute`, + 'Add descriptive alt text to all images for accessibility and SEO.' + )); + } else if (images.length > 0) { + issues.push(formatIssue('pass', 'All images have alt attributes', '')); + } + + if (imagesWithoutDimensions.length > 0) { + issues.push(formatIssue('info', + `${imagesWithoutDimensions.length} image(s) missing width/height`, + 'Set explicit dimensions to prevent layout shifts (improves CLS score).' + )); + } + + if (imagesWithoutLazy.length > 2) { + issues.push(formatIssue('info', + `${imagesWithoutLazy.length} image(s) not using lazy loading`, + 'Add loading="lazy" to below-the-fold images for faster page loads.' + )); + } + + // --- Links --- + const links = { internal: [], external: [], broken: [] }; + $('a[href]').each((_, el) => { + const href = $(el).attr('href') || ''; + const rel = $(el).attr('rel') || ''; + const text = $(el).text().trim(); + + if (href.startsWith('http') && !href.includes(new URL(url).hostname)) { + links.external.push({ href, text, rel, nofollow: rel.includes('nofollow') }); + } else if (href.startsWith('/') || href.startsWith('http')) { + links.internal.push({ href, text }); + } + }); + data.links = links; + + if (links.internal.length === 0) { + issues.push(formatIssue('warning', 'No internal links found', 'Add internal links to help search engines discover and rank your pages.')); + } else { + issues.push(formatIssue('pass', `${links.internal.length} internal link(s) found`, '')); + } + + // Check for empty anchor text + const emptyAnchors = [...links.internal, ...links.external].filter((l) => !l.text); + if (emptyAnchors.length > 0) { + issues.push(formatIssue('warning', + `${emptyAnchors.length} link(s) with empty anchor text`, + 'Use descriptive anchor text for better link context signals.' + )); + } + + // --- Word Count --- + const bodyText = $('body').text().replace(/\s+/g, ' ').trim(); + const wordCount = bodyText.split(/\s+/).filter(Boolean).length; + data.wordCount = wordCount; + + if (wordCount < 300) { + issues.push(formatIssue('warning', + `Low word count (${wordCount} words)`, + 'Aim for at least 300+ words of quality content for better rankings.' + )); + } else if (wordCount >= 300 && wordCount < 1000) { + issues.push(formatIssue('info', `Word count: ${wordCount}`, 'Consider expanding to 1000+ words for competitive keywords.')); + } else { + issues.push(formatIssue('pass', `Good word count: ${wordCount} words`, '')); + } + + // --- Paragraph Length --- + const paragraphs = []; + $('p').each((_, el) => { + const text = $(el).text().trim(); + if (text) paragraphs.push(text); + }); + const longParagraphs = paragraphs.filter((p) => p.split(/\s+/).length > 150); + if (longParagraphs.length > 0) { + issues.push(formatIssue('info', + `${longParagraphs.length} paragraph(s) exceed 150 words`, + 'Break up long paragraphs for better readability and user engagement.' + )); + } + + const checks = issues.map((i) => ({ passed: i.severity === 'pass', ...i })); + + return { category: 'content', data, issues, checks }; +} + +module.exports = { analyzeContent }; diff --git a/seo-toolkit/src/analyzers/keyword-analyzer.js b/seo-toolkit/src/analyzers/keyword-analyzer.js new file mode 100644 index 0000000..87a1d51 --- /dev/null +++ b/seo-toolkit/src/analyzers/keyword-analyzer.js @@ -0,0 +1,152 @@ +const { formatIssue } = require('../utils/scoring'); + +/** + * Analyzes keyword usage, density, and placement across the page. + */ +function analyzeKeywords($, url, targetKeywords = []) { + const issues = []; + const data = {}; + + const bodyText = $('body').text().replace(/\s+/g, ' ').trim().toLowerCase(); + const words = bodyText.split(/\s+/).filter(Boolean); + const totalWords = words.length; + + // --- Word Frequency (auto-detect top keywords) --- + const stopWords = new Set([ + 'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', + 'of', 'with', 'by', 'from', 'is', 'it', 'this', 'that', 'are', 'was', + 'be', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could', + 'should', 'may', 'might', 'can', 'not', 'no', 'so', 'if', 'as', 'we', + 'they', 'he', 'she', 'you', 'i', 'my', 'your', 'our', 'their', 'its', + 'all', 'each', 'every', 'both', 'few', 'more', 'most', 'other', 'some', + 'such', 'than', 'too', 'very', 'just', 'about', 'above', 'after', 'again', + 'also', 'am', 'been', 'before', 'being', 'below', 'between', 'into', 'up', + 'out', 'over', 'under', 'here', 'there', 'when', 'where', 'which', 'while', + 'who', 'whom', 'what', 'how', 'why', 'then', 'once', 'only', 'own', 'same', + ]); + + const frequency = {}; + for (const word of words) { + const clean = word.replace(/[^a-z0-9]/g, ''); + if (clean.length > 2 && !stopWords.has(clean)) { + frequency[clean] = (frequency[clean] || 0) + 1; + } + } + + // Sort by frequency + const topKeywords = Object.entries(frequency) + .sort((a, b) => b[1] - a[1]) + .slice(0, 20) + .map(([word, count]) => ({ + word, + count, + density: ((count / totalWords) * 100).toFixed(2) + '%', + })); + + data.topKeywords = topKeywords; + data.totalWords = totalWords; + + // --- N-gram analysis (2-word and 3-word phrases) --- + const bigrams = {}; + const trigrams = {}; + for (let i = 0; i < words.length - 1; i++) { + const w1 = words[i].replace(/[^a-z0-9]/g, ''); + const w2 = words[i + 1].replace(/[^a-z0-9]/g, ''); + if (w1.length > 2 && w2.length > 2 && !stopWords.has(w1) && !stopWords.has(w2)) { + const bigram = `${w1} ${w2}`; + bigrams[bigram] = (bigrams[bigram] || 0) + 1; + } + if (i < words.length - 2) { + const w3 = words[i + 2].replace(/[^a-z0-9]/g, ''); + if (w1.length > 2 && w3.length > 2) { + const trigram = `${w1} ${w2} ${w3}`; + trigrams[trigram] = (trigrams[trigram] || 0) + 1; + } + } + } + + data.topPhrases = { + twoWord: Object.entries(bigrams).sort((a, b) => b[1] - a[1]).slice(0, 10) + .map(([phrase, count]) => ({ phrase, count })), + threeWord: Object.entries(trigrams).sort((a, b) => b[1] - a[1]).slice(0, 10) + .map(([phrase, count]) => ({ phrase, count })), + }; + + // --- Target Keyword Analysis --- + if (targetKeywords.length > 0) { + data.targetKeywordAnalysis = []; + + for (const keyword of targetKeywords) { + const kw = keyword.toLowerCase(); + const title = $('title').first().text().toLowerCase(); + const description = ($('meta[name="description"]').attr('content') || '').toLowerCase(); + const h1 = $('h1').first().text().toLowerCase(); + const h2Texts = []; + $('h2').each((_, el) => h2Texts.push($(el).text().toLowerCase())); + + const inTitle = title.includes(kw); + const inDescription = description.includes(kw); + const inH1 = h1.includes(kw); + const inH2 = h2Texts.some((t) => t.includes(kw)); + const inUrl = url.toLowerCase().includes(kw.replace(/\s+/g, '-')); + const inFirst100Words = words.slice(0, 100).join(' ').includes(kw); + + const regex = new RegExp(kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'); + const occurrences = (bodyText.match(regex) || []).length; + const density = totalWords > 0 ? ((occurrences / totalWords) * 100).toFixed(2) : '0.00'; + + const analysis = { + keyword: kw, + occurrences, + density: density + '%', + placement: { inTitle, inDescription, inH1, inH2, inUrl, inFirst100Words }, + }; + data.targetKeywordAnalysis.push(analysis); + + // Generate issues for this keyword + if (!inTitle) { + issues.push(formatIssue('critical', `Target keyword "${kw}" not found in title`, 'Include your primary keyword naturally in the page title.')); + } else { + issues.push(formatIssue('pass', `Target keyword "${kw}" found in title`, '')); + } + + if (!inH1) { + issues.push(formatIssue('warning', `Target keyword "${kw}" not in H1`, 'Include the keyword in your H1 heading.')); + } + + if (!inDescription) { + issues.push(formatIssue('warning', `Target keyword "${kw}" not in meta description`, 'Use the keyword naturally in your meta description.')); + } + + if (!inFirst100Words) { + issues.push(formatIssue('info', `Target keyword "${kw}" not in first 100 words`, 'Place important keywords early in your content.')); + } + + const densityNum = parseFloat(density); + if (densityNum > 3) { + issues.push(formatIssue('warning', `Keyword "${kw}" density too high (${density}%)`, 'Reduce keyword stuffing. Keep density between 1-3%.')); + } else if (densityNum < 0.5 && occurrences > 0) { + issues.push(formatIssue('info', `Keyword "${kw}" density low (${density}%)`, 'Consider using the keyword more naturally. Aim for 1-2%.')); + } else if (occurrences > 0) { + issues.push(formatIssue('pass', `Keyword "${kw}" density good (${density}%)`, '')); + } + } + } + + // --- General keyword issues --- + if (topKeywords.length > 0) { + const topDensity = parseFloat(topKeywords[0].density); + if (topDensity > 5) { + issues.push(formatIssue('warning', + `Potential keyword stuffing: "${topKeywords[0].word}" at ${topKeywords[0].density}`, + 'High keyword density may trigger spam filters. Diversify your content.' + )); + } + } + + const checks = issues.map((i) => ({ passed: i.severity === 'pass', ...i })); + + return { category: 'keywords', data, issues, checks }; +} + +module.exports = { analyzeKeywords }; diff --git a/seo-toolkit/src/analyzers/link-analyzer.js b/seo-toolkit/src/analyzers/link-analyzer.js new file mode 100644 index 0000000..e51aaa1 --- /dev/null +++ b/seo-toolkit/src/analyzers/link-analyzer.js @@ -0,0 +1,117 @@ +const { formatIssue } = require('../utils/scoring'); +const { fetchPage } = require('../utils/fetcher'); + +/** + * Deep link analysis: checks for broken links, redirect chains, nofollow usage. + */ +async function analyzeLinks($, url, options = {}) { + const issues = []; + const data = {}; + const checkBroken = options.checkBroken || false; + const baseUrl = new URL(url); + + // Collect all links + const allLinks = []; + $('a[href]').each((_, el) => { + const href = $(el).attr('href') || ''; + const text = $(el).text().trim(); + const rel = $(el).attr('rel') || ''; + const title = $(el).attr('title') || ''; + + let absoluteUrl = href; + try { + absoluteUrl = new URL(href, url).href; + } catch (e) { + // Invalid URL + } + + allLinks.push({ href, absoluteUrl, text, rel, title }); + }); + + // Categorize + const internal = allLinks.filter((l) => { + try { return new URL(l.absoluteUrl).hostname === baseUrl.hostname; } + catch { return false; } + }); + const external = allLinks.filter((l) => { + try { return new URL(l.absoluteUrl).hostname !== baseUrl.hostname && l.href.startsWith('http'); } + catch { return false; } + }); + + data.total = allLinks.length; + data.internal = internal.length; + data.external = external.length; + data.internalLinks = internal; + data.externalLinks = external; + + // --- Check for empty/generic anchor text --- + const genericAnchors = ['click here', 'read more', 'here', 'link', 'learn more', 'more']; + const badAnchors = allLinks.filter((l) => genericAnchors.includes(l.text.toLowerCase())); + if (badAnchors.length > 0) { + issues.push(formatIssue('warning', + `${badAnchors.length} link(s) use generic anchor text ("click here", "read more")`, + 'Use descriptive, keyword-rich anchor text for better link signals.' + )); + } + + // --- Nofollow analysis --- + const nofollowInternal = internal.filter((l) => l.rel.includes('nofollow')); + if (nofollowInternal.length > 0) { + issues.push(formatIssue('info', + `${nofollowInternal.length} internal link(s) have nofollow`, + 'Avoid nofollowing internal links to allow PageRank to flow through your site.' + )); + } + + // --- External links without nofollow --- + const followExternal = external.filter((l) => !l.rel.includes('nofollow') && !l.rel.includes('sponsored') && !l.rel.includes('ugc')); + data.followExternal = followExternal.length; + + // --- Check for broken links (optional, slower) --- + if (checkBroken) { + const brokenLinks = []; + const linksToCheck = [...internal.slice(0, 20), ...external.slice(0, 10)]; + + for (const link of linksToCheck) { + try { + const response = await fetchPage(link.absoluteUrl); + if (response.statusCode >= 400) { + brokenLinks.push({ ...link, statusCode: response.statusCode }); + } + } catch (e) { + brokenLinks.push({ ...link, error: e.message }); + } + } + + data.brokenLinks = brokenLinks; + if (brokenLinks.length > 0) { + issues.push(formatIssue('critical', + `${brokenLinks.length} broken link(s) detected`, + 'Fix or remove broken links to improve user experience and crawlability.' + )); + } else { + issues.push(formatIssue('pass', 'No broken links detected (sampled)', '')); + } + } + + // --- Link ratio --- + if (external.length > internal.length * 2 && external.length > 5) { + issues.push(formatIssue('info', + `High external-to-internal link ratio (${external.length} ext / ${internal.length} int)`, + 'Balance your link profile with more internal links to retain link equity.' + )); + } + + if (internal.length > 0) { + issues.push(formatIssue('pass', `${internal.length} internal links found`, '')); + } + if (external.length > 0) { + issues.push(formatIssue('pass', `${external.length} external links found`, '')); + } + + const checks = issues.map((i) => ({ passed: i.severity === 'pass', ...i })); + + return { category: 'links', data, issues, checks }; +} + +module.exports = { analyzeLinks }; diff --git a/seo-toolkit/src/analyzers/meta-analyzer.js b/seo-toolkit/src/analyzers/meta-analyzer.js new file mode 100644 index 0000000..7d40362 --- /dev/null +++ b/seo-toolkit/src/analyzers/meta-analyzer.js @@ -0,0 +1,112 @@ +const { formatIssue } = require('../utils/scoring'); + +/** + * Analyzes meta tags, Open Graph, Twitter Cards, and canonical URLs. + */ +function analyzeMeta($, url) { + const issues = []; + const data = {}; + + // --- Title Tag --- + const title = $('title').first().text().trim(); + data.title = title; + if (!title) { + issues.push(formatIssue('critical', 'Missing tag', 'Add a unique, descriptive title tag (50-60 characters).')); + } else if (title.length < 30) { + issues.push(formatIssue('warning', `Title too short (${title.length} chars): "${title}"`, 'Aim for 50-60 characters for optimal display in SERPs.')); + } else if (title.length > 60) { + issues.push(formatIssue('warning', `Title too long (${title.length} chars): "${title}"`, 'Keep title under 60 characters to avoid truncation in SERPs.')); + } else { + issues.push(formatIssue('pass', `Title tag is good (${title.length} chars): "${title}"`, '')); + } + + // --- Meta Description --- + const description = $('meta[name="description"]').attr('content') || ''; + data.description = description; + if (!description) { + issues.push(formatIssue('critical', 'Missing meta description', 'Add a compelling meta description (120-160 characters) with target keywords.')); + } else if (description.length < 70) { + issues.push(formatIssue('warning', `Meta description too short (${description.length} chars)`, 'Aim for 120-160 characters for optimal SERP display.')); + } else if (description.length > 160) { + issues.push(formatIssue('warning', `Meta description too long (${description.length} chars)`, 'Keep under 160 characters to prevent truncation.')); + } else { + issues.push(formatIssue('pass', `Meta description length is good (${description.length} chars)`, '')); + } + + // --- Meta Keywords --- + const keywords = $('meta[name="keywords"]').attr('content') || ''; + data.keywords = keywords; + if (keywords) { + issues.push(formatIssue('info', `Meta keywords found: "${keywords.substring(0, 80)}..."`, 'Meta keywords have minimal SEO impact but do not hurt.')); + } + + // --- Canonical URL --- + const canonical = $('link[rel="canonical"]').attr('href') || ''; + data.canonical = canonical; + if (!canonical) { + issues.push(formatIssue('warning', 'Missing canonical URL', 'Add <link rel="canonical"> to prevent duplicate content issues.')); + } else { + issues.push(formatIssue('pass', `Canonical URL set: ${canonical}`, '')); + } + + // --- Meta Robots --- + const robots = $('meta[name="robots"]').attr('content') || ''; + data.robots = robots; + if (robots.includes('noindex')) { + issues.push(formatIssue('warning', 'Page is set to noindex', 'Ensure this is intentional. Noindexed pages will not appear in search results.')); + } + + // --- Viewport --- + const viewport = $('meta[name="viewport"]').attr('content') || ''; + data.viewport = viewport; + if (!viewport) { + issues.push(formatIssue('critical', 'Missing viewport meta tag', 'Add <meta name="viewport" content="width=device-width, initial-scale=1"> for mobile support.')); + } else { + issues.push(formatIssue('pass', 'Viewport meta tag present', '')); + } + + // --- Charset --- + const charset = $('meta[charset]').attr('charset') || $('meta[http-equiv="Content-Type"]').attr('content') || ''; + data.charset = charset; + if (!charset) { + issues.push(formatIssue('warning', 'Missing charset declaration', 'Add <meta charset="UTF-8"> for proper character encoding.')); + } + + // --- Open Graph --- + const ogTags = {}; + $('meta[property^="og:"]').each((_, el) => { + ogTags[$(el).attr('property')] = $(el).attr('content'); + }); + data.openGraph = ogTags; + if (!ogTags['og:title'] || !ogTags['og:description'] || !ogTags['og:image']) { + const missing = ['og:title', 'og:description', 'og:image'].filter((t) => !ogTags[t]); + issues.push(formatIssue('warning', `Missing Open Graph tags: ${missing.join(', ')}`, 'Add OG tags for better social media sharing previews.')); + } else { + issues.push(formatIssue('pass', 'Open Graph tags are configured', '')); + } + + // --- Twitter Card --- + const twitterTags = {}; + $('meta[name^="twitter:"]').each((_, el) => { + twitterTags[$(el).attr('name')] = $(el).attr('content'); + }); + data.twitterCard = twitterTags; + if (!twitterTags['twitter:card']) { + issues.push(formatIssue('info', 'Missing Twitter Card tags', 'Add Twitter Card meta tags for better Twitter sharing.')); + } + + // --- Language --- + const lang = $('html').attr('lang') || ''; + data.lang = lang; + if (!lang) { + issues.push(formatIssue('warning', 'Missing lang attribute on <html>', 'Add lang="en" (or appropriate language) to the <html> tag.')); + } else { + issues.push(formatIssue('pass', `Language declared: ${lang}`, '')); + } + + const checks = issues.map((i) => ({ passed: i.severity === 'pass', ...i })); + + return { category: 'meta', data, issues, checks }; +} + +module.exports = { analyzeMeta }; diff --git a/seo-toolkit/src/analyzers/technical-analyzer.js b/seo-toolkit/src/analyzers/technical-analyzer.js new file mode 100644 index 0000000..ef77e78 --- /dev/null +++ b/seo-toolkit/src/analyzers/technical-analyzer.js @@ -0,0 +1,154 @@ +const { formatIssue } = require('../utils/scoring'); + +/** + * Analyzes technical SEO: performance, structured data, security, mobile. + */ +function analyzeTechnical($, pageData) { + const issues = []; + const data = {}; + + // --- Page Load Time --- + data.loadTime = pageData.loadTime; + if (pageData.loadTime > 3000) { + issues.push(formatIssue('critical', + `Slow page load: ${pageData.loadTime}ms`, + 'Optimize server response time. Target under 3 seconds for good UX.' + )); + } else if (pageData.loadTime > 1500) { + issues.push(formatIssue('warning', + `Page load time: ${pageData.loadTime}ms`, + 'Good but aim for under 1.5s for the best user experience.' + )); + } else { + issues.push(formatIssue('pass', `Fast page load: ${pageData.loadTime}ms`, '')); + } + + // --- Page Size --- + data.pageSize = pageData.contentLength; + const pageSizeKB = Math.round(pageData.contentLength / 1024); + if (pageSizeKB > 3000) { + issues.push(formatIssue('warning', + `Large page size: ${pageSizeKB}KB`, + 'Compress and minify resources. Target under 3MB total page weight.' + )); + } else { + issues.push(formatIssue('pass', `Page size: ${pageSizeKB}KB`, '')); + } + + // --- HTTPS --- + const isHttps = pageData.url.startsWith('https://'); + data.https = isHttps; + if (!isHttps) { + issues.push(formatIssue('critical', 'Page not served over HTTPS', 'Migrate to HTTPS. It is a confirmed Google ranking factor.')); + } else { + issues.push(formatIssue('pass', 'Page served over HTTPS', '')); + } + + // --- Status Code --- + data.statusCode = pageData.statusCode; + if (pageData.statusCode !== 200) { + issues.push(formatIssue('critical', + `HTTP status ${pageData.statusCode}`, + 'Page should return 200 status code for proper indexing.' + )); + } else { + issues.push(formatIssue('pass', 'HTTP 200 OK', '')); + } + + // --- Structured Data (JSON-LD) --- + const jsonLdScripts = []; + $('script[type="application/ld+json"]').each((_, el) => { + try { + jsonLdScripts.push(JSON.parse($(el).html())); + } catch (e) { + issues.push(formatIssue('warning', 'Invalid JSON-LD structured data found', 'Fix JSON syntax errors in your structured data.')); + } + }); + data.structuredData = jsonLdScripts; + + if (jsonLdScripts.length === 0) { + issues.push(formatIssue('warning', + 'No structured data (JSON-LD) found', + 'Add Schema.org structured data to enable rich results in search.' + )); + } else { + issues.push(formatIssue('pass', `${jsonLdScripts.length} structured data block(s) found`, '')); + } + + // --- Mobile Friendliness --- + const viewport = $('meta[name="viewport"]').attr('content') || ''; + const hasResponsiveHints = viewport.includes('width=device-width'); + data.mobileReady = hasResponsiveHints; + if (!hasResponsiveHints) { + issues.push(formatIssue('critical', + 'Not mobile-friendly (missing responsive viewport)', + 'Add proper viewport meta tag for mobile-first indexing.' + )); + } else { + issues.push(formatIssue('pass', 'Responsive viewport configured', '')); + } + + // --- Favicon --- + const favicon = $('link[rel="icon"], link[rel="shortcut icon"]').attr('href'); + data.favicon = favicon || null; + if (!favicon) { + issues.push(formatIssue('info', 'No favicon detected', 'Add a favicon for brand recognition in browser tabs and bookmarks.')); + } + + // --- CSS/JS Resources --- + const stylesheets = $('link[rel="stylesheet"]').length; + const scripts = $('script[src]').length; + const inlineStyles = $('style').length; + const inlineScripts = $('script:not([src])').filter((_, el) => $(el).attr('type') !== 'application/ld+json').length; + data.resources = { stylesheets, scripts, inlineStyles, inlineScripts }; + + if (stylesheets > 10) { + issues.push(formatIssue('warning', + `Too many CSS files (${stylesheets})`, + 'Combine and minify CSS files to reduce HTTP requests.' + )); + } + if (scripts > 15) { + issues.push(formatIssue('warning', + `Too many JS files (${scripts})`, + 'Bundle and minify JavaScript to improve load times.' + )); + } + + // --- Render-blocking resources --- + const renderBlocking = $('link[rel="stylesheet"]:not([media]):not([rel="preload"])').length; + const blockingScripts = $('script[src]:not([async]):not([defer])').length; + data.renderBlocking = { css: renderBlocking, js: blockingScripts }; + + if (blockingScripts > 3) { + issues.push(formatIssue('warning', + `${blockingScripts} render-blocking scripts detected`, + 'Add async or defer attribute to non-critical scripts.' + )); + } + + // --- Compression hints --- + const contentEncoding = pageData.headers['content-encoding'] || ''; + data.compression = contentEncoding; + if (!contentEncoding) { + issues.push(formatIssue('info', + 'No content compression detected', + 'Enable gzip or brotli compression on your server.' + )); + } else { + issues.push(formatIssue('pass', `Compression enabled: ${contentEncoding}`, '')); + } + + // --- Hreflang --- + const hreflangTags = []; + $('link[rel="alternate"][hreflang]').each((_, el) => { + hreflangTags.push({ lang: $(el).attr('hreflang'), href: $(el).attr('href') }); + }); + data.hreflang = hreflangTags; + + const checks = issues.map((i) => ({ passed: i.severity === 'pass', ...i })); + + return { category: 'technical', data, issues, checks }; +} + +module.exports = { analyzeTechnical }; diff --git a/seo-toolkit/src/cli.js b/seo-toolkit/src/cli.js new file mode 100644 index 0000000..41047b8 --- /dev/null +++ b/seo-toolkit/src/cli.js @@ -0,0 +1,483 @@ +#!/usr/bin/env node + +const { Command } = require('commander'); +const chalk = require('chalk'); +const ora = require('ora'); +const Table = require('cli-table3'); +const fs = require('fs'); +const path = require('path'); + +const { runAudit, fetchPage } = require('./index'); +const { analyzeMeta } = require('./analyzers/meta-analyzer'); +const { analyzeContent } = require('./analyzers/content-analyzer'); +const { analyzeTechnical } = require('./analyzers/technical-analyzer'); +const { analyzeKeywords } = require('./analyzers/keyword-analyzer'); +const { analyzeLinks } = require('./analyzers/link-analyzer'); +const { generateSitemap } = require('./generators/sitemap-generator'); +const { generateRobots } = require('./generators/robots-generator'); +const { generateMetaTags } = require('./generators/meta-tags-generator'); +const { generateHtmlReport, generateJsonReport } = require('./generators/report-generator'); +const { getScoreGrade } = require('./utils/scoring'); +const { + generateWebsiteSchema, + generateOrganizationSchema, + generateArticleSchema, + generateFaqSchema, + generateLocalBusinessSchema, + wrapInScriptTag, +} = require('./generators/structured-data-generator'); + +const program = new Command(); + +program + .name('seo-toolkit') + .description('Powerful SEO automation toolkit for auditing, analyzing, and optimizing websites') + .version('1.0.0'); + +// ────────────────────────────────────────────────────────────── +// AUDIT COMMAND - Full SEO audit +// ────────────────────────────────────────────────────────────── +program + .command('audit <url>') + .description('Run a full SEO audit on a URL') + .option('-k, --keywords <keywords>', 'Comma-separated target keywords to check', '') + .option('-o, --output <path>', 'Save report to file (html or json)') + .option('--check-links', 'Check for broken links (slower)', false) + .option('--json', 'Output results as JSON', false) + .action(async (url, opts) => { + const spinner = ora('Analyzing page...').start(); + + try { + url = normalizeInputUrl(url); + const keywords = opts.keywords ? opts.keywords.split(',').map((k) => k.trim()) : []; + const results = await runAudit(url, { + targetKeywords: keywords, + checkBrokenLinks: opts.checkLinks, + }); + + spinner.stop(); + + if (opts.json) { + console.log(JSON.stringify(results, null, 2)); + } else { + printAuditReport(results); + } + + // Save report if output specified + if (opts.output) { + const ext = path.extname(opts.output).toLowerCase(); + if (ext === '.html') { + generateHtmlReport(results, opts.output); + console.log(chalk.green(`\nHTML report saved to: ${opts.output}`)); + } else { + generateJsonReport(results, opts.output); + console.log(chalk.green(`\nJSON report saved to: ${opts.output}`)); + } + } + } catch (err) { + spinner.fail(chalk.red(`Audit failed: ${err.message}`)); + process.exit(1); + } + }); + +// ────────────────────────────────────────────────────────────── +// KEYWORDS COMMAND - Keyword analysis +// ────────────────────────────────────────────────────────────── +program + .command('keywords <url>') + .description('Analyze keyword density and usage on a page') + .option('-t, --target <keywords>', 'Comma-separated target keywords', '') + .option('-n, --top <number>', 'Number of top keywords to show', '20') + .action(async (url, opts) => { + const spinner = ora('Analyzing keywords...').start(); + + try { + url = normalizeInputUrl(url); + const pageData = await fetchPage(url); + const keywords = opts.target ? opts.target.split(',').map((k) => k.trim()) : []; + const result = analyzeKeywords(pageData.$, url, keywords); + + spinner.stop(); + + console.log(chalk.bold.cyan('\n KEYWORD ANALYSIS')); + console.log(chalk.gray(` ${url}\n`)); + + // Top keywords table + const table = new Table({ + head: ['Rank', 'Keyword', 'Count', 'Density'].map((h) => chalk.gray(h)), + style: { head: [], border: ['gray'] }, + }); + + const top = parseInt(opts.top, 10) || 20; + result.data.topKeywords.slice(0, top).forEach((kw, i) => { + table.push([i + 1, kw.word, kw.count, kw.density]); + }); + + console.log(table.toString()); + + // Phrases + if (result.data.topPhrases.twoWord.length > 0) { + console.log(chalk.bold('\n Top 2-Word Phrases:')); + result.data.topPhrases.twoWord.slice(0, 10).forEach((p) => { + console.log(` "${p.phrase}" (${p.count}x)`); + }); + } + + if (result.data.topPhrases.threeWord.length > 0) { + console.log(chalk.bold('\n Top 3-Word Phrases:')); + result.data.topPhrases.threeWord.slice(0, 10).forEach((p) => { + console.log(` "${p.phrase}" (${p.count}x)`); + }); + } + + // Target keyword analysis + if (result.data.targetKeywordAnalysis) { + console.log(chalk.bold.yellow('\n TARGET KEYWORD PLACEMENT:')); + for (const kw of result.data.targetKeywordAnalysis) { + console.log(chalk.bold(`\n "${kw.keyword}" - ${kw.occurrences} occurrences (${kw.density})`)); + const p = kw.placement; + console.log(` In title: ${p.inTitle ? chalk.green('YES') : chalk.red('NO')}`); + console.log(` In H1: ${p.inH1 ? chalk.green('YES') : chalk.red('NO')}`); + console.log(` In description: ${p.inDescription ? chalk.green('YES') : chalk.red('NO')}`); + console.log(` In URL: ${p.inUrl ? chalk.green('YES') : chalk.red('NO')}`); + console.log(` In first 100w: ${p.inFirst100Words ? chalk.green('YES') : chalk.red('NO')}`); + console.log(` In H2: ${p.inH2 ? chalk.green('YES') : chalk.red('NO')}`); + } + } + + console.log(''); + } catch (err) { + spinner.fail(chalk.red(`Analysis failed: ${err.message}`)); + process.exit(1); + } + }); + +// ────────────────────────────────────────────────────────────── +// SITEMAP COMMAND - Generate XML sitemap +// ────────────────────────────────────────────────────────────── +program + .command('sitemap <url>') + .description('Crawl a website and generate an XML sitemap') + .option('-m, --max <pages>', 'Maximum pages to crawl', '50') + .option('-o, --output <path>', 'Output file path', 'sitemap.xml') + .action(async (url, opts) => { + const spinner = ora('Crawling website...').start(); + + try { + url = normalizeInputUrl(url); + const maxPages = parseInt(opts.max, 10) || 50; + + const result = await generateSitemap(url, { + maxPages, + onProgress: (visited, queued) => { + spinner.text = `Crawling... ${visited} pages found, ${queued} in queue`; + }, + }); + + spinner.succeed(`Crawled ${result.pageCount} pages`); + + fs.writeFileSync(opts.output, result.xml, 'utf8'); + console.log(chalk.green(`Sitemap saved to: ${opts.output}`)); + console.log(chalk.gray(`Pages found: ${result.pageCount}`)); + } catch (err) { + spinner.fail(chalk.red(`Sitemap generation failed: ${err.message}`)); + process.exit(1); + } + }); + +// ────────────────────────────────────────────────────────────── +// ROBOTS COMMAND - Generate robots.txt +// ────────────────────────────────────────────────────────────── +program + .command('robots') + .description('Generate a robots.txt file') + .option('-s, --sitemap <url>', 'Sitemap URL to include') + .option('-d, --disallow <paths>', 'Comma-separated paths to disallow', '') + .option('-a, --allow <paths>', 'Comma-separated paths to allow', '') + .option('--block-ai', 'Block AI training crawlers', false) + .option('--delay <seconds>', 'Crawl delay in seconds') + .option('-o, --output <path>', 'Output file path', 'robots.txt') + .action((opts) => { + const content = generateRobots({ + sitemapUrl: opts.sitemap || '', + disallowPaths: opts.disallow ? opts.disallow.split(',').map((p) => p.trim()) : [], + allowPaths: opts.allow ? opts.allow.split(',').map((p) => p.trim()) : [], + crawlDelay: opts.delay ? parseInt(opts.delay, 10) : null, + blockAiBots: opts.blockAi, + }); + + fs.writeFileSync(opts.output, content, 'utf8'); + console.log(chalk.green(`robots.txt saved to: ${opts.output}`)); + console.log(chalk.gray('\nPreview:')); + console.log(content); + }); + +// ────────────────────────────────────────────────────────────── +// META COMMAND - Generate meta tags +// ────────────────────────────────────────────────────────────── +program + .command('meta') + .description('Generate optimized meta tags, Open Graph, and Twitter Card markup') + .requiredOption('--title <title>', 'Page title') + .option('--description <desc>', 'Meta description') + .option('--url <url>', 'Page URL') + .option('--image <url>', 'OG/Twitter image URL') + .option('--site-name <name>', 'Site name for OG') + .option('--twitter <handle>', 'Twitter @handle') + .option('--canonical <url>', 'Canonical URL') + .option('--keywords <keywords>', 'Meta keywords') + .option('-o, --output <path>', 'Save to file') + .action((opts) => { + const tags = generateMetaTags({ + title: opts.title, + description: opts.description || '', + url: opts.url || '', + image: opts.image || '', + siteName: opts.siteName || '', + twitterSite: opts.twitter || '', + canonical: opts.canonical || '', + keywords: opts.keywords || '', + }); + + console.log(chalk.bold.cyan('\n Generated Meta Tags:\n')); + console.log(tags); + + if (opts.output) { + fs.writeFileSync(opts.output, tags, 'utf8'); + console.log(chalk.green(`\nSaved to: ${opts.output}`)); + } + console.log(''); + }); + +// ────────────────────────────────────────────────────────────── +// SCHEMA COMMAND - Generate structured data +// ────────────────────────────────────────────────────────────── +program + .command('schema <type>') + .description('Generate Schema.org structured data (types: website, organization, article, faq, local-business)') + .option('--name <name>', 'Name/title') + .option('--url <url>', 'URL') + .option('--description <desc>', 'Description') + .option('--image <url>', 'Image URL') + .option('--author <name>', 'Author name') + .option('--phone <number>', 'Phone number') + .option('--address <address>', 'Address (street, city, state, zip)') + .option('-o, --output <path>', 'Save to file') + .action((type, opts) => { + let schema; + + switch (type.toLowerCase()) { + case 'website': + schema = generateWebsiteSchema({ + name: opts.name || 'My Website', + url: opts.url || 'https://example.com', + description: opts.description || '', + }); + break; + case 'organization': + schema = generateOrganizationSchema({ + name: opts.name || 'My Company', + url: opts.url || 'https://example.com', + description: opts.description || '', + phone: opts.phone || '', + }); + break; + case 'article': + schema = generateArticleSchema({ + title: opts.name || 'Article Title', + description: opts.description || '', + author: opts.author || 'Author', + image: opts.image || '', + publisherName: 'Publisher', + datePublished: new Date().toISOString(), + }); + break; + case 'faq': + schema = generateFaqSchema([ + { question: 'Sample Question 1?', answer: 'Sample answer 1.' }, + { question: 'Sample Question 2?', answer: 'Sample answer 2.' }, + ]); + console.log(chalk.yellow('\n Note: Edit the generated JSON to add your actual FAQ content.\n')); + break; + case 'local-business': + schema = generateLocalBusinessSchema({ + name: opts.name || 'Business Name', + url: opts.url || '', + phone: opts.phone || '', + image: opts.image || '', + street: '', + city: '', + state: '', + zip: '', + }); + break; + default: + console.log(chalk.red(`Unknown schema type: ${type}`)); + console.log('Available types: website, organization, article, faq, local-business'); + process.exit(1); + } + + const scriptTag = wrapInScriptTag(schema); + console.log(chalk.bold.cyan(`\n Generated ${type} Schema:\n`)); + console.log(scriptTag); + + if (opts.output) { + fs.writeFileSync(opts.output, scriptTag, 'utf8'); + console.log(chalk.green(`\nSaved to: ${opts.output}`)); + } + console.log(''); + }); + +// ────────────────────────────────────────────────────────────── +// COMPARE COMMAND - Compare two URLs +// ────────────────────────────────────────────────────────────── +program + .command('compare <url1> <url2>') + .description('Compare SEO metrics of two URLs side-by-side') + .action(async (url1, url2) => { + const spinner = ora('Analyzing both pages...').start(); + + try { + url1 = normalizeInputUrl(url1); + url2 = normalizeInputUrl(url2); + + const [result1, result2] = await Promise.all([ + runAudit(url1), + runAudit(url2), + ]); + + spinner.stop(); + + console.log(chalk.bold.cyan('\n SEO COMPARISON\n')); + + const table = new Table({ + head: ['Metric', truncate(url1, 30), truncate(url2, 30)].map((h) => chalk.gray(h)), + style: { head: [], border: ['gray'] }, + colWidths: [22, 32, 32], + }); + + table.push( + ['Overall Score', colorScore(result1.overall), colorScore(result2.overall)], + ['Grade', result1.grade.grade, result2.grade.grade], + ['Meta Score', colorScore(result1.categories.meta.score), colorScore(result2.categories.meta.score)], + ['Content Score', colorScore(result1.categories.content.score), colorScore(result2.categories.content.score)], + ['Technical Score', colorScore(result1.categories.technical.score), colorScore(result2.categories.technical.score)], + ['Links Score', colorScore(result1.categories.links.score), colorScore(result2.categories.links.score)], + ['Load Time', `${result1.pageMetrics.loadTime}ms`, `${result2.pageMetrics.loadTime}ms`], + ['Page Size', formatBytes(result1.pageMetrics.pageSize), formatBytes(result2.pageMetrics.pageSize)], + ['Word Count', result1.pageMetrics.wordCount, result2.pageMetrics.wordCount], + ); + + console.log(table.toString()); + console.log(''); + } catch (err) { + spinner.fail(chalk.red(`Comparison failed: ${err.message}`)); + process.exit(1); + } + }); + +// ────────────────────────────────────────────────────────────── +// HELPERS +// ────────────────────────────────────────────────────────────── +function normalizeInputUrl(url) { + if (!url.startsWith('http://') && !url.startsWith('https://')) { + url = 'https://' + url; + } + return url; +} + +function printAuditReport(results) { + const grade = results.grade; + + console.log(chalk.bold.cyan('\n ╔══════════════════════════════════════╗')); + console.log(chalk.bold.cyan(' ║ SEO AUDIT REPORT ║')); + console.log(chalk.bold.cyan(' ╚══════════════════════════════════════╝\n')); + + console.log(chalk.gray(` URL: ${results.url}`)); + console.log(chalk.gray(` Date: ${results.timestamp}\n`)); + + // Overall score + const scoreColor = grade.grade === 'A' ? chalk.green : grade.grade === 'B' ? chalk.greenBright : grade.grade === 'C' ? chalk.yellow : grade.grade === 'D' ? chalk.hex('#FFA500') : chalk.red; + + console.log(` Overall Score: ${scoreColor.bold(results.overall + '/100')} (${grade.grade} - ${grade.label})\n`); + + // Category scores + const scoreTable = new Table({ + head: ['Category', 'Score', 'Rating'].map((h) => chalk.gray(h)), + style: { head: [], border: ['gray'] }, + }); + + for (const [name, cat] of Object.entries(results.categories)) { + const g = getScoreGrade(cat.score); + scoreTable.push([ + name.charAt(0).toUpperCase() + name.slice(1), + colorScore(cat.score), + `${g.grade} - ${g.label}`, + ]); + } + + console.log(scoreTable.toString()); + + // Issues by category + for (const [name, cat] of Object.entries(results.categories)) { + const criticalCount = cat.issues.filter((i) => i.severity === 'critical').length; + const warningCount = cat.issues.filter((i) => i.severity === 'warning').length; + + console.log(chalk.bold(`\n ${name.toUpperCase()} ISSUES`)); + if (criticalCount > 0) console.log(chalk.red(` ${criticalCount} critical`)); + if (warningCount > 0) console.log(chalk.yellow(` ${warningCount} warnings`)); + + for (const issue of cat.issues) { + const icon = issue.severity === 'critical' ? chalk.red(' ✗') + : issue.severity === 'warning' ? chalk.yellow(' !') + : issue.severity === 'pass' ? chalk.green(' ✓') + : chalk.blue(' ℹ'); + + console.log(`${icon} ${issue.message}`); + if (issue.recommendation && issue.severity !== 'pass') { + console.log(chalk.gray(` → ${issue.recommendation}`)); + } + } + } + + // Quick metrics + console.log(chalk.bold('\n PAGE METRICS')); + console.log(` Load Time: ${results.pageMetrics.loadTime}ms`); + console.log(` Page Size: ${formatBytes(results.pageMetrics.pageSize)}`); + console.log(` Word Count: ${results.pageMetrics.wordCount}`); + console.log(` Status: ${results.pageMetrics.statusCode}`); + + // Top keywords + if (results.keywords.length > 0) { + console.log(chalk.bold('\n TOP KEYWORDS')); + results.keywords.slice(0, 10).forEach((kw, i) => { + console.log(` ${i + 1}. "${kw.word}" - ${kw.count}x (${kw.density})`); + }); + } + + console.log(''); +} + +function colorScore(score) { + if (score >= 90) return chalk.green(score.toString()); + if (score >= 80) return chalk.greenBright(score.toString()); + if (score >= 70) return chalk.yellow(score.toString()); + if (score >= 50) return chalk.hex('#FFA500')(score.toString()); + return chalk.red(score.toString()); +} + +function formatBytes(bytes) { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'; + return (bytes / 1048576).toFixed(1) + ' MB'; +} + +function truncate(str, len) { + return str.length > len ? str.substring(0, len - 3) + '...' : str; +} + +program.parse(process.argv); + +if (!process.argv.slice(2).length) { + program.outputHelp(); +} diff --git a/seo-toolkit/src/generators/meta-tags-generator.js b/seo-toolkit/src/generators/meta-tags-generator.js new file mode 100644 index 0000000..999d681 --- /dev/null +++ b/seo-toolkit/src/generators/meta-tags-generator.js @@ -0,0 +1,85 @@ +/** + * Generates optimized meta tags, Open Graph, and Twitter Card markup. + */ + +function generateMetaTags(config) { + const tags = []; + + // Basic meta tags + tags.push(`<meta charset="${config.charset || 'UTF-8'}">`); + tags.push('<meta name="viewport" content="width=device-width, initial-scale=1.0">'); + + if (config.title) { + tags.push(`<title>${escapeHtml(config.title)}`); + } + + if (config.description) { + tags.push(``); + } + + if (config.keywords) { + tags.push(``); + } + + if (config.author) { + tags.push(``); + } + + if (config.robots) { + tags.push(``); + } else { + tags.push(''); + } + + // Canonical URL + if (config.canonical) { + tags.push(``); + } + + // Language alternates + if (config.alternates) { + for (const alt of config.alternates) { + tags.push(``); + } + } + + // Open Graph + tags.push(''); + tags.push(''); + tags.push(``); + if (config.title) tags.push(``); + if (config.description) tags.push(``); + if (config.url) tags.push(``); + if (config.image) tags.push(``); + if (config.siteName) tags.push(``); + if (config.locale) tags.push(``); + + // Twitter Card + tags.push(''); + tags.push(''); + tags.push(``); + if (config.title) tags.push(``); + if (config.description) tags.push(``); + if (config.image) tags.push(``); + if (config.twitterSite) tags.push(``); + if (config.twitterCreator) tags.push(``); + + // Favicon + if (config.favicon) { + tags.push(''); + tags.push(''); + tags.push(``); + } + + return tags.join('\n'); +} + +function escapeHtml(str) { + return str.replace(/&/g, '&').replace(//g, '>'); +} + +function escapeAttr(str) { + return str.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); +} + +module.exports = { generateMetaTags }; diff --git a/seo-toolkit/src/generators/report-generator.js b/seo-toolkit/src/generators/report-generator.js new file mode 100644 index 0000000..729d4f8 --- /dev/null +++ b/seo-toolkit/src/generators/report-generator.js @@ -0,0 +1,150 @@ +const fs = require('fs'); +const path = require('path'); +const { getScoreGrade } = require('../utils/scoring'); + +/** + * Generates a full HTML report from analysis results. + */ +function generateHtmlReport(results, outputPath) { + const { url, overall, categories, timestamp } = results; + const grade = getScoreGrade(overall); + + const html = ` + + + + + SEO Audit Report - ${escapeHtml(url)} + + + +
+
+

SEO Audit Report

+
${escapeHtml(url)}
+
Generated: ${timestamp}
+
+ ${overall} + ${grade.label} +
+
+ +
+ ${Object.entries(categories).map(([name, cat]) => ` +
+
${cat.score}
+
${name}
+
+ `).join('')} +
+ + ${Object.entries(categories).map(([name, cat]) => ` +
+

${capitalize(name)} (${cat.score}/100)

+ ${cat.issues.map((issue) => ` +
+
${issue.severity}
+
${escapeHtml(issue.message)}
+ ${issue.recommendation ? `
${escapeHtml(issue.recommendation)}
` : ''} +
+ `).join('')} +
+ `).join('')} + + ${results.keywords ? ` +
+

Top Keywords

+ + + ${results.keywords.slice(0, 15).map((kw) => ` + + + + + + + `).join('')} +
KeywordCountDensity
${escapeHtml(kw.word)}${kw.count}${kw.density}
+
+ ` : ''} + + +
+ +`; + + if (outputPath) { + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(outputPath, html, 'utf8'); + } + + return html; +} + +/** + * Generates a JSON report. + */ +function generateJsonReport(results, outputPath) { + const json = JSON.stringify(results, null, 2); + if (outputPath) { + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(outputPath, json, 'utf8'); + } + return json; +} + +function getScoreColor(score) { + if (score >= 90) return '#22c55e'; + if (score >= 80) return '#84cc16'; + if (score >= 70) return '#eab308'; + if (score >= 50) return '#f97316'; + return '#ef4444'; +} + +function capitalize(str) { + return str.charAt(0).toUpperCase() + str.slice(1); +} + +function escapeHtml(str) { + return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +module.exports = { generateHtmlReport, generateJsonReport }; diff --git a/seo-toolkit/src/generators/robots-generator.js b/seo-toolkit/src/generators/robots-generator.js new file mode 100644 index 0000000..00c907f --- /dev/null +++ b/seo-toolkit/src/generators/robots-generator.js @@ -0,0 +1,127 @@ +/** + * Generates robots.txt content based on configuration. + */ +function generateRobots(config = {}) { + const { + sitemapUrl = '', + disallowPaths = [], + allowPaths = [], + crawlDelay = null, + customRules = [], + blockAiBots = false, + } = config; + + let content = '# robots.txt generated by SEO Toolkit\n'; + content += `# Generated: ${new Date().toISOString()}\n\n`; + + // Default rules for all bots + content += 'User-agent: *\n'; + + for (const path of allowPaths) { + content += `Allow: ${path}\n`; + } + + for (const path of disallowPaths) { + content += `Disallow: ${path}\n`; + } + + // Common paths to disallow + const defaultDisallow = [ + '/admin/', + '/login/', + '/api/', + '/tmp/', + '/*.json$', + '/search', + '/cart/', + '/checkout/', + '/account/', + ]; + + if (disallowPaths.length === 0) { + content += '# Recommended blocks:\n'; + for (const path of defaultDisallow) { + content += `Disallow: ${path}\n`; + } + } + + if (crawlDelay) { + content += `Crawl-delay: ${crawlDelay}\n`; + } + + content += '\n'; + + // Googlebot-specific rules + content += '# Google-specific rules\n'; + content += 'User-agent: Googlebot\n'; + content += 'Allow: /\n'; + for (const path of disallowPaths) { + content += `Disallow: ${path}\n`; + } + content += '\n'; + + // Block AI training bots (optional) + if (blockAiBots) { + content += '# Block AI training crawlers\n'; + const aiBots = ['GPTBot', 'ChatGPT-User', 'CCBot', 'anthropic-ai', 'Google-Extended', 'Bytespider', 'Omgilibot']; + for (const bot of aiBots) { + content += `User-agent: ${bot}\n`; + content += 'Disallow: /\n\n'; + } + } + + // Custom rules + for (const rule of customRules) { + content += `User-agent: ${rule.userAgent}\n`; + if (rule.allow) { + for (const path of rule.allow) { + content += `Allow: ${path}\n`; + } + } + if (rule.disallow) { + for (const path of rule.disallow) { + content += `Disallow: ${path}\n`; + } + } + content += '\n'; + } + + // Sitemap reference + if (sitemapUrl) { + content += `# Sitemap\nSitemap: ${sitemapUrl}\n`; + } + + return content; +} + +/** + * Parses an existing robots.txt and returns analysis. + */ +function analyzeRobots(robotsTxt) { + const lines = robotsTxt.split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#')); + const rules = []; + let currentAgent = null; + + for (const line of lines) { + const [directive, ...valueParts] = line.split(':'); + const value = valueParts.join(':').trim(); + + if (directive.toLowerCase() === 'user-agent') { + currentAgent = value; + } else if (currentAgent) { + rules.push({ + userAgent: currentAgent, + directive: directive.toLowerCase(), + value, + }); + } + } + + const sitemaps = lines + .filter((l) => l.toLowerCase().startsWith('sitemap:')) + .map((l) => l.split(':').slice(1).join(':').trim()); + + return { rules, sitemaps, lineCount: lines.length }; +} + +module.exports = { generateRobots, analyzeRobots }; diff --git a/seo-toolkit/src/generators/sitemap-generator.js b/seo-toolkit/src/generators/sitemap-generator.js new file mode 100644 index 0000000..b53e96b --- /dev/null +++ b/seo-toolkit/src/generators/sitemap-generator.js @@ -0,0 +1,120 @@ +const { fetchPage } = require('../utils/fetcher'); + +/** + * Crawls a website and generates an XML sitemap. + */ +async function generateSitemap(startUrl, options = {}) { + const maxPages = options.maxPages || 100; + const baseUrl = new URL(startUrl); + const visited = new Set(); + const queue = [startUrl]; + const pages = []; + + while (queue.length > 0 && visited.size < maxPages) { + const url = queue.shift(); + const normalized = normalizeUrl(url); + + if (visited.has(normalized)) continue; + visited.add(normalized); + + try { + const pageData = await fetchPage(url); + if (pageData.statusCode === 200) { + pages.push({ + loc: normalized, + lastmod: pageData.headers['last-modified'] + ? new Date(pageData.headers['last-modified']).toISOString().split('T')[0] + : new Date().toISOString().split('T')[0], + changefreq: guessChangeFreq(normalized), + priority: guessPriority(normalized, startUrl), + }); + + // Extract links to crawl + pageData.$('a[href]').each((_, el) => { + const href = pageData.$(el).attr('href'); + try { + const absoluteUrl = new URL(href, url).href; + const absNormalized = normalizeUrl(absoluteUrl); + if ( + new URL(absoluteUrl).hostname === baseUrl.hostname && + !visited.has(absNormalized) && + !absoluteUrl.includes('#') && + !absoluteUrl.match(/\.(jpg|jpeg|png|gif|svg|pdf|zip|css|js)$/i) + ) { + queue.push(absoluteUrl); + } + } catch (e) { + // Skip invalid URLs + } + }); + } + + if (options.onProgress) { + options.onProgress(visited.size, queue.length); + } + } catch (e) { + // Skip failed URLs + } + } + + return buildSitemapXml(pages); +} + +function normalizeUrl(url) { + try { + const u = new URL(url); + u.hash = ''; + u.search = ''; + let path = u.pathname; + if (path.endsWith('/') && path !== '/') { + path = path.slice(0, -1); + } + return `${u.protocol}//${u.host}${path}`; + } catch { + return url; + } +} + +function guessChangeFreq(url) { + if (url.match(/\/(blog|news|articles)\//)) return 'daily'; + if (url.match(/\/(about|contact|privacy|terms)/)) return 'monthly'; + if (new URL(url).pathname === '/') return 'daily'; + return 'weekly'; +} + +function guessPriority(url, startUrl) { + const path = new URL(url).pathname; + if (path === '/' || url === startUrl) return '1.0'; + const depth = path.split('/').filter(Boolean).length; + if (depth === 1) return '0.8'; + if (depth === 2) return '0.6'; + return '0.4'; +} + +function buildSitemapXml(pages) { + let xml = '\n'; + xml += '\n'; + + for (const page of pages) { + xml += ' \n'; + xml += ` ${escapeXml(page.loc)}\n`; + xml += ` ${page.lastmod}\n`; + xml += ` ${page.changefreq}\n`; + xml += ` ${page.priority}\n`; + xml += ' \n'; + } + + xml += '\n'; + return { xml, pageCount: pages.length, pages }; +} + +function escapeXml(str) { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +module.exports = { generateSitemap }; diff --git a/seo-toolkit/src/generators/structured-data-generator.js b/seo-toolkit/src/generators/structured-data-generator.js new file mode 100644 index 0000000..b558888 --- /dev/null +++ b/seo-toolkit/src/generators/structured-data-generator.js @@ -0,0 +1,151 @@ +/** + * Generates common Schema.org structured data (JSON-LD) templates. + */ + +function generateWebsiteSchema(config) { + return { + '@context': 'https://schema.org', + '@type': 'WebSite', + name: config.name, + url: config.url, + description: config.description || '', + potentialAction: config.searchUrl ? { + '@type': 'SearchAction', + target: `${config.searchUrl}?q={search_term_string}`, + 'query-input': 'required name=search_term_string', + } : undefined, + }; +} + +function generateOrganizationSchema(config) { + return { + '@context': 'https://schema.org', + '@type': 'Organization', + name: config.name, + url: config.url, + logo: config.logo || '', + description: config.description || '', + sameAs: config.socialLinks || [], + contactPoint: config.phone ? { + '@type': 'ContactPoint', + telephone: config.phone, + contactType: config.contactType || 'customer service', + } : undefined, + }; +} + +function generateArticleSchema(config) { + return { + '@context': 'https://schema.org', + '@type': 'Article', + headline: config.title, + description: config.description || '', + image: config.image || '', + author: { + '@type': 'Person', + name: config.author, + }, + publisher: { + '@type': 'Organization', + name: config.publisherName, + logo: { '@type': 'ImageObject', url: config.publisherLogo || '' }, + }, + datePublished: config.datePublished, + dateModified: config.dateModified || config.datePublished, + }; +} + +function generateBreadcrumbSchema(items) { + return { + '@context': 'https://schema.org', + '@type': 'BreadcrumbList', + itemListElement: items.map((item, index) => ({ + '@type': 'ListItem', + position: index + 1, + name: item.name, + item: item.url, + })), + }; +} + +function generateFaqSchema(questions) { + return { + '@context': 'https://schema.org', + '@type': 'FAQPage', + mainEntity: questions.map((q) => ({ + '@type': 'Question', + name: q.question, + acceptedAnswer: { + '@type': 'Answer', + text: q.answer, + }, + })), + }; +} + +function generateLocalBusinessSchema(config) { + return { + '@context': 'https://schema.org', + '@type': 'LocalBusiness', + name: config.name, + image: config.image || '', + address: { + '@type': 'PostalAddress', + streetAddress: config.street, + addressLocality: config.city, + addressRegion: config.state, + postalCode: config.zip, + addressCountry: config.country || 'US', + }, + telephone: config.phone || '', + url: config.url || '', + openingHoursSpecification: (config.hours || []).map((h) => ({ + '@type': 'OpeningHoursSpecification', + dayOfWeek: h.days, + opens: h.opens, + closes: h.closes, + })), + priceRange: config.priceRange || '', + }; +} + +function generateProductSchema(config) { + return { + '@context': 'https://schema.org', + '@type': 'Product', + name: config.name, + description: config.description || '', + image: config.image || '', + brand: { '@type': 'Brand', name: config.brand || '' }, + offers: { + '@type': 'Offer', + price: config.price, + priceCurrency: config.currency || 'USD', + availability: config.inStock + ? 'https://schema.org/InStock' + : 'https://schema.org/OutOfStock', + url: config.url || '', + }, + aggregateRating: config.rating ? { + '@type': 'AggregateRating', + ratingValue: config.rating, + reviewCount: config.reviewCount || 0, + } : undefined, + }; +} + +function wrapInScriptTag(schema) { + const json = JSON.stringify(schema, null, 2); + return ``; +} + +module.exports = { + generateWebsiteSchema, + generateOrganizationSchema, + generateArticleSchema, + generateBreadcrumbSchema, + generateFaqSchema, + generateLocalBusinessSchema, + generateProductSchema, + wrapInScriptTag, +}; diff --git a/seo-toolkit/src/index.js b/seo-toolkit/src/index.js new file mode 100644 index 0000000..fe33123 --- /dev/null +++ b/seo-toolkit/src/index.js @@ -0,0 +1,126 @@ +const { fetchPage } = require('./utils/fetcher'); +const { calculateCategoryScore, calculateOverallScore, getScoreGrade } = require('./utils/scoring'); +const { analyzeMeta } = require('./analyzers/meta-analyzer'); +const { analyzeContent } = require('./analyzers/content-analyzer'); +const { analyzeTechnical } = require('./analyzers/technical-analyzer'); +const { analyzeKeywords } = require('./analyzers/keyword-analyzer'); +const { analyzeLinks } = require('./analyzers/link-analyzer'); +const { generateSitemap } = require('./generators/sitemap-generator'); +const { generateRobots, analyzeRobots } = require('./generators/robots-generator'); +const { generateHtmlReport, generateJsonReport } = require('./generators/report-generator'); +const { generateMetaTags } = require('./generators/meta-tags-generator'); +const { + generateWebsiteSchema, + generateOrganizationSchema, + generateArticleSchema, + generateBreadcrumbSchema, + generateFaqSchema, + generateLocalBusinessSchema, + generateProductSchema, + wrapInScriptTag, +} = require('./generators/structured-data-generator'); + +/** + * Run a full SEO audit on a URL. + */ +async function runAudit(url, options = {}) { + const { + targetKeywords = [], + checkBrokenLinks = false, + } = options; + + // Fetch the page + const pageData = await fetchPage(url); + + // Run all analyzers + const metaResult = analyzeMeta(pageData.$, url); + const contentResult = analyzeContent(pageData.$, url); + const technicalResult = analyzeTechnical(pageData.$, pageData); + const keywordResult = analyzeKeywords(pageData.$, url, targetKeywords); + const linkResult = await analyzeLinks(pageData.$, url, { checkBroken: checkBrokenLinks }); + + // Calculate scores + const categoryScores = { + meta: calculateCategoryScore(metaResult.checks), + content: calculateCategoryScore(contentResult.checks), + technical: calculateCategoryScore(technicalResult.checks), + links: calculateCategoryScore(linkResult.checks), + }; + + const overallScore = calculateOverallScore(categoryScores); + const grade = getScoreGrade(overallScore); + + // Compile results + const results = { + url, + timestamp: new Date().toISOString(), + overall: overallScore, + grade, + categories: { + meta: { + score: categoryScores.meta, + issues: metaResult.issues, + data: metaResult.data, + }, + content: { + score: categoryScores.content, + issues: contentResult.issues, + data: contentResult.data, + }, + technical: { + score: categoryScores.technical, + issues: technicalResult.issues, + data: technicalResult.data, + }, + links: { + score: categoryScores.links, + issues: linkResult.issues, + data: linkResult.data, + }, + }, + keywords: keywordResult.data.topKeywords || [], + keywordPhrases: keywordResult.data.topPhrases || {}, + targetKeywordAnalysis: keywordResult.data.targetKeywordAnalysis || [], + pageMetrics: { + loadTime: pageData.loadTime, + pageSize: pageData.contentLength, + statusCode: pageData.statusCode, + wordCount: contentResult.data.wordCount, + }, + }; + + return results; +} + +module.exports = { + // Core audit + runAudit, + + // Individual analyzers + analyzeMeta, + analyzeContent, + analyzeTechnical, + analyzeKeywords, + analyzeLinks, + + // Generators + generateSitemap, + generateRobots, + analyzeRobots, + generateMetaTags, + generateHtmlReport, + generateJsonReport, + + // Structured data + generateWebsiteSchema, + generateOrganizationSchema, + generateArticleSchema, + generateBreadcrumbSchema, + generateFaqSchema, + generateLocalBusinessSchema, + generateProductSchema, + wrapInScriptTag, + + // Utilities + fetchPage, +}; diff --git a/seo-toolkit/src/utils/fetcher.js b/seo-toolkit/src/utils/fetcher.js new file mode 100644 index 0000000..e16e328 --- /dev/null +++ b/seo-toolkit/src/utils/fetcher.js @@ -0,0 +1,61 @@ +const axios = require('axios'); +const cheerio = require('cheerio'); + +/** + * Fetches a URL and returns parsed HTML with cheerio and raw data. + */ +async function fetchPage(url, options = {}) { + const timeout = options.timeout || 15000; + const headers = { + 'User-Agent': 'SEO-Toolkit/1.0 (Audit Bot)', + 'Accept': 'text/html,application/xhtml+xml', + 'Accept-Language': 'en-US,en;q=0.9', + ...options.headers, + }; + + const startTime = Date.now(); + const response = await axios.get(url, { + timeout, + headers, + maxRedirects: 5, + validateStatus: (status) => status < 500, + }); + const loadTime = Date.now() - startTime; + + const $ = cheerio.load(response.data); + + return { + url, + statusCode: response.status, + headers: response.headers, + html: response.data, + $, + loadTime, + contentLength: response.headers['content-length'] + ? parseInt(response.headers['content-length'], 10) + : Buffer.byteLength(response.data, 'utf8'), + }; +} + +/** + * Fetches multiple URLs concurrently with a concurrency limit. + */ +async function fetchPages(urls, concurrency = 5) { + const results = []; + for (let i = 0; i < urls.length; i += concurrency) { + const batch = urls.slice(i, i + concurrency); + const batchResults = await Promise.allSettled( + batch.map((url) => fetchPage(url)) + ); + for (const result of batchResults) { + if (result.status === 'fulfilled') { + results.push(result.value); + } else { + results.push({ url: batch[results.length % batch.length], error: result.reason.message }); + } + } + } + return results; +} + +module.exports = { fetchPage, fetchPages }; diff --git a/seo-toolkit/src/utils/scoring.js b/seo-toolkit/src/utils/scoring.js new file mode 100644 index 0000000..ce4cda3 --- /dev/null +++ b/seo-toolkit/src/utils/scoring.js @@ -0,0 +1,52 @@ +/** + * SEO scoring utilities. + * Calculates weighted scores for different SEO categories. + */ + +const WEIGHTS = { + meta: 25, + content: 20, + technical: 20, + performance: 15, + mobile: 10, + links: 10, +}; + +function calculateCategoryScore(checks) { + if (!checks.length) return 0; + const passed = checks.filter((c) => c.passed).length; + return Math.round((passed / checks.length) * 100); +} + +function calculateOverallScore(categories) { + let totalWeight = 0; + let weightedSum = 0; + + for (const [category, score] of Object.entries(categories)) { + const weight = WEIGHTS[category] || 10; + weightedSum += score * weight; + totalWeight += weight; + } + + return Math.round(weightedSum / totalWeight); +} + +function getScoreGrade(score) { + if (score >= 90) return { grade: 'A', color: 'green', label: 'Excellent' }; + if (score >= 80) return { grade: 'B', color: 'greenBright', label: 'Good' }; + if (score >= 70) return { grade: 'C', color: 'yellow', label: 'Needs Improvement' }; + if (score >= 50) return { grade: 'D', color: 'rgb(255,165,0)', label: 'Poor' }; + return { grade: 'F', color: 'red', label: 'Critical' }; +} + +function formatIssue(severity, message, recommendation) { + const icons = { critical: '[CRITICAL]', warning: '[WARNING]', info: '[INFO]', pass: '[PASS]' }; + return { + severity, + icon: icons[severity] || icons.info, + message, + recommendation, + }; +} + +module.exports = { calculateCategoryScore, calculateOverallScore, getScoreGrade, formatIssue, WEIGHTS }; From 0f3e573fbf300be9e059ba3fbb001885d47627d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Feb 2026 13:00:53 +0000 Subject: [PATCH 2/2] Add package-lock.json for reproducible installs https://claude.ai/code/session_01TLg5Fub8uKYrgeYmJUtiLS --- seo-toolkit/package-lock.json | 1073 +++++++++++++++++++++++++++++++++ 1 file changed, 1073 insertions(+) create mode 100644 seo-toolkit/package-lock.json diff --git a/seo-toolkit/package-lock.json b/seo-toolkit/package-lock.json new file mode 100644 index 0000000..cdbaf01 --- /dev/null +++ b/seo-toolkit/package-lock.json @@ -0,0 +1,1073 @@ +{ + "name": "seo-toolkit", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "seo-toolkit", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "axios": "^1.6.0", + "chalk": "^4.1.2", + "cheerio": "^1.0.0-rc.12", + "cli-table3": "^0.6.3", + "commander": "^11.1.0", + "ora": "^5.4.1", + "xml2js": "^0.6.2" + }, + "bin": { + "seo-toolkit": "src/cli.js" + } + }, + "node_modules/@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-table3": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", + "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", + "license": "MIT", + "dependencies": { + "string-width": "^4.2.0" + }, + "engines": { + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "@colors/colors": "1.5.0" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz", + "integrity": "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/undici": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.21.0.tgz", + "integrity": "sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + } + } +}