${escapeHtml(label)}
+ ${groups.length + ? groups.map((group) => `
+ ${escapeHtml(group.value)}
+ ${group.urls.map(escapeHtml).join(" · ")}
+
`).join("")
+ : "No duplicates found.
"} +diff --git a/README.md b/README.md index 0cba792..d0cf92a 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ A compact Node.js application that audits a public webpage or pasted HTML, expla - Edits draft metadata with instant preview updates and generated `
` tags. - Exposes the same URL analyzer through a versioned JSON response. - Copies shareable audit links and exports JSON or Markdown reports without storage. +- Audits up to 10 same-origin sitemap pages for scores, failures, and duplicate metadata. +- Validates JSON-LD syntax and reports detected Schema.org-style `@type` values. - Lists discovered Open Graph and Twitter metadata for inspection. - Resolves relative preview URLs against the audited page. - Returns clear errors for invalid, unreachable, oversized, slow, or non-HTML targets. @@ -26,6 +28,8 @@ Because the application fetches user-provided URLs, outbound requests are treate - Image requests reuse the SSRF boundary with a 7-second timeout and 5 MB limit. - Preview images are loaded through a bounded same-origin route instead of directly from metadata-controlled hosts. - Pasted HTML is processed in memory and capped at 256 KB. +- Sitemaps use the same SSRF-safe 2 MB fetch boundary and may include up to three same-origin child sitemaps. +- Site audits fetch at most 10 same-origin pages with three concurrent requests. - Non-HTML responses are rejected. - Metadata and URL attributes are escaped or sanitized before rendering. - Helmet security headers, a Content Security Policy, and per-IP rate limiting are enabled. @@ -85,6 +89,14 @@ GET /report.md?url=https%3A%2F%2Fexample.com URL results also include controls to copy the reproducible audit URL, open the JSON export, or download the Markdown report. Pasted HTML stays local to the request and therefore does not expose share/export controls. +Audit a sitemap through the UI at `/site?url=...` or as JSON: + +```http +GET /api/site?url=https%3A%2F%2Fexample.com%2Fsitemap.xml +``` + +The site response includes discovery limits, success/failure totals, average score, duplicate titles/descriptions/canonicals, structured-data summaries, and page-level checks. Sitemap indexes are followed one level deep, with at most three same-origin child sitemaps. + If the application runs behind a reverse proxy, set the number of trusted proxy hops so rate limiting uses the client address safely: ```bash @@ -114,6 +126,8 @@ The test suite covers: - Slack and WhatsApp preview rendering - URL and pasted-HTML analysis modes - Stable JSON analysis and Markdown report exports +- Bounded sitemap parsing, aggregation, and same-origin enforcement +- JSON-LD syntax and `@type` summaries - Deterministic audit rules and scoring - Bounded social-image inspection and safe same-origin previews - Generated metadata-tag escaping and unsafe URL omission @@ -130,7 +144,8 @@ The test suite covers: │ ├── image-inspector.js # Bounded social-image validation │ ├── metadata.js # Normalized metadata parsing │ ├── report.js # Public JSON and Markdown serializers -│ └── safe-fetch.js # URL validation and bounded page fetching +│ ├── safe-fetch.js # URL validation and bounded page fetching +│ └── site-audit.js # Bounded sitemap and cross-page analysis ├── public/ │ ├── app.js # Input tabs and live draft previews │ ├── tag-generator.js # Shared browser/server tag generation @@ -155,6 +170,8 @@ The previous Heroku demo is no longer linked because it currently returns 404. - Public websites may block automated requests or return different metadata based on geography or user agent. - Draft edits update previews and generated tags but do not recalculate the source-page audit. - Share links and exports rerun the live URL analysis; results can change when the target page changes. +- Site audits intentionally stop after 10 pages and one sitemap-index level; they are not a general-purpose crawler. +- Structured-data checks validate JSON-LD syntax and declared types, not full Schema.org vocabulary requirements. ## License diff --git a/lib/audit.js b/lib/audit.js index 6b372a5..f8c8ed0 100644 --- a/lib/audit.js +++ b/lib/audit.js @@ -191,6 +191,19 @@ function analyzeMetadata(metadata, targetUrl) { checks.push(socialField("twitter-description", "twitter", "twitter:description", metadata.twitter.description, metadata.openGraph.description)); checks.push(socialField("twitter-image", "twitter", "twitter:image", metadata.twitter.image.raw, metadata.openGraph.image.raw)); + const structuredData = metadata.structuredData || []; + const invalidStructuredData = structuredData.filter((item) => !item.valid).length; + const structuredTypes = [...new Set(structuredData.flatMap((item) => item.types || []))]; + checks.push( + !structuredData.length + ? result("structured-data", "structured-data", "info", "No JSON-LD structured data was found.") + : invalidStructuredData + ? result("structured-data", "structured-data", "error", `${invalidStructuredData} invalid JSON-LD block${invalidStructuredData === 1 ? " was" : "s were"} found.`) + : !structuredTypes.length + ? result("structured-data", "structured-data", "warning", "JSON-LD is valid but does not declare an @type.") + : result("structured-data", "structured-data", "pass", "JSON-LD structured data is valid.", structuredTypes.join(", ")), + ); + return { checks, score: summarizeChecks(checks), diff --git a/lib/metadata.js b/lib/metadata.js index 77b5b3c..c2d8eda 100644 --- a/lib/metadata.js +++ b/lib/metadata.js @@ -20,6 +20,46 @@ function urlValue(raw, baseUrl) { return { raw, url: safeWebUrl(raw, baseUrl) }; } +function structuredDataSummary($) { + return $('script[type="application/ld+json"]') + .map((_index, element) => { + try { + const value = JSON.parse($(element).text()); + const types = new Set(); + const queue = [value]; + let cursor = 0; + const enqueue = (values) => { + for (const child of values) { + if (queue.length >= 10_000) break; + queue.push(child); + } + }; + while (cursor < queue.length && cursor < 10_000 && types.size < 20) { + const node = queue[cursor]; + cursor += 1; + if (!node) continue; + if (Array.isArray(node)) { + enqueue(node); + continue; + } + if (typeof node !== "object") continue; + const nodeTypes = Array.isArray(node["@type"]) + ? node["@type"] + : [node["@type"]]; + nodeTypes.filter((type) => typeof type === "string" && type.trim()).forEach((type) => { + const normalizedType = type.trim(); + if (types.size < 20 && normalizedType.length <= 100) types.add(normalizedType); + }); + enqueue(Object.values(node)); + } + return { valid: true, types: [...types] }; + } catch { + return { valid: false, types: [], error: "Invalid JSON-LD." }; + } + }) + .get(); +} + function parseMetadata(html, baseUrl) { const $ = cheerio.load(html || ""); const canonicalRaw = ($('link[rel="canonical"]').first().attr("href") || "").trim(); @@ -69,6 +109,7 @@ function parseMetadata(html, baseUrl) { description: metaContent($, 'meta[name="twitter:description"]'), image: urlValue(twitterImageRaw, baseUrl), }, + structuredData: structuredDataSummary($), socialTags, }; } diff --git a/lib/safe-fetch.js b/lib/safe-fetch.js index 6b0c60a..c3ff4ac 100644 --- a/lib/safe-fetch.js +++ b/lib/safe-fetch.js @@ -101,17 +101,23 @@ function validateRedirect(options) { ); } -async function fetchPage( - value, - { client = axios, lookup = dns.lookup } = {}, -) { +async function fetchTextResource(value, { + accept, + allowedContentTypes, + client = axios, + fetchMessage, + invalidTypeMessage, + lookup = dns.lookup, + sizeMessage, + timeoutMessage, +}) { const url = normalizeTargetUrl(value); try { const response = await client.get(url, { beforeRedirect: validateRedirect, headers: { - Accept: "text/html,application/xhtml+xml", + Accept: accept, "User-Agent": "OGPreview/1.0 (+https://github.com/alianjum0/ogpreview-node)", }, lookup: createSafeLookup(lookup), @@ -127,8 +133,8 @@ async function fetchPage( .split(";", 1)[0] .trim() .toLowerCase(); - if (!["text/html", "application/xhtml+xml"].includes(contentType)) { - throw new TargetUrlError("The target URL did not return an HTML page."); + if (!allowedContentTypes.includes(contentType)) { + throw new TargetUrlError(invalidTypeMessage); } return response.data; @@ -136,23 +142,48 @@ async function fetchPage( if (error instanceof TargetUrlError) throw error; if (error.cause instanceof TargetUrlError) throw error.cause; if (error.code === "ECONNABORTED") { - throw new FetchPageError("The target website took too long to respond."); + throw new FetchPageError(timeoutMessage); } if ( error.code === "ERR_BAD_RESPONSE" && error.message.includes("maxContentLength") ) { - throw new FetchPageError("The target page is larger than 2 MB."); + throw new FetchPageError(sizeMessage); } - throw new FetchPageError("The target website could not be fetched."); + throw new FetchPageError(fetchMessage); } } +async function fetchPage(value, options = {}) { + return fetchTextResource(value, { + accept: "text/html,application/xhtml+xml", + allowedContentTypes: ["text/html", "application/xhtml+xml"], + fetchMessage: "The target website could not be fetched.", + invalidTypeMessage: "The target URL did not return an HTML page.", + sizeMessage: "The target page is larger than 2 MB.", + timeoutMessage: "The target website took too long to respond.", + ...options, + }); +} + +async function fetchSitemap(value, options = {}) { + return fetchTextResource(value, { + accept: "application/xml,text/xml,text/plain", + allowedContentTypes: ["application/xml", "text/xml", "text/plain"], + fetchMessage: "The sitemap could not be fetched.", + invalidTypeMessage: "The target URL did not return an XML sitemap.", + sizeMessage: "The sitemap is larger than 2 MB.", + timeoutMessage: "The sitemap took too long to respond.", + ...options, + }); +} + module.exports = { FetchPageError, TargetUrlError, createSafeLookup, fetchPage, + fetchSitemap, isBlockedAddress, normalizeTargetUrl, validateRedirect, diff --git a/lib/site-audit.js b/lib/site-audit.js new file mode 100644 index 0000000..c525896 --- /dev/null +++ b/lib/site-audit.js @@ -0,0 +1,190 @@ +const cheerio = require("cheerio"); +const { analyzeHtml } = require("./analyze"); +const { TargetUrlError, normalizeTargetUrl } = require("./safe-fetch"); + +const DEFAULT_MAX_PAGES = 10; +const DEFAULT_MAX_SITEMAPS = 3; +const DEFAULT_CONCURRENCY = 3; + +function parseSitemap(xml, sitemapUrl) { + const $ = cheerio.load(xml || "", { xmlMode: true }); + const type = $("urlset").length ? "urlset" : $("sitemapindex").length ? "index" : ""; + if (!type) throw new TargetUrlError("The target URL did not contain a valid sitemap."); + const selector = type === "urlset" ? "url > loc" : "sitemap > loc"; + const locations = $(selector) + .map((_index, element) => { + try { + const url = new URL($(element).text().trim(), sitemapUrl); + return ["http:", "https:"].includes(url.protocol) ? url.toString() : null; + } catch { + return null; + } + }) + .get() + .filter(Boolean); + return { type, locations }; +} + +function sameOriginLocations(locations, origin) { + const accepted = []; + let externalSkipped = 0; + let invalidSkipped = 0; + for (const value of locations) { + try { + const normalized = normalizeTargetUrl(value); + if (new URL(normalized).origin !== origin) { + externalSkipped += 1; + } else { + accepted.push(normalized); + } + } catch { + invalidSkipped += 1; + } + } + return { accepted, externalSkipped, invalidSkipped }; +} + +async function mapWithConcurrency(items, concurrency, mapper) { + const results = new Array(items.length); + let cursor = 0; + const worker = async () => { + while (cursor < items.length) { + const index = cursor; + cursor += 1; + results[index] = await mapper(items[index], index); + } + }; + await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker)); + return results; +} + +function duplicateGroups(pages, field) { + const groups = new Map(); + for (const page of pages.filter((item) => item.status === "ok")) { + const value = page.metadata[field]; + if (!value) continue; + const key = value.trim().toLowerCase(); + if (!groups.has(key)) groups.set(key, { value, urls: [] }); + groups.get(key).urls.push(page.url); + } + return [...groups.values()].filter((group) => group.urls.length > 1); +} + +function pageResult(url, analysis) { + const structuredData = analysis.metadata.structuredData || []; + return { + url, + status: "ok", + score: analysis.score, + metadata: { + title: analysis.metadata.title, + description: analysis.metadata.description, + canonical: analysis.metadata.canonical.url || analysis.metadata.canonical.raw, + openGraphImage: analysis.metadata.openGraph.image.url || analysis.metadata.openGraph.image.raw, + twitterCard: analysis.metadata.twitter.card, + }, + structuredData: { + blocks: structuredData.length, + valid: structuredData.filter((item) => item.valid).length, + invalid: structuredData.filter((item) => !item.valid).length, + types: [...new Set(structuredData.flatMap((item) => item.types || []))], + }, + checks: analysis.checks, + }; +} + +async function auditSite(sitemapValue, { + concurrency = DEFAULT_CONCURRENCY, + maxPages = DEFAULT_MAX_PAGES, + maxSitemaps = DEFAULT_MAX_SITEMAPS, + pageFetcher, + sitemapFetcher, +} = {}) { + if (typeof sitemapFetcher !== "function" || typeof pageFetcher !== "function") { + throw new TypeError("Site audit fetchers are required."); + } + const sitemapUrl = normalizeTargetUrl(sitemapValue); + const origin = new URL(sitemapUrl).origin; + const root = parseSitemap(await sitemapFetcher(sitemapUrl), sitemapUrl); + let pageLocations = root.type === "urlset" ? root.locations : []; + let externalSkipped = 0; + let invalidSkipped = 0; + let childSitemaps = 0; + let childSitemapFailures = 0; + + if (root.type === "index") { + const filtered = sameOriginLocations(root.locations, origin); + externalSkipped += filtered.externalSkipped; + invalidSkipped += filtered.invalidSkipped; + const sitemapUrls = [...new Set(filtered.accepted)].slice(0, maxSitemaps); + childSitemaps = sitemapUrls.length; + for (const childUrl of sitemapUrls) { + try { + const child = parseSitemap(await sitemapFetcher(childUrl), childUrl); + if (child.type === "urlset") pageLocations.push(...child.locations); + } catch { + childSitemapFailures += 1; + } + } + } + + const filteredPages = sameOriginLocations(pageLocations, origin); + externalSkipped += filteredPages.externalSkipped; + invalidSkipped += filteredPages.invalidSkipped; + const discoveredPages = [...new Set(filteredPages.accepted)]; + if (!discoveredPages.length) { + throw new TargetUrlError("The sitemap did not contain any same-origin page URLs."); + } + const selectedPages = discoveredPages.slice(0, maxPages); + const pages = await mapWithConcurrency(selectedPages, concurrency, async (url) => { + try { + return pageResult(url, analyzeHtml(await pageFetcher(url), url)); + } catch (error) { + return { + url, + status: "error", + error: Number.isInteger(error.statusCode) + ? error.message + : "The page could not be analyzed.", + }; + } + }); + const successfulPages = pages.filter((page) => page.status === "ok"); + const averageScore = successfulPages.length + ? Math.round(successfulPages.reduce((total, page) => total + page.score.value, 0) / successfulPages.length) + : null; + + return { + schemaVersion: 1, + source: { sitemapUrl, sitemapType: root.type }, + limits: { maxPages, maxSitemaps, concurrency }, + discovery: { + discovered: discoveredPages.length, + truncated: Math.max(0, discoveredPages.length - selectedPages.length), + externalSkipped, + invalidSkipped, + childSitemaps, + childSitemapFailures, + }, + summary: { + total: pages.length, + succeeded: successfulPages.length, + failed: pages.length - successfulPages.length, + averageScore, + }, + duplicates: { + titles: duplicateGroups(pages, "title"), + descriptions: duplicateGroups(pages, "description"), + canonicals: duplicateGroups(pages, "canonical"), + }, + pages, + }; +} + +module.exports = { + DEFAULT_CONCURRENCY, + DEFAULT_MAX_PAGES, + DEFAULT_MAX_SITEMAPS, + auditSite, + parseSitemap, +}; diff --git a/package.json b/package.json index 31f9287..4c6ad48 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "A Node.js application that performs a basic SEO audit and displays social media previews based on a website's meta tags.", "main": "server.js", "scripts": { - "check:syntax": "node --check server.js && node --check lib/analyze.js && node --check lib/audit.js && node --check lib/image-inspector.js && node --check lib/metadata.js && node --check lib/report.js && node --check lib/safe-fetch.js && node --check public/app.js && node --check public/tag-generator.js", + "check:syntax": "node --check server.js && node --check lib/analyze.js && node --check lib/audit.js && node --check lib/image-inspector.js && node --check lib/metadata.js && node --check lib/report.js && node --check lib/safe-fetch.js && node --check lib/site-audit.js && node --check public/app.js && node --check public/tag-generator.js", "start": "node server.js", "test": "node --test", "test:ui": "node --test test/app.test.js", diff --git a/public/styles.css b/public/styles.css index 1281429..9808cb0 100644 --- a/public/styles.css +++ b/public/styles.css @@ -1003,6 +1003,87 @@ summary:focus-visible { margin: 0; } +.site-results { + padding: 10px 0 88px; +} + +.site-table { + overflow-x: auto; + border-radius: 10px; +} + +.site-table table { + width: 100%; + min-width: 840px; + border-collapse: collapse; +} + +.site-table th, +.site-table td { + padding: 14px 16px; + border-bottom: 1px solid var(--line); + font-size: 0.76rem; + text-align: left; + vertical-align: top; +} + +.site-table th { + color: var(--muted); + background: #f8f8fb; + font-family: var(--mono); + font-size: 0.66rem; + text-transform: uppercase; +} + +.site-table a { + display: block; + max-width: 280px; + overflow: hidden; + color: var(--violet-dark); + text-overflow: ellipsis; + white-space: nowrap; +} + +.duplicate-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 18px; + padding: 22px; + border-radius: 10px; +} + +.duplicate-group h3 { + margin: 0 0 12px; + font-size: 0.9rem; +} + +.duplicate-group p { + color: var(--muted); + font-size: 0.75rem; +} + +.duplicate-group__item { + display: grid; + gap: 5px; + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid var(--line); +} + +.duplicate-group__item strong, +.duplicate-group__item span { + overflow-wrap: anywhere; +} + +.duplicate-group__item strong { + font-size: 0.76rem; +} + +.duplicate-group__item span { + color: var(--muted); + font-size: 0.65rem; +} + @media (max-width: 900px) { .hero { grid-template-columns: 1fr; @@ -1026,6 +1107,10 @@ summary:focus-visible { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .duplicate-grid { + grid-template-columns: 1fr; + } + .preview-layout { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -1189,7 +1274,7 @@ summary:focus-visible { .input-tabs { display: grid; - grid-template-columns: 1fr 1fr; + grid-template-columns: repeat(3, 1fr); gap: 4px; margin-bottom: 22px; padding: 4px; diff --git a/server.js b/server.js index 7cbb861..9bcdf80 100644 --- a/server.js +++ b/server.js @@ -6,7 +6,8 @@ const { analyzeDocument, analyzeHtml } = require("./lib/analyze"); const { fetchImage, inspectImage } = require("./lib/image-inspector"); const { safeWebUrl } = require("./lib/metadata"); const { serializeAnalysis, toMarkdownReport } = require("./lib/report"); -const { fetchPage, normalizeTargetUrl } = require("./lib/safe-fetch"); +const { fetchPage, fetchSitemap, normalizeTargetUrl } = require("./lib/safe-fetch"); +const { auditSite } = require("./lib/site-audit"); const { generateTags } = require("./public/tag-generator"); const PLACEHOLDER_IMAGE = "/preview-placeholder.svg"; @@ -142,6 +143,7 @@ function renderAnalysis(htmlOrAnalysis, targetUrl) { "twitter-title": "Twitter title", "twitter-description": "Twitter description", "twitter-image": "Twitter image", + "structured-data": "Structured data", }; const seoAuditRows = checks @@ -329,7 +331,75 @@ function renderAnalysis(htmlOrAnalysis, targetUrl) { - `; + `; +} + +function renderDuplicateGroup(label, groups) { + return `No duplicates found.
"} +Site audit
+${site.summary.succeeded} completed · ${site.summary.failed} failed · ${site.discovery.truncated} truncated
+ ${escapeHtml(site.source.sitemapUrl)} +Page inventory
Bounded to ${site.limits.maxPages} same-origin pages with ${site.limits.concurrency} concurrent requests.
+| Page | Score | Errors | Warnings | Title | Schema types |
|---|
Cross-page checks
Repeated non-empty values found across successfully analyzed pages.
+Inspect a public URL or paste HTML directly.
+Inspect a page, paste HTML, or audit a bounded sitemap.