DEV Community

Cizze R
Cizze R

Posted on

I Replaced Browser-Based Social Card Checks with One Metadata API

I Replaced Browser-Based Social Card Checks with One Metadata API

I used to open a browser in CI just to confirm that release pages had titles, descriptions, and social images. The checks were slow, the browser image needed regular maintenance, and a passing page render still did not tell me whether the Open Graph tags were complete.

I replaced that step with the Link Preview & Metadata Extractor and a small Python policy checker. It fetches the HTML over HTTP, returns the metadata and response details as structured JSON, and leaves the project-specific pass or fail rules in my code.

The browser was testing the wrong layer

A browser is useful when the question is whether a page renders correctly. My actual question was narrower: what will a link unfurler or crawler find in the document metadata?

For that check I care about:

  • A successful HTML response
  • A usable title and description
  • An absolute social image URL
  • A canonical URL on the expected host
  • An Open Graph type
  • Twitter Card metadata where the product supports X sharing
  • Robots directives that do not accidentally block indexing

None of those requires screenshots or layout inspection. The actor uses HTTP and parses the document metadata directly, including Open Graph fields, Twitter Cards, favicons, language, canonical URLs, all meta tags, status code, content type, and response timing.

That also creates a better failure boundary. DNS, SSL, timeout, HTTP, and non-HTML errors come back as structured errors instead of a generic browser navigation failure.

Fetch the exact deployed URL

The audit runs after deployment against the public URL, not against rendered template source. That catches reverse proxy redirects, missing production environment variables, and CDN responses that local tests never see.

import os
from typing import Any

import requests

APIFY_TOKEN = os.getenv("APIFY_TOKEN", "YOUR_APIFY_TOKEN")
ACTOR_ID = "weeknds~link-preview-metadata-extractor"
RUN_URL = (
    f"https://api.apify.com/v2/acts/{ACTOR_ID}/"
    "run-sync-get-dataset-items"
)


def extract_metadata(url: str) -> dict[str, Any]:
    response = requests.post(
        RUN_URL,
        params={"token": APIFY_TOKEN},
        json={
            "url": url,
            "includeFavicon": True,
            "userAgent": "Release-Metadata-Audit/1.0",
            "timeout": 20,
        },
        timeout=75,
    )
    response.raise_for_status()
    items = response.json()
    if len(items) != 1:
        raise RuntimeError(f"Expected one result for {url}, got {len(items)}")
    return items[0]
Enter fullscreen mode Exit fullscreen mode

A custom user agent makes the audit identifiable in server logs. It should describe the client honestly, not impersonate a search crawler.

Flatten the response before applying policy

Metadata extractors can group Open Graph and HTTP data into nested objects. A recursive flattener lets the policy work with either nested values or literal keys such as og:title.

from collections.abc import Mapping
from typing import Any


def flatten(value: Mapping[str, Any], prefix: str = "") -> dict[str, Any]:
    output = {}
    for key, child in value.items():
        path = f"{prefix}.{key}" if prefix else key
        output[path] = child
        if isinstance(child, Mapping):
            output.update(flatten(child, path))
    return output


def find(item: dict[str, Any], *keys: str) -> Any:
    fields = flatten(item)
    for key in keys:
        for path, value in fields.items():
            if path == key or path.endswith(f".{key}"):
                if value not in (None, "", []):
                    return value
    return None
Enter fullscreen mode Exit fullscreen mode

I keep this adapter separate from the rules. If the actor adds a new output field, the audit can use it without changing the network layer.

Turn metadata expectations into explicit rules

A release check should produce actionable messages. "Metadata invalid" sends someone back to the logs, while "og:image must be an absolute HTTPS URL" points at the fix.

from dataclasses import dataclass
from urllib.parse import urlparse


@dataclass(frozen=True)
class Finding:
    severity: str
    field: str
    message: str


def audit_page(item: dict, expected_host: str) -> list[Finding]:
    findings = []

    if item.get("error"):
        return [Finding(
            "error",
            item.get("error_type", "fetch"),
            item["error"],
        )]

    status = find(item, "status_code")
    content_type = str(find(item, "content_type") or "")
    title = find(item, "og:title", "title")
    description = find(item, "og:description", "description")
    image = find(item, "og:image")
    canonical = find(item, "canonical_url", "canonical", "og:url")
    og_type = find(item, "og:type")
    robots = str(find(item, "robots") or "").lower()

    if status and not 200 <= int(status) < 300:
        findings.append(Finding("error", "status_code", f"HTTP {status}"))
    if content_type and "text/html" not in content_type:
        findings.append(Finding("error", "content_type", content_type))
    if not title or not 10 <= len(str(title)) <= 70:
        findings.append(Finding("error", "title", "Use a 10-70 character title"))
    if not description or not 50 <= len(str(description)) <= 200:
        findings.append(Finding(
            "warning",
            "description",
            "Use a 50-200 character description",
        ))
    if not image or urlparse(str(image)).scheme != "https":
        findings.append(Finding(
            "error",
            "og:image",
            "Use an absolute HTTPS image URL",
        ))
    if not canonical:
        findings.append(Finding("warning", "canonical", "Canonical URL is missing"))
    elif urlparse(str(canonical)).hostname != expected_host:
        findings.append(Finding(
            "error",
            "canonical",
            f"Expected canonical host {expected_host}",
        ))
    if not og_type:
        findings.append(Finding("warning", "og:type", "Open Graph type is missing"))
    if "noindex" in robots:
        findings.append(Finding("error", "robots", "Page is marked noindex"))

    return findings
Enter fullscreen mode Exit fullscreen mode

The length ranges are policy, not universal laws. I use them to catch obviously empty or accidentally huge values, not to pretend every social platform truncates at the same character.

Audit a release manifest instead of crawling everything

A full-site crawl on every release wastes time and makes ownership unclear. My deployment exports a short manifest of URLs affected by the release, then the audit checks only those pages.

import json
from pathlib import Path


def load_release_urls(path: Path) -> list[dict[str, str]]:
    payload = json.loads(path.read_text(encoding="utf-8"))
    urls = payload.get("urls", [])
    if not urls:
        raise ValueError("Release manifest contains no URLs")
    return urls


def audit_release(manifest_path: Path) -> int:
    error_count = 0

    for entry in load_release_urls(manifest_path):
        url = entry["url"]
        expected_host = entry["expected_host"]

        try:
            item = extract_metadata(url)
            findings = audit_page(item, expected_host)
        except (requests.RequestException, RuntimeError, ValueError) as error:
            findings = [Finding("error", "request", str(error))]

        if not findings:
            print(f"PASS {url}")
            continue

        for finding in findings:
            print(
                f"{finding.severity.upper()} {url} "
                f"[{finding.field}] {finding.message}"
            )
            if finding.severity == "error":
                error_count += 1

    return error_count


if __name__ == "__main__":
    failures = audit_release(Path("release-urls.json"))
    raise SystemExit(1 if failures else 0)
Enter fullscreen mode Exit fullscreen mode

The manifest stays simple and reviewable:

{
  "urls": [
    {
      "url": "https://docs.example.com/python/cache-invalidation",
      "expected_host": "docs.example.com"
    },
    {
      "url": "https://example.com/releases/summer-update",
      "expected_host": "example.com"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Warnings appear in the job output but do not block the release. Errors return a non-zero exit code. That distinction stopped minor description preferences from becoming production incidents while still blocking broken canonicals, failed fetches, insecure image URLs, and accidental noindex directives.

Save evidence for failed checks

The worst version of this job only printed a summary. By the time someone investigated, a redeploy had changed the page and the original response was gone.

Now the audit writes the actor item whenever a page fails. The artifact contains the metadata and HTTP details observed at release time, without requiring another request.

import hashlib
import json
from datetime import datetime, timezone
from pathlib import Path

ARTIFACT_DIR = Path("metadata-audit-artifacts")


def save_failure_artifact(url: str, item: dict, findings: list[Finding]) -> Path:
    ARTIFACT_DIR.mkdir(parents=True, exist_ok=True)
    digest = hashlib.sha256(url.encode()).hexdigest()[:12]
    timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
    path = ARTIFACT_DIR / f"{timestamp}-{digest}.json"
    path.write_text(
        json.dumps(
            {
                "url": url,
                "findings": [finding.__dict__ for finding in findings],
                "metadata": item,
            },
            indent=2,
            sort_keys=True,
        ),
        encoding="utf-8",
    )
    return path
Enter fullscreen mode Exit fullscreen mode

Treat these files as operational artifacts, not permanent content. A 30-day retention window is usually enough to diagnose a release without building an unbounded archive.

What the switch changed

The audit no longer downloads a browser image or waits for client rendering. It tests the metadata layer directly, and each rule names the field that failed. Browser-based visual checks still exist for pages where layout matters, but they are no longer the default tool for validating social cards.

The Link Preview & Metadata Extractor is currently listed as pay per usage on Apify. The live price depends on platform resource consumption and the account plan, so the job should check the current store pricing before turning a small release audit into a large crawl.

Run the audit only after the deployment URL is reachable, keep the token in the CI secret store, and cap retries for network errors. A timeout can deserve one retry; a noindex directive, non-HTML response, or wrong canonical host needs a code or configuration change, not another request.

Top comments (0)