Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 28 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
# MetaScope — SEO Audit & Social Preview

A compact Node.js application that audits a public webpage's SEO metadata and renders Open Graph and Twitter Card previews. It demonstrates server-side HTML parsing, defensive outbound HTTP requests, secure rendering, and automated HTTP testing.
A compact Node.js application that audits a public webpage or pasted HTML, explains deterministic SEO and social metadata findings, and lets you edit metadata against live previews before copying generated tags. It demonstrates server-side HTML parsing, defensive outbound HTTP requests, secure rendering, and automated HTTP testing.

## Features

- Checks title, description, canonical URL, H1, language, robots, and viewport metadata.
- Reports completeness of Open Graph and Twitter Card tags.
- 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.
- Validates social-image reachability, type, bytes, dimensions, and aspect ratio.
- Edits draft metadata with instant preview updates and generated `<head>` tags.
- 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.
Expand All @@ -18,7 +20,10 @@ Because the application fetches user-provided URLs, outbound requests are treate
- Only HTTP and HTTPS URLs without embedded credentials are accepted.
- Local, private, reserved, link-local, and non-public IPv4/IPv6 targets are blocked.
- DNS results and redirect destinations are checked to reduce SSRF risk.
- Requests have a 10-second timeout, five-redirect limit, and 2 MB response limit.
- Page requests have a 10-second timeout, five-redirect limit, and 2 MB response limit.
- 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.
- 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.
Expand All @@ -31,6 +36,7 @@ These controls are appropriate for a demonstration project, but a public high-tr
- Express 5
- Axios
- Cheerio
- image-size
- Responsive, dependency-free CSS
- Node's built-in test runner and Supertest

Expand All @@ -49,7 +55,7 @@ pnpm install
pnpm start
```

Open [http://localhost:3001](http://localhost:3001), then enter a complete public URL such as `https://example.com`.
Open [http://localhost:3001](http://localhost:3001), then enter a complete public URL such as `https://example.com` or switch to **HTML** and paste page markup. Supply a public base URL in HTML mode when relative canonical or image URLs need to be resolved.

To use another port:

Expand Down Expand Up @@ -85,16 +91,29 @@ The test suite covers:
- HTML and attribute escaping
- Unsafe preview URL removal
- Successful SEO and social metadata rendering
- URL and pasted-HTML analysis modes
- Deterministic audit rules and scoring
- Bounded social-image inspection and safe same-origin previews
- Generated metadata-tag escaping and unsafe URL omission
- HTTP security headers, error responses, and rate limiting

## Project structure

```text
.
├── .github/workflows/ci.yml # Node 20/22 CI checks
├── lib/safe-fetch.js # URL validation and bounded HTTP fetching
├── lib/
│ ├── analyze.js # Shared analysis pipeline
│ ├── audit.js # Deterministic rules and scoring
│ ├── image-inspector.js # Bounded social-image validation
│ ├── metadata.js # Normalized metadata parsing
│ └── safe-fetch.js # URL validation and bounded page fetching
├── public/
│ ├── app.js # Input tabs and live draft previews
│ ├── tag-generator.js # Shared browser/server tag generation
│ └── styles.css
├── test/ # Unit and HTTP integration tests
├── server.js # Express app, metadata parsing, and rendering
├── server.js # Express routes and server rendering
├── package.json
└── pnpm-lock.yaml
```
Expand All @@ -108,8 +127,10 @@ The previous Heroku demo is no longer linked because it currently returns 404.
## Known limitations

- Social previews approximate platform layouts; platforms may apply additional rules and image processing.
- The numerical score is a transparent MetaScope rules score, not a search-engine ranking prediction.
- 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.

## License

Expand Down
109 changes: 109 additions & 0 deletions lib/analyze.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
const { analyzeMetadata, summarizeChecks } = require("./audit");
const { parseMetadata } = require("./metadata");

function analyzeHtml(html, sourceUrl) {
const metadata = parseMetadata(html, sourceUrl);
return {
source: { mode: "html", requestedUrl: sourceUrl || "", finalUrl: sourceUrl || "" },
metadata,
...analyzeMetadata(metadata, sourceUrl),
};
}

async function analyzeDocument(html, sourceUrl, { imageInspector, mode = "html" } = {}) {
const analysis = analyzeHtml(html, sourceUrl);
analysis.source.mode = mode;
const inspect = async (image) => {
if (!image.url || typeof imageInspector !== "function") return null;
try {
return await imageInspector(image.url);
} catch (error) {
return {
url: image.url,
reachable: false,
error: Number.isInteger(error.statusCode)
? error.message
: "The image could not be inspected.",
};
}
};

const [openGraph, twitter] = await Promise.all([
inspect(analysis.metadata.openGraph.image),
analysis.metadata.twitter.image.url === analysis.metadata.openGraph.image.url
? Promise.resolve(null)
: inspect(analysis.metadata.twitter.image),
]);

const imageResult = (id, label, report) => {
if (!report) return null;
const category = id.startsWith("og-") ? "open-graph" : "twitter";
if (!report.reachable) {
return {
id,
category,
severity: "error",
status: "error",
message: `${label} ${report.error || "could not be inspected."}`,
evidence: report.url || "",
weight: 10,
};
}
if (!report.https) {
return {
id,
category,
severity: "warning",
status: "warning",
message: `${label} should use HTTPS.`,
evidence: report.url,
weight: 3,
};
}
if (!report.recommendedDimensions) {
return {
id,
category,
severity: "warning",
status: "warning",
message: `${label} is ${report.width} × ${report.height}; consider 1200 × 630.`,
evidence: `${report.contentType}, ${report.bytes} bytes`,
weight: 3,
};
}
return {
id,
category,
severity: "pass",
status: "pass",
message: `${label} is reachable and uses the recommended dimensions.`,
evidence: `${report.width} × ${report.height}, ${report.contentType}`,
weight: 0,
};
};

const twitterReport = twitter || (
analysis.metadata.twitter.image.url &&
analysis.metadata.twitter.image.url === analysis.metadata.openGraph.image.url
? openGraph
: null
);
const replacements = [
imageResult("og-image", "Open Graph image", openGraph),
analysis.metadata.twitter.image.raw
? imageResult("twitter-image", "Twitter image", twitterReport)
: null,
].filter(Boolean);
for (const replacement of replacements) {
const index = analysis.checks.findIndex((check) => check.id === replacement.id);
if (index >= 0) analysis.checks[index] = replacement;
}

return {
...analysis,
score: summarizeChecks(analysis.checks),
images: { openGraph, twitter: twitterReport },
};
}

module.exports = { analyzeDocument, analyzeHtml };
Loading
Loading