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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "canonry",
"private": true,
"version": "1.28.2",
"version": "1.29.0",
"type": "module",
"packageManager": "pnpm@10.28.2",
"scripts": {
Expand Down
7 changes: 7 additions & 0 deletions packages/api-routes/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -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),
Expand Down
28 changes: 28 additions & 0 deletions packages/api-routes/src/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
41 changes: 41 additions & 0 deletions packages/api-routes/src/snapshot.ts
Original file line number Diff line number Diff line change
@@ -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<SnapshotReportDto>
}

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',
},
})
}
})
}
152 changes: 152 additions & 0 deletions packages/api-routes/test/snapshot.test.ts
Original file line number Diff line number Diff line change
@@ -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<Omit<ApiRoutesOptions, 'db'>> = {}) {
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<typeof Fastify>
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 })
})
})
16 changes: 9 additions & 7 deletions packages/canonry/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions packages/canonry/src/cli-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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,
Expand Down
46 changes: 46 additions & 0 deletions packages/canonry/src/cli-commands/snapshot.ts
Original file line number Diff line number Diff line change
@@ -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 <company-name> --domain <domain> [--phrases "a,b"] [--competitors "x,y"] [--pdf <path>] [--format table|json]',
options: {
domain: stringOption(),
phrases: stringOption(),
competitors: stringOption(),
pdf: stringOption(),
},
run: async (input) => {
const usage = 'canonry snapshot <company-name> --domain <domain> [--phrases "a,b"] [--competitors "x,y"] [--pdf <path>] [--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,
})
},
},
]
4 changes: 4 additions & 0 deletions packages/canonry/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ Usage:
canonry keyword generate <project> Auto-generate key phrases (--provider, --count, --save)
canonry competitor add <project> <domain> Add competitors
canonry competitor list <project> List competitors
canonry snapshot <company> --domain <domain> One-shot AI perception report
canonry run <project> Trigger a run (all providers)
canonry run <project> --provider <name> Trigger a run for a specific provider
canonry run <project> --location <label> Run with a specific location
Expand Down Expand Up @@ -132,6 +133,9 @@ Options:
--language <lang> Language code (default: en)
--provider <name> Provider to use (gemini, openai, claude, perplexity, local, cdp:chatgpt, or cdp for all CDP targets)
--format <fmt> Output format: text (default) or json
--phrases <list> Comma-separated category queries (snapshot)
--competitors <list> Comma-separated competitor hints (snapshot)
--pdf <path> Write a PDF snapshot report to a file
--location <label> Run with a specific configured location
--all-locations Run for every configured location
--no-location Explicitly skip location context
Expand Down
Loading
Loading