diff --git a/seo-reporter/package.json b/seo-reporter/package.json new file mode 100644 index 000000000..bc587c7bd --- /dev/null +++ b/seo-reporter/package.json @@ -0,0 +1,17 @@ +{ + "name": "@tangled/seo-reporter", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "deploy": "wrangler deploy", + "typecheck": "tsc --noEmit", + "report:dry": "tsx scripts/dry-report.ts" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260317.1", + "tsx": "^4.19.0", + "typescript": "^5.9.3", + "wrangler": "^4.75.0" + } +} diff --git a/seo-reporter/scripts/dry-report.ts b/seo-reporter/scripts/dry-report.ts new file mode 100644 index 000000000..5db97a575 --- /dev/null +++ b/seo-reporter/scripts/dry-report.ts @@ -0,0 +1,4 @@ +import { sendWeeklySeoReport } from "../src/report"; + +const res = await sendWeeklySeoReport({}, { dryRun: true }); +console.log(JSON.stringify(res.payload, null, 2)); diff --git a/seo-reporter/src/index.ts b/seo-reporter/src/index.ts new file mode 100644 index 000000000..cb173e605 --- /dev/null +++ b/seo-reporter/src/index.ts @@ -0,0 +1,11 @@ +import { sendWeeklySeoReport, type Env } from "./report"; + +export default { + async scheduled(_event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise { + ctx.waitUntil(sendWeeklySeoReport(env)); + }, + + async fetch(): Promise { + return new Response("not found", { status: 404 }); + } +}; diff --git a/seo-reporter/src/report.ts b/seo-reporter/src/report.ts new file mode 100644 index 000000000..2bb4dc7c8 --- /dev/null +++ b/seo-reporter/src/report.ts @@ -0,0 +1,195 @@ +export interface Env { + BASE_URL?: string; + DISCORD_WEBHOOK_URL?: string; + GSC_SERVICE_ACCOUNT?: string; +} + +export interface GscMetric { + query: string; + clicks: number; + impressions: number; + position: number; +} + +export interface GscReportData { + clicks: number; + clicksDeltaPercent: number; + impressions: number; + impressionsDeltaPercent: number; + ctrPercent: number; + averagePosition: number; + positionDelta: number; + topQueries: GscMetric[]; + isMock?: boolean; +} + +export interface EndpointProbe { + name: string; + url: string; + ok: boolean; + status: number; + detail: string; +} + +export interface DiscordEmbed { + title: string; + color: number; + fields: { name: string; value: string; inline?: boolean }[]; + footer: { text: string }; + timestamp: string; +} + +export interface DiscordWebhookPayload { + username: string; + avatar_url?: string; + embeds: DiscordEmbed[]; +} + +export function getMockSearchMetrics(): GscReportData { + return { + clicks: 1420, + clicksDeltaPercent: 18.4, + impressions: 52400, + impressionsDeltaPercent: 32.1, + ctrPercent: 2.7, + averagePosition: 11.8, + positionDelta: 2.4, + isMock: true, + topQueries: [ + { query: "stacked pull requests", clicks: 340, impressions: 8200, position: 3.2 }, + { query: "atproto git repo", clicks: 280, impressions: 6400, position: 1.8 }, + { query: "jujutsu code review", clicks: 210, impressions: 5100, position: 4.1 }, + { query: "git collaboration tangled", clicks: 195, impressions: 4300, position: 2.0 }, + { query: "self-hosted knot git", clicks: 140, impressions: 3800, position: 4.9 } + ] + }; +} + +export async function probeEndpoints( + baseUrl = "https://tangled.org", + fetchImpl: typeof fetch = fetch +): Promise { + const cleanBase = baseUrl.replace(/\/+$/, ""); + const targets = [ + { name: "robots.txt", path: "/robots.txt", expectedStatus: 200 }, + { name: "llms.txt", path: "/llms.txt", expectedStatus: 200 }, + { name: "sitemap.xml", path: "/sitemap.xml", expectedStatus: 200 }, + { name: "indexnow", path: "/d76fae270607bfc761b35c14f3182c9e.txt", expectedStatus: 200 } + ]; + + const results: EndpointProbe[] = []; + for (const t of targets) { + const url = `${cleanBase}${t.path}?_t=${Date.now()}`; + try { + const res = await fetchImpl(url, { headers: { "user-agent": "tangled-seo-monitor/1.0" } }); + results.push({ + name: t.name, + url: `${cleanBase}${t.path}`, + ok: res.status === t.expectedStatus, + status: res.status, + detail: res.status === t.expectedStatus ? "ok" : `http ${res.status}` + }); + } catch (err: any) { + results.push({ + name: t.name, + url: `${cleanBase}${t.path}`, + ok: false, + status: 0, + detail: err?.message || "unreachable" + }); + } + } + return results; +} + +export function buildDiscordEmbed( + probes: EndpointProbe[], + search: GscReportData | null +): DiscordWebhookPayload { + const allProbesOk = probes.every((p) => p.ok); + const color = allProbesOk ? 0x10b981 : 0xef4444; + const fields: { name: string; value: string; inline?: boolean }[] = []; + + if (search) { + const deltaSign = search.clicksDeltaPercent >= 0 ? "+" : ""; + const posArrow = search.positionDelta >= 0 ? "+" : "-"; + const mockSuffix = search.isMock ? " (dry run)" : ""; + + fields.push({ + name: `search performance${mockSuffix}`, + value: [ + `clicks: ${search.clicks.toLocaleString()} (${deltaSign}${search.clicksDeltaPercent}% wow)`, + `impressions: ${search.impressions.toLocaleString()} (${search.impressionsDeltaPercent >= 0 ? "+" : ""}${search.impressionsDeltaPercent}% wow)`, + `position: ${search.averagePosition} (${posArrow}${Math.abs(search.positionDelta)}) ยท ctr: ${search.ctrPercent}%` + ].join("\n") + }); + + if (search.topQueries.length > 0) { + const lines = search.topQueries.map( + (q, i) => `${i + 1}. ${q.query} (#${q.position.toFixed(1)}, ${q.clicks} clicks)` + ); + fields.push({ + name: "top queries", + value: lines.join("\n") + }); + } + } else { + fields.push({ + name: "search performance", + value: "gsc cutover pending" + }); + } + + const probeLines = probes.map((p) => `${p.name}: ${p.ok ? "ok" : p.detail}`); + fields.push({ + name: "endpoints", + value: probeLines.join("\n") + }); + + return { + username: "tangled seo monitor", + avatar_url: "https://assets.tangled.network/tangled_og.png", + embeds: [ + { + title: "tangled weekly seo report", + color, + fields, + footer: { text: "tangled seo" }, + timestamp: new Date().toISOString() + } + ] + }; +} + +export async function sendWeeklySeoReport( + env: Env, + options?: { dryRun?: boolean; mockGsc?: boolean; fetchImpl?: typeof fetch } +): Promise<{ ok: boolean; payload: DiscordWebhookPayload; status?: number; error?: string }> { + const fetchImpl = options?.fetchImpl || fetch; + const baseUrl = env.BASE_URL || "https://tangled.org"; + + const probes = await probeEndpoints(baseUrl, fetchImpl); + const search = options?.mockGsc || !env.GSC_SERVICE_ACCOUNT ? getMockSearchMetrics() : null; + const payload = buildDiscordEmbed(probes, search); + + if (options?.dryRun || !env.DISCORD_WEBHOOK_URL) { + return { ok: true, payload }; + } + + try { + const res = await fetchImpl(env.DISCORD_WEBHOOK_URL, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload) + }); + + if (!res.ok) { + const errText = await res.text(); + return { ok: false, payload, status: res.status, error: errText }; + } + + return { ok: true, payload, status: res.status }; + } catch (err: any) { + return { ok: false, payload, error: err?.message || "webhook failed" }; + } +} diff --git a/seo-reporter/tsconfig.json b/seo-reporter/tsconfig.json new file mode 100644 index 000000000..0ab55909e --- /dev/null +++ b/seo-reporter/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "lib": [ + "ESNext" + ], + "typeRoots": [ + "../sitemap/node_modules/@cloudflare", + "../sitemap/node_modules/@types" + ], + "types": [ + "workers-types" + ] + }, + "include": [ + "src/**/*", + "scripts/**/*" + ] +} diff --git a/seo-reporter/wrangler.jsonc b/seo-reporter/wrangler.jsonc new file mode 100644 index 000000000..d85585634 --- /dev/null +++ b/seo-reporter/wrangler.jsonc @@ -0,0 +1,17 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "seo-reporter", + "main": "src/index.ts", + "compatibility_date": "2026-03-07", + "workers_dev": false, + "routes": [], + "observability": { + "enabled": true + }, + "triggers": { + "crons": ["0 12 * * 1"] + }, + "vars": { + "BASE_URL": "https://tangled.org" + } +}