From c8a4ce0efdba8daaff79be0aab98b1927ad0e2c1 Mon Sep 17 00:00:00 2001 From: Muhammad Ali Anjum Date: Thu, 1 Oct 2026 18:30:56 +0200 Subject: [PATCH] Implement Release 2 sharing and API tools --- README.md | 26 ++++++++++- lib/report.js | 63 +++++++++++++++++++++++++++ package.json | 2 +- public/app.js | 14 ++++++ public/styles.css | 87 +++++++++++++++++++++++++++++++++++++ server.js | 103 ++++++++++++++++++++++++++++++++++++++++++-- test/app.test.js | 98 +++++++++++++++++++++++++++++++++++++++++ test/report.test.js | 64 +++++++++++++++++++++++++++ 8 files changed, 452 insertions(+), 5 deletions(-) create mode 100644 lib/report.js create mode 100644 test/report.test.js diff --git a/README.md b/README.md index 44fbbbf..0cba792 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,11 @@ A compact Node.js application that audits a public webpage or pasted HTML, expla - Accepts a public URL or pasted HTML for localhost, staging, and bot-blocked pages. - Scores transparent SEO, indexability, Open Graph, and Twitter/X rules from 0–100. -- Renders search, Facebook, X/Twitter, and LinkedIn-style preview cards. +- Renders search, Facebook, X/Twitter, LinkedIn, Slack, and WhatsApp-style preview cards. - Validates social-image reachability, type, bytes, dimensions, and aspect ratio. - 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. - 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. @@ -65,6 +67,24 @@ PORT=4000 pnpm start No database, API key, or environment file is required. +## API and report exports + +Analyze a public page as JSON: + +```http +GET /api/analyze?url=https%3A%2F%2Fexample.com +``` + +The response contains `schemaVersion`, the analyzed URL and source details, the score summary, normalized metadata, deterministic checks, and image-inspection results. API failures return a JSON `error` without exposing unexpected upstream details. + +Download the same URL audit as Markdown: + +```http +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. + If the application runs behind a reverse proxy, set the number of trusted proxy hops so rate limiting uses the client address safely: ```bash @@ -91,7 +111,9 @@ The test suite covers: - HTML and attribute escaping - Unsafe preview URL removal - Successful SEO and social metadata rendering +- Slack and WhatsApp preview rendering - URL and pasted-HTML analysis modes +- Stable JSON analysis and Markdown report exports - Deterministic audit rules and scoring - Bounded social-image inspection and safe same-origin previews - Generated metadata-tag escaping and unsafe URL omission @@ -107,6 +129,7 @@ The test suite covers: │ ├── audit.js # Deterministic rules and scoring │ ├── 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 ├── public/ │ ├── app.js # Input tabs and live draft previews @@ -131,6 +154,7 @@ The previous Heroku demo is no longer linked because it currently returns 404. - JavaScript-rendered metadata is not executed because the application analyzes the fetched HTML response. - 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. ## License diff --git a/lib/report.js b/lib/report.js new file mode 100644 index 0000000..f0a4870 --- /dev/null +++ b/lib/report.js @@ -0,0 +1,63 @@ +function serializeAnalysis(analysis) { + return { + schemaVersion: 1, + url: analysis.source.finalUrl || analysis.source.requestedUrl || "", + source: analysis.source, + score: analysis.score, + metadata: analysis.metadata, + checks: analysis.checks, + images: analysis.images || { openGraph: null, twitter: null }, + }; +} + +function markdownCell(value) { + const normalized = String(value ?? "") + .replace(/\r?\n/g, " ") + .replace(/\|/g, "\\|") + .trim(); + return normalized || "—"; +} + +function toMarkdownReport(analysis) { + const report = serializeAnalysis(analysis); + const { metadata, score } = report; + const metadataRows = [ + ["Title", metadata.title], + ["Meta description", metadata.description], + ["Canonical URL", metadata.canonical?.url || metadata.canonical?.raw], + ["Open Graph title", metadata.openGraph?.title], + ["Open Graph description", metadata.openGraph?.description], + ["Open Graph image", metadata.openGraph?.image?.url || metadata.openGraph?.image?.raw], + ["Open Graph URL", metadata.openGraph?.url?.url || metadata.openGraph?.url?.raw], + ["Twitter card", metadata.twitter?.card], + ["Twitter title", metadata.twitter?.title], + ["Twitter description", metadata.twitter?.description], + ["Twitter image", metadata.twitter?.image?.url || metadata.twitter?.image?.raw], + ]; + const findings = report.checks.map((check) => + `| ${markdownCell(check.status).toUpperCase()} | ${markdownCell(check.category)} | ${markdownCell(check.id)} | ${markdownCell(check.message)} | ${markdownCell(check.evidence)} |`, + ); + + return [ + `# MetaScope audit: ${markdownCell(report.url || "Pasted HTML")}`, + "", + `Score: **${score.value}/100**`, + "", + `${score.errors} errors · ${score.warnings} warnings · ${score.passed} passed · ${score.info || 0} informational`, + "", + "## Detected metadata", + "", + "| Field | Value |", + "| --- | --- |", + ...metadataRows.map(([label, value]) => `| ${label} | ${markdownCell(value)} |`), + "", + "## Findings", + "", + "| Status | Category | Check | Finding | Evidence |", + "| --- | --- | --- | --- | --- |", + ...findings, + "", + ].join("\n"); +} + +module.exports = { serializeAnalysis, toMarkdownReport }; diff --git a/package.json b/package.json index ddc3367..31f9287 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/safe-fetch.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 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/app.js b/public/app.js index 21cdeaf..7dd82db 100644 --- a/public/app.js +++ b/public/app.js @@ -13,6 +13,20 @@ }); }); + const copyAuditLink = document.querySelector("[data-copy-audit-link]"); + if (copyAuditLink) { + const shareStatus = document.querySelector("[data-share-status]"); + copyAuditLink.addEventListener("click", async () => { + const auditUrl = new URL(copyAuditLink.dataset.auditPath, window.location.origin).toString(); + try { + await navigator.clipboard.writeText(auditUrl); + shareStatus.textContent = "Audit link copied."; + } catch { + shareStatus.textContent = "Copy the audit URL from your browser address bar."; + } + }); + } + const editor = document.querySelector("[data-metadata-editor]"); if (!editor || !globalThis.MetaScopeTags) return; diff --git a/public/styles.css b/public/styles.css index 734ba3e..1281429 100644 --- a/public/styles.css +++ b/public/styles.css @@ -468,6 +468,19 @@ summary:focus-visible { white-space: nowrap; } +.result-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-top: 18px; +} + +.result-actions [data-share-status] { + color: var(--green); + font-size: 0.72rem; +} + .score { display: flex; flex: 0 0 112px; @@ -680,6 +693,16 @@ summary:focus-visible { font-size: 0.67rem; } +.preview-icon--slack { + color: white; + background: #611f69; +} + +.preview-icon--whatsapp { + color: white; + background: #1fa855; +} + .search-result { max-width: 720px; padding: 21px 24px; @@ -780,6 +803,66 @@ summary:focus-visible { color: #a9a9a9; } +.message-preview { + overflow: hidden; + border: 1px solid #d9dce3; + border-radius: 10px; +} + +.message-preview__body { + min-width: 0; + padding: 13px 14px; +} + +.message-preview__body > span { + display: block; + overflow: hidden; + color: #6d7078; + font-size: 0.62rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.message-preview__body h3 { + margin: 5px 0 4px; + font-size: 0.84rem; + line-height: 1.3; +} + +.message-preview__body p { + margin: 0 0 8px; + color: #676971; + font-size: 0.68rem; + line-height: 1.35; +} + +.message-preview__body img, +.message-preview--whatsapp > img { + width: 100%; + aspect-ratio: 1.91 / 1; + object-fit: cover; + background: #e7e6ed; + border-radius: 6px; +} + +.message-preview--slack { + display: grid; + grid-template-columns: 4px minmax(0, 1fr); + background: white; +} + +.message-preview__accent { + background: #b9b9bd; +} + +.message-preview--whatsapp { + background: #f0f2f5; +} + +.message-preview--whatsapp > img { + border-radius: 0; +} + .metadata { margin-top: 18px; overflow: hidden; @@ -1233,12 +1316,16 @@ summary:focus-visible { } .secondary-button { + display: inline-flex; + align-items: center; + justify-content: center; color: var(--ink-soft); background: white; border: 1px solid var(--line); border-radius: 7px; cursor: pointer; font-weight: 700; + text-decoration: none; } [data-copy-status] { diff --git a/server.js b/server.js index 09f4852..7cbb861 100644 --- a/server.js +++ b/server.js @@ -5,6 +5,7 @@ const helmet = require("helmet"); 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 { generateTags } = require("./public/tag-generator"); @@ -90,6 +91,21 @@ function renderEditor(metadata) { `; } +function renderShareActions(analysis) { + if (analysis.source.mode !== "url") return ""; + const requestedUrl = analysis.source.requestedUrl || analysis.source.finalUrl; + if (!requestedUrl) return ""; + const query = `url=${encodeURIComponent(requestedUrl)}`; + const auditPath = `/?${query}`; + + return `
+ + Export JSON + Download report + +
`; +} + function renderAnalysis(htmlOrAnalysis, targetUrl) { const analysis = typeof htmlOrAnalysis === "string" ? analyzeHtml(htmlOrAnalysis, targetUrl) @@ -180,6 +196,7 @@ function renderAnalysis(htmlOrAnalysis, targetUrl) { ${safeOgUrl} + ${renderShareActions(analysis)}
${score.value} @@ -262,6 +279,37 @@ function renderAnalysis(htmlOrAnalysis, targetUrl) {
+ +
+
+ +
SlackOpen Graph unfurl
+
+
+ +
+ ${safeOgUrl} +

${facebookTitle}

+

${facebookDescription}

+ Slack link preview image +
+
+
+ +
+
+ +
WhatsAppMessage link preview
+
+
+ WhatsApp link preview image +
+

${facebookTitle}

+

${facebookDescription}

+ ${safeOgUrl} +
+
+
${analysis.images ? `
@@ -411,6 +459,16 @@ function createApp({ const parseHtmlForm = express.urlencoded({ extended: false, limit: "256kb" }); const analysisLimiter = rateLimit({ + handler: (req, res, _next, options) => { + const message = typeof options.message === "string" + ? options.message + : "Too many analysis requests. Please try again later."; + if (req.path.startsWith("/api/")) { + res.status(options.statusCode).json({ error: message }); + return; + } + res.status(options.statusCode).type("text").send(message); + }, legacyHeaders: false, limit: 30, message: "Too many analysis requests. Please try again later.", @@ -419,6 +477,17 @@ function createApp({ ...rateLimitOptions, }); + const analyzeUrl = async (value) => { + if (!value) { + const error = new Error("A URL is required."); + error.statusCode = 400; + throw error; + } + const requestedUrl = normalizeTargetUrl(value); + const html = await pageFetcher(requestedUrl); + return analyzeDocument(html, requestedUrl, { imageInspector, mode: "url" }); + }; + app.get( "/", (req, res, next) => @@ -437,7 +506,7 @@ function createApp({

Preview every share

-

See how your page can appear across search, Facebook, X, and LinkedIn.

+

See how your page can appear across search, Facebook, X, LinkedIn, Slack, and WhatsApp.

@@ -448,8 +517,7 @@ function createApp({ if (requestedUrl) { try { - const html = await pageFetcher(requestedUrl); - const analysis = await analyzeDocument(html, requestedUrl, { imageInspector, mode: "url" }); + const analysis = await analyzeUrl(requestedUrl); content = renderAnalysis(analysis); } catch (error) { const isPublicError = Number.isInteger(error.statusCode); @@ -462,6 +530,35 @@ function createApp({ }, ); + app.get("/api/analyze", analysisLimiter, async (req, res) => { + try { + const requestedUrl = typeof req.query.url === "string" ? req.query.url : ""; + const analysis = await analyzeUrl(requestedUrl); + res.json(serializeAnalysis(analysis)); + } catch (error) { + const isPublicError = Number.isInteger(error.statusCode); + res.status(isPublicError ? error.statusCode : 502).json({ + error: isPublicError ? error.message : "The target website could not be analyzed.", + }); + } + }); + + app.get("/report.md", analysisLimiter, async (req, res) => { + try { + const requestedUrl = typeof req.query.url === "string" ? req.query.url : ""; + const analysis = await analyzeUrl(requestedUrl); + res + .attachment("metascope-report.md") + .type("text/markdown") + .send(toMarkdownReport(analysis)); + } catch (error) { + const isPublicError = Number.isInteger(error.statusCode); + res.status(isPublicError ? error.statusCode : 502).type("text").send( + isPublicError ? error.message : "The target website could not be analyzed.", + ); + } + }); + app.post("/analyze/html", analysisLimiter, parseHtmlForm, async (req, res) => { const pastedHtml = typeof req.body.html === "string" ? req.body.html : ""; const suppliedBaseUrl = typeof req.body.baseUrl === "string" ? req.body.baseUrl.trim() : ""; diff --git a/test/app.test.js b/test/app.test.js index fa4185a..860873b 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -57,6 +57,103 @@ test("renders a successful SEO and social metadata audit", async () => { assert.match(response.text, /SEO Audit/); }); +test("renders Slack and WhatsApp previews with URL sharing actions", async () => { + const app = createApp({ + pageFetcher: async () => fixtureHtml, + imageInspector: successfulImageInspector, + }); + const response = await request(app) + .get("/") + .query({ url: "https://example.com/source" }) + .expect(200); + + assert.match(response.text, />SlackWhatsApp { + const app = createApp({ + pageFetcher: async () => fixtureHtml, + imageInspector: successfulImageInspector, + }); + const response = await request(app) + .get("/api/analyze") + .query({ url: "https://example.com/source" }) + .expect("Content-Type", /json/) + .expect(200); + + assert.equal(response.body.schemaVersion, 1); + assert.equal(response.body.url, "https://example.com/source"); + assert.equal(response.body.source.mode, "url"); + assert.equal(response.body.metadata.title, "A useful fixture title for search results"); + assert.equal(typeof response.body.score.value, "number"); + assert.ok(response.body.checks.some((check) => check.id === "og-title")); + assert.equal(response.body.images.openGraph.width, 1200); +}); + +test("returns safe JSON API validation and upstream errors", async () => { + const app = createApp({ + pageFetcher: async () => { + throw new Error("sensitive upstream detail"); + }, + }); + + const missing = await request(app).get("/api/analyze").expect(400); + assert.deepEqual(missing.body, { error: "A URL is required." }); + + const failed = await request(app) + .get("/api/analyze") + .query({ url: "https://example.com" }) + .expect(502); + assert.deepEqual(failed.body, { error: "The target website could not be analyzed." }); + assert.doesNotMatch(JSON.stringify(failed.body), /sensitive upstream detail/); +}); + +test("keeps API rate-limit errors in the JSON contract", async () => { + const app = createApp({ + pageFetcher: async () => fixtureHtml, + imageInspector: successfulImageInspector, + rateLimitOptions: { limit: 1 }, + }); + + await request(app) + .get("/api/analyze") + .query({ url: "https://example.com" }) + .expect(200); + const limited = await request(app) + .get("/api/analyze") + .query({ url: "https://example.com" }) + .expect("Content-Type", /json/) + .expect(429); + + assert.deepEqual(limited.body, { + error: "Too many analysis requests. Please try again later.", + }); +}); + +test("downloads a Markdown audit report for a public URL", async () => { + const app = createApp({ + pageFetcher: async () => fixtureHtml, + imageInspector: successfulImageInspector, + }); + const response = await request(app) + .get("/report.md") + .query({ url: "https://example.com/source" }) + .expect("Content-Type", /text\/markdown/) + .expect(200); + + assert.match(response.headers["content-disposition"], /attachment; filename="metascope-report\.md"/); + assert.match(response.text, /# MetaScope audit: https:\/\/example\.com\/source/); + assert.match(response.text, /Score: \*\*\d+\/100\*\*/); + assert.match(response.text, /\| Status \| Category \| Check \| Finding \|/); + assert.match(response.text, /Fixture Open Graph title/); +}); + test("explains the product and next step on the landing page", async () => { const app = createApp(); const response = await request(app).get("/").expect(200); @@ -279,6 +376,7 @@ test("serves the local editor script without unsafe DOM insertion", async () => assert.match(response.headers["content-type"], /javascript/); assert.match(response.text, /textContent/); + assert.match(response.text, /data-copy-audit-link/); assert.doesNotMatch(response.text, /\.innerHTML\s*=/); }); diff --git a/test/report.test.js b/test/report.test.js new file mode 100644 index 0000000..b2e78e5 --- /dev/null +++ b/test/report.test.js @@ -0,0 +1,64 @@ +const assert = require("node:assert/strict"); +const test = require("node:test"); +const { serializeAnalysis, toMarkdownReport } = require("../lib/report"); + +const analysis = { + source: { + mode: "url", + requestedUrl: "https://example.com/page", + finalUrl: "https://example.com/page", + }, + score: { value: 82, errors: 1, warnings: 2, passed: 10, info: 1 }, + metadata: { + title: "Example | title", + description: "A useful description", + canonical: { raw: "/page", url: "https://example.com/page" }, + openGraph: { + title: "Open Graph title", + description: "Open Graph description", + image: { raw: "/social.png", url: "https://example.com/social.png" }, + url: { raw: "/page", url: "https://example.com/page" }, + }, + twitter: { card: "summary_large_image" }, + }, + checks: [ + { + id: "title", + category: "seo", + status: "warning", + message: "Title contains a | delimiter\nand a new line.", + evidence: "Example | title", + weight: 3, + }, + ], + images: { openGraph: null, twitter: null }, +}; + +test("serializes only the stable public analysis contract", () => { + const result = serializeAnalysis({ ...analysis, privateState: "do not expose" }); + + assert.equal(result.schemaVersion, 1); + assert.equal(result.url, "https://example.com/page"); + assert.equal(result.metadata.title, "Example | title"); + assert.equal(result.privateState, undefined); + assert.deepEqual(Object.keys(result), [ + "schemaVersion", + "url", + "source", + "score", + "metadata", + "checks", + "images", + ]); +}); + +test("creates a deterministic Markdown report without broken table rows", () => { + const report = toMarkdownReport(analysis); + + assert.match(report, /^# MetaScope audit: https:\/\/example\.com\/page/m); + assert.match(report, /Score: \*\*82\/100\*\*/); + assert.match(report, /Example \\| title/); + assert.match(report, /Title contains a \\| delimiter and a new line\./); + assert.match(report, /## Detected metadata/); + assert.ok(report.endsWith("\n")); +});