prometheus/service/seo.js
Local Dev 9f16ce3128 prometheus: weekly s3 snapshot of the browse surface to bns/prometheus
scripts/snapshot.mjs renders every public page (home, browse, publish,
boundaries, 8 categories, every active listing) to <out>/<path>/index.html,
plus /sitemap.xml, /robots.txt, and the two static assets. Writing to a
directory next to the service AND uploading via `aws s3 sync` are two
flags on the same command: --out to pick the directory, --upload to push.

Live routes added for /sitemap.xml and /robots.txt so a crawler hitting
the live service sees the same enumeration the snapshot bakes into the
bucket. renderSitemap / renderRobots in service/seo.js are the single
rendering path both call sites use.

Scope deliberately limited to the browse surface — /submit and the API
routes depend on the live backend and were never going to work on a
static mirror. The browse surface IS what the fallback is for: readers
survive an outage, publishers can't.

systemd unit + timer under deploy/ run the snapshot weekly on Sunday
04:00 UTC with 30 min jitter and Persistent=yes so a missed run after a
reboot catches up. Unit uses the sia-storage AWS profile per
[[sia-storage-credentials-location]] and the per-process endpoint URL
baked in via Environment=.

Local dry-run against an empty DB produced 14 HTML files + sitemap +
robots + the two assets.
2026-10-08 21:42:54 +02:00

99 lines
3 KiB
JavaScript

/**
* SEO artefacts — sitemap and robots.
*
* sitemap.xml enumerates every crawlable URL. The snapshot writes it to the
* bucket root; the live service serves the same file on request so a crawler
* hitting prometheus.x (via the apps.silentmode.st mirror in production) sees
* the same contents the snapshot does.
*
* robots.txt is the usual shape: allow everything except the admin and the
* raw API routes, which have no public content.
*/
const BASE = process.env.PUBLIC_BASE || 'https://prometheus.x';
/**
* @param {{categories: string[], listings: Array<{publisher: string, slug: string, lastmod?: string}>}} data
*/
export function renderSitemap({ categories, listings }) {
const now = new Date().toISOString();
const urls = [
{ loc: '/', lastmod: now, priority: '1.0' },
{ loc: '/browse', lastmod: now, priority: '0.9' },
{ loc: '/publish', lastmod: now, priority: '0.6' },
{ loc: '/boundaries', lastmod: now, priority: '0.5' },
{ loc: '/submit', lastmod: now, priority: '0.4' },
...categories.map((slug) => ({
loc: `/c/${slug}`, lastmod: now, priority: '0.7',
})),
...listings.map((l) => ({
loc: `/app/${l.publisher}/${l.slug}`,
lastmod: l.lastmod || now,
priority: '0.8',
})),
];
const body = urls.map((u) =>
` <url><loc>${BASE}${u.loc}</loc><lastmod>${u.lastmod}</lastmod><priority>${u.priority}</priority></url>`
).join('\n');
return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${body}
</urlset>
`;
}
export function renderRobots() {
return `User-agent: *
Allow: /
Disallow: /admin
Disallow: /admin/
Disallow: /api/
Disallow: /.well-known/
Sitemap: ${BASE}/sitemap.xml
`;
}
/**
* schema.org JSON-LD for a listing — Google renders a rich card for apps.
* Returns the raw JSON string; the template wraps it in a <script> tag.
*/
export function softwareApplicationLd({ listing, scanVerdict }) {
const payload = {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: listing.name,
description: listing.description,
softwareVersion: listing.version,
applicationCategory: humanCategory(listing.category),
url: `${BASE}/app/${listing.publisher}/${listing.slug}`,
downloadUrl: listing.download_url,
fileSize: `${listing.download_size} B`,
offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' },
author: {
'@type': 'Organization',
name: listing.publisher,
},
};
if (listing.icon) payload.image = listing.icon;
if (scanVerdict === 'clean') {
payload.contentRating = 'ClamAV clean';
}
return JSON.stringify(payload);
}
function humanCategory(slug) {
const map = {
communication: 'CommunicationApplication',
privacy: 'SecurityApplication',
wallets: 'FinanceApplication',
games: 'GameApplication',
devtools: 'DeveloperApplication',
media: 'MultimediaApplication',
utilities: 'UtilitiesApplication',
other: 'ApplicationSuite',
};
return map[slug] || 'ApplicationSuite';
}