From 0b7ec75a7554062a6f0cdcf74c5e25fe9e4153c2 Mon Sep 17 00:00:00 2001 From: Arber Xhindoli <14798762+arberx@users.noreply.github.com> Date: Sun, 29 Mar 2026 23:07:05 -0500 Subject: [PATCH] feat: add snapshot report command --- package.json | 2 +- packages/api-routes/src/index.ts | 7 + packages/api-routes/src/openapi.ts | 28 + packages/api-routes/src/snapshot.ts | 41 + packages/api-routes/test/snapshot.test.ts | 152 ++++ packages/canonry/package.json | 16 +- packages/canonry/src/cli-commands.ts | 2 + packages/canonry/src/cli-commands/snapshot.ts | 46 + packages/canonry/src/cli.ts | 4 + packages/canonry/src/client.ts | 10 + packages/canonry/src/commands/snapshot.ts | 109 +++ packages/canonry/src/server.ts | 5 + packages/canonry/src/snapshot-format.ts | 5 + packages/canonry/src/snapshot-pdf.ts | 377 ++++++++ packages/canonry/src/snapshot-service.ts | 853 ++++++++++++++++++ .../canonry/test/snapshot-command.test.ts | 210 +++++ .../canonry/test/snapshot-service.test.ts | 210 +++++ packages/contracts/src/index.ts | 1 + packages/contracts/src/snapshot.ts | 113 +++ pnpm-lock.yaml | 48 +- .../canonry-setup/references/canonry-cli.md | 6 + 21 files changed, 2230 insertions(+), 15 deletions(-) create mode 100644 packages/api-routes/src/snapshot.ts create mode 100644 packages/api-routes/test/snapshot.test.ts create mode 100644 packages/canonry/src/cli-commands/snapshot.ts create mode 100644 packages/canonry/src/commands/snapshot.ts create mode 100644 packages/canonry/src/snapshot-format.ts create mode 100644 packages/canonry/src/snapshot-pdf.ts create mode 100644 packages/canonry/src/snapshot-service.ts create mode 100644 packages/canonry/test/snapshot-command.test.ts create mode 100644 packages/canonry/test/snapshot-service.test.ts create mode 100644 packages/contracts/src/snapshot.ts diff --git a/package.json b/package.json index a231b0b..734edf3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "canonry", "private": true, - "version": "1.28.2", + "version": "1.29.0", "type": "module", "packageManager": "pnpm@10.28.2", "scripts": { diff --git a/packages/api-routes/src/index.ts b/packages/api-routes/src/index.ts index 7e2177a..0dc8c42 100644 --- a/packages/api-routes/src/index.ts +++ b/packages/api-routes/src/index.ts @@ -17,6 +17,8 @@ import { openApiRoutes } from './openapi.js' import type { OpenApiInfo } from './openapi.js' import { settingsRoutes } from './settings.js' import type { SettingsRoutesOptions, ProviderSummaryEntry, ProviderAdapterInfo } from './settings.js' +import { snapshotRoutes } from './snapshot.js' +import type { SnapshotRoutesOptions } from './snapshot.js' import { telemetryRoutes } from './telemetry.js' import type { TelemetryRoutesOptions } from './telemetry.js' import { scheduleRoutes } from './schedules.js' @@ -63,6 +65,8 @@ export interface ApiRoutesOptions { onScheduleUpdated?: (action: 'upsert' | 'delete', projectId: string) => void /** Callback when a project is deleted */ onProjectDeleted?: (projectId: string) => void + /** Callback to generate a one-shot AI perception snapshot */ + onSnapshotRequested?: SnapshotRoutesOptions['onSnapshotRequested'] /** Callback to generate keyword suggestions using an LLM provider */ onGenerateKeywords?: KeywordRoutesOptions['onGenerateKeywords'] /** Telemetry status/toggle callbacks */ @@ -185,6 +189,9 @@ export async function apiRoutes(app: FastifyInstance, opts: ApiRoutesOptions) { bing: opts.bingSettingsSummary, onBingUpdate: opts.onBingSettingsUpdate, } satisfies SettingsRoutesOptions) + await api.register(snapshotRoutes, { + onSnapshotRequested: opts.onSnapshotRequested, + } satisfies SnapshotRoutesOptions) await api.register(scheduleRoutes, { onScheduleUpdated: opts.onScheduleUpdated, validProviderNames: opts.providerAdapters?.map(a => a.name), diff --git a/packages/api-routes/src/openapi.ts b/packages/api-routes/src/openapi.ts index 4e7a0f1..bcbadd4 100644 --- a/packages/api-routes/src/openapi.ts +++ b/packages/api-routes/src/openapi.ts @@ -736,6 +736,34 @@ const routeCatalog: OpenApiOperation[] = [ 501: { description: 'Google settings updates are not supported.' }, }, }, + { + method: 'post', + path: '/api/v1/snapshot', + summary: 'Generate a one-shot AI perception snapshot', + tags: ['snapshot'], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['companyName', 'domain'], + properties: { + companyName: stringSchema, + domain: stringSchema, + phrases: stringArraySchema, + competitors: stringArraySchema, + }, + }, + }, + }, + }, + responses: { + 200: { description: 'Snapshot report returned.' }, + 400: { description: 'Invalid snapshot input.' }, + 501: { description: 'Snapshot reporting is not supported.' }, + }, + }, { method: 'put', path: '/api/v1/settings/bing', diff --git a/packages/api-routes/src/snapshot.ts b/packages/api-routes/src/snapshot.ts new file mode 100644 index 0000000..700b752 --- /dev/null +++ b/packages/api-routes/src/snapshot.ts @@ -0,0 +1,41 @@ +import type { FastifyInstance } from 'fastify' +import { notImplemented, snapshotRequestSchema, validationError, type SnapshotReportDto, type SnapshotRequestDto } from '@ainyc/canonry-contracts' + +export interface SnapshotRoutesOptions { + onSnapshotRequested?: (input: SnapshotRequestDto) => Promise +} + +export async function snapshotRoutes(app: FastifyInstance, opts: SnapshotRoutesOptions) { + app.post<{ + Body: SnapshotRequestDto + }>('/snapshot', async (request, reply) => { + const parsed = snapshotRequestSchema.safeParse(request.body) + if (!parsed.success) { + const err = validationError('Invalid snapshot payload', { + issues: parsed.error.issues.map(issue => ({ + path: issue.path.join('.'), + message: issue.message, + })), + }) + return reply.status(err.statusCode).send(err.toJSON()) + } + + if (!opts.onSnapshotRequested) { + const err = notImplemented('Snapshot reporting is not supported in this deployment') + return reply.status(err.statusCode).send(err.toJSON()) + } + + try { + const report = await opts.onSnapshotRequested(parsed.data) + return reply.send(report) + } catch (err) { + request.log.error({ err }, 'Snapshot report generation failed') + return reply.status(500).send({ + error: { + code: 'INTERNAL_ERROR', + message: err instanceof Error ? err.message : 'Failed to generate snapshot report', + }, + }) + } + }) +} diff --git a/packages/api-routes/test/snapshot.test.ts b/packages/api-routes/test/snapshot.test.ts new file mode 100644 index 0000000..65ec5dd --- /dev/null +++ b/packages/api-routes/test/snapshot.test.ts @@ -0,0 +1,152 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import Fastify from 'fastify' +import { createClient, migrate } from '@ainyc/canonry-db' +import { apiRoutes } from '../src/index.js' +import type { ApiRoutesOptions } from '../src/index.js' + +function buildApp(opts: Partial> = {}) { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'api-routes-snapshot-')) + const db = createClient(path.join(tmpDir, 'test.db')) + migrate(db) + + const app = Fastify() + app.register(apiRoutes, { db, skipAuth: true, ...opts }) + return { app, tmpDir } +} + +const SNAPSHOT_FIXTURE = { + companyName: 'Acme Corp', + domain: 'acme.example.com', + homepageUrl: 'https://acme.example.com', + generatedAt: '2026-03-29T12:00:00.000Z', + phrases: ['best enterprise widget provider'], + competitors: ['widgetco.com'], + profile: { + industry: 'Manufacturing', + summary: 'Acme Corp sells enterprise widget manufacturing services.', + services: ['Widget manufacturing'], + categoryTerms: ['enterprise widgets'], + }, + audit: { + url: 'https://acme.example.com', + finalUrl: 'https://acme.example.com', + auditedAt: '2026-03-29T12:00:00.000Z', + overallScore: 58, + overallGrade: 'D+', + summary: 'Overall grade D+ with weak schema completeness.', + factors: [], + }, + queryResults: [ + { + phrase: 'best enterprise widget provider', + providerResults: [ + { + provider: 'openai', + displayName: 'OpenAI', + model: 'gpt-5.4', + mentioned: false, + cited: false, + describedAccurately: 'not-mentioned', + accuracyNotes: null, + incorrectClaims: [], + recommendedCompetitors: ['widgetco.com'], + citedDomains: ['widgetco.com'], + groundingSources: [], + searchQueries: ['best enterprise widget provider'], + answerText: 'WidgetCo is a strong option.', + error: null, + }, + ], + }, + ], + summary: { + totalQueries: 1, + totalProviders: 1, + totalComparisons: 1, + mentionCount: 0, + citationCount: 0, + topCompetitors: [{ name: 'widgetco.com', count: 1 }], + visibilityGap: 'Acme Corp was not mentioned in any provider responses.', + whatThisMeans: ['Competitors are winning category queries.'], + recommendedActions: ['Improve schema completeness.'], + }, +} + +describe('snapshot routes', () => { + let app: ReturnType + let tmpDir: string + + beforeAll(async () => { + const ctx = buildApp({ + onSnapshotRequested: async () => SNAPSHOT_FIXTURE, + }) + app = ctx.app + tmpDir = ctx.tmpDir + await app.ready() + }) + + afterAll(async () => { + await app.close() + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + it('POST /api/v1/snapshot returns a structured report', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/v1/snapshot', + payload: { + companyName: 'Acme Corp', + domain: 'acme.example.com', + }, + }) + + expect(res.statusCode).toBe(200) + const body = res.json() as typeof SNAPSHOT_FIXTURE + expect(body.companyName).toBe('Acme Corp') + expect(body.audit.overallScore).toBe(58) + expect(body.queryResults[0]?.providerResults[0]?.recommendedCompetitors).toEqual(['widgetco.com']) + }) + + it('POST /api/v1/snapshot validates the payload', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/v1/snapshot', + payload: { + companyName: '', + domain: '', + }, + }) + + expect(res.statusCode).toBe(400) + const body = res.json() as { + error: { + code: string + details: { issues: Array<{ path: string }> } + } + } + expect(body.error.code).toBe('VALIDATION_ERROR') + expect(body.error.details.issues.map(issue => issue.path)).toEqual(['companyName', 'domain']) + }) + + it('POST /api/v1/snapshot returns 501 when the server has no snapshot implementation', async () => { + const ctx = buildApp() + await ctx.app.ready() + + const res = await ctx.app.inject({ + method: 'POST', + url: '/api/v1/snapshot', + payload: { + companyName: 'Acme Corp', + domain: 'acme.example.com', + }, + }) + + expect(res.statusCode).toBe(501) + + await ctx.app.close() + fs.rmSync(ctx.tmpDir, { recursive: true, force: true }) + }) +}) diff --git a/packages/canonry/package.json b/packages/canonry/package.json index 881bb4d..256362f 100644 --- a/packages/canonry/package.json +++ b/packages/canonry/package.json @@ -1,6 +1,6 @@ { "name": "@ainyc/canonry", - "version": "1.28.2", + "version": "1.29.0", "type": "module", "description": "The ultimate open-source AEO monitoring tool - track how answer engines cite your domain", "license": "FSL-1.1-ALv2", @@ -42,31 +42,33 @@ "lint": "eslint src/ test/" }, "dependencies": { + "@ainyc/aeo-audit": "1.3.2", "@anthropic-ai/sdk": "^0.78.0", "@fastify/static": "^8.1.0", "@google/genai": "^1.46.0", "better-sqlite3": "^12.6.2", + "chrome-remote-interface": "^0.33.2", "drizzle-orm": "^0.45.1", "fastify": "^5.4.0", "node-cron": "^4.2.1", "openai": "^6.0.0", + "pdf-lib": "^1.17.1", "pino-pretty": "^13.1.3", "yaml": "^2.7.1", - "zod": "^4.1.12", - "chrome-remote-interface": "^0.33.2" + "zod": "^4.1.12" }, "devDependencies": { "@ainyc/canonry-api-routes": "workspace:*", "@ainyc/canonry-config": "workspace:*", "@ainyc/canonry-contracts": "workspace:*", "@ainyc/canonry-db": "workspace:*", - "@ainyc/canonry-provider-claude": "workspace:*", - "@ainyc/canonry-provider-gemini": "workspace:*", - "@ainyc/canonry-provider-cdp": "workspace:*", - "@ainyc/canonry-provider-local": "workspace:*", "@ainyc/canonry-integration-bing": "workspace:*", "@ainyc/canonry-integration-google": "workspace:*", "@ainyc/canonry-integration-wordpress": "workspace:*", + "@ainyc/canonry-provider-cdp": "workspace:*", + "@ainyc/canonry-provider-claude": "workspace:*", + "@ainyc/canonry-provider-gemini": "workspace:*", + "@ainyc/canonry-provider-local": "workspace:*", "@ainyc/canonry-provider-openai": "workspace:*", "@ainyc/canonry-provider-perplexity": "workspace:*", "@types/better-sqlite3": "^7.6.13", diff --git a/packages/canonry/src/cli-commands.ts b/packages/canonry/src/cli-commands.ts index a9cef47..c34541c 100644 --- a/packages/canonry/src/cli-commands.ts +++ b/packages/canonry/src/cli-commands.ts @@ -11,6 +11,7 @@ import { PROJECT_CLI_COMMANDS } from './cli-commands/project.js' import { RUN_CLI_COMMANDS } from './cli-commands/run.js' import { SCHEDULE_CLI_COMMANDS } from './cli-commands/schedule.js' import { SETTINGS_CLI_COMMANDS } from './cli-commands/settings.js' +import { SNAPSHOT_CLI_COMMANDS } from './cli-commands/snapshot.js' import { SYSTEM_CLI_COMMANDS } from './cli-commands/system.js' import { WORDPRESS_CLI_COMMANDS } from './cli-commands/wordpress.js' @@ -20,6 +21,7 @@ export const REGISTERED_CLI_COMMANDS: readonly CliCommandSpec[] = [ ...KEYWORD_CLI_COMMANDS, ...COMPETITOR_CLI_COMMANDS, ...SETTINGS_CLI_COMMANDS, + ...SNAPSHOT_CLI_COMMANDS, ...RUN_CLI_COMMANDS, ...OPERATOR_CLI_COMMANDS, ...SCHEDULE_CLI_COMMANDS, diff --git a/packages/canonry/src/cli-commands/snapshot.ts b/packages/canonry/src/cli-commands/snapshot.ts new file mode 100644 index 0000000..5471dcf --- /dev/null +++ b/packages/canonry/src/cli-commands/snapshot.ts @@ -0,0 +1,46 @@ +import { createSnapshotReport } from '../commands/snapshot.js' +import type { CliCommandSpec } from '../cli-dispatch.js' +import { getString, requirePositional, requireStringOption, stringOption } from '../cli-command-helpers.js' + +function parseCsvOption(value: string | undefined): string[] | undefined { + if (!value) return undefined + const parts = value + .split(',') + .map(part => part.trim()) + .filter(Boolean) + return parts.length > 0 ? [...new Set(parts)] : undefined +} + +export const SNAPSHOT_CLI_COMMANDS: readonly CliCommandSpec[] = [ + { + path: ['snapshot'], + usage: 'canonry snapshot --domain [--phrases "a,b"] [--competitors "x,y"] [--pdf ] [--format table|json]', + options: { + domain: stringOption(), + phrases: stringOption(), + competitors: stringOption(), + pdf: stringOption(), + }, + run: async (input) => { + const usage = 'canonry snapshot --domain [--phrases "a,b"] [--competitors "x,y"] [--pdf ] [--format table|json]' + const companyName = requirePositional(input, 0, { + command: 'snapshot', + usage, + message: 'company name is required', + }) + const domain = requireStringOption(input, 'domain', { + command: 'snapshot', + usage, + message: '--domain is required', + }) + + await createSnapshotReport(companyName, { + domain, + phrases: parseCsvOption(getString(input.values, 'phrases')), + competitors: parseCsvOption(getString(input.values, 'competitors')), + pdf: getString(input.values, 'pdf'), + format: input.format, + }) + }, + }, +] diff --git a/packages/canonry/src/cli.ts b/packages/canonry/src/cli.ts index 40fdd7d..c8b5d9c 100644 --- a/packages/canonry/src/cli.ts +++ b/packages/canonry/src/cli.ts @@ -31,6 +31,7 @@ Usage: canonry keyword generate Auto-generate key phrases (--provider, --count, --save) canonry competitor add Add competitors canonry competitor list List competitors + canonry snapshot --domain One-shot AI perception report canonry run Trigger a run (all providers) canonry run --provider Trigger a run for a specific provider canonry run --location